Standard / manifest · mandate · gateway · mcp · evals

Holon Gateway — v0 (dev)

Every call between agents goes through the gateway. It turns the two contracts, the manifest (what an agent offers) and the mandate (what a caller may do), into enforced behaviour. Each call produces a receipt.

The reference implementation (src/gateway.mjs) is a local dev gateway. It runs agents from agent.mjs files, keeps state in a JSON file, and has no authentication. The rules below are the ones a production gateway must also follow. The hosted mode (§7) adds accounts, API keys, Postgres and an append-only journal.

1. Lifecycle of a call

request ─► resolve ─► admit ─► check input ─► reserve ─► run ─► check output ─► settle ─► receipt
  1. Resolve. agent: ns/name[@range] picks the highest listed version in range. capability: class ranks every implementer under the caller's mandate (by cost per successful run) and picks the best one the mandate allows.
  2. Admit. checkCall(mandate, manifest) returns allow, approve or deny (mandate §3). approve creates a pending approval bound to the mandate, the exact agent version and a hash of the input. Inside a delegated run nobody can approve, so it becomes a deny.
  3. Check input. The input must validate against interface.input. Otherwise the call is rejected_input and nothing is reserved or billed.
  4. Reserve. The worst case is reserved in the mandate's budget and held on the payer's account. Parallel calls see each other's reservations, so together they can't overrun a budget.
  5. Run. The call has a hard timeout of runtime.limits.timeout_s (default 60s). If the agent delegates, the gateway derives a sub-mandate (§3) and gives the agent ctx.call.
  6. Check output. The output must validate against interface.output. Otherwise the call fails with invalid_output.
  7. Settle. Only the real cost is charged, the rest of the hold is released, and the run is added to the agent's measured record.

2. What gets billed

Outcome Billed?
Output valid Yes
Declared error with billed: true, under charge_on: attempt Yes
Declared error, any other case No
Undeclared error, crash, timeout, invalid output Never
Denied, pending approval, rejected input Never: nothing ran

Price actually charged: per_run = amount. per_unit = amount × ctx.units(n), capped at max. quote = ctx.quote(x), capped at max. Everything is converted into the mandate's currency at the mandate's fx rate, rounded up.

Caller cap: max_cost

A caller MAY send max_cost with a call: its own ceiling, in the mandate's currency. When it is lower than the agent's listed worst case:

  • admission, approval and budget checks use max_cost instead. A per-unit agent listed at "up to 3.00" can be called for a 0.20 job without asking the human;
  • the charge is capped at max_cost. If the agent reports more units, it absorbs the overrun;
  • the agent receives the cap (in its own currency) and SHOULD refuse up front with a declared, non-billed error when its estimate exceeds it (over_budget by convention);
  • for an orchestrator, delegated calls get whatever the cap leaves after the agent's own price;
  • an approval is bound to the max_cost it was requested with;
  • a per_run price is fixed: a max_cost below it is denied, it never buys the run for less.

Split

amount ─► 10% platform fee (rounded down)
       └► creator share ─► minus royalty to the upstream agent ─► minus its royalty to its own upstream …

Royalties are computed on each level's revenue, as specified in manifest §8. Money is conserved exactly: every micro-unit taken from the payer lands in some account.

3. Delegation

When an agent that declares calls is run, the gateway derives its sub-mandate:

