Self-host
The contracts are permissionless and keyed by owner, so one deployment serves any number of firms and nobody needs to deploy their own. What a firm hosts is the supervisory plane — and its database is rebuildable from chain, which is both the disaster recovery story and the integrity argument.
Or one command, and none of that
A reviewer who wants to read the thing rather than install it needs Docker and nothing else:
docker compose up --build
# http://localhost:8080/console/
The console, its API, the evidence spine and a preview chain come up on one
origin — the arrangement the browser’s typed-data signature assumes, not a
convenience. The recorded incident is loaded from db/seed/incident.sql:
public chain data, each commitment naming a real sequence on a public HCS topic,
so a reader who does not want to take our word for it can check the digests
against the mirror node. The preview answers from the same enforcement bytecode
that runs on testnet, on a simulated chain that settles nothing.
What that leaves out is everything that needs a funded key: the relayer, so nothing can be written, and the indexer, because what it finds is anchored to HCS as it is written. Both are behind a profile rather than failing in a way that reads as a broken product:
docker compose --profile live up --build # with keys in .env
Started without them, each refuses with the line that says what it needs and
stops after three attempts instead of printing the same stack every second.
docker compose down -v takes the spine with it, which is how to
start over.
Or the four services, by hand
Node 22 or newer, PostgreSQL 16, and a funded Hedera testnet key.
cp .env.example .env
npm install
createdb amw
npm run db:setup # every migration in db/, in order
npm run verify # secrets, docs, types and 270 tests
npm run -w @amw/contracts deploy # and verify:contracts, in one step
The database comes first because four packages test against a real one. Without a server those suites skip rather than fail, each naming the reason — so on a machine with no PostgreSQL, read the skip count as well as the pass count.
Then the four services, each in its own shell:
npm run -w @amw/api dev # 8788 — what the console may read
npm run -w @amw/relay dev # 8789 — postage, no authority
npm run -w @amw/console dev # 5174 — the screens
npm run -w @amw/intent-log watch # the indexer, following the chain
npm run -w @amw/contracts sim # 8545 — the preview chain, optional
The crew's credential, and a backup
The supervisory crew needs model access; nothing else on any page does. A deployment with no
interactive login uses a token from claude setup-token, and the numbered variants
are backups, tried in order:
CLAUDE_CODE_OAUTH_TOKEN=… # tried first
CLAUDE_CODE_OAUTH_TOKEN_2=… # when the first is out of capacity
CLAUDE_CODE_OAUTH_TOKEN_3=…
ANTHROPIC_API_KEY=… # billed to API credit instead
A run falls through to the next credential only for capacity or authorisation — a spent session limit, a rate limit, an empty balance, an expired login. It never falls through because the model answered and the gate refused the answer: that would spend a second budget to be told the same thing. Which slot paid for a case is logged; the token never is.
With no credential at all, every route still answers and every figure still renders — case assembly reports that this deployment has no model credential, and says so in the reader's language rather than the operator's.
When a hold has nobody watching
A held action waits for a person. The console shows that wait and escalates its wording after four hours, but a control whose trigger is somebody having a tab open is not a control at three in the morning. Point the indexer at an endpoint and every hold is announced as it is indexed:
AMW_HELD_WEBHOOK_URL=https://firm.example/hooks/amw
AMW_HELD_WEBHOOK_SECRET=… # shared with the receiver
AMW_CONSOLE_URL=https://… # so the page carries a way in
The body states the action, the guard’s own flag word and the clauses it cites, when the hold was recorded and how long it has been waiting. It carries no notional and no counterparty — those stay in the firm’s custody and are reached through the console by someone who can prove they are entitled to them, exactly as the published commitment withholds them. It states no verdict: what a hold means is decided by the human who reads it.
POST /hooks/amw
x-amw-event: action.held
x-amw-timestamp: 1788201600
x-amw-signature: sha256=… # HMAC over "timestamp.body"
{"event":"action.held","actionId":"9","clauses":["3.4","3.7"],
"breachFlags":"0x140","heldForSeconds":150,"transaction":"…","console":"…"}
Delivery is recorded in amw.held_pages, not fired and forgotten: the row says when
the hold was first seen, when the page arrived and what it took to get there, because
“the firm was notified” is a claim about the past like any other. A refused endpoint is
retried further apart each time to a ceiling of fifteen minutes, and retries stop when the hold
is resolved. With no endpoint configured the hold is still recorded the moment it is seen — the
clock starts either way — and the indexer says on start-up that nobody is being told.
Database roles
Three roles, and the separation is the point: amw_ingest writes what the
chain said, amw_worker reads everything and may only append case steps and
findings, and amw_console serves screens. case_steps is
append-only at the grant level — no UPDATE, no DELETE — so a
bug in a screen cannot manufacture evidence.