Architecture
Two options, npx and the SDK, converging on one gate file, one contract format and one ledger shape.
Architecture
There is one gate. gate/coco_gate.py reads a payload, evaluates the contracts
that govern the call, writes a receipt and answers. Everything else in the
repository is either a way of getting a payload to that file or a way of reading
what it wrote.
Two options reach it, and they exist because agents live in two kinds of place.
npx SDK
─── ───
Claude Code your harness
│ tool call │ gate.check(...)
▼ ▼
PreToolUse hook coco_gate.py / coco-gate.mjs
│ payload on stdin │ POST /check
│ ▼
│ serve.py
│ │ payload on stdin
└──────────────┬──────────────────────┘
▼
gate/coco_gate.py the core · no model · no network
│
┌─────────────┼──────────────┐
▼ ▼ ▼
contracts live state ledger.db
compiled/ case, session, hash-chained
platform receipts
│
▼
ALLOW · BLOCK · ESCALATEThe convergence is the design, not a coincidence of reuse. serve.py does not
reimplement a verdict. It runs gate/coco_gate.py as a subprocess with the payload
on stdin, exactly as Claude Code would, and translates the answer back.
A checker that behaves differently over HTTP than it does on disk is not the same checker, and that difference would be the only thing a design partner is actually buying. So the transport is deliberately dull and the decision path has one implementation.
What each option adds
| npx | SDK | |
|---|---|---|
| Where the check sits | A PreToolUse hook on the machine | One call in your harness |
| How the payload arrives | stdin, from the runtime | POST /check, from your code |
| How the answer comes back | Exit code and a permission decision | Named verdict in JSON |
| Where the contracts live | ~/.coco/compiled/ | Baked into the image at build time |
| Where the ledger lives | ~/.coco/ledger.db | /data/ledger.db on a mounted volume |
| Network on the decision path | None | The round trip to the gate |
| What a failure to reach it does | Not possible, it is a local process | BLOCK, from the client |
| Who operates it | You | Coco, or you |
| Which runtimes it covers | Runtimes with hooks. Claude Code, Cursor | Every other one |
The last two rows are the real trade. On the npx road nothing leaves the machine and there is no service to keep running. On the SDK road you gain every runtime that is not Claude Code, and you pay for it with a network hop and an availability dependency you did not have before.
Verdicts, translated twice
The core answers in one vocabulary and each road translates it.
| Core | npx, to Claude Code | SDK, over HTTP |
|---|---|---|
| ALLOW | nothing, exit 0 | {"verdict": "ALLOW"} |
| ESCALATE | permissionDecision: "ask", exit 0 | {"verdict": "ESCALATE"} |
| BLOCK | permissionDecision: "deny", exit 2 | {"verdict": "BLOCK"} |
Two rows there are load-bearing on the npx side. ALLOW stays silent, because emitting an explicit allow would approve the call and skip Claude Code's own permission prompt, and a guardrail that widens permissions is not a guardrail. ESCALATE exits 0, because exit code 2 blocks a call whatever the JSON says.
On the SDK side the translation has one rule it must never break. An unrecognised decision is treated as a BLOCK, never as permission.
Replayability
Because both roads run the same file against the same contract format, a payload
decided on a laptop can be replayed against the service and produce the verdict it
produced there. serve.py passes a payload that already speaks the hook shape
through untouched for exactly that reason.
That is what lets a team run the hook on developer machines and the SDK in
production without keeping two stories straight for an auditor. One contract
format, one receipt shape, one chain algorithm, two transports. The one verdict
outside that story is the BLOCK a client produces itself when the gate did not
answer, which is marked unreached and has no receipt, because the gate never saw
the call.
What ships where
The two roads are distributed differently, and the npm package is deliberately the smaller of the two.
| Artefact | Carries | For |
|---|---|---|
| npm package | bin/, installer/, gate/, sdk/, the baseline and session contracts, the default mandate | The npx road, and the SDK client |
| Container image | The gate, serve.py, dashboard.py, and the contract set compiled at build time | The SDK road |
| Repository | All of it, plus the vertical contract sets, the tests, the bench and the deploy scripts | Design partners and this documentation |
The npm package does not carry the KYC or payments contract sets. Neither can fire for a coding agent, because neither has the state it reads, so shipping them would add contracts that can only sit idle. They live in the repository, and a mandate has to name a set before the gate loads it.
The image
Two build stages, for one reason. Compiling contracts from YAML into the JSON the gate reads needs Node. Answering a call needs nothing but Python, because the gate is standard library only.
So Node does its work at build time and does not ship. The runtime carries no
compiler, which means whatever a container is going to enforce was compiled into
it, and COCO_MANDATE pins that contract set to the image. Put the mandate in the
tag and an auditor can tell two images apart.
The contracts are baked in and the receipts are not. Everything under /coco/home
is the contract as shipped, and /data is the ledger, which is the only thing
worth a volume.
The hosted plane, as deployed
One small virtual machine in Sydney runs the whole plane under Docker Compose. Data residency is why it is Sydney.
| Container | What it is |
|---|---|
| Caddy | TLS per name from Let's Encrypt. Ports 80 and 443, and nothing else is open |
| A gate | serve.py. Answers POST /check in observe mode behind a bearer key |
| A pilot dashboard | dashboard.py in demo mode, enforcing the payments contract set |
| A second dashboard | Healthy and unrouted, waiting for the first customer name |
Each customer is two containers from the same image, a gate and a dashboard, sharing one volume, with their own keys. Keys live in an environment file on the machine, and a deploy re-run never rotates a key a customer holds.
Two public names reach it.
| Name | What answers |
|---|---|
gate.trustcoco.ai/demo | The gate API. GET /health is open and needs no key |
dash.trustcoco.ai | The pilot dashboard, behind HTTP Basic |
curl -s https://gate.trustcoco.ai/demo/health{"status": "ok", "mode": "observe"}Why a dashboard gets a name and a gate gets a path
The gate is an API. Its caller is handed a base URL and posts to /check under it,
so one path prefix per customer works and the container always sees /check at its
root.
The dashboard is a browser application, and its shell asks for /static and /api
at the root of the host, because that is what an absolute path means. Behind a
stripped prefix the HTML arrives and every asset and API call after it lands
outside the prefix, so the page renders bare.
That was measured rather than assumed. A prefixed dashboard answered 200 while its stylesheet, its script and its API all came back as no such dashboard. So a customer dashboard gets its own name with its own A record, and Caddy fetches a certificate per name.
What is not in place yet
The ledger lives on the machine's disk, and the nightly copy to storage off that machine is not built. Until it is, that disk is the only copy of the chain.
The hosted gate runs in observe mode. No customer gate is enforcing yet, and saying otherwise would be wrong.
Receipts are tamper-evident and unsigned, on both roads. Cross-session state does not exist, so two agents co-ordinating across separate sessions are outside what either road sees.
Next
- How a call is decided for the ordered path through the core
- Installation for either option
- The dashboard for what the hosted surface shows