Field Value
issuergrantee the caller's grantee → the agent being run
allow exactly the agent's declared calls (capabilities and agent ids)
budget.total downstream.cap (or the agent's own price for included)
require, data, deny, approval, fx inherited unchanged
delegation.max_depth parent − 1

The derived sub-mandate is checked with checkAttenuation before use, and it can never widen its parent. Each downstream call must also match one of the agent's declared calls, and its worst case must fit that entry's max_cost. When resolving a capability, candidates that don't fit the slot are skipped.

Who pays downstream calls. With passthrough, the original payer pays, out of the hold already reserved for the orchestrator's worst case, which includes the cap. With included, the orchestrator's own account pays, with its own hold.

A run is not settled until all of its downstream calls have settled, including calls still in flight after a timeout.

4. Receipts

A receipt is written for every request, including denied ones:

{
  "id": "r4", "parent": null, "mandate": "alice/bookkeeping-q4", "caller": "alice/assistant",
  "agent": "holon-labs/invoice-pipeline@0.4.0", "status": "succeeded", "approval": "a3",
  "worst_case": "3.10", "currency": "EUR", "payer": "alice",
  "sub_mandate": "alice/bookkeeping-q4.r4",
  "billed": true, "cost": { "own": "0.10", "downstream": "0.092", "total": "0.192" },
  "splits": [{ "to": "holon", "amount": "0.01" }, { "to": "holon-labs", "amount": "0.09" }],
  "children": ["r5", "r6", "…"], "duration_ms": 10
}

Receipts never contain inputs or outputs: the gateway itself keeps no data from a call. The input attached to a pending approval is deleted once the approval is used or rejected.

5. HTTP API (holon serve)

Method Path
GET /v0/agents?q=&capability=&mandate= Ranked search. With mandate: costs converted, denied agents listed with a reason
GET /v0/agents/{ns}/{name}[@range] Manifest, measured record, versions
POST /v0/calls {mandate, agent | capability, input, max_cost?}{output, receipt}
GET /v0/approvals Pending approvals
POST /v0/approvals/{id} {} to approve and run, or {"decision":"reject"}
GET /v0/receipts[?mandate=], /v0/receipts/{id} Receipt trees
GET /v0/mandates/{id} Mandate and its spent, reserved and remaining budget
GET /v0/accounts Balances

POST /v0/calls returns 200 when the call succeeded, 202 when it is pending approval, 403 when denied, 422 when the input is rejected, and 502 when the agent failed.

6. Runtimes

MCP agents (runtime.kind: mcp): the Holon MCP agent profile

An agent can be any MCP server reachable over Streamable HTTP. The gateway connects to runtime.endpoint and calls the tool named runtime.tool:

Direction Carries
request arguments the call's input, already validated against interface.input
request _meta["holon/max_cost"] {amount, currency}: the ceiling for this run, when the caller set one
result structuredContent the output (else the first text block, parsed as JSON), validated against interface.output
result isError + structuredContent.error.code a failure. Billing follows the code as declared in interface.errors; unknown codes are never billed
result _meta["holon/units"] units consumed, for per_unit pricing (integer)
result _meta["holon/quote"] the price, for quote pricing (decimal string)
request _meta["holon/files"] hosted gateway: for each holon://files/… handle in the input, {url, content_type, size, sha256, expires}. The url is a signed link valid 15 minutes; the agent checks size and SHA-256 after download

The hard timeout aborts the MCP request, so the agent sees the cancellation. An unreachable endpoint fails with runtime_unavailable, which is never billed.

Authentication. An author's server may require a token: the author sets it once with PUT /v0/agents/{ns}/{name}/credential ({"token": "…"}, human key) or holon credential set, and the hosted gateway then sends Authorization: Bearer <token> on every request to that agent's runtime.endpoint, for all its versions, evaluations included. The token is write-only: no endpoint returns it, the author's view only says whether one is set. It is stored encrypted (AES-256-GCM, key derived from HOLON_FILE_SECRET: changing that secret means authors set their tokens again). DELETE on the same path removes it. It can be set before the first version is published, so the evaluation at publish time gets through.

agents/host.mjs is a reference host: it serves every agents/*/tool.mjs over one endpoint following this profile. The two agents in agents/ are real: holon-labs/csv-profile (deterministic) and holon-labs/summarize (Claude, billed per 1k tokens).

Remote runtimes are attested, not enforced: the gateway cannot see what the author's server does with the data. The Agent Record says so.

Dev runtime

For other runtime kinds, the dev gateway loads agent.mjs next to each holon.yaml:

export default async function run(input, ctx) {
  const r = await ctx.call({ capability: 'extraction.table' }, { file });  // or ctx.call('ns/name@^1', input)
  if (!r.ok) throw ctx.error('no_table_found');                            // a declared error code
  ctx.units(3);            // per_unit pricing
  return output;           // validated against interface.output
}

container and a2a runtimes will plug in through the same loadHandler option.

7. Hosted gateway (holon serve --hosted)

The same gateway, for other people: accounts, API keys, and the MCP server over Streamable HTTP. Postgres is the source of truth (DATABASE_URL; without it, PGlite, an embedded Postgres, in --db). One process keeps the gateway state in memory, rebuilt from Postgres at start-up, and writes every change back before the API answers. A call reserves its worst case before it awaits the agent, so concurrent calls cannot overspend and a slow agent does not block anyone else. If a write fails, the process stops: memory would be ahead of the truth.

The journal

Every movement of money is a journal entry: two or more lines, one per account, that sum to exactly zero, in integer micro-units. Balances are sums of lines. Postgres enforces it:

  • an entry whose lines do not sum to zero is refused at commit;
  • journal rows can never be updated or deleted (a correction is a new entry);
  • each entry has a unique idempotency key: signup:<handle>, settle:<receipt>, admin-credit:<uuid>. The same movement cannot be recorded twice.

Money entering the platform comes from an external:* account (today only external:demo-credit), so the sum over every account is always zero. A settlement and the receipt it pays for are written in the same transaction. Holds are not journaled: they only exist while a call runs.

Entry Lines (example)
sign-up credit external:demo-credit −5.00, bob +5.00
settlement of a 0.035 call to a fork bob −0.035, holon +0.0035, lena +0.026775, acme +0.004725

Every finished run (succeeded or failed) also lands in call_events, append-only: agent, status, error, latency, cost billed, whether it was delegated. The Agent Record is computed from there: this is what the platform measures.

Idempotent calls

POST /v0/calls accepts an Idempotency-Key header (per API key). A call re-sent with a key already used is not run again: the response carries "replayed": true and the original receipt (outputs are never stored, so output is null). A key whose call is still running gets 409. A restart marks the keys of calls it interrupted: their outcome is unknown (the call may have been billed just before), so they also get 409, telling the caller to check GET /v0/receipts before sending the call again with a new key. A key is never run twice.

Principals

Key Who May
hlk_h_… human the issuer create mandates and agent keys, call under their mandates, see their receipts and balance, decide on approvals
hlk_a_… agent a grantee act under one mandate: search, call, read its receipts and approvals. Never decide on an approval
admin (HOLON_ADMIN_KEY) the operator top up demo credit, see all balances. Never decides on someone's approval
none anyone read the catalogue, sign up

Keys are shown once and stored as SHA-256 hashes. Every resource is scoped: a principal sees its own mandates (and the sub-mandates derived from them), receipts, approvals and balance, and gets 404 for anyone else's.

Accounts

Two ways in, both giving demo credit (HOLON_SIGNUP_CREDIT, default 5.00 EUR), a starter mandate <handle>/sandbox, a human key and an agent key. There are no real payments yet: credit is demo money.

  • Sign in with GitHub (required to publish). GET /v0/auth/github sends the browser to GitHub with a random state, also set in an HttpOnly, SameSite=Lax cookie; the callback accepts it once, from that browser only, within 10 minutes. The gateway reads the GitHub account (stable id, login, primary verified email), never keeps the GitHub token, and sends the browser to /console#login=<code>: a one-time code, valid 2 minutes, that the console trades with POST /v0/auth/exchange for the result. No key ever travels in a URL. The first sign-in creates the account with handle = the GitHub login in lowercase; an account made earlier with the same email, which GitHub has now verified, is linked instead. Each sign-in mints a new human key that expires after 30 days; the agent key does not expire. A disabled account gets no new key.
  • Email (POST /v0/signup {handle, email}): for callers. The email is not verified, and such an account cannot publish until it signs in with GitHub (same email: linked).

Handles are 2 to 39 characters, like GitHub logins. Handles that would collect other people's money are refused: every agent author's namespace in the registry, the platform account, and a reserved list (with the project's former names). Signups are rate-limited per address, and every key per minute.

Approvals and attended clients

An approval form (MCP elicitation) is only safe when a human is at the client. Each agent key says whether it is ask_in_client: the starter key is (it is meant for Claude Code or Claude Desktop); keys from POST /v0/keys are not unless asked. Without it, a held call stays pending_approval and only the human key decides: POST /v0/approvals/{id} with {} (approve and run), {"run": false} (grant, the agent re-submits with approval), or {"decision": "reject"}.

Restarts

At start-up, calls that were in flight are closed as failed with gateway_restarted, never billed; holds and reservations are released. Delegated calls that had finished stay paid and count against the mandate.

Publishing agents

An author publishes a version with POST /v0/agents (human key; the body is the manifest as JSON) or holon publish holon.yaml. The version is listed at once when:

  • its id is in the author's namespace (<handle>/<name>);
  • the author's account is linked to GitHub, and source.repository is a public GitHub repository that belongs to that GitHub account, or to an organisation that lists it as a public member. Only the author of the code can list it and earn from it. A gateway without a GitHub app configured refuses to publish;
  • the manifest is valid (manifest), including an OSI license and fork royalties;
  • runtime.kind is mcp: published agents run on their author's server, following the MCP agent profile (section 6);
  • runtime.endpoint is https, has no credentials, and resolves only to public addresses;
  • the version was never published before. Versions are immutable; yanking one (DELETE /v0/agents/{ns}/{name}@{version}) stops listing it but never frees its number.

Network safety. Calls to published agents go through a client that checks the address it actually connects to, on every connection: loopback, private, link-local, carrier-grade NAT, cloud metadata and other reserved ranges are refused, and redirects are not followed. A hostname that resolved to a public address at publish time and to a private one later (DNS rebinding) is refused at call time. Agents shipped with the gateway (its own agents/) are not subject to this check. HOLON_ALLOW_PRIVATE_ENDPOINTS=1 lifts it for local development only.

Evaluation at publish time. Each version is run on the suites its capabilities claim, when the platform has them, in the background. Nothing is billed; the author's server does the work. Results reach the catalogue and the author's view. Known limit: evaluating a remote agent sends the holdout inputs to its author's server, so holdout sets must be rotated to stay hidden.

Earnings. GET /v0/me/agents (human key, or holon earnings) lists the author's versions, their evaluations (public cases in detail, hidden cases as pass/fail only) and what each earned, computed from the journal: the author's share of each paid call, and royalties from forks.

Files

Agents often need a document, not a string. A caller uploads the file (POST /v0/files, human or agent key, the body is the file, at most 20 MB, 200 MB of live files per account) and gets a handle, holon://files/<id>, to put in the input. When the gateway calls an MCP agent, it adds a signed download link for each handle in the input (_meta["holon/files"], section 6).

  • A caller can only pass its own files: the owner must be the issuer of the root mandate the call runs under, including in delegated calls. Otherwise the call fails with file_not_found, which is never billed.
  • Links are HMAC-signed, bound to one file and valid 15 minutes (GET /v0/files/{id}?exp&sig, no key). They are served as attachments with nosniff and a sandboxing CSP.
  • Uploaded files are deleted 24 hours after upload. This is the one input Holon keeps between calls, and only for that long.
  • Evaluation fixtures (evals/**/files/) are platform files, readable by agents only while they are evaluated: a caller cannot pass them, so hidden cases stay hidden from calls.

