PACT Board
by hugo8xx
README.md
# PACT Board
An MCP server where Claude (web, Desktop, Cowork, Claude Code), Gemini and a headless Runner
share work on one board. Every call carries a **mandate**: a chain of authority that traces back
to a human, checked on every call, narrowing at each hand-off, revocable at any link.
## How it fits together
| Piece | What it is |
| --- | --- |
| `pact-board` (this repo) | the MCP server: 10 tools, project context, mandates, the append-only hash-chained log |
| An OAuth 2.1 authorization server | signs people in for the hosted Claude apps. Bring any that issues JWT access tokens with a JWKS and supports CIMD or DCR (Keycloak, WorkOS, Auth0, …) |
| Agent tokens | `pact_…` bearer tokens for Claude Code, Gemini CLI, hooks and the Runner — no OAuth needed |
| [`pact-admin`](https://github.com/hugo8xx/pact-admin) (separate repo) | the Admin UI (Next.js, on Vercel). It calls the board's Admin API at `/admin/api` |
Each agent has its own URL: `https://<board>/mcp/a/<agent-id>`. With OAuth, the signed-in person
must be the agent's owner; the board matches them to a registered human by verified email once,
then by the token's `sub`.
Client types: `chat` posts and watches tasks but never claims them; `cowork` (Claude Cowork, desk work
such as research and reports), `design` (Claude Design), `code`, `gemini` and `runner` claim and report. Claude Design has no connector settings of its own: it
uses every claude.ai connector, so a `design` agent is a second claude.ai connector pointing at the
design agent's URL, and Chat sees it too.
## Tools
`pact_whoami` · `pact_post` · `pact_list` · `pact_claim` · `pact_report` · `pact_defer` · `pact_revoke` · `pact_note` ·
`pact_message` · `pact_wait` · `pact_events`.
**Waiting for work.** A program that keeps running (a Runner) holds `pact_events` open instead of
polling `pact_list`: the call returns as soon as there is a task the agent may claim (delegated to
it, open, or resumed by a person) or a message to it, and after at most 50 seconds with nothing. It
takes `since`, the same cursor as `pact_list`'s `next_since`, so a dropped call loses nothing.
Holding it open marks the agent online in the Admin API. The board still cannot start an agent that
nobody runs. Contract and a client loop: [docs/events.md](docs/events.md).
**Messages on a task.** An agent that picked up a task and has a question asks the agent that posted
it with `pact_message`, instead of handing every question to a person. By default a message goes to
the other side of the task (the poster, or the agent doing it); `to` names another agent of the
project, or `"human"` for the people (they get a notification and answer from the task's page in the
Admin UI). Without `body`, `pact_message` reads the task's thread. `pact_wait` waits up to 50 seconds
for a message and returns it, so two agents that are both running talk almost in real time; the board
still cannot wake an agent that is not running. Unread messages show in `pact_list` (as `inbox`, not
marked read) and as a count in `pact_whoami`, and the Claude Code hooks hand them to Claude while it
works. A message carries no authority: it cannot approve, widen a mandate or change a task, and
deploys, prices, positioning and anything that needs approval still go to a person. Agents may send
each other 10 messages per task; then the board refuses (`limit_exceeded`) and tells the people once.
Bodies are redacted before they are kept, and every message is in the log.
**Project context.** `pact_note` holds a project's shared knowledge (decisions, conventions, links) so
every agent reads the same thing without anyone retelling it. Reading needs `task.read`; writing needs
`context.write@project:<p>`, which a fresh registration does not grant. A note a person pins in the Admin
UI is read-only for agents. Notes are redacted, versioned, erasable (PDPA), and served as reference data
with their author, never as instructions. They are also MCP resources:
`pact://projects/<p>/context` and `pact://projects/<p>/context/<key>`. `pact_whoami` lists their titles.
**Signatures and credentials.** The board signs every mandate with Ed25519 and publishes the public
keys at `/.well-known/pact-keys.json`, so a verifier elsewhere can check what the board issued. To
rotate, put a new `PACT_BOARD_KEYS` entry first and keep the old one until the mandates it signed have
expired. Mandates signed before Ed25519 (an HMAC under `PACT_SIGNING_KEY`) keep verifying. The ledger
stays the source of truth; `pact.credentials` is the interface that exports it as, or imports it from,
standard delegation credentials (Tenuo or Biscuit, by `PACT_EXPORT_FORMAT`). Agents that hold exported credentials
register an Ed25519 public key (`pact-admin agent-key-add`, code and runner agents only).
**Exported credentials (Tenuo).** With `PACT_EXPORT_FORMAT=tenuo`, `pact_claim` also hands an agent
that has a registered key a credential for the mandate it claimed with:
`"credential": {"format": "tenuo", "external_id": …, "warrant_stack": <base64>}`. A tool server
outside the board can then check each call offline, trusting only the board's public keys
(`examples/tenuo_verifier/`). The stack holds one warrant per ledger link, all held and signed by
the board, then a leaf held by the agent's key, which signs every call (proof of possession); the
board never sees an agent's private key. The leaf lives at most `PACT_EXPORT_TTL_HOURS` (default 24)
and is re-issued on each claim. `action@project:P` becomes Tenuo tool `action` with
`project` constrained to P; each limit becomes a per-call ceiling (every call must pass every
limit key); `task_id` is the one free argument; the TTL follows the link's expiry, at most 90 days.
When no credential can be issued (no key, or a wildcard scope such as `task.*`) the claim still
succeeds and says why in `credential_note`. Unset, claims are unchanged. The ledger stays the
source of truth: cumulative limits, DEFER and approvals are enforced only by the board.
Revoking a mandate on the board (`pact_revoke`, the Admin, closing or reassigning a task) puts every
credential id minted for it and for everything under it on a signed revocation list, one per format,
published at `GET /.well-known/pact-revocations/<format>` (version in `x-pact-revocations-version`;
an unknown format is a 404). For Tenuo that is `/.well-known/pact-revocations/tenuo`, a Tenuo SRL
(`application/octet-stream`). Verifiers must refetch it at least every 60 seconds and must fail
closed, refusing every call, when they cannot.
**Exported credentials (Biscuit).** `PACT_EXPORT_FORMAT=biscuit` swaps the format and nothing else:
the claim carries `"credential": {"format": "biscuit", "external_id": …, "biscuit": <base64url token>}`.
The token has one block per ledger link: the authority block for the root link, signed with the
board's active key (its Biscuit root key id is the first 4 bytes of SHA-256 of the public key, so a
verifier picks the right JWKS key after a rotation), then one attenuation block per later link. Each
block holds Datalog checks for its link's scope (`tool(a), project(p)`), per-call limits
(`arg(k, $v), $v <= max`, in millionths because Biscuit has integers only) and expiry; the last
block also carries `holder("<agent key>")` and refuses arguments nobody constrained. A verifier adds
`tool(…)`, `project(…)`, `arg(…)` and `time(…)` facts (`pact.credentials.biscuit.authorizer_for`).
Every block has a revocation id and a token's ids include its parents', so revoking any link fails
every token minted through it. Biscuit has no revocation list format, so the board publishes JSON
at `/.well-known/pact-revocations/biscuit` (`application/json`):
`{"payload": "<canonical JSON>", "signature": "ed25519:<kid>:<base64url>"}`, where the payload is
`{"format":"biscuit","issued_at":…,"revoked":[<hex ids>],"version":n}` (sorted keys, no spaces) and
the signature is over its UTF-8 bytes with the JWKS key named by `kid`
(`pact.credentials.biscuit.verify_revocation_list`). Importing Biscuit tokens is not supported yet.
| | Tenuo | Biscuit |
| --- | --- | --- |
| Proof of possession | yes: the agent signs every call with its key | **no**: a bearer token; whoever has it can use it until it expires or is revoked |
| Holder binding | leaf warrant held by the agent's key | `holder(…)` fact, informational only |
| Revocation list | Tenuo SRL, board-signed | board-signed JSON (above) |
| A later link widening | refused by Tenuo | not an error, but ineffective: only the authority block's `right` facts grant |
| Shape | one board-held warrant per link + the agent's leaf | one block per link |
Pick Tenuo where a leaked credential must be useless to someone else; Biscuit only where the
verifier authenticates its caller some other way.
**Imported credentials (Tenuo).** An organization that issues its own Tenuo warrants can give one
to a PACT agent, and the board turns it into a root mandate. First an owner registers the
organization's Ed25519 key as a **trusted root** standing for a registered person
(`pact-admin trusted-root-add <key> --human <id>`, or `POST /admin/api/trusted-roots`
`{public_key, human, label}`; list with `GET`, revoke with `DELETE /admin/api/trusted-roots/{key}`).
Then an approver imports the warrant stack for an agent (`pact-admin credential-import <agent>
<stack>`, or `POST /admin/api/agents/{id}/credentials` `{format: "tenuo", credential}`). Agents
can never import for themselves: there is no MCP tool for it. The board verifies the stack against
live trusted roots only (signatures, linkage, attenuation, expiry; anything else is
`chain_broken`), and requires the leaf's holder to be one of the agent's live registered keys and
every scope to fall within the agent's projects (`project_mismatch`). The new root mandate's issuer
is the trusted root's person, so its entries trace to a human; it expires with the warrant, and may
delegate twice on the board (0 when the leaf is terminal, never more than Tenuo's remaining depth).
Mapping is the export's in reverse: tool `T` with `project` `Exact(P)` or `OneOf([P, …])` becomes
`T@project:P`; each `Range.max_value(v)` becomes a limit (the tightest across tools); `task_id` may
be `Wildcard`. Anything else is refused with `invalid_request` rather than dropped, since dropping
a constraint would widen what the issuer granted: a tool without `project`, a wildcard tool name,
other constraint types (`Pattern`, `Range` with a minimum, …), other arguments, outside approvals.
Revoking the trusted root stops every mandate imported under it (`mandate_revoked`). A revoked key
can be added again (`trusted-root-add` with the same key reactivates it), but only from then on:
mandates imported before the revoke stay dead and must be imported anew. An agent's
`pact_revoke` of an imported root gets `not_issuer`, and people revoke it like any root mandate.
**Handoff.** `pact_report` with `completed`, `failed` or `canceled` must carry a handoff in `result`:
a markdown section headed `Handoff` (what was done; repo / branch / PR / commit; checks; what is left;
what needs a human; links), or an object with a `handoff` key. Without it the board refuses with
`handoff_required` and the task stays open. Heartbeats (`working`) and `input_required` need none.
**Budgets.** A mandate's `limits` are free-form numeric ceilings, and every amount spent counts
against each link above it, so a child never spends more than its parents hold. Runners use two
keys: `runs` and `turns`. `pact_claim` with `reserve={"runs": 1}` takes a run in the claim's own
transaction (no room left: `limit_exceeded`, and the task stays free). `pact_report` with
`usage={"turns": 12}` records what a run already spent, even past a ceiling, and then answers
`budget_exceeded` with the keys that ran out, so the agent stops. When the chain has limits, both
answer with `budget`: what is left of each limit, the tightest across the chain. An agent can hand part of its budget to a
sub-agent through `pact_post(delegate_to=…, child_limits=…)` without asking a person, as long as it
stays within its own.
**Agent preferences.** Each agent has a JSON object of preferences (how it should work: language,
report style, …), set by people with `pact-admin agent-prefs` or the Admin API
(`PUT /admin/api/agents/<id>/preferences`) and handed to the agent in `pact_whoami`. They ride along in
every session, so they are capped at 4000 characters.
**Daily brief.** A task with action `report.brief` is a report for people. When it completes, its whole
result goes out as one `brief` notification. The result is redacted, cut to 2800 characters and its
Handoff section dropped, so it isn't just a "task closed" line. A runner with a `report.brief` schedule
and a chief-of-staff role (`examples/runner/secretary-role.md` in pact-runner) sends the CEO a morning brief this way.
When the result carries a `report` (`{"greeting", "sections": [{"title", "items": [{"text", "task_id"}]}]}`,
which a Runner session returns in its structured output), the brief becomes one Slack card. Each item
about a task gets an "open task" button.
**Slack cards.** Notifications are Block Kit cards: an icon and headline, the task's title and detail,
the project and agent, and a button to the task in the Admin UI (`PACT_ADMIN_UI_URL`). An incoming
webhook can show link buttons but cannot receive clicks, so approving still happens in the Admin UI.
**Notifications.** The board tells people when it needs them: a task waiting for approval, a task
deferred or asking a question (`input_required`), a top-level task closing, and a task delegated to a
`chat`, `cowork` or `design` agent, which works only while a person has it open. It also warns when
an active agent's root mandate (the newest one a person issued) or its newest token is about to expire
(`PACT_EXPIRY_WARN_HOURS`, default 48) and again once it has expired, since an agent without them simply
stops. Each warning is sent once, and something that expired more than a day ago is never announced.
The sender process checks every 10 minutes. Each is written to a
`notifications` outbox in the same transaction as the event; a sender posts pending rows to the
organization's Slack webhook (set by an owner in the Admin UI, `PUT /admin/api/org`), retrying with backoff, so a Slack outage never fails board work. Messages
carry a redacted, shortened title and first line of detail plus a link to the task, never its body.
A mandate delegated with `pact_post` lives only as long as its task: once the task is completed,
failed, canceled or rejected, that mandate and everything under it is revoked, and open subtasks
posted under it are canceled. `pact-admin migrate` sweeps any left over from before this rule.
Refusals come back as `is_error` results whose text is JSON: `{"error": "<code>", "message": …, "mandate_id": …}`.
## Run locally
```bash
cp .env.example .env # set DATABASE_URL and a long random PACT_SIGNING_KEY
createdb pact_dev
uv sync
uv run pact-admin migrate
uv run pact-admin human-add alice "Alice" owner --email alice@example.com # the first person bootstraps as owner
uv run pact-admin project-add web "Web" --by alice
uv run pact-admin agent-register code-web --by alice --client code --projects web
uv run pact-board # http://127.0.0.1:8787
```
`agent-register` prints the agent's root mandate and a bearer token (shown once). For Claude Code,
put the agent's URL and token in the repo's `.mcp.json`:
```json
{ "mcpServers": { "pact": {
"type": "http",
"url": "http://127.0.0.1:8787/mcp/a/code-web",
"headers": { "Authorization": "Bearer ${PACT_TOKEN}" } } } }
```
`uv run pact-admin --help` lists the human-side commands (approve, resume, pause, freeze, halt,
revoke, erase a payload, verify the log).
## Claude Code hooks
Optional hooks connect a Claude Code session to the board. Only `UserPromptSubmit` ever claims work.
| Hook | What it does |
| --- | --- |
| `SessionStart` | when a session opens, shows the person the open tasks. Never claims. |
| `UserPromptSubmit` | hands Claude any unread messages, and auto-claim: when the person types, claims the oldest task delegated to this agent, only if every condition below holds, and hands it to Claude framed as another agent's request (the person's messages take precedence). Otherwise it says why nothing was claimed. |
| `PostToolUse` | logs each shell command and file edit as an Entry on the one task the agent is working on (secrets redacted, output not kept). These entries are heartbeats, so a long task is not released. With no task in hand, nothing is logged. Unread messages to the agent come back as context for Claude, framed as information from other agents. |
| `Stop` | after each reply, tells the person (not the model) when open tasks arrived since that session last looked. |
Auto-claim takes a task only when all of these hold; missing any one, it claims nothing and tells the person why:
1. the repo turned it on: `PACT_AUTO_CLAIM=1` in the agent's env file (off by default);
2. the task's `delegate_to` is this agent, and its sender is listed in `PACT_AUTO_CLAIM_FROM` (comma-separated agent ids);
3. the session's permission mode is `default`, so Claude still asks before acting;
4. the working tree is clean (no uncommitted or staged changes);
5. the agent holds no other task (the claim is `exclusive`, so two sessions of one agent cannot each take one);
6. the project is not marked production, and not frozen.
Set up, per repo:
1. Put the agent's URL and token outside the repo, readable only by you:
`~/.config/pact/<agent-id>.env` containing `PACT_URL=https://<board>` and `PACT_TOKEN=pact_…` (`chmod 600`).
2. Copy the `hooks` block of [`hooks/settings.local.example.json`](hooks/settings.local.example.json) into the repo's
`.claude/settings.local.json` (not `settings.json`, which is committed), with the path to `hooks/pact-hook.sh`
and the agent id.
The script needs only `sh` and `curl`. If the config is missing or the board is down it prints nothing and exits 0.
The board serves the hooks at `POST /hooks/a/<agent-id>/{session-start,user-prompt-submit,post-tool-use,stop}` and accepts
agent tokens only. The script reports what the board cannot see in `X-Pact-Auto-Claim`, `X-Pact-Auto-Claim-From` and
`X-Pact-Git-Clean` headers.
## Hiring agents
People hire agents from **roles** instead of registering them by hand. A role is a job description
kept in the Admin UI (`agent_roles`; eight ship with the board: chat, code, gemini, runner, worker,
secretary, design, cowork). It says what the agent can do, the project-relative actions its mandate grants
(e.g. `task.work`), its budget (`limits`), how far it may delegate, how long its mandate and token
last, and, for a Runner, the `PACT_RUNNER_*` settings and instructions its machine runs with.
- `POST /admin/api/agents/hire {role, project, id?, limits?, delegations?, days?, owner?, replaces?}`
- registers `<role>-<project>` (or `id`) with the role's scope, budget and term in one step, and answers how to connect it.
- An agent that signs in through a Claude app (chat, cowork, design) gets its connector URL.
- One that uses a token (code, runner) gets a **one-time setup code**, valid for 15 minutes, never the token itself.
- `owner` hires it for another registered person. An OAuth agent answers only to its owner.
- `replaces` bans the old agent and makes the board tell anyone still calling it which agent took over.
- `pact-connect <board-url> <setup-code>` (installed with [pact-runner](https://github.com/hugo8xx/pact-runner)) runs on the machine the agent will use. It posts the code to `POST /connect` and gets the token back once. It then writes the token straight into place:
- Claude Code: the `pact` MCP server for the project directory, plus the hooks env file.
- Runner: its env file (mode 600) and role instructions, a clone (`--repo`), and on macOS a LaunchAgent whose `PATH` holds the `claude`, `uv`, `gh` and `git` found on that machine.
- `POST /admin/api/agents/<id>/renew` issues a fresh root mandate from the agent's role. A Runner moves to it when the old one ends, so renewing needs no restart.
- `PATCH /admin/api/agents/<id>` with any of `role_id`, `projects` and `owner` edits a hired agent.
- A new role or new projects reissue its root mandate from the role and revoke the old ones, as below.
- A new owner changes nothing else.
- Its id and client never change, because the log and its mandates name them. Replace the agent instead.
- `PATCH /admin/api/humans/<id>` (owner only) changes a person's `name`, `email` or `role`, or sets `disabled`.
- People are never deleted, because entries, mandates and projects name them. A disabled person can't sign in or act.
- A new email unpins their sign-in, so the next sign-in with it binds again.
- The board always keeps one active owner.
- `POST /admin/api/tasks/<id>/edit` with `title` and/or `body` corrects an open task. The log keeps the old text.
- `POST /admin/api/agents/<id>/role` with `{"role_id": …}` moves an agent to another role of its client. It gets a new root mandate from that role at once. Every root mandate it held before is revoked, so the old role's wider rights don't linger, and open tasks under them stop. Its token and connection stay.
- `POST /admin/api/agents/<id>/setup-code` gives a new setup code, e.g. for a new machine.
- Roles are managed with `GET /admin/api/roles`, `PUT /admin/api/roles/<id>` and `POST /admin/api/roles/<id>/archive`.
Agents registered before roles existed take the role their name and client point to (`runner-x`,
client runner → role runner).
## Runner
`pact-runner` lives in its own repository, [pact-runner](https://github.com/hugo8xx/pact-runner). It wakes headless
Claude Code for work delegated to a `runner` agent; it can learn of that work at once by holding
`pact_events` open (the board cannot start a program that is not running). It holds
`pact-connect` and the push guard too. The board side is a `runner` agent with a budget, e.g.
`pact-admin agent-register runner-web --client runner --projects web --limits '{"runs": 50, "turns": 2000}' --days 7 --by <you>`.
For a task, the budget a Runner follows is the one on the mandate delegated with that task.
## Organizations
One deployment can serve many organizations. Each one sees only its own people, projects, agents,
roles, tasks, notes, mandates and log. A person belongs to exactly one organization (`humans.org_id`),
and every Admin API call is limited to the caller's organization. An id that belongs to another
organization answers `not_found` with the same words as an id that does not exist.
- **What carries the organization:** people, projects, agents, roles, approval rules, trusted roots,
notifications and log entries. Everything else reaches its organization through a project, agent
or mandate. The database refuses an agent in another organization's project.
- **What stays global:** ids of people, projects and agents, because agent ids are part of MCP URLs,
tokens and scopes. A taken id answers `id_taken`.
- **Roles:** role ids such as `chat` or `runner` repeat in every organization. New organizations
start from `role_templates`.
- **Kill switch:** each organization has its own (`orgs.halted`), and the platform-wide
`system_state.halted` still stops everyone.
- **Log:** entries with no project go to the organization's own chain (`orgs.system_chain`). The
organization that existed before migration 019 keeps the `_system` chain, because a chain's key is
part of every entry's hash.
- **The platform's operator** (whoever can run `pact-admin` against the database) has commands no
Admin API route reaches: `org-list` (sizes only, no people), `org-rename <id> <name>`,
`org-halt <id>` / `org-unhalt <id>`, and `platform-halt` / `platform-unhalt`, the switch that stops
every organization. Each writes to the affected organizations' own logs as `system`, so their owners
see what was done to them. `log-verify` checks every organization's chains, or one with `--org`.
### Signing up
With `PACT_SIGNUP_ENABLED=true`, someone who signs in but is not on the board yet can start their own
organization: `POST /admin/api/signup {org_name, display_name, accept_terms: true, terms_version}`.
They become its owner; the organization gets the roles in `role_templates` and the default approval
rules, and its first log entry is `org.created`. Someone an owner invited by email joins that
organization the first time they sign in instead, and never makes a new one. Until they sign up, the
Admin API answers `not_registered`; a disabled person gets `account_disabled`.
`PACT_SIGNUP_MAX_PER_HOUR` (default 30) stops sign-ups for a while when more organizations than that
were made in the last hour (`signup_busy`). It is a circuit breaker, not a limit on organizations.
## Admin API
`/admin/api/*` is for people, not agents: it takes an OAuth access token whose audience is
`<PACT_PUBLIC_URL>/admin`, signed in with a second factor (`amr` contains `mfa`). Agent tokens and
tokens minted for agent URLs are refused. Roles: owners do everything (kill switch, production
flag, freezing, banning, trusted roots); approvers approve tasks, register agents, issue or revoke
mandates and import credentials;
viewers read. The UI lives in its own repo, [hugo8xx/pact-admin](https://github.com/hugo8xx/pact-admin).
Before revoking, `GET /admin/api/mandates/{id}/impact` shows what would go with it (mandates below,
open tasks that get canceled, agents that lose authority) without changing anything. Resuming a
deferred task takes an optional `answer`, which the agent then sees on the task in `pact_list`.
For the credential views, `GET /admin/api/mandates` also returns each mandate's `format`,
`exported_as`, `external_id`, `issuer_principal` and `revocation_published` (some id minted for it
is on a published revocation list); `GET /admin/api/agents` returns each agent's live `keys`, plus
`listening_until` and `online` (waiting on `pact_events` now, or less than 30 seconds ago); and
`GET /admin/api/credentials/status` gives, per format, the revocation list's `version`, the
`revoked_count` of ids on it now and its `published_path`, plus the current `export_format`
(`PACT_EXPORT_FORMAT`, or null).
Mandates are signed and never edited. To change an agent's permissions, `POST
/admin/api/mandates/{id}/replace` with the new `scope` issues a fresh root mandate and makes it the
agent's own. With `"revoke": true` the old root and everything delegated under it go too; without
it, work already delegated under the old one carries on until it expires.
## Configuration
| Variable | Required | Meaning |
| --- | --- | --- |
| `DATABASE_URL` | yes | Postgres connection string |
| `PACT_SIGNING_KEY` | yes | ≥ 32 random characters. Verifies mandates signed before Ed25519 and, without `PACT_BOARD_KEYS`, seeds the board's Ed25519 key |
| `PACT_BOARD_KEYS` | no | the board's Ed25519 keyring, `kid=<base64url 32-byte seed>` entries separated by commas, newest first. The first signs; all verify. `pact-admin key-new` prints an entry |
| `PACT_PUBLIC_URL` | yes in production | this server's public URL, e.g. `https://board.example.com` |
| `PACT_AUTH_ISSUER` | for the hosted Claude apps | your authorization server's issuer URL; unset = agent tokens only |
| `PACT_ADMIN_REQUIRE_MFA` | no | default `1`; set `0` only if your issuer never reports `amr` |
| `PACT_ROLE` | no | `admin` runs the Admin API service (`pact-admin-api`) instead of the board |
| `PACT_ADMIN_AUDIENCE` | Admin API service | audience of Admin API tokens; set it to `<board URL>/admin` so tokens stay the same |
| `PACT_ADMIN_API` | no | default `on`; `off` makes the board stop serving `/admin/api` once the Admin API service is live |
| `PACT_SLACK_WEBHOOK_URL` | no | a Slack incoming webhook for the `default` organization until it sets its own; other organizations set theirs with `PUT /admin/api/org` (only `https://hooks.slack.com/services/…` is accepted). An organization with no webhook is sent nothing |
| `PACT_ADMIN_UI_URL` | no | the Admin UI's URL, so a notification links to its task, e.g. `https://admin.example.com` |
| `PACT_EXPORT_FORMAT` | no | `tenuo` or `biscuit`: hand agents with a registered key an outside credential on claim; unset = none |
| `PACT_EXPORT_TTL_HOURS` | no | default `24`; the longest an exported credential an agent holds lives |
| `PORT`, `HOST` | no | default `8787`, `127.0.0.1` (`0.0.0.0` in the container) |
## Deploy on Railway
1. Create a project, add a **PostgreSQL** service.
2. Add a service from this repo. Railway builds the `Dockerfile`; `railway.json` sets the health check.
3. Set the variables above. `DATABASE_URL` = `${{Postgres.DATABASE_URL}}`. Generate a public domain
and put it in `PACT_PUBLIC_URL`.
4. Deploy. Migrations run on start. Then, from your machine with the same `DATABASE_URL`
(Railway shows a public proxy URL), run the `pact-admin` commands above.
5. In Claude: *Settings → Connectors → Add custom connector* → `https://<domain>/mcp/a/<agent-id>`.
6. The Admin API runs as a second service from the same repo, so the kill switch keeps working when the
MCP side is down: add a service from this repo with `PACT_ROLE=admin`, the same `DATABASE_URL`,
`PACT_SIGNING_KEY` and `PACT_AUTH_ISSUER` (Railway reference variables), `PACT_ADMIN_AUDIENCE=<board URL>/admin`,
and its own public domain in `PACT_PUBLIC_URL`. Point the Admin UI's `PACT_ADMIN_API_URL` at it, then set
`PACT_ADMIN_API=off` on the board.
## Checks
```bash
createdb pact_test # once; .env.test points DATABASE_URL at it
uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
```
The test suite drops and recreates the `public` schema of the test database on every run.
## Layout
| Path | What |
| --- | --- |
| `src/pact/board.py` | the 10 tools as plain async methods |
| `src/pact/context.py` | project context notes |
| `src/pact/mandates.py` | issuing, whole-chain verification, aggregate limits, revocation |
| `src/pact/entries.py` | append-only log, one hash chain per project, PDPA payload erasure |
| `src/pact/admin.py` | human-only operations (the Admin UI backend) |
| `src/pact/admin_server.py` | the Admin API as its own service (`pact-admin-api`) |
| `src/pact/oauth.py` | the board as an OAuth protected resource (RFC 9728, JWKS verification) |
| `src/pact/server.py` | MCP tools, `/mcp/a/<agent>` routing, agent-token and OAuth auth |
| `src/pact/credentials/` | credential adapters (`tenuo.py`, `biscuit.py`); `tests/test_credential_conformance.py` runs every format |
| `src/pact/hooks.py` | the Claude Code hook endpoints |
| `hooks/` | the hook script and a settings example |
| `src/pact/migrations/` | SQL schema |
| `tests/` | one test per done-criterion |
## License
[Apache-2.0](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues