bash or zsh, after npm install && npm run build. Scratch files go in
try/ops/ (delete the folder to start over). Services use ports 7800 to 7849 on 127.0.0.1 only; change them if they are busy.
Every command and output below comes from a real run against this repository, in the order shown. IDs, timestamps and hashes
will differ on your machine. Slack, Teams, email, PagerDuty, Opsgenie and OpenTelemetry are exercised only against fakes you
start on localhost, never against the real services. The console is checked with
curl (it is server-rendered HTML); open the
same URLs in a browser to see them drawn. See Not covered here for the full list.# Terminal A runs in its own terminal, which you leave running; every other block goes in your
working terminal, called B here. Stop a terminal with Ctrl-C when the text says so.
0. Setup
You need Node 22.13 or newer, Python 3,curl, openssl and shasum. The psql client is optional (section 9).
Manifests, a policy (an approval rule, secret detection and a retention: section) and one API key per role:
rv is the CLI, j calls the API with a key, post calls a
console page and prints its status and text, login signs a key in to the console and keeps the cookie in try/ops/jar-NAME.
Each new terminal starts with . try/ops/.env.sh.
1. Console modes
The console is off by default. Choose a mode withREVERSI_CONSOLE on the server, --console on the gateway, or
console: { mode: … } in reversi.config.yaml:
1.1 Off
reversi-server listening on http://127.0.0.1:7800 (6 tools, files store, console off).
1.2 Open: no login, this machine only, never admin
… (6 tools, files store, console open). Give the console something to show: a charge over 100 waits for approval.
localhost and made from the same origin:
local-console:
reversi console is the shortcut for your own laptop: it opens the engine on your data directory and serves the open console
(default port 7421, here 7802). Its --role is auditor, approver or operator.
console: http://localhost:7802/console (open mode as auditor; Ctrl-C to stop).
localhost; it can never become admin; and it cannot be started where other people could reach it.
1.3 Login: paste a key, roles apply, a disabled key ends the session
… console login). In a browser, open http://localhost:7800/console: it sends you to a sign-in form.
Here the same with curl. Wrong keys and agent keys are refused; then sign in as the auditor, approver and admin:
HttpOnly; SameSite=Strict, and it follows the key. Disable the auditor’s key and the session ends at once:
1.4 The same switch on the gateway
The gateway serves the console on its own port, so it needs alisten: section. Build its config and manifests:
--console open and with --console login:
2. API keys: file format, console, API, rotation, expiry
A key file is a JSON list. Each entry has aname (it becomes the actor in the ledger), role or roles, and key_sha256 (the hex
SHA-256 of the key; the key itself is never stored). Optional: max_permissions (agents), groups, tenant, not_before,
expires_at, disabled, allow_observe. The server re-reads the file on SIGHUP or when it changes (checked every two seconds).
Terminal A from section 1.3 is still running.
2.1 Create, use, expire and remove a key over the API
POST /v1/keys is admin only. The new key is shown once:
2.2 The same in the console Settings page
Sign in as the admin and create, disable and remove a key from Settings. The new key is shown once, on the page after the form:2.3 Rotation and expiry by editing the file
Rotate “carol” without downtime: add her new key beside the old one, give the old entry anexpires_at, and the server follows the file.
A file that does not parse is ignored and the old keys stay in force.
3. OIDC with the mock identity provider
examples/oidc/mock-idp.mjs is a throwaway OpenID provider that mints signed tokens. It is for trying this out, never for production.
It listens on PORT (default 9000; here 7810).
sub (bot-1), and a console session ends when its token does. Redirect (browser SSO) login is not built.
4. Notifications
Notifications are configured undernotifications: in reversi.config.yaml (see Notifications). Three fakes
stand in for the services, all on localhost. First the scripts. The fake HTTP endpoint prints one line per POST and checks the webhook
signature; the fake SMTP server accepts a message and prints it decoded:
examples/webhook-sink.mjs) takes the older single approval webhook, REVERSI_APPROVAL_WEBHOOK,
which works beside the channels:
4.1 A test message from the CLI and from the console
reversi doctor --send-test sends one message through every channel in the config, and checks each URL against the egress rules.
4.2 An approval request
amount) and a console link, never argument values.
4.3 Verify the webhook signature
Thewebhook channel signs the body with HMAC-SHA-256 and sends x-reversi-signature: sha256=<hex>. The fake endpoint saved the last
signed delivery. The fake already printed signature: valid; check it yourself with the Python helper in the SDK:
4.4 A dead channel never changes a decision
Stop the three fakes (Ctrl-C in terminals C, D and E), then ask for another approval. Becausenotify.yaml has log: { format: json }, the
server writes its warnings as JSON lines on stderr (the logger redacts credential-looking fields and values; section 5.5 shows it):
5. Alerts, tracing, logs and metrics
One config turns on alerts every two seconds with a denial threshold of 3, sends onlyalert events to the webhook, and exports
traces to an OTLP endpoint. It uses its own data directory so the numbers are easy to read.
5.1 A denial spike and an unclassified tool
GET /v1/alerts and the console Alerts page, use the
same alerts.denial_spike, so they list the spike too:
5.2 An alert that resolves
Alerts are sent once, repeated as reminders (alerts.remind, default one hour) and resolved when the condition clears. A kill switch is the simplest condition to clear:
5.3 An unresolved step
An outcome that stays unknown for more than one hour raises “Outcome still unknown”. The hour is fixed, so this guide cannot wait for it. Leave one behind and look again later:Outcome still unknown: create_order in al-3.
This part is not verified here.
5.4 OpenTelemetry traces
Spans are posted as OTLP JSON to<endpoint>/v1/traces every five seconds. Terminal D received them:
o1, c1, B) appear in any span. Spans carry names and ids only.
5.5 Structured logs and Prometheus metrics
REVERSI_LOG=json writes one JSON line per request. Bodies and credentials are never logged:
createLogger, used by embedders) redacts credentials by field name and by pattern. Try it directly:
Two logs exist.
REVERSI_LOG=json writes the per-request line above (server only). Warnings from the engine itself (a failed notification, anchor or trace
export) go through the log: section of reversi.config.yaml: format: json makes them redacted JSON lines on stderr, as section 4.4 showed, and
format: pretty writes reversi [warn] message, and level is debug, info, warn or error. The code above is the same logger, for embedders./metrics needs an auditor key.
6. Evidence: ledger, bundles, reports, anchors
This section runs on the main data directory. The config adds one anchor sink, a file, and a dataregion label (section 8). A server start writes one anchor.
6.1 Verify the ledger and make signed bundles
6.2 HTML and PDF run reports
try/ops/report.html in a browser, and the PDF in a viewer; the PDF is a one-page summary. The console links the same files on the run page:
6.3 The period report
With no run id,reversi report summarises a period for an auditor: what agents did, what was blocked, approvals, rollbacks. Formats are md, html or json.
6.4 Anchoring
An anchor is a signed note of the ledger’s length and head hash, written somewhere you control. It lets you prove later that history was not cut back or rewritten: the check compares the anchored hash with the ledger’s record at that position, so a tail that was cut off or a chain that was rebuilt differently both fail. The server already wrote one when it started; write another through the API and the CLI, then verify:reversi verify (the chain is intact up to where it stops) but fails the anchors:
reversi verify and fails the anchors:
--file, anchor publish uses the anchor: sinks of the config (the CLI reads anchor:, notifications: and alerts: from the project file). Sign it with --key:
%PDF file) and are linked in the console; reports show names and
notes, never argument values; anchors catch a truncated or rewritten history that a chain check alone accepts.
7. Retention and legal holds
Retention deletes the working data around a run (run state, approval requests and captured arguments). The ledger is never deleted; apurged record is appended instead. The policy’s retention: section sets default_days (at least 1) and optional rules by tool,
tenant, environment or data class. A manifest can raise a run’s retention with audit.retention_days and can require an evidence bundle first with
audit.evidence_export: required (our charge_card does). We work on a copy so the main data survives.
--days 0, which makes every run due:
c-1, then do a dry run, a real run without an archive, and a real run with one:
ev-1 uses charge_card, whose manifest requires an evidence bundle, so it was kept in the dry run (no archive directory given) and deleted only once a directory and key were. With them, every deleted run gets a signed bundle, not only the ones that require it.
The held run c-1 survived and says why. Release the hold and apply again:
GET /v1/retention/plan, POST /v1/holds, POST /v1/retention/apply (admin), and the console Settings page with Dry run and Apply buttons.
Against the main data (nothing is due under the 30 day policy):
purged ledger record and the chain still verifies.
8. Egress and data residency
Every outbound call Reversi makes (notifications, anchor webhooks, trace export, remote manifests, upstreams) passes the egress rules inreversi.config.yaml. allow lists host patterns; unset means any host. block_private: true refuses loopback, private and link-local
addresses (off by default so local development works). Cloud metadata addresses such as 169.254.169.254 are always refused.
region is only a label: it appears in refusal messages, in signed anchors (section 6.4) and in the PDF report (section 6.2).
reversi doctor checks every configured destination without sending anything:
localhost is not in allow, and the metadata address is refused even though it is listed:
block_private stops it going to internal addresses, metadata
endpoints are never reachable, and the refusal is a warning that does not affect any decision. The same guard covers anchor webhooks, OTLP export,
remote manifest URLs and gateway upstream URLs; only notifications are exercised here.
9. The three stores, with the same scenario
The same four calls against each store: open a run, create an order, ask for a charge over 100 (blocked for approval), and try a tool out of scope. Then a second server on the same store approves the charge and the first server lets it through, which shows that the ledger and approvals are shared.9.1 Files (the default)
9.2 SQLite
SQLite needs Node 22.13 or newer and prints an experimental-feature warning.9.3 PostgreSQL
Use a throwaway database. The server creates its own tables (ledger, runs, approvals, kv, reversi_migrations) in the default schema on first
start. This guide used a local PostgreSQL 15 instance at a unix socket (the project’s test database); any URL works.
psql you can read the tables (reading only):
10. Deployment artifacts
Docker, Compose and Helm were not run. What can be checked without a Docker daemon or a cluster:POSTGRES_PASSWORD (${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}), publishes only on 127.0.0.1 and turns on the login console.
To try the stack yourself, follow the comments at the top of deploy/docker-compose.yml and the Deployment page.
The workflows in .github/workflows/ run on GitHub and were not executed here.
Not covered here
- Real third-party services. Slack (including interactive Approve and Deny buttons), Teams, PagerDuty, Opsgenie, a real SMTP relay (STARTTLS, AUTH, implicit TLS), and a real OpenTelemetry collector. Only localhost fakes were used. The anchor webhook sink, remote manifest URLs and gateway upstream URLs through the egress guard were not run either.
- Alerts that need time or a failure: an unresolved step (needs more than an hour), approval timeout and escalation, a failed compensation, an unhealthy gateway upstream.
- Browser sign-in with SSO redirects (not built: the console takes a pasted key or token). The console was exercised with
curl; nothing was clicked in a browser. - TLS and
REVERSI_SECURE_COOKIES=1, which need HTTPS in front. - Docker image build and run, Docker Compose, Helm (lint, template, install), the GitHub workflows.
- PostgreSQL over the network, with TLS, or with a restricted role; append-only triggers were not provoked.
- A Terraform provider and a Kubernetes operator. They are not built; use
reversi applyand the Helm chart.