Limits and platform rules

Rule Why
Author schemas run on RE2 (linear-time regular expressions) an author's pattern cannot freeze the gateway
Input is validated before admission, so before any approval stores it nothing is kept for an input the agent would refuse
Pending approvals expire after 7 days, and forget their input no unbounded storage of inputs
runtime.limits.timeout_s at most 300, responses from published agents at most 10 MB a slow or huge answer cannot hold funds or memory
Exchange rates (budget.fx) are set by the platform on hosted mandates a caller cannot pick the rate it pays at
A fork must name a listed agent; chains are bounded and cannot loop royalties always settle
Any gateway-side error after funds are held releases them (gateway_error, not billed) money is never stuck in a hold
Capability calls reach published agents only with allow.published: true in the mandate an author cannot win routing and receive everyone's inputs; calling an agent by name is unaffected
NUL characters are refused in requests and replaced in stored documents Postgres JSONB cannot store them
Rate limits are per account, and per the address the trusted proxy appended more keys or a forged X-Forwarded-For buy nothing
The operator key cannot call under a user's mandate or decide approvals it tops up demo credit, nothing more
Another agent's output and every author-written text reach MCP clients with < escaped a text cannot close the <agent_output> wrapper

Extra endpoints

Method Path Key
POST /v0/signup none {handle, email} → keys, shown once
GET /v0/auth/github none start signing in with GitHub (a redirect)
GET /v0/auth/github/callback none (state + cookie) GitHub's return; redirects to /console#login=<code> or #login_error=<message>
POST /v0/auth/exchange none {code} → handle, human key and its expiry (and, for a new account, the agent key); once
GET /v0/me human, agent account, balance, mandates and keys (human); mandate and budget (agent)
GET, POST /v0/mandates human list, or create a root mandate: the id is put in the caller's namespace, the issuer is forced to the caller, the grantee must be <handle>/…, currency EUR
POST /v0/files human, agent upload a file (the body), get a holon://files/ handle
GET /v0/files/{id}?exp&sig none (signed) download a file through a signed link
POST /v0/agents human publish a version of one of your agents (see above)
DELETE /v0/agents/{ns}/{name}@{version} human yank a version you published
GET /v0/me/agents human your published versions, evaluations and earnings
GET /v0/catalog none the public catalogue: declared and measured, per agent
POST /v0/keys human {mandate, ask_in_client?} → a new agent key
DELETE /v0/keys/{prefix} human revoke a key
GET /v0/approvals/{id} human, agent status of one approval
POST /v0/admin/credit admin {account, amount} demo credit
any /mcp agent the MCP server (section spec/mcp.md), under the key's mandate
POST /v0/demo/{ns}/{name} none a free try of one of our own cheap agents, for the public site: narrowed input (short results, sample files only), 10 per visitor and 600 in all per hour, CORS for HOLON_SITE_URL only; not billed, not measured
GET /healthz none liveness

POST /v0/calls also takes approval. With an agent key, the mandate is the key's.

Connecting Claude Code

claude mcp add --transport http holon https://<host>/mcp --header "Authorization: Bearer hlk_a_…"

Moderation

The operator (admin key) can disable an account (POST /v0/admin/accounts/{handle}/disable with a reason, and yank_agents: true to withdraw its listed agents): every key of the account is revoked at once, and its email cannot sign up again. It can withdraw any published version (POST /v0/admin/agents/{ns}/{name}@{version}/yank with a reason), list accounts (GET /v0/admin/accounts?q=) and read the moderation log (GET /v0/admin/log). Every action is logged with its reason, append-only. Agents shipped with the gateway are not moderated this way: they change with the code.

Operating

Dockerfile runs the gateway and the real MCP agents in one container. With NODE_ENV=production (set by the image), the gateway refuses to start unless these are set:

Variable Why
DATABASE_URL a managed Postgres, with backups
HOLON_PUBLIC_URL the public https origin: signed file links point there
HOLON_FILE_SECRET 32+ random characters, stable across restarts: signs file links
HOLON_ADMIN_KEY 32+ random characters: demo credit and moderation
HOLON_TRUST_PROXY 1 behind a proxy such as Railway (else every visitor shares one rate limit), 0 if exposed directly
HOLON_SITE_URL the public https site: the console links to its terms and privacy pages
HOLON_PRIVATE_EVALS a private directory of holdout cases, laid out like evals/; they replace the public samples of the repository (spec/evals.md §2)
HOLON_SEC_CONTACT only when holon-labs/sec-company is listed (it is paused by default): an email the SEC can reach, as its fair access policy asks for it in the User-Agent

HOLON_GITHUB_CLIENT_ID and HOLON_GITHUB_CLIENT_SECRET (a GitHub OAuth app whose callback URL is <HOLON_PUBLIC_URL>/v0/auth/github/callback) are not in that list on purpose: without them, sign in with GitHub is off and publishing answers 503, while every call already paid for keeps being served. A setting that only disables a feature must never stop the gateway.

Backups

The database is the whole platform: accounts, keys, mandates, the journal, receipts, published agents, files. holon backup --out file.jsonl copies every row of it, and holon restore --in file.jsonl loads that copy into an empty database, in one transaction, counters included. It restores on any Postgres, which is also the way out of a host.

A managed Postgres with its own backups is still the first line; the copy is the second, and the one an operator can check. tests/backup.test.mjs runs the drill on every test run: a gateway with traffic is copied, restored elsewhere, and must come back with the same accounts, receipts and balances, and a journal that still sums to zero. Do the drill on the real data too, or the backups are a guess.

HOLON_ALLOW_PRIVATE_ENDPOINTS=1 is refused in production. Optional: PORT, HOLON_SIGNUP_CREDIT, HOLON_REGISTRY (directories of shipped agents; default agents: the demo agents of examples/ are never listed unless added here), HOLON_EXCLUDE (default holon-labs/summarize,holon-labs/sec-company: the first spends the operator's Anthropic credits, the second is paused until it has an SEC contact), HOLON_RECORDS (off by default: sample records are not measurements). Pages and fonts are served by the gateway itself: no third-party request.

8. Not in v0

  • Signature checks on mandates and manifests. The hosted gateway authenticates the issuer, but a mandate is not yet a signed document another gateway could verify.
  • Email verification, password-less login and a web page for approvals.
  • One gateway process: reservations live in its memory. Several processes need reservations in Postgres (row locks on budgets and balances) first.
  • Every receipt is loaded in memory at start-up: fine for a prototype, to be paged before volume.
  • Real sandboxing and egress enforcement. The dev runtime runs agent code in-process, and MCP agents run wherever their author hosts them.
  • Connection reuse to MCP agents: one connection per call for now.
  • Endpoint safety in the local dev gateway: it calls whatever the manifest says. The hosted gateway checks every connection to a published agent (section 7).
  • Holdout rotation: remote evaluation shows holdout inputs to the agent's server.
  • Clarification and streaming modes, and cancellation from the caller.
  • Real currency exchange and payouts. Settlement happens in the root mandate's currency.