npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ixo/decision-oracle

v0.2.28

Published

Run an IXO evaluation oracle: pull claims, produce facts with your handlers, let the decision engine decide, sign on chain

Readme

@ixo/decision-oracle

An evaluation oracle is the service that answers "did this actually happen?" for a project on the IXO chain. Someone submits a claim against a collection — a delivery, a cookstove refill, a site inspection. Your oracle pulls that claim, produces the facts the collection's rubric asks for (a courier API says the parcel arrived, a model says the photo shows what it should, a registry says this is not a duplicate), hands those facts to the IXO decision engine, and signs the verdict the engine returns on chain with your own key.

This package is all of that except the facts. You write a config object and a handful of async functions; @ixo/decision-oracle brings the claim pulling, the engine handshake, the D1 ledgers, human-review folds, the chain write, billing pauses, email and Matrix notices, the cron and the Workflows. It is for developers who want to run an oracle as a service, for teams publishing a protocol and its rubric, and for anyone who needs a claim judged the same way twice.

The rubric is the work order. The engine decides. You produce facts and sign.

Learn more · Step-by-step guide: docs.ixo.world — Decision oracle · Full API reference: docs.ixo.world — Decision oracle API reference


What you can do with it

  • Run your own oracle for a protocol. decision-oracle init scaffolds a Cloudflare Worker with three files that are yours; everything else is the package's.
  • Write fact handlers — one async function per named fact. External HTTP sources, AI checks, duplicate detection and submission-rate limits all have a stock lane already, so you only write the ones stock cannot answer.
  • Test them in plain node. @ixo/decision-oracle/testing runs the real production runner against fake tools. No Worker, no network, no chain — just vitest.
  • Dry-run against a real claim and the real engine. facts run --preview produces your facts with REAL tools and asks the engine for the decision they would yield: nothing recorded, nothing billed, no on-chain grant needed.
  • Publish protocols, rubrics, deeds and collections from the CLI — rubric validate, publish-protocol, create-deed, create-collection, submit-claim: the whole commissioner side, so you can stand up an end-to-end loop by yourself.
  • Provision the whole oracle on chain with one command — oracle init funds its wallet, writes its IID document and its Ed25519 verification key, creates its Matrix account, then the oracle entity with its full public identity — profile, domain card, Agent Card — the #eval service and the protocol links that make you discoverable in the IXO portal's marketplace.
  • Be an OpenHands checker with one flag. init --openhands wires the add-on: the OpenHands app's hire webhook makes your oracle authorize itself on each ask that picked it, register it and start judging, with a backstop that keeps every hired ask topped up.
  • Drive all of it with the shipped Claude skills. init copies eight skills into .claude/skills/: create-oracle, write-facts, create-rubric, publish-protocol, create-collection, decision-api-oracle, decision-api-client, add-openhands-support.

How it works

