Coco

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 · ESCALATE

The 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

npxSDK
Where the check sitsA PreToolUse hook on the machineOne call in your harness
How the payload arrivesstdin, from the runtimePOST /check, from your code
How the answer comes backExit code and a permission decisionNamed 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 pathNoneThe round trip to the gate
What a failure to reach it doesNot possible, it is a local processBLOCK, from the client
Who operates itYouCoco, or you
Which runtimes it coversRuntimes with hooks. Claude Code, CursorEvery 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.

Corenpx, to Claude CodeSDK, over HTTP
ALLOWnothing, exit 0{"verdict": "ALLOW"}
ESCALATEpermissionDecision: "ask", exit 0{"verdict": "ESCALATE"}
BLOCKpermissionDecision: "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.

ArtefactCarriesFor
npm packagebin/, installer/, gate/, sdk/, the baseline and session contracts, the default mandateThe npx road, and the SDK client
Container imageThe gate, serve.py, dashboard.py, and the contract set compiled at build timeThe SDK road
RepositoryAll of it, plus the vertical contract sets, the tests, the bench and the deploy scriptsDesign 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.

ContainerWhat it is
CaddyTLS per name from Let's Encrypt. Ports 80 and 443, and nothing else is open
A gateserve.py. Answers POST /check in observe mode behind a bearer key
A pilot dashboarddashboard.py in demo mode, enforcing the payments contract set
A second dashboardHealthy 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.

NameWhat answers
gate.trustcoco.ai/demoThe gate API. GET /health is open and needs no key
dash.trustcoco.aiThe 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

On this page