05 · Surface
The agent is a user, not a client library.
Every capability a human can reach, an agent reaches through the same functions, with the same authorisation, and with failures that are values rather than sentences to parse. Nothing throws. Error types are per-operation and never one global union, because a caller cannot recover from what it cannot distinguish.
// a refusal is a value with a shape, and the shape is the same everywhere
{
"ok": false,
"error": {
"code": "BLOCKING_QUESTIONS_OPEN",
"reason": "7 blocking question(s) must be answered before this ontology can run",
"detail": { "slots": ["action.add_to_design.count", "action.add_to_landing.count", /* … */] }
}
}
That is what activate returns when the human has not answered. The detail names the exact slots, so the bot can ask again for the one thing it lacks rather than restarting the conversation. These are the codes the core, the accept gate and the conversation layer can return:
SOURCE_UNREADABLE
SOURCE_EMPTY
UNSUPPORTED_SOURCE
BLOCKING_QUESTIONS_OPEN
NO_TRANSITION
NO_INVARIANTS
NOT_ACCEPTED
POLICY_THREW
POLICY_EMPTY
EMPTY_TRAJECTORY
OBJECTIVE_THREW
UNANSWERED_BLOCKING
UNKNOWN_QUESTION
EMPTY_REPLY
Two of them are the gate itself. NOT_ACCEPTED is returned by the only supported way to read the world out of an ontology, and the brand is checked there at runtime rather than only by the type system — a compile-time-only brand is documentation, and as any, plain JavaScript, or a value crossing a deserialisation boundary all walk straight past it. NO_INVARIANTS reads, in full:
"a domain with no invariants has no verifier, and an unverifiable
simulation is the thing this system exists to refuse"
The hub and the tool surface add their own codes in the same shape: a stable code, a reason a human can read, and a detail a machine can branch on. There is no second error vocabulary for the programmatic path, because there is no second path.
The same capabilities, three ways in
# at a terminal — a typed refusal exits 2, an unexpected throw exits 1,
# and a script can branch on the code rather than on a message
$ bun run src/cli.ts accept
{"code":"MISSING_FLAG","reason":"accept needs --proposal",
"detail":{"command":"accept","flag":"proposal","usage":"parallax accept --proposal <id> …"}}
exit 2
# over HTTP — same values, same codes, no translation layer
$ PORT=8791 bun run src/hub/serve.ts
parallax hub 0.1.0 listening on http://0.0.0.0:8791 (health: /health)
$ curl -s localhost:8791/health
{"ok":true,"version":"0.1.0","commit":"local","uptimeSeconds":2}
# deployed, the same route reports the commit it is running --
# `version` is a source constant and a deploy API reports intent, so the
# commit is the only field on this response a stale image cannot fake
$ curl -s https://parallax-hub.onrender.com/health
{"ok":true,"version":"0.1.0","commit":"d5516b00f7f6a5cf49e60249b3874eea23f9c279","uptimeSeconds":41}
$ curl -s -X POST localhost:8791/api/ontology/accept \
-d '{"proposalId":"nope","answers":{},"acceptedBy":"you"}'
{"code":"UNKNOWN_PROPOSAL",
"reason":"this hub has no proposal with that id; propose again and accept the proposal you get back",
"detail":{"proposalId":"nope"}}
The hub answers /health, /api/ontology/propose, /api/ontology/accept, /api/run, /api/whatsapp/turn and /r/:id, and serves this page from the same process. The refusal body above also repeats the same object under an error key. The CLI's subcommands are propose, accept, run, receipt, status and reject, over the same handler functions the tool surface calls.
The one place the surfaces deliberately diverge is --root. An arbitrary absolute root is safe at a terminal, because the person typing the path is the confinement. It is absent from every tool schema, because inside a sandboxed session a derived path is denied and a denied read comes back as an empty directory rather than an error — so a wrong path would look like an empty workspace. Same capability, different confinement, and the difference is stated rather than assumed.