Agent Standup
by Zaida-3dO
README.md
<p align="right"><a href="https://github.com/Zaida-3dO/agent-standup/releases/latest"><img src="https://img.shields.io/github/v/release/Zaida-3dO/agent-standup?label=latest%20version&logo=github&logoColor=white" alt="Latest version"></a> <a href="https://github.com/Zaida-3dO/agent-standup/actions/workflows/release.yml"><img src="https://img.shields.io/github/commits-since/Zaida-3dO/agent-standup/latest?label=unreleased%20commits&logo=git&logoColor=white" alt="Unreleased commits"></a> <a href="https://github.com/Zaida-3dO/agent-standup/actions/workflows/release.yml"><img src="https://img.shields.io/github/actions/workflow/status/Zaida-3dO/agent-standup/release.yml?label=latest%20build%20status&logo=githubactions&logoColor=white" alt="Latest build status"></a></p>
# Agent Standup
A task tracker for AI coding agents: a database, a rules engine every change goes
through, an MCP so agents can talk to it, a CLI for the parts MCP can't do, and a
web front end.
**The point:** the rules live in the backend and are _enforced_ rather than
requested. An agent can't skip a step, because the server refuses the change.
## What this needs
Read this before anything else — each of these is otherwise met as a failure
rather than a decision.
- **Postgres, and it is not swappable.** Not SQLite, not a file, not an
in-memory mode. There is no embedded fallback, so there is no way to try this
without a Postgres to point it at. The reasoning is in
[`DECISIONS.md`](docs/plans/DECISIONS.md); the practical consequence is that
standing up a database is the first real step and the main adoption cost.
- **Node >= 24**, enforced by the package's own `engines` field.
- **Something to run the liveness sweep on a timer.** The application has no
internal one, by design, so nothing releases the claims of sessions that died
until something invokes the sweep. Wire it to cron or a scheduler as part of
installing, not after — see
[The liveness sweep has to be run by something](#the-liveness-sweep-has-to-be-run-by-something).
Measured on an installation running without one: the first manual sweep
released **174** stale claims that had been sitting for three days.
## Installing
> **Install `latest`, and let the registry say what that is.** The docs here
> deliberately name no version number for the npm package, so there is nothing
> in this file that can disagree with what `npm install agent-standup` actually
> gets you. `npm view agent-standup version` answers that question, and it is
> the only answer worth trusting. The same reasoning applies to the container
> image: pull a tag, not a number copied out of prose.
**Which path you want depends on what you are setting up**, and these are
different jobs:
| I want to… | Use |
| --------------------------------------------- | ------------------------------------- |
| Run the server (the database and the rules) | [Run a server](#run-a-server) |
| Point a machine at a server someone else runs | [Install a client](#install-a-client) |
### Run a server
The published container image is the supported path, and needs no registry
credential — the package is public. It is built by CI and pushed to
`ghcr.io/<owner>/agent-standup`.
Settings come from a **file**, `.env.production`, which compose reads because of
`--env-file`. Copy the template and fill it in — variables exported in your
shell do not reach compose by this path:
```bash
cp .env.production.example .env.production
```
`.env.production.example` documents all four required settings inline. In
short:
| Setting | What it is |
| ---------------- | -------------------------------------------------------------------------------------------- |
| `GHCR_IMAGE` | The image to run, e.g. `ghcr.io/OWNER/agent-standup:latest` |
| `DATABASE_URL` | The Postgres this server owns, e.g. `postgres://user:password@host:5432/agent_standup` |
| `STANDUP_TOKENS` | One bearer token per machine, `machine:token` comma-separated. The front end needs `browser` |
| `SWEEP_TOKEN` | The token half of the sweeper's entry in `STANDUP_TOKENS` |
`STANDUP_TOKENS` has no default: unset, the server refuses every authenticated
call, which is deliberate. `.env.production` is gitignored, so the tokens you
put in it stay out of the repository.
Then start it:
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml pull
docker compose --env-file .env.production -f docker-compose.prod.yml up -d
```
Leave a required setting out and compose refuses to start and names the one
that is missing — `docker-compose.prod.yml` declares each as `${VAR:?...}`, so
an incomplete file fails at `up` rather than booting a half-configured server.
`docker-compose.prod.yml` also ships the `sweep-scheduler` service that answers
the third requirement above. Full detail, including the two health probes and
what to do when Postgres is a sibling container, is under
[Deployment](#deployment).
### Install a client
A client never opens a database connection — it talks to the server's API, which
is where the rules live. That is the whole of what a client needs, and it is why
the npm package is enough: **no database, no schema, no generate step.**
```bash
npm install -g agent-standup # or: npm install agent-standup, for a local install
```
To run it without installing anything at all — which is what an MCP entry or a
hook wants, because it picks up new releases on its own:
```bash
npx -y -p agent-standup standup --help
```
Then point it at the server and check it before relying on it:
```bash
export STANDUP_URL=https://standup.example.internal
export STANDUP_TOKEN=<this machine's token>
standup doctor --json
```
`doctor` is the command to run when anything else refuses: it reports what is
configured, which layer supplied each value, and whether a binding could be
resolved at all. It answers without needing a working configuration — that is
its whole reason to exist — and it never prints a connection string or a token.
A correctly configured client reports `"binding":"http"` and `"configured":true`
with no `DATABASE_URL` set at all.
A global install puts `standup` on `PATH`; every example below is written that
short way. From a local install the binary is at `./node_modules/.bin/standup`.
#### Direct mode needs one extra command. HTTP mode does not.
**Skip this unless you are running `--direct` or `standup mcp`** — those two are
the only commands that open the database themselves. Everything else in this
README, and every agent integration, goes over HTTP and is fully set up by the
`STANDUP_URL` above.
Direct mode needs a generated Prisma client, and npm generates one for an
installed _dependency_ only when the package asks it to at install time. This
package deliberately does not ask — see
[`DECISIONS.md`](docs/plans/DECISIONS.md) — so on an npm or npx install the
client is a placeholder until you generate it once, pointing Prisma at the
schema the package ships:
```bash
npx prisma generate --schema ./node_modules/agent-standup/prisma/schema.prisma
```
Run `--direct` without it and the CLI says so and names that command, rather
than failing somewhere inside Prisma. A bare `prisma generate` is **not** the
same thing and will not work: it looks for a schema in your own project, and
the one that matters belongs to the installed package.
### Start here once something is installed
```bash
standup --help # what this build can do, setup commands first
standup init --help # set up a database and write local configuration
standup doctor # what is configured, and whether it works
```
`standup init` is the one command that runs _before_ the "not configured" gate —
establishing configuration is its job, so it cannot require configuration to
already exist. With no flags it looks for a database (`--database-url`, then
`DATABASE_URL`, then a previous `init`'s configuration file) and tries a local
container runtime if it finds none. Point it at a database you already have
with `standup init --database-url <url>`, or have it provision one for you from
an admin connection with `--provision-url`.
To connect an agent rather than a person, see
[using-agent-standup.md](docs/using-agent-standup.md); `standup mcp` serves MCP
over stdio for an installation with no server, which makes it one of the two
commands that needs
[the generate step above](#direct-mode-needs-one-extra-command-http-mode-does-not).
## Docs
**If you are an agent about to use this tracker, start here:**
| Doc | What it is |
| ----------------------------------------------------- | --------------------------------------------------------------------------------- |
| [using-agent-standup.md](docs/using-agent-standup.md) | **How to use the product correctly** — claims, checkpoints, artifacts, completion |
| [orchestration.md](docs/orchestration.md) | Running a queue of work, and dispatching rows to other agents |
> **Ask the server rather than reading about it.** `describe_tool {tool: "claim"}` returns one tool's
> full contract, **including the conditional rules a JSON schema cannot express** — the ones that
> actually refuse you. Call it before your first use of any write tool; bare `describe_tool` reports
> what this build is and the limits it enforces.
The design record is in [`docs/plans/`](docs/plans/):
| Doc | What it is |
| ----------------------------------------- | ------------------------------------------------ |
| [PLAN.md](docs/plans/PLAN.md) | The readable plan — how it works, in plain terms |
| [SCHEMA.md](docs/plans/SCHEMA.md) | Tables, config, MCP tools, HTTP endpoints |
| [DECISIONS.md](docs/plans/DECISIONS.md) | Every decision with its reasoning |
| [MILESTONES.md](docs/plans/MILESTONES.md) | The work, broken into pull requests, in order |
## Stack
Next.js (front end and API in one bundle) · Prisma · Postgres. The image is built
in CI, pushed to GHCR, and **pulled** wherever it runs — never built on the deploy
host, no bind mounts.
## Local development
**This section is for working on Agent Standup itself.** To install and use it,
see [Installing](#installing) above — the steps below set up the repository for
development and are not the shortest path to a running installation.
Requires Node 24 and a reachable Postgres. **Docker is one way to get that Postgres, not a
requirement of the app** — `npm run db:up` is a convenience wrapper around `docker compose up -d db`
and is the only thing in the repo that shells out to Docker. Nothing under `src/` touches it. The app
reads one connection string, so a natively installed Postgres (Postgres.app, Homebrew, a distribution
package, or a userspace `initdb`) works identically: point `DATABASE_URL` at it and skip `db:up`.
```bash
cp .env.example .env # fill in DATABASE_URL etc.
npm install
npm run db:up # OPTIONAL — starts local Postgres in Docker on a non-default port.
# Skip it if you already have a Postgres; just set DATABASE_URL.
npx prisma migrate deploy # apply the committed migrations
npx prisma generate
npm run dev # http://localhost:3000
```
`PORT` and `HOSTNAME` control what the server listens on, so `http://localhost:3000` above is the
default rather than a fixed address — see [Configuration](#configuration).
### Configuration
Only what must be known before the process can reach a database is an
environment variable — `DATABASE_URL`, plus `HOSTNAME` and `PORT` for what
interface and port the server listens on. `.env.example` lists these and the
handful of others that are genuinely bootstrap (the local Postgres readiness
wait, the disposable shadow database the migration drift check uses).
Authentication is the other bootstrap value: `STANDUP_TOKENS` holds one
bearer token per machine, and clients present theirs as `STANDUP_TOKEN`.
It has no default — with it unset the server refuses every authenticated
call, which is deliberate (a gate that switched itself off when its
configuration was missing would be open exactly when a deployment had gone
wrong). It is an environment variable rather than a setting for the same
reason as the rest of this list, plus one specific to it: settings are
served to the front end and printed by the command line, with no redaction
path, so a credential cannot live there.
**The front end needs one of those tokens too, and it must be its own.** A
browser is not a machine: it holds no configuration, and anything handed to a
page is readable by whoever opens the developer tools, which would make
revoking one machine's access meaningless. So the browser is never given a
credential. It calls `/api/ui/*`, a server-side route that attaches the token
for the machine named `browser` (override with `STANDUP_BROWSER_MACHINE`) and
forwards to the same authenticated handlers every other client reaches — so
the call is authenticated by the ordinary gate rather than exempted from it,
and the token stays in the server process. Configure one alongside the rest:
```
STANDUP_TOKENS=browser:TOKEN-A,laptop:TOKEN-B
```
With no token configured for that machine the front end serves a 503 saying
so, rather than falling back to calling the API without one.
Everything else is a setting: typed, defaulted in code, and readable and
writable once the app is running, from `/settings` in the front end or
`standup config set` on the command line. A fresh database boots fully
working with no settings configured at all — each one has a default. Setting
an old environment variable that has moved into settings does nothing; a
startup check catches this — it fails immediately in development, and logs
loudly (without stopping the process) in production.
Useful scripts:
| Command | What it does |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run dev` | Next.js dev server |
| `npm run build` / `npm start` | Production build / run it |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run lint` / `npm run format` | ESLint / Prettier (`:check` variants exist for CI) |
| `npm test` | Vitest, wrapped so a failing run cannot look green — it also fails on the printed summary, so an empty or failing run is caught even through a pipe. **Use this one.** |
| `npm run test:raw` | Bare `vitest run`. Careful: `... \| tail` reports the pipe's exit status, not vitest's |
| `npm run db:migrate` | Create/apply a dev migration (`prisma migrate dev`) |
| `npm run db:deploy` | Apply committed migrations without prompting (`prisma migrate deploy`) |
| `npm run db:check-drift` | Fail if `schema.prisma` and `prisma/migrations` disagree — needs `SHADOW_DATABASE_URL` pointed at an empty, disposable Postgres |
| `npm run db:studio` | Prisma Studio |
The initial baseline migration (the whole schema in one shot — see
[`SCHEMA.md`](docs/plans/SCHEMA.md)) lives in `prisma/migrations/`. CI applies it to a
throwaway Postgres on every run and fails if `schema.prisma` and the migration history
have drifted apart.
## Deployment
The image is built by [`.github/workflows/release.yml`](.github/workflows/release.yml)
on a version tag or manual dispatch, and pushed to `ghcr.io/<owner>/agent-standup`
tagged `latest` and the version. The package is public, so pulling it needs no
registry credential. Wherever it runs, pull and run it with
[`docker-compose.prod.yml`](docker-compose.prod.yml):
```bash
cp .env.production.example .env.production # then fill in the four settings
docker compose --env-file .env.production -f docker-compose.prod.yml pull
docker compose --env-file .env.production -f docker-compose.prod.yml up -d
```
The settings themselves are described in
[`.env.production.example`](.env.production.example) and summarised under
[Run a server](#run-a-server). They are read from the file named by
`--env-file`, not from the surrounding shell.
`docker-compose.prod.yml` has no `build:` block and no bind mounts by design — it
only ever pulls. It ships a health check on `GET /api/health` (liveness only —
deliberately doesn't touch the database, so a slow DB doesn't make the process
report unhealthy).
**Two probes, answering two different questions.** Point each consumer at the
one it actually needs, because giving either the other's answer is wrong in a
way that is quiet:
| Endpoint | Asks | Reads the database | For |
| ------------- | --------------------- | ------------------ | --------------------------------------------------------- |
| `/api/health` | Is this process alive | No | Restart policies — a container that has stopped serving |
| `/api/ready` | Can I use this yet | Yes | Deployment gates, `depends_on` conditions, load balancers |
A process whose Postgres is still starting is **alive and not ready**, which
is normal and common. Report that as unhealthy and a restart policy kills a
container that was about to work; report it as ready and a load balancer
sends traffic to a process that cannot serve it.
`/api/ready` answers `200` when it can query the database and no migration is
half-applied, and `503` otherwise, with a body carrying the migration counts:
_connected but two migrations behind_ and _migrated and ready_ are different
answers, and only one is safe to send traffic to. Both probes are
unauthenticated — the things that ask them run before an installation is
configured and hold no credential — and both report only booleans and counts.
### Many machines, one server
The schema is built for a fleet: `machines` is a first-class entity, work is
claimed per session, and `assignments` records which machine holds what. A
single-host compose file is the simplest deployment of that design, not the
limit of it — the usual shape is **one server and its database, and a client
on every machine doing the work.**
**A remote client talks to the API. It never opens a connection to the
database.** This is the one deployment rule worth stating outright, because
the alternative is available and looks equivalent from the outside:
- Every rule this product enforces — a merge needing an approving review at
tip, a completion needing a structured summary, a transition needing an
approved plan — is **application code in the service layer.** Postgres
does not know those rules exist and cannot be taught them: _allowed only
with an approving review at tip_ is conditional on state a grant cannot
evaluate.
- So a client on `DATABASE_URL` does not defeat those checks; it never
reaches the code that performs them. An item can land in `merged` with no
commit, no review and no summary, and nothing in the system is wrong about
anything — the rules were simply never consulted.
- **Database-level permissions are not a substitute.** A restricted role can
refuse a write to a table. It cannot express the condition above, which is
the one that matters.
Point each machine at the server and give it its own token:
```bash
STANDUP_URL=https://standup.example.internal
STANDUP_TOKEN=<this machine's token>
```
Both the command line and the MCP client use the API when `STANDUP_URL` is
set. `DATABASE_URL` belongs to the server alone; a client that has one is
configured as though it were the server.
Tokens are per machine rather than one shared secret, which buys two things:
a machine can be revoked without rotating every other machine's
configuration, and the actor a client declares stops being an unverified
self-report — the server knows which machine presented the token, so an
attributed write means something.
### The liveness sweep has to be run by something
**A deployment that never runs the sweep leaks claims that can never be handed
back.** A session takes ownership of an item by claiming it; if that session
crashes rather than releasing, the claim outlives it and every later claim on
that item is refused as already-held. The liveness sweep is what notices — it
ages quiet sessions, releases what died, and escalates what is stuck — and it
runs only when something invokes it. Measured on an installation running without
one: the first manual sweep released **174** stale claims that had been sitting
for three days, every one of them blocking ownership of its item.
**The application deliberately has no internal timer.** It runs as a bundle that
may be one replica or several, so a timer inside it fires once _per replica_ — a
multiple of the intended rate on a scaled deployment, or not at all if the
replica holding it is the one that restarted — and neither mistake produces any
output to notice. Invoke it from outside the process, where there is exactly one
of whatever you choose.
`docker-compose.prod.yml` ships a `sweep-scheduler` service that does exactly
that: the same image, one replica, `node scripts/sweep-schedule.mjs`. It needs
its own machine entry in `STANDUP_TOKENS` and that machine's token in
`SWEEP_TOKEN`:
```bash
STANDUP_TOKENS=browser:AAA,laptop:BBB,sweeper:CCC
SWEEP_TOKEN=CCC
```
**It proves it can authenticate before it schedules anything** — one real
`{"dryRun": true}` sweep at startup, which writes nothing — and refuses to start
if the server rejects the token. A 401 or 403 at any later point is fatal too,
because a revoked or mistyped token will never start working on its own; every
other failure (the app restarting, a timeout, a 500) is retried at the next tick.
That asymmetry matters because of how a scheduler fails badly: one that treats a
rejected credential as retryable logs a 401 every tick, sits `Up` in `docker ps`,
and sweeps exactly zero times while looking correctly configured. A scheduler
that reports healthy while doing nothing is worse than no scheduler, so an
unusable credential stops it outright.
If you would rather run it from outside compose, either surface works and
nothing in the application distinguishes the callers:
```bash
# Host cron, every five minutes — over HTTP:
*/5 * * * * curl -fsS -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/sweep >/dev/null
# …or over the command line, which reports what it released:
*/5 * * * * standup sweep --json
```
`POST /api/sweep` authenticates like every other route, so a scheduler calling
it needs a token in `STANDUP_TOKENS` the same as any machine. The endpoint is
`POST` rather than `GET` on purpose: it writes, and a `GET` that releases other
sessions' claims is one a crawler or a browser prefetch will invoke without
anyone asking it to. It takes no input, so an empty body is fine.
**Worth knowing before you automate it.** A timer reclaims on the strength of a
liveness signal that may not be written — heartbeats are optional, and the
process check is what usually answers — so a session that claims an item and
then works for half an hour can look the same as one that crashed. Reclaiming at
the point of contention, when another session actually wants that item, is a
safer place to be wrong than a fixed tick. Escalation is the part that genuinely
needs a push, because nobody is reading by definition.
### Postgres
This app needs its own Postgres reachable via `DATABASE_URL`. Prefer a
**dedicated Postgres instance** over adding a database to one that already
serves another app — it keeps credentials, backups, and version upgrades
independent, and the cost of one more small container is low. Only share an
existing instance if there's a specific reason to (e.g. a hosting limit on
how many database services are allowed).
If Postgres runs as its own container next to this one, order startup with
`depends_on: condition: service_healthy` — the entrypoint runs
`prisma migrate deploy` at boot, which opens a real database connection even
when there are zero pending migrations (expect and ignore
`No migration found in prisma/migrations` until the baseline migration
ships — see `MILESTONES.md`). Give Postgres's own health check a generous
`start_period`: a cold first boot (`initdb` plus the official image's own
internal restart) can take noticeably longer than a short window allows,
which can make `depends_on` give up right before Postgres would have come up
healthy on its own.
### Deploying alongside other services
Some hosts run several unrelated apps under one shared Docker Compose
project rather than one compose file per app — a shared `.env` holding
per-service location/config variables, one compose file defining every
service, sub-folders per service holding data only. If that's the target,
fold this app's service block (and a Postgres block per the section above)
into the shared file instead of running `docker-compose.prod.yml` standalone
— the service definitions are the same either way, only which file they live
in changes. In that setup:
- **Back up the shared compose file first**, before editing it.
- **Never run a bare `up`, `down`, or `restart` with no service names** in a
directory that already has other services running from that file — always
name the services you mean to affect explicitly, e.g.
`docker compose up -d agent-standup agent-standup-db`. An unscoped command
recreates (or stops) everything the file defines, not just what you're
deploying.
- **Pick a host port that isn't already in use** — check what the shared
compose file and the host's listening ports already claim before adding
`APP_PORT`.
- Keep real secrets (the generated `DATABASE_URL` password, etc.) only in
that host's own `.env` — never copied into this repo.
## What is built
The service layer holds **69 registered operations** (`src/lib/service/registry.ts`). Every rule
lives there, so an adapter is a thin shell over one service call and adds no rule of its own —
which is what makes a refusal the same refusal whichever way in you came.
**The four adapters do not all expose the same set, and the difference is worth knowing before you
pick one.** MCP derives its tools from the registry and so carries 66 of the 69, declining three by
written waiver (`src/lib/adapters/waivers.ts`). The command line routes 46 and the web API 49,
because each maps operations through its own table and those tables lag the registry — `service_info`
and `describe_tool`, for instance, are reachable from MCP and the command line but have no HTTP
route. Ask a running instance rather than taking any of this on trust:
```bash
standup service info --json # the operation catalogue, and the limits a caller must respect
standup --help # every noun and verb, built from the command table itself
```
| Surface | What it is |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Web API** | 45 operations over JSON routes under `src/app/api` — items, claims, transitions, artifacts, events, settings, admin entities. Three further routes are not part of that surface: a liveness check, the MCP transport below, and one that serves the hook script itself |
| **MCP** | The agent-facing surface, over streamable HTTP (`/api/mcp`) and over stdio. Tools are derived from the operation registry, so there is no second list to forget an operation in |
| **Command line** | `standup <noun> <verb>`, 46 operations, on either of two bindings — over HTTP against a server, or `--direct` against `DATABASE_URL` in-process |
| **Front end** | The board, an item detail view, a since-your-last-visit ledger, a settings editor and an admin section |
An item minted through the product walks the full state machine on service calls alone —
`plan_review → executing → in_review → merged` — because the artifacts each transition guard reads
are writable through the service. The rules are enforced in the service layer, so a refusal is the
same refusal on every surface: a missing approving review at tip, a claim already held, or a
completion with no structured summary is rejected identically whether it arrived from an agent, a
terminal or the API.
The schema ships as one baseline migration, and a one-time bulk import (`docs/plans/BACKFILL.md`)
loads a backlog held in an external file-based store.
**Where the edges are.** [`MILESTONES.md`](docs/plans/MILESTONES.md) is the honest inventory: it
carries every row with its status, and the queue is worked in dependency order rather than
front-to-back. One limit is worth knowing before deploying: the liveness sweep only runs when
something invokes it — see above, because claims leak while nothing does.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessResponsive