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
- Resolve.
agent: ns/name[@range]picks the highest listed version in range.capability: classranks every implementer under the caller's mandate (by cost per successful run) and picks the best one the mandate allows. - Admit.
checkCall(mandate, manifest)returns allow, approve or deny (mandate §3).approvecreates 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. - Check input. The input must validate against
interface.input. Otherwise the call isrejected_inputand nothing is reserved or billed. - 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.
- 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 agentctx.call. - Check output. The output must validate against
interface.output. Otherwise the call fails withinvalid_output. - 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_costinstead. 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_budgetby convention); - for an orchestrator, delegated calls get whatever the cap leaves after the agent's own price;
- an approval is bound to the
max_costit was requested with; - a
per_runprice is fixed: amax_costbelow 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 |
|---|---|
issuer → grantee |
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/githubsends the browser to GitHub with a randomstate, also set in anHttpOnly,SameSite=Laxcookie; 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 withPOST /v0/auth/exchangefor 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
idis in the author's namespace (<handle>/<name>); - the author's account is linked to GitHub, and
source.repositoryis 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.kindismcp: published agents run on their author's server, following the MCP agent profile (section 6);runtime.endpointishttps, 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 withnosniffand 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.