One ClaimIngest Workflow per claim; every step is checkpointed and retried independently.

  1. Watch — POST /v1/collections/register passes four gates (who / hired / protocol / paid) and lands a watched-collection row.
  2. Discover — a cron tick scans watched collections on Blocksync behind a forward-only cursor and starts one Workflow per unseen claim.
  3. Pull — the claim body from the first store that has it: the VFS claims lane (under your Evaluate grant), a VFS public copy, the Matrix claim bot (via the owner's delegation), the IPFS gateway, then a Cellnode the entity declares. Every store but the bot is CID-verified. Plus attachment descriptors — a bare CID resolves to the body's own store. Bytes stay where they are.
  4. Produce facts — read the work order from the engine (a manifest: the ext.* / ai.* names the rubric declares), run your handlers with the stock lanes behind them, validate the answer against the order, and freeze a strict FactSet.
  5. Ask the engine — POST /v1/decisions → { decision, resultDigest, receipt: { cid, jws } }, cached once. The engine is the only judge of rubric ⇄ form ⇄ facts.
  6. Act — on a terminal verdict, sign MsgEvaluateClaim with verification_proof = receipt.cid. A review verdict waits for a human's answers, which the ReviewComplete Workflow folds into the same decision — never a second evaluation.
                    ┌──────────────────────────────────────────────────┐
                    │  commissioner  (IXO portal)                      │
                    │  publishes a protocol: claim form + rubric       │
                    │  grants your ORACLE_ADDRESS the Evaluate role    │
                    │  registers their collection with your oracle     │
                    └───────────────────────┬──────────────────────────┘
                                            │
   IXO chain + Blocksync                    ▼                 IXO decision engine
   ┌──────────────────────┐         ┌───────────────┐        ┌────────────────────┐
   │ collections, claim   │ claims  │  YOUR ORACLE  │manifest│ binds the rubric   │
   │ ids, grants, the     │────────►│   (Workers)   │◄──────►│ to the claim form, │
   │ evaluation payment   │         │               │decision│ decides, signs a   │
   │                      │◄────────│  src/facts/   │◄──────►│ receipt            │
   └──────────────────────┘ verdict └───────┬───────┘        └────────────────────┘
        MsgEvaluateClaim                    │
                                            │ claim bodies + attachments
                                            ▼
                                ┌───────────────────────┐
                                │  VFS · Matrix bot     │
                                │  · IPFS · Cellnode    │
                                └───────────────────────┘

Prerequisites

| You need | Why | | --- | --- | | bun ≥ 1.3 | the package manager and the CLI runtime — decision-oracle loads your TypeScript handlers directly | | node ≥ 20 | the build's node-load check, and the plain-node lane your handler tests run in | | A Cloudflare account + wrangler login | the oracle is a Worker with D1, KV, Workflows, a Durable Object and a cron | | An IXO wallet / AuthHub login | only for the chain-authoring commands (login, delegation, oracle init, publish-protocol, create-deed, create-collection, submit-claim) | | A decision engine host | defaults per IXO_NETWORK (see Configuration); EVAL_ENGINE_HOSTNAME overrides it for a local engine or a tunnel | | An AI provider | only if the rubric you serve declares AI checks (or a unique rule with its judge on): the Cloudflare AI Gateway by default (the ai binding + AI_GATEWAY_ID, paid from your Cloudflare credits), or an OpenRouter key with AI_PROVIDER=openrouter |

Install from npm: bunx @ixo/decision-oracle init <dir> scaffolds a project that depends on @ixo/decision-oracle, and inside it the CLI is bunx decision-oracle …. The customer-side SDK and CLI is @ixo/decision-oracle-client (npm i @ixo/decision-oracle-client).

Quick start

Zero to a hired oracle. One command per step.

# 1. the project — three source files, the skills, wrangler.jsonc
bunx @ixo/decision-oracle init my-oracle && cd my-oracle && bun install

# 2. binding types from your wrangler.jsonc (re-run after ANY binding/var change)
bun run cf-typegen && cp .dev.vars.example .dev.vars

# 3. storage — the worker and `bun run dev` need a local D1 with the migrations applied
wrangler d1 create my-oracle          # paste the id into wrangler.jsonc
wrangler kv namespace create my-oracle-cache # …and this one (KV names are per account)
bun run db:migrate

# 4. identities — two keys, written into .dev.vars, never printed
bunx decision-oracle derive --new --write .dev.vars --as SERVICE_ED25519_MNEMONIC
bunx decision-oracle derive --new --write .dev.vars

# 5. what the protocol's rubric asks your oracle to produce
bunx decision-oracle protocol review <protocol did>

# 6. a stub handler + a test for every name stock cannot answer
bunx decision-oracle facts scaffold <protocol did>

# …write the handlers in src/facts/ …

# 7. plain vitest — the real runner, fake tools, no Worker, no network
bun run test

# 8. the same handlers over a claim of your own, with REAL tools
bunx decision-oracle facts run --protocol <protocol did> --claim claim.json

# 9. run the oracle locally (local D1; the cron is fireable by hand) — leave it running
bun run dev

# 10. …and now ask the engine what your facts would decide — nothing billed, nothing recorded
bunx decision-oracle facts run --protocol <protocol did> --claim claim.json --preview

# 11. the owner's billing delegation to the engine (a 402 means: buy credits, then retry)
bunx decision-oracle login
bunx decision-oracle delegation --for engine --audience did:web:<engine host> \
  --oracle did:web:<ORACLE_HOSTNAME> --out owner-delegation.car --yes

# 12. provision the oracle on chain: it funds the wallet, writes the IID document and the Ed25519
#     key, creates the Matrix account (the token is written into .dev.vars, never printed), then
#     the entity (its DID pinned into .dev.vars as ORACLE_ENTITY_DID) with #orz, #pro, the #dmn
#     domain card and the #acard Agent Card, the protocol edges, #eval and the domain indexer.
#     Dry run first — read the plan — then the same line with --yes
bunx decision-oracle oracle init --protocol <protocol did> --kit-url http://localhost:8787
bunx decision-oracle oracle init --protocol <protocol did> --kit-url http://localhost:8787 --yes

# 13. migrate the deployed D1 BEFORE the first deploy, then ship
bun run db:migrate:remote && bun run deploy

# 14. give the deployed worker the token oracle init wrote, and re-run it with the real host —
#     the host move re-anchors exactly three things: `service` re-points #eval, and `domain` /
#     `acard` replace the two cards (they name the host), merged onto the live ones. `profile`
#     fills in #pro only if step 12 had to skip it (no /v1/info then). `fund` is a floor check, so
#     it tops the wallet back up to 1 IXO if step 12's own txs spent it below; the rest say `skip`
wrangler secret put ORACLE_MATRIX_ACCESS_TOKEN
bunx decision-oracle oracle init --protocol <protocol did> --kit-url https://<your host> --yes

# 15. get hired: a commissioner's Grant Role + register — or hire yourself
bunx decision-oracle create-deed --protocol <protocol did> --title "<name>" --yes
bunx decision-oracle create-collection --entity <deed did> --protocol <protocol did> \
  --eval-amount <uixo> --evaluate <your ORACLE_ADDRESS> --yes
bunx decision-oracle register --collection <collection id>

A few things that are easy to get wrong:

  • Step 4 runs derive twice, each with --write. The first writes SERVICE_ED25519_MNEMONIC (the did:web key); the second writes ORACLE_SECP_MNEMONIC (the chain wallet) and prints ORACLE_ADDRESS / ORACLE_DID / ORACLE_MATRIX_USER_ID — public vars → wrangler.jsonc. Without --write, derive prints the mnemonic (as ORACLE_SECP_MNEMONIC=).
  • Step 8's claim.json is the answers a real submitter would give, under the same question names the rubric reads:
    { "reference": "ABC-123", "deliveredAt": "2026-09-08", "recipient": "A. Nakamura" }
    --claim also takes the full envelope { "answers": {…}, "attachments": [{ "question": …, "name": …, "mediaType": …, "path": … }] } when the claim carries files; an attachment path is relative to the claim file.
  • --preview needs step 9 running. The engine resolves your did:web by fetching /.well-known/did.json from your oracle, which bun run dev is what serves locally.
  • Step 11's 402 is the owner's credits, not a bug. The engine bills the oracle OWNER's IXO subscription per decision. Open the portal URL the error prints, buy credits, register again. Put the CAR in .dev.vars as OWNER_DELEGATION_CAR (never print it — it is a bearer credential) and restart wrangler dev: a running one reloads source, not secrets.
  • Step 12 wants step 9 still running. The #pro profile is built from your oracle's own GET /v1/info, so with nothing listening at --kit-url that one step prints profile skip … not reachable — a skip, never a failure, and step 14's re-run fills it in.
  • oracle init prints an entity DID — put it in ORACLE_ENTITY_DID (a wrangler.jsonc var) and redeploy, or reviews can only be answered from the portal's claim page, never from a Matrix card.

Writing fact handlers

A rubric declares named facts — ext.<name> for an external source, ai.<name> for an AI check — and the engine publishes that list as a manifest: the work order. Every declared name already has a stock lane (an external source becomes a UCAN-signed POST to the endpoint the rubric declares; an AI check runs the configured AI provider with the rubric's own instructions). You write a handler only for the names stock cannot answer — anything that needs YOUR system, your vendor, or a value the rubric derives. A handler table is always a diff against stock, never a replacement of it.

import { defineFacts, fact } from "@ixo/decision-oracle/facts";

export const facts = defineFacts({
  ext: {
    // input: { name, expected, send, unresolved, claim, answers, ctx, settings, manifest }
    // `send` is the rubric's own declared send map, already resolved over the claim's answers
    // and the frozen ctx; `answers` is the whole claim body if you need more than it names.
    deliveryConfirmed: async ({ send }, tools) => {
      const res = await tools.http.json<{ status?: string }>(
        `https://courier.example/deliveries/${send.reference}`,
      );
      if (!res.ok) return fact.error(`courier responded ${res.status}`);
      return res.body?.status === "delivered";     // boolean | number ⇒ an `ok` fact
    },
  },
  ai: {
    // "*" answers every AI check you did not name by name
    "*": async (_input, tools) => tools.stock(),
  },
});

What a handler is handed

| Field | What it is | | --- | --- | | name | the <name> in ext.<name> / ai.<name> — the work-order key this handler answers | | expected / check | the manifest entry for that name: boolean or score, its send map, its onError | | send | the rubric's declared send map, already resolved over the claim's answers and ctx | | unresolved | the ~derived refs only the engine computes — reported, never guessed | | claim / answers | the pulled claim, and claim.body (the whole credentialSubject) verbatim | | ctx | the build's ONE clock read and the collection window — never read the clock yourself | | settings | this build's ai/image policy, from defineOracle's policies | | manifest | the whole work order — fields, derived, ctx, usedBy — as context | | inputs (ai only) | the send map as typed, ordered model inputs: attachments on the lane, scalars as text |

The tools

tools is the package's own machinery, built per fact, so a handler is a few lines instead of a connector:

| Tool | For | | --- | --- | | tools.stock() | the stock lane for this very fact — wrap it, fall back to it, or ignore it | | tools.http.json(url, init?) | a JSON request with the stock retry budget, a timeout, the SSRF guard and a recorded transcript — never throws for an HTTP outcome | | tools.http.json(url, { ucan }) | the call signed as this oracle for the URL's own host — for a source that authenticates you. Capability ixo:external-source unless you set your own in code (the rubric's capability is ignored; an ixo: service capability is refused) | | tools.http.ucanHeaders(audience, capability?) | the same token as headers (back-compat; the audience must be the host you call) | | tools.ai.judge(instructions, inputs, opts?) | the stock judge with YOUR rules | | tools.ai.inputsFor(check) | re-slice a manifest check's declared inputs for that judge | | tools.attachments.on(question) | the claim's file descriptors for one question | | tools.attachments.bytes(att, opts?) | fetch one attachment's bytes, on demand | | tools.attachments.size(att) | an attachment's byte size without downloading it | | tools.c2pa.screen(bytes, mediaType?) | Content Credentials (provenance) on an image | | tools.chain.linkedResources(entityDid) | what an entity anchors on chain (#vct, #rub, a card), each with its proof hash | | tools.chain.claimIntent({ agentAddress, collectionId, before, after? }) | the newest MsgClaimIntent this agent filed on this collection before before, bare or authz-wrapped (an agent filing as an authz grantee — the portal's lane and the boilerplate chain client's — has its intent nested inside a MsgExec) — a delivery clock and the tx hash to cite for it, or null. One server-side filtered query, never a scan. Pass the agreement's consent time as after so an earlier engagement's intent can never become the clock | | tools.chain.query() | the chain RPC query client — loaded lazily, memoised per isolate | | tools.agreement(counterparty?) | the engine's buyer statement for this collection — read it LAST, right before returning the fact it feeds. null is a real "no agreement on file"; a transient engine failure throws instead of returning anything, so don't wrap the call in your own try/catch | | tools.logger | the request logger | | tools.config | the validated config | | tools.env | your own bindings and secrets |

Two pure readers come with the API: readAnswer(answers, "$q.row.col") picks a value out of a claim body the way the engine's binder does, and rootQuestionName("$deliveryPhoto") gives the root question an attachment is keyed by.

What a handler may return

| Return | Becomes | | --- | --- | | true / false, or an integer 0–10000 for a score fact | an ok fact, frozen by the runner | | fact.ok(value, { reason, confidence, transcript }) | an ok fact with full control | | fact.ai.ok(value, { reason, failedRules, score, transcript }) | the AI-check equivalent | | fact.error(detail) | an error fact stamped with the rubric's own onError | | anything thrown | the same error fact, logged — UNLESS it's transient (next row) | | a retryable engine/chain failure (5xx, UNAVAILABLE, RATE_LIMITED, an outage) | not a fact at all: it propagates like retryLater below, so the step retries | | throw fact.retryLater(detail) | the transient signal: the workflow step retries, every fact already answered is kept |

Three rules the runner enforces, not you:

  • The manifest's onError always wins. A handler can never turn a failure into an approval, and can never pick a different onError. Returning false says "I checked, and it is false".
  • Unlisted names run the stock lane. Your table is a diff.
  • "*" under ai catches every check you did not name; a named handler still wins.
  • groups: [group(["quality", "price"], handler)] answers several declared sources from ONE call — four criteria out of one model call, not four completions. The handler returns one fact per name; a name it leaves out becomes that name's own error.

unique (duplicate detection) and frequency (submission rate) are handlers too, and stock covers both: the duplicate lane runs over this oracle's own D1 registries, the frequency lane over a single Blocksync count. Override either only if you have a better registry.

A deterministic failure is a fact; a transient one is a retry. tools.agreement() and tools.chain.* already follow this rule — don't wrap either in your own try/catch and turn a transient engine/chain failure into fact.error(...): a claim is decided once, so freezing trouble that was never the claim's fault holds it for a review it can never get replayed.

Test them without a Worker

@ixo/decision-oracle/testing runs the SAME runner production uses, minus the fact cache and D1, then the same validator the workflow runs before it freezes a FactSet. Plain vitest, node environment:

import { fakeTools, httpResponse, runFacts, sampleClaim, sampleManifest } from "@ixo/decision-oracle/testing";
import { describe, expect, it } from "vitest";
import { facts } from "../src/facts";

describe("deliveryConfirmed", () => {
  it("is ok when the courier says delivered", async () => {
    const out = await runFacts(facts, {
      claim: sampleClaim({ reference: "ABC" }),
      manifest: sampleManifest({
        ext: { deliveryConfirmed: { expect: "boolean", send: { reference: "$reference" } } },
      }),
      tools: fakeTools({
        http: { "https://courier.example/deliveries/ABC": { status: "delivered" } },
      }),
    });
    expect(out.issues).toEqual([]);
    expect(out.ext.deliveryConfirmed).toMatchObject({ status: "ok", value: true });
  });

  it("is an error, not a false, when the courier is down", async () => {
    const out = await runFacts(facts, {
      claim: sampleClaim({ reference: "ABC" }),
      manifest: sampleManifest({
        ext: { deliveryConfirmed: { expect: "boolean", send: { reference: "$reference" } } },
      }),
      tools: fakeTools({ http: { "https://courier.example/deliveries/ABC": httpResponse(503, {}) } }),
    });
    expect(out.ext.deliveryConfirmed).toMatchObject({ status: "error" });
  });
});

fakeTools, httpResponse, sampleClaim, sampleManifest, sampleCtx and runFacts are the whole surface. out.issues is empty iff the engine would accept this FactSet. Two traps:

  • fakeTools().ai.judge answers true unless you override it. An AI handler that only forwards to the judge therefore passes vacuously in a test that never configures a verdict — assert the judge's input, or configure ai: { judge: … }, or the test proves nothing.
  • tools.stock() is an error fact by default — the stock lane is not available off-Worker. Pass stock in fakeTools({ … }) (or runFacts's stock option) when the handler under test wraps it.

Full reference: Decision oracle API reference.

CLI reference

The CLI runs on bun — it loads your TypeScript handlers directly. Run bunx decision-oracle --help for the same list at the terminal.

Global flags

| Flag | Applies to | Meaning | | --- | --- | --- | | --network devnet\|testnet\|mainnet | every network-facing command | selects the engine, Blocksync, Matrix, the UCAN Store, the RPC, the portal and AuthHub in one go. Default devnet — testnet and mainnet are always explicit | | --engine-url <url> | every engine-facing command | talk to this engine instead of the network's (or EVAL_ENGINE_HOSTNAME's) | | --env-file <path> | every config-reading command | the secrets file to read. Default .dev.vars in the current directory |

Chain-writing commands need a login first and sign nothing without --yes — without it they print the exact plan and stop. Three of them are resumable via --phase (publish-protocol, create-deed, create-collection); oracle init has no phases — every one of its thirteen steps re-decides itself against live state, so re-running it is always safe.

Project

| Command | What it does | When you use it | | --- | --- | --- | | init <dir> [--name <project-name>] [--openhands] | scaffolds a project from the package's template plus the shipped skills (it never installs for you). --openhands makes it an OpenHands checker: openhands: {} in src/index.ts, the OpenhandsHire re-export, and the HIRE_WORKFLOW workflow and OPENHANDS_RATE_LIMITER ratelimit in every env of wrangler.jsonc | once, at the very start | | skills sync [--dir <path>] | refreshes .claude/skills/ from the installed package, leaving your own skills alone | after upgrading @ixo/decision-oracle |

Identity and billing

| Command | What it does | When you use it | | --- | --- | --- | | derive ("<mnemonic>" \| --new) [--homeserver <host>] [--write <file> [--as <VAR>]] | derives a chain wallet and prints the ORACLE_SECP_MNEMONIC / ORACLE_ADDRESS / ORACLE_DID / ORACLE_MATRIX_USER_ID lines; --write appends the mnemonic to that file (0600) under --as (default ORACLE_SECP_MNEMONIC) instead of printing it, and refuses a name the file already holds | setting up the oracle's two keys (run it twice — see Quick start) | | login | AuthHub browser sign-in; the session is saved and reused | once per network, before any chain-authoring command | | login --session-file <json> [--yes] [--network <n>] | saves a session obtained elsewhere — the shape login writes (an ixo1… address, its did:ixo: DID, sessionMnemonic, edSigningMnemonic; the rest optional) — to the same ~/.ixo/publish-session-<network>.json (0600), without a browser. A plain key account (no sessionAuthenticatorId; its sessionMnemonic must derive the address) signs standard transactions directly; an AuthHub smart-account session carries its sessionAuthenticatorId and signs through that authenticator. Refuses a file missing a key, a DID that is not the address's, or a plain account whose mnemonic does not derive its address, and keeps an existing session unless --yes. Prints the path, the address and, for a plain account, that it signs as one | a service or test account that cannot sign in through a browser | | logout [--network <n>] | deletes that network's saved session file (~/.ixo/publish-session-<network>.json) and prints { removed, path } — never its contents; removed: false when there was none | handing the machine back, or switching accounts | | delegation --audience <did> --out <file> [--for engine\|bot] [--oracle <did:web>] [--days <n>] [--yes] | mints a UCAN delegation CAR. --for engine (needs --oracle, defaults to 100 years — nothing renews it) is the owner's billing delegation; --for bot (the default, 30 days) lets the oracle pull claim bodies from Matrix | before register — the engine one once as the owner, the bot one per collection |

Your oracle

| Command | What it does | When you use it | | --- | --- | --- | | protocol review <protocol did> [--json] | the rubric's work order in plain words: per name, whether stock covers it or you need a handler | first thing, for any protocol you might serve | | facts scaffold <protocol did> [--all] [--dir <path>] | writes a stub handler and a test per name that needs one (--all for every declared name); nothing existing is overwritten | right after protocol review | | facts run --protocol <did> --claim <file> [--collection <id>] [--agent <address>] [--facts <file>] [--preview] [--no-ai] [--json] [--env <name>] | runs your handlers over a claim file with REAL tools and prints the facts plus any validation issue; --preview asks the engine for the decision they would produce — nothing persisted, nothing billed, no grant needed | the first real end-to-end proof, and every time a handler changes | | manifest <protocol did> [--rubric <pin>] [--json] | a one-line summary of a protocol's manifest (--rubric reads an exact historical pin) | a quick "is this rubric usable yet?" | | engine register [--env <name>] [--env-file <path>] [--network <n>] [--engine-url <url>] [--yes] [--json] | registers this oracle with the decision engine now — the same ADR-36 proof and owner delegation the Worker sends lazily at its first decide, minted as the --env block's did:web (so it runs after that env's deploy) — then prints its billing status. A did:web the engine already binds to a DIFFERENT address is refused without --yes (old → new printed: an identity change) | right after the first deploy: registering makes the oracle payable at once at the engine's Standard plan terms — nothing else to do (IXO staff move IXO's own oracles to the IXO-owned plan) | | register --collection <id> [--delegation <car>] [--contact-email <addr>] [--kit <url>] | enrolls a collection with your oracle, as its owner — runs the four gates below | when you are hired (or hire yourself) | | scan --collection <id> [--kit <url>] | makes the oracle discover and start claims now instead of at the next cron tick | testing, or catching up after a pause | | relink [--collection <id>]… [--kit <url>] | as an operator (your DID in ORACLE_ADMIN_DIDS), links watched collections — every one, or those named — to the active Decision API sources of their company, exactly as register now does (POST /v1/admin/collections/relink); prints each collection's outcome. Idempotent | once, for collections registered before register linked them | | reviewer-note <claimId> [--kit <url>] | as an operator (your DID in ORACLE_ADMIN_DIDS), (re)makes the plain-words reviewer note for a claim held for a person (POST /v1/admin/claims/:claimId/reviewer-note): the oracle re-pulls the body and spends one model call; the note is read on GET /v1/reviews/:claimId | a claim held before policies.reviewerNote was on, or one whose note failed | | redeliver <claimId> [--yes] [--kit <url>] | as an operator (your DID in ORACLE_ADMIN_DIDS), releases a decided approve/reject whose chain delivery is parked without facts (POST /v1/admin/claims/:claimId/redeliver): the oracle re-pulls the body, rebuilds the facts its act policy reads (the engine's decision and receipt stay as recorded), stores them and signs MsgEvaluateClaim. Dry run without --yes; idempotent once delivered | a blocked claim whose chainError says its facts could not be rebuilt | | erase (<claimId>… \| --collection <id>) [--kit <url>] [--yes] | as the collection's current chain owner, erases claims' transcripts and registry text (DELETE /v1/claims/:claimId/body per claim; --collection = every claim this oracle has seen, skipping and listing those still being decided; a 429 waits its Retry-After). Dry run without --yes | when the organisation deletes its data — the oracle never does it on its own | | oracle init --protocol <did> --kit-url <url> [--logo <url|file>] [--openhands [--price-pay <n>]] [--only <step>] [--relogin] [--yes] | provisions the whole oracle in thirteen idempotent steps — fund tops the wallet up to 1 IXO for gas, iid its IID document, key the Ed25519 verification method, matrix its bot account (the access token is written into .dev.vars), entity, orz, profile, domain (the #dmn domain card), acard (the #acard Agent Card), linked, service (the #eval endpoint, re-pointed when it moved), backlink, index — --logo is optional but recommended (without it the portal and the console show a placeholder); --openhands (or an oracle whose /v1/info names its openhands webhooks — one built with init --openhands) adds check-openhands (the OpenHands app's checker marker), priced in PAY from /v1/info's pricing.api (else --price-pay <n>, else the protocol's evaluation fee when it is in upay; 1 PAY = 1 USD); each deciding do or skip against live state, so a re-run is always safe. Dry run without --yes; --only <step> runs exactly one, --relogin forces a fresh Matrix token. Only iid, key and matrix need the oracle's own ORACLE_SECP_MNEMONIC / SERVICE_ED25519_MNEMONIC (a step that does names the missing one); every other step runs on the AuthHub session alone, so a deployed oracle whose keys are only worker secrets can still --only orz|profile|domain|acard (what each step does) | once per network, and again after a deploy that changes your host |

facts run --claim takes a flat answers object or the { answers, attachments } envelope. What is real in that run: the manifest, the runner and its rules, HTTP, UCAN headers and tools.ai.judge — that last one spends real model money, so --no-ai turns the ai and image lanes off for the run. What is not real: the unique lane (it needs the deployed oracle's D1, so it stands in as review), C2PA provenance (its WASM needs a Worker), and the collection window unless you pass --collection.

Organisations

A Decision API source belongs to an organisation: an on-chain entity your login controls. These sign as your login too (org create and org fund are chain writes, so a dry run without --yes).

| Command | What it does | When you use it | | --- | --- | --- | | org create --name <name> [--logo <https url>] [--network <n>] [--yes] | creates an organisation the way the Decision Console does: its profile (#pro: name, logo) stored on the IXO Vault under your login's key (else the IXO IPFS gateway), then one MsgCreateEntity of type dao your login controls and owns. Idempotent by name — when your login already controls a dao whose profile has that name (the console's org list: owned or co-controlled), it prints that one and signs nothing. Waits for Blocksync to index a new one. Stdout is { entityDid, created } (the plan and progress go to stderr) | before api source create, when the organisation has no entity yet | | org balance --entity <did:ixo:entity:…> [--network <n>] | the organisation entity's admin account (Blocksync) and every denom it holds (the chain's bank API), amounts as base-unit strings: { adminAddress, balances: [{ denom, amount }] } | checking what the organisation's account holds | | org fund --entity <did:ixo:entity:…> --amount <n> [--coin ixo\|usdc] [--network <n>] [--yes] [--json] | tops up that same admin account from your login's wallet: one MsgSend of --amount whole coins (ixo, the default, or Noble USDC with --coin usdc — mainnet only), memo Openhands company top-up. Your login must own or control the organisation, and the wallet must hold the amount plus the ~0.02 IXO fee, else it refuses before signing. Without --yes a dry run (the plan on stderr, stdout empty); with it, prints the account's new balance and the tx hash — --json: { adminAddress, sent, balance, tx } | when the organisation's account runs low (it pays for its evaluations) |

The Decision API (with API_LANE on)

Console calls to your own oracle's /v1/api, signed as your login (a 120-second UCAN over ixo:oracle/<your did:web>, or …/source/<id> with X-Source-Id on a source's routes). The oracle is --kit <url>, else your .dev.vars ORACLE_HOSTNAME (a project whose config does not load says which var is wrong). On a source's routes your login must be a current controller (admin) of the source's organisation — its on-chain entity — which makes it root with no membership; api admin … needs your DID in the oracle's ORACLE_ADMIN_DIDS. Every one takes --network (else the project's IXO_NETWORK, else devnet), --env-file and --json (the API's answer as is, alone on stdout — a plan goes to stderr); a refusal prints the error envelope's code, message and requestId and exits 1. Every write carries a fresh Idempotency-Key. decision-oracle api --help lists them.

| Command | What it does | When you use it | | --- | --- | --- | | api source create --name <n> --contact <email> --entity <did:ixo:entity:…> | adds this oracle to an organisation your login controls; prints the source's id and its webhook signing secret — shown once. It stays pending-link (no claims, no keys) until the organisation's link is signed (api source link, or the Decision Console) | adding your own organisation | | api source link <sourceId> [--engine-url <url>] [--yes] | finishes a pending-link source as the console's "Add oracle" does: signs the organisation's SourceCompanyLink as your login, PUTs it to the decision engine (company/link on ixo:eval-engine), then calls the oracle's company sync and prints the source — active, or disputed when the engine holds another organisation's link. An already-linked source is printed and left alone | right after api source create | | api source delete <sourceId> [--yes] | removes this oracle from the organisation: the source and every row off it (members, keys, webhooks, claims and their decisions, audit). The plan names the source, its status and its organisation (read from GET /v1/api/me); --yes sends DELETE /v1/api/sources/:sourceId (sources/delete) as your login — a current controller of the organisation, no proof — and prints { "deleted": true, "sourceId" }. Nothing on chain or at the engine changes | a source linked to the wrong organisation, or disputed — delete it and add the oracle again | | api sources [--entity <did:ixo:entity:…>] | the sources your login belongs to, with your role on each; with --entity, every source of an organisation your login controls, pending-link ones included (a controller is a member of none) | finding a source id; finding a pending source before adding another | | api key create --source <id> --scopes <scope,…> [--name <n>] [--max-spend-cents <n>] | mints an API key (claims:write, claims:read, webhooks:manage; --max-spend-cents caps its spend per day) — the key is shown once, the oracle keeps its hash | giving a partner's backend its credential | | api keys --source <id> | the source's keys, masked, with their scopes and last use | auditing, or before a rotation | | api key revoke --source <id> <prefix> [--grace-days <n>] [--yes] | revokes a key (by its 8-char prefix, or the masked eo_test_<prefix>_… a listing prints — only the prefix is sent) at once, or after --grace-days (≤ 30) so a rotation can overlap | a leaked key, or the old half of a rotation | | api admin sources | every source on the oracle (operator) | the operator view | | api admin pause <sourceId> [--note <text>] [--yes] / api admin resume <sourceId> [--yes] | pauses or resumes a source's submits (operator) | a partner in breach, or back in good standing | | api admin protocols <sourceId> <did,…>\|--none [--yes] | replaces the source's allowed protocols whole; --none allows nothing (operator) | enabling or narrowing what a source may submit | | api admin billing-mode <sourceId> x402\|none [--yes] | sets how the source pays — none for a partner who should not (operator only, by design) | a commercial term changes | | api review-billing enable --source <id> [--cap <PAY>] [--yes] | the Decision Console's "Pay for reviews": refused unless the oracle's /v1/info says reviewBilling.mode: "x402" (IXO's plan), the source is billed and your login is the organisation's owner on chain (Blocksync, the source's companyEntityDid from GET /v1/api/me). Signs, as your login, an x402-ixo-pay payment delegation — billing/pay on billing:decision-api, nb: { maxAmount, denom: "upay", oracles: [<this oracle's did:web>] }, to the network's facilitator did:web, ~100 years — PUTs it on the source (review-billing/set), keeps a copy in the UCAN store (so disable can revoke it) and prints the stored state. --cap is the delegation's LIFETIME total in PAY (default 1000, about 2 000 reviews at 0.5 PAY; 1 PAY = 1 000 000 upay = $1): every review adds to it and nothing refunds it — enable again with a higher --cap replaces it, from 0 spent | an organisation pays for its chain reviews from its owner's credits | | api review-billing status --source <id> | the stored delegation (review-billing/read): state, payer, cap / spent / remaining in PAY, expiry, credits, price, paused collections, CID — or that there is none | how much is left | | api review-billing disable --source <id> [--yes] | DELETEs it on the source (review-billing/delete: this oracle stops charging it, its collections' reviews pause), then revokes it in the UCAN store (store/revoke, by CID) when your login signed it and the store holds the copy enable left. Otherwise it says the delegation stays valid until it expires — only this oracle could charge it — and that its signer revokes it in the Decision Console | stopping paying for reviews |

A source link or delete, a key revoke, every api admin change and a review-billing enable / disable print their plan and stop (exit 0) without --yes; under --json that dry run prints nothing on stdout.

Agents reach the same lane through the hosted MCP server at https://<oracle>/v1/mcp — claude mcp add --transport http ixo https://<oracle>/v1/mcp --header "Authorization: Bearer eo_live_…" (plus X-Payment-Delegation for an x402 source): submit_claim, get_decision, list_protocols, verify_receipt, under the key's own scopes and limits (API reference, "Use with Claude Code and other MCP clients").

Protocol authoring (the commissioner's side)

| Command | What it does | When you use it | | --- | --- | --- | | rubric fetch-form --protocol <did> --out <file> [--rub <file>] [--blocksync <url>] | downloads a protocol's anchored claim form (#vct), and its rubric with --rub | writing a rubric against a form already on chain | | rubric validate --rubric <file> --form <file> [--proof <value>] [--json] | validates a rubric against a form through the engine's own binder — the same check publish-protocol runs | every rubric edit, before anchoring | | publish-protocol --rubric <file> --form <file> [--entity <did>] [--phase entity\|resources\|profile\|room\|all] [--name <text> [--description <text>]] [--yes] | anchors a claim form and rubric as a protocol entity on chain. --name also anchors the protocol's #pro profile (the profile phase, delete+add when one differs), so the portal, the console and the Openhands app show its name instead of "Unnamed" | once per protocol; resumable after a half-finished run | | publish-protocol --phase rub --protocol <did> --rubric <file> --form <file> [--yes] | anchors — or replaces, delete+add in one tx — #rub ALONE on a protocol that already carries its #vct; a no-op when the same rubric id is already anchored | adopting someone else's protocol, or publishing a new version of your rules | | create-deed --protocol <did> --title "<name>" [--description <text>] [--type <type>] [--entity <did>] [--phase entity\|room\|claims\|all] [--yes] | creates the deed entity that hosts claims and links it to the protocol | before a collection — a collection's entity is a deed, never the protocol | | create-collection --entity <deed did> --protocol <did> --eval-amount <uixo> [--eval-denom <denom>] [--fund <amount>] [--evaluate <addr,…>] [--submit <addr,…>] [--max-custom-amount <amount><denom>] [--quota <n>] [--days <n>] [--phase collection\|fund\|grants\|all] [--yes] | creates the claim collection, sets its evaluation payment (--eval-amount 0 leaves that leg empty, as a per-review or 0-minimum oracle registers), funds its admin account and grants the Evaluate/Submit roles (the evaluate grant's MaxCustomAmount is written explicitly: 0<eval-denom> unless you pass more — claims that carry coins need at least the largest claim amount, or approvals fail) | standing up a collection, or hiring your own oracle to prove the loop | | submit-claim --collection <id> --answers <file> [--file <question>=<path> …] [--yes] | submits a claim as the logged-in wallet: media upload, claim-bot save, the chain message | putting a real claim through your deployed oracle |

Configuration

Two places, and the split is the rule: public config in wrangler.jsonc vars, secrets in .dev.vars locally and wrangler secret put <NAME> once deployed. Nothing secret ever goes in wrangler.jsonc.

wrangler.jsonc vars

| Var | What it is | | --- | --- | | IXO_NETWORK | devnet / testnet / mainnet — supplies every per-network URL below | | ORACLE_HOSTNAME | the host this oracle's did:web is served from: your Custom Domain in routes, or, with no custom domain, the Worker's free <worker>.<account>.workers.dev host | | ORACLE_ADDRESS, ORACLE_DID | the public chain identity (asserted against ORACLE_SECP_MNEMONIC at first use — a mismatch fails loud) | | ORACLE_MATRIX_USER_ID | the oracle bot's MXID, @did-ixo-<address>:<homeserver> | | ORACLE_ENTITY_DID | the on-chain oracle ENTITY from oracle init — required for a deployed oracle: unset on a non-local ORACLE_HOSTNAME, /health/ready fails its identity check (local dev is exempt). Unset, the Matrix review card also falls back to ORACLE_DID and reviews work only from the portal page | | AUTO_SIGN | "true" to sign MsgEvaluateClaim unattended — the policies.autoSign flag must be on too | | AI_CHECK_MODEL | the pinned model for AI checks (any provider) | | AI_PROVIDER | cloudflare (default — the Cloudflare AI Gateway through the ai binding) or openrouter (needs OPENROUTER_API_KEY) | | AI_GATEWAY_ID | the AI Gateway the cloudflare provider routes through; its per-call USD lands in the ai_calls table within ~5 minutes | | AI_AUDIO_VIDEO | optional, default off: audio/video attachments are sent to the model only when on — OpenRouter only (the worker refuses to start otherwise); off, they reach the model as a "content not sent" descriptor | | AI_IMAGE_DETAIL | optional: low / high / auto, default high — the detail images are sent to the model at. high is what lets it see a photographed screen or a generated image; it costs more image tokens per check | | AI_REASONING_EFFORT | optional: low / medium / high, default low — the judge's reasoning effort on every AI call. Higher thinks more: slower and more output tokens | | C2PA_IMAGE_CHECK | the image-provenance gate — a verified AI-generated manifest fails an AI check outright | | EVAL_ENGINE_HOSTNAME | optional override of the network's engine host (a local engine, a tunnel) | | EVALUATION_DENOM, EVALUATION_MIN_AMOUNT | the per-network evaluation fee: when set (both or neither) they replace pricing.evaluation at register and on /v1/info. Required on mainnet (the worker refuses to start without them) — real USDC there; the template sets a uixo stand-in on devnet/testnet | | EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME | the From address/name for pause, resume and review notices; unset ⇒ the email lane logs once and skips | | CLAIM_BOT_URL_OVERRIDE | a claim bot not at the derived host — usually set in .dev.vars for local harnesses only | | API_LANE, API_PAYMENTS | the Decision API lane (paid, off-chain claims at /v1/api) and its x402 gate — both "false" in the template; the lane is dormant until API_LANE is on, and with API_PAYMENTS off new submits answer 503 payments-disabled | | CHAIN_LANE | the chain lane (collections hired on chain) — on unless "false" (commented in the template). "false" makes an API-only oracle: register, scan and the OpenHands webhooks answer 409 chain_lane_off, the cron runs no chain job and nothing is signed on chain, so nobody can hire it on a collection of their own and spend your engine credits. Needs API_LANE on (the worker refuses to start with no lane); watched collections are kept and resume when it is back on | | API_PRICE_USD_CENTS, X402_FACILITATOR_URL, X402_NETWORK, EARNINGS_ADDRESS, EARNINGS_BASE_WALLET, BILLING_ENGINE_URL, CONSOLE_URL, ORACLE_ADMIN_DIDS | the Decision API lane's fallback price (pricing.api wins), payment services, console and the oracle's admins (ORACLE_ADMIN_DIDS, your own people) — unset (commented) until the lane is on | | STATUS_READER_DIDS | DIDs of any method (a monitoring service's did:web) allowed to read GET /v1/admin/status only — never an operator. Unset ⇒ nobody |

Plus the usual ENVIRONMENT, LOG_LEVEL, SERVICE_NAME, SERVICE_VERSION, CORS_ORIGINS and IXO_CLIENT_SEQUENCE_MIN_DELAY_MS.

Secrets

| Secret | Why | | --- | --- | | SERVICE_ED25519_MNEMONIC | the did:web key — signs every UCAN invocation this oracle mints and backs its did.json | | ORACLE_SECP_MNEMONIC | the chain wallet that signs MsgEvaluateClaim; must be the wallet behind ORACLE_ADDRESS | | OWNER_DELEGATION_CAR | the oracle owner's delegation to the engine, forwarded verbatim at register — the engine bills the OWNER's credits, never this oracle's wallet | | OPENROUTER_API_KEY | only with AI_PROVIDER=openrouter: AI checks and the duplicate lane's same-event judge, when a rubric asks for one | | UNIQUE_HMAC_KEY | the duplicate lane's body digest and the API lane's submitter digest — required with API_LANE. One per oracle, for life — rotating it orphans every registered digest | | ORACLE_MATRIX_ACCESS_TOKEN | the oracle bot account's token — written by oracle init into .dev.vars (wrangler secret put it for a deployed env). Read by the review-notification lane; unset ⇒ that lane logs and skips | | WEBHOOK_ENCRYPTION_KEY | the Decision API lane's key for webhook signing secrets at rest (endpoints' and each source's) — one per deployment, for life; required when API_LANE is on (the worker refuses to start without it) | | SENTRY_DSN | optional; unset ⇒ error reporting is a no-op | | OPS_ALERT_WEBHOOK_URL | optional; a Slack-compatible webhook for ops alerts (a paused collection or source, a dead webhook, a lost settlement, a failing cron job); unset ⇒ alerts reach Sentry only |

Per-network defaults

Set by IXO_NETWORK (and by the CLI's --network). A normal deployment sets no URL at all; set one only to override it.

| | devnet | testnet | mainnet | | --- | --- | --- | --- | | Decision engine | decision-engine.devnet.ixo.earth | decision-engine.testnet.ixo.earth | decision-engine.ixo.earth | | Blocksync | devnet-blocksync-graphql.ixo.earth/graphql | testnet-blocksync-graphql.ixo.earth/graphql | blocksync-graphql.ixo.earth/graphql | | Chain RPC | devnet.ixo.earth/rpc/ | testnet.ixo.earth/rpc/ | impacthub.ixo.world/rpc/ | | Matrix | devmx.ixo.earth | testmx.ixo.earth | mx.ixo.earth | | UCAN Store | devnet.store.ucan.ixo.earth | testnet.store.ucan.ixo.earth | store.ucan.ixo.earth | | VFS | devnet.vfs.ixo.earth | testnet.vfs.ixo.earth | vfs.ixo.earth | | IPFS gateway | ipfs.gateway.ixo.world | ipfs.gateway.ixo.world | ipfs.gateway.ixo.world | | Portal | dev.portal.qi.space | test.portal.qi.space | portal.qi.space | | AuthHub | dev.auth.ixo.earth | test.auth.ixo.earth | auth.ixo.earth |

src/index.ts — what the oracle IS

import { defineOracle } from "@ixo/decision-oracle";
import { facts } from "./facts";

const oracle = defineOracle({
  name: "my-oracle",
  description: "Evaluates claims against the collection's rubric via the IXO decision engine.",
  supportedProtocols: [],                            // protocol entity DIDs; [] ⇒ any
  pricing: { evaluation: { denom: "uixo", minAmount: "1000000" } },
  policies: { autoSign: true, allowAiChecks: true, allowImageChecks: true, allowStandingApprovals: false },
  facts,
});

export default oracle;
export const { ClaimIngest, ReviewComplete, SequenceManagerDO } = oracle;

defineOracle validates the config at module evaluation (a bad price or an empty name throws loudly) and hands back every export the Workers runtime needs — the fetch/scheduled handlers, both Workflow classes, and the chain sequence keeper.

| Setting | Meaning | | --- | --- | | supportedProtocols | protocol entity DIDs this oracle serves; [] means any. Enforced at register, and shown on GET /v1/info | | pricing.evaluation | the minimum on-chain evaluation payment a collection must carry, { denom, minAmount } in integer base units — replaced per network by the EVALUATION_DENOM / EVALUATION_MIN_AMOUNT vars when set (required on mainnet) | | pricing.byProtocol | optional { [protocolDid]: { denom, minAmount } } override of pricing.evaluation per protocol — minAmount: "0" lets that protocol's collections register with an EMPTY evaluation leg, for a protocol whose income isn't the evaluation split | | pricing.api | optional { amountMicroUsd, byProtocol? } — the Decision API lane's price per decision in micro-USD (1_200_000 = $1.20), per protocol, served publicly at GET /v1/api/protocols/:did and checked by the facilitator against every offer. Unset ⇒ the API_PRICE_USD_CENTS var; neither ⇒ price: null, nothing offered | | policies.autoSign | sign MsgEvaluateClaim unattended on a terminal verdict — the AUTO_SIGN var must also be on | | policies.allowAiChecks | let the facts lane call a model. Off while the work order declares AI checks ⇒ every ai fact freezes error and the rubric's onError decides | | policies.allowImageChecks | let it screen images (C2PA provenance) | | policies.allowStandingApprovals | let a signed-in AI app's own auto-approve limits pay for a claim without a per-claim approval (hosted MCP); off, AuthHub cannot set one here (403 standing_approvals_off) and every submit is approved per claim |

Keep supportedProtocols in lockstep with the linkedEntity set oracle init writes on chain — register enforces only the config side, so a drift between the two is invisible to both.

Deploy and get hired

wrangler d1 create my-oracle          # paste the id into wrangler.jsonc (once per env)
wrangler kv namespace create my-oracle-cache # …and this one (KV names are per account)
bun run db:migrate                    # the package's migrations → your LOCAL D1
bun run db:migrate:remote             # …and to the deployed one, BEFORE the first deploy
bun run deploy:devnet                 # or deploy:testnet / deploy:production

The bindings the template declares: D1 (DB — the ledgers), KV (CACHE_KV — caches only), two Workflows (CLAIM_WORKFLOW, REVIEW_WORKFLOW), a Durable Object (SEQUENCE_MANAGER, the chain sequence keeper), two rate limiters (API_RATE_LIMITER per IP, API_KEY_RATE_LIMITER per API key for the Decision API lane), email (EMAIL), and three cron triggers (every minute, every five minutes, hourly — keep all three) — plus an optional R2 bucket (API_UPLOADS, commented out: absent ⇒ the upload route answers 501), and an optional answer archive (ANSWERS_ARCHIVE, commented out: bound ⇒ every evaluated ask, answer and photo is kept in that private bucket, one <askId>-<title>/ folder per ask, shaped by defineOracle's archive hooks — guide, "Operating it"). Every binding and the whole vars block are redeclared per env — they inherit nothing from the top level.

db:migrate:remote applies the migrations that ship inside the package (migrations_dir points at node_modules/@ixo/decision-oracle/migrations, so you never write or generate one). It is a per-environment step: for a named env, run wrangler d1 migrations apply <name>-testnet --remote --env testnet, likewise --env production. A Worker deployed against an unmigrated D1 fails on its first claim.

ORACLE_HOSTNAME must be the host the env is served from: callers verify you by fetching https://<ORACLE_HOSTNAME>/.well-known/did.json, and the engine binds its records to that did:web. A custom domain is optional. Without one, delete that env's routes entry and set ORACLE_HOSTNAME to the Worker's free <worker name>.<your account subdomain>.workers.dev host (wrangler prints it on deploy; with no routes, wrangler serves workers.dev by default). With one, the Custom Domain in routes must equal ORACLE_HOSTNAME. Pick the host before oracle init: it IS the oracle's identity, so moving to a custom domain later means a new did:web.

Declaring yourself on chain

GET /v1/info only answers someone who already knows your host. Discovery that starts from the protocol — and a claim body you can actually pull — needs the oracle to exist on chain:

bunx decision-oracle oracle init --protocol <protocol did> --kit-url https://<your host> \
  [--logo <url|file>] [--only <step>] [--relogin] [--network <n>] [--yes]

A full on-chain identity is mandatory for every oracle built with this kit: the entity, its #pro profile, its #dmn domain card and its #acard Agent Card. --logo takes an https URL or a local .png/.jpg/.webp/.svg file (up to 5 MB) (stored on the IXO Vault) — always pass one: without it the portal, the console and the Openhands app show a placeholder.

What oracle init does. Thirteen steps, in order. Each one reads live state and prints do or skip with the reason — so a dry run (no --yes) is exactly the plan that --yes would execute, and re-running after a partial failure, a redeploy or a second provider's attempt converges on the same state. Two signers: steps 2–4 are the oracle's own wallet (only its key may write its own IID document or prove its address to the rooms bot); everything else is your AuthHub session, which owns the entity it creates.

| Step | Signer | What | Skipped when | | --- | --- | --- | --- | | fund | session | MsgSend topping ORACLE_ADDRESS up to 1 IXO (1000000uixo), which pays for every MsgEvaluateClaim | the wallet's spendable balance is already ≥ 1 IXO | | iid | oracle wallet | MsgCreateIidDocument for did:ixo:<ORACLE_ADDRESS>, with a #matrix service pointing at the network's homeserver | that DID already resolves | | key | oracle wallet | MsgAddVerification adding the SERVICE_ED25519_MNEMONIC public key (authentication + assertionMethod) — what makes a UCAN minted as that DID verifiable to the claim bot and the UCAN Store | a verification method for that exact key is already on the document | | matrix | oracle wallet (it signs the rooms bot's challenge) | creates the oracle's Matrix account if absent, logs in, and writes ORACLE_MATRIX_ACCESS_TOKEN into .dev.vars — never printed | .dev.vars holds a token whose whoami is ORACLE_MATRIX_USER_ID; --relogin forces a new one | | entity | session | MsgCreateEntity — the oracle entity, carrying both services and the protocol edge in one transaction | THIS oracle already has one: ORACLE_ENTITY_DID when set — read from env.<--network>.vars, else the top-level vars, .dev.vars winning — and checked to be an oracle entity the session owns AND bound to this oracle; else the session's oracle entity whose #orz grants ORACLE_ADDRESS, else whose #eval is --kit-url. Another oracle's entity is never picked up or written to; two matches, or an #orz that cannot be read, stop the run. A created entity's DID is written into .dev.vars at once, so a re-run never mints a second | | orz | session | the #orz oracleAuthZConfig resource: the portal's entity → ORACLE_ADDRESS bridge | #orz is on the entity and not on Matrix media (one still there is moved to the Vault, same document) | | profile | session | the #pro Settings resource, built from the running oracle's GET <kit-url>/v1/info (plus --logo) | #pro is on the entity — unless --logo differs from its logo, which replaces it as a merge (the portal's avatar), or it is still on Matrix media (moved to the Vault, same profile) — or the kit URL is not reachable: a skip, never a failure | | domain | session | the #dmn domainCard resource — the portal's ixo:DomainCard credential (name, description, url = --kit-url, logo/image only with --logo), hosted like #pro and #acard: stored on the IXO Vault under your session's key (else the IXO IPFS gateway), its public …/public/cid/<cid> URL as serviceEndpoint, that bafkrei… CID as proof; a card whose kit-owned fields drifted is deleted and re-added in ONE transaction, merged onto the live one (your own logo, images, keywords survive; without --logo the live logo stays) | every field the kit owns (name, description, url, and the logo when --logo is given) already matches AND the proof is the CID of the served bytes AND it is not on Matrix media, or the kit URL is not reachable, or the live card cannot be read (a ⚠, never a replace) | | acard | session | the #acard agentCard resource — an ixo:AgentCard credential (services: claim-evaluation when the kit publishes evaluation pricing, decision-api when its Decision API publishes a price for --protocol — each at the oracle's effective price, byProtocol and API_PRICE_USD_CENTS included; check-openhands with --openhands; your own services survive a replace), validated against the engine's Agent Card schema before anything is uploaded; replaced like #dmn | same as domain | | linked | session | the oracle → protocol linkedEntity edge | the edge is already on the entity | | service | session | #eval → --kit-url; an existing one pointing somewhere else is deleted and re-added in ONE transaction | #eval already equals --kit-url | | backlink | session (must own the protocol) | the protocol's edge back to the oracle, which is what the marketplace filters on | the edge is there, or the session does not own the protocol — it prints the exact edge to ask its owner for | | index | — | tells the network's domain indexer the entity exists — last, so it reads the #dmn just anchored | on --yes only (a dry run asks the indexer nothing): its GET /domain-cards/<did> serves that #dmn (blockchain_version_id = the resource's proof) with status: completed; a refusal or an unreachable indexer is a ⚠ line, not a failure |

Run it once against http://localhost:8787 while bun run dev is up, and again with the deployed host afterwards — the second run re-points #eval, fills in the #pro it had to skip and re-anchors both cards (they name the host). Put the entity DID it prints into ORACLE_ENTITY_DID (a wrangler.jsonc var) and redeploy: a deployed oracle is not ready without it (/health/ready fails its identity check), and unset, the Matrix review card cannot resolve your API and a review can only be answered from the portal's claim page.

The enrollment gates

POST /v1/collections/register is refused unless, in order (and outright, 409 chain_lane_off, on an oracle whose CHAIN_LANE is "false"):

| Gate | Check | Else | | --- | --- | --- | | who | the caller is the collection entity's chain owner, or holds an active on-chain EvaluateClaimAuthorization for it | 403 | | hired | this oracle's ORACLE_ADDRESS holds that grant too — without it MsgEvaluateClaim can never be signed | 409 | | protocol | if supportedProtocols is non-empty, the collection's protocol DID must be in it | 409 | | paid | the collection's on-chain Payments.evaluation is in the required denom and ≥ the required minAmount — pricing.byProtocol[collection's protocol], else the EVALUATION_DENOM / EVALUATION_MIN_AMOUNT vars, else pricing.evaluation | 402 |

The 402 is the business rule: the chain splits that payment on every MsgEvaluateClaim, so it is the oracle's only income — a collection paying less would be evaluated for free. The one exception is a zero fee (minAmount: "0", typically set per protocol via pricing.byProtocol): an empty, missing, or wrong-denom evaluation leg is then accepted AS zero rather than refused, for a protocol whose income isn't the evaluation split. The protocol gate runs before payment, so a wrong-protocol collection is refused outright rather than sent off to fix a payment that would never have mattered. Re-registering re-runs the gates and resumes a paused collection.

How a commissioner hires you

They grant your ORACLE_ADDRESS an EvaluateClaimAuthorization on their collection (the IXO portal's Grant Role flow) and register that collection with your oracle — the portal makes that call, and bunx decision-oracle register --collection <id> is the same two calls from a terminal. Registration also carries the owner→oracle Matrix delegation (delegation --for bot, passed as --delegation <car>): without it your oracle holds the grant but still cannot pull claim bodies out of Matrix (a body on the VFS or IPFS needs only the grant).

To prove the loop end to end yourself, hire yourself: create-deed → create-collection --evaluate <your ORACLE_ADDRESS> → register → submit-claim.

The HTTP API

Everything under /v1 requires a UCAN invocation (X-Auth-Type: ucan + Bearer) addressed to did:web:<ORACLE_HOSTNAME> — that proves identity only. "Read gate" = the collection's current chain owner, or any holder of a live evaluate grant on it (the enroller only while it still holds one).

| Route | Auth | Purpose | | --- | --- | --- | | GET /health | public | liveness | | GET /health/ready | public, rate-limited | readiness: { ok }, 503 when D1, KV, the engine (or, with API_LANE + API_PAYMENTS, the facilitator) is down, or a deployed oracle has no ORACLE_ENTITY_DID | | GET /.well-known/did.json | public | this oracle's did:web document | | GET /v1/info | public | the marketplace listing: name, pricing, policies, supported protocols, the `lane