Phase 2 configures forge for the agents (operator only sets a credential_ref + optional version pin) and lets them drive a junior→senior→deploy workflow: - Credential resolution/injection (control/credentials.py): credential_ref pointers (env:/file:/cmd:) resolved only at spawn, injected as FORGE_TOKEN + host var, with a forge-host-scoped git credential helper reading the token from env (never on disk / in the DB). Resolution is a fail-fast spawn gate. - Role-based forge skills committed into the managed repo (control/skills_gen.py, `handler forge-init`): forge-junior/senior/deploy + a workflow overview. - Hard approval gate (hooks/gate.py, approvals table, migration 0002): merge/deploy — and direct pushes to protected branches — are denied unless a DIFFERENT agent has an `approved` record for the branch, pinned to the reviewed commit (approved_sha). Senior records verdicts via `handler approve`/`reject`. - forge/git seams (control/forge.py, control/gitops.py) matching the Phase 1 seam pattern. - CI status poller (control/poller.py, `handler poll-ci [--watch]`) backfilling ci_status/ci_checked_at via `forge ci list`. - Fix: migrations/env.py commits explicitly after run_migrations — pysqlite on Py 3.12+ was rolling back the final migration's DDL + alembic_version stamp (latent in Phase 1). Reviewed via a separate code-reviewer pass; gate-bypass and credential-scoping findings addressed. 106 tests, ruff clean, verified end-to-end against real git + migrations.
handler
A remote control wrapper for Claude Code agents.
Run many claude agents across many projects — each isolated, each leaving behind a
checkmark (its current state) and an entry in a big log (the complete history) —
all backed by a centralized database, driven entirely through an HTTP API.
Every agent process is a real claude binary invocation. There is no hard dependency on
any particular git host or network layer: you bring your own Claude Code login, your own
git remote, and your own network exposure.
Status: Phase 2 (forge integration) implemented on top of the Phase 1 MVP. The control layer, HTTP API, database, migrations, and verification/approval hooks are implemented and tested (106 tests, SQLite). Phase 2 adds credential resolution + injection, role-based forge-workflow skills, a hard approval gate, and a CI-status poller. Live end-to-end agent spawning against a real
claudebinary + tmux is stubbed behind mockable seams (tmux,verify,forge,gitops,spawn.resume) and wired but not yet exercised against production binaries. Seedocs/PLAN.mdfor the full design and roadmap.
Why
One operator running several of their own projects wants to fan work out to background
Claude Code agents and keep a reliable, queryable picture of what each one is doing —
without babysitting a wall of tmux panes. handler gives every agent:
- A checkmark — one small, always-current row: where it stopped, what's next, any open question for you. Overwritten on every checkpoint, like a file you keep saving.
- A big log — the append-only history of everything every agent has ever done.
- A verification gate — an agent never reaches
doneon its own say-so. AStophook runs the project's own test task and blocks the turn on failure, sodonein the database means a test run passed. - A push gate — a
git pushdoesn't leave until tests pass and a throwaway image build succeeds locally, so a push already known to fail CI never goes out. - Isolation — each project has its own working directory, agents, history, and credentials; nothing crosses the boundary unless you explicitly share it.
Architecture
Three components over one database. The database is the only thing that holds state, so the control layer and API are disposable compute that can restart or scale out freely.
writes reads (+ answer backfill)
┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ control layer │───────▶│ database │◀───────│ HTTP API │
│ (CLI + hooks) │ │ PG / SQLite │ │ (FastAPI) │
└──────────────────┘ └──────────────┘ └──────────────────┘
│ ▲ ▲
│ spawns │ Stop / PreToolUse / Notification hooks │ curl, UI, any client
▼ │ write checkmark + log rows │ (bearer token)
tmux + claude binary (one working dir / worktree per agent)
- Control layer (
handler.control) — the only writer. Spawns/lists/attaches/kills agents astmuxsessions running theclaudebinary, one working directory or git worktree per agent, namespacedproject__agent. Stateless; every write goes straight to the database. - Hooks (
handler.hooks) — run inside each agent via a generatedsettings.json. They write the checkpoint/log rows and enforce the test and push gates. - API (
handler.api) — a thin, read-mostly HTTP layer over the same database (the one write it does is backfilling an operator's answer). Bearer-token auth on every route; every agent route is nested under/projects/:project/so nothing leaks across a project boundary.
One schema, two backends
The data model is defined once (SQLAlchemy Core) and renders correctly on both:
- Postgres (default for real deployments) —
BIGSERIAL,TIMESTAMPTZ,JSONB. A live central server is what makes the stateless-container story true. - SQLite (minimal-infra fallback) — a single file, zero services. Same schema shape,
simpler types (
INTEGER PRIMARY KEY, TEXT, JSON).
Portable column types bridge the two, and the checkmark upsert uses native
INSERT … ON CONFLICT DO UPDATE on both dialects. Migrations are Alembic, dual-dialect.
Requirements
- Python 3.11+
gitandtmux(for live spawning)- A
claudebinary, authenticated (for live spawning) misein each managed project, with a.mise.tomldefining at least atesttask- Postgres (default) — or nothing but a file path for the SQLite fallback
Install
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Configure
Configuration is entirely environment-driven (see .env.example):
| Variable | Purpose | Default |
|---|---|---|
DATABASE_URL |
sqlite:////abs/path.db or postgresql+psycopg://… |
sqlite:///./handler.db |
AUTH_TOKEN |
Global bearer token gating every API route | (required for the API) |
SHARED_CONTEXT_WRITE_TOKEN |
Higher-trust token gating PUT /shared/context/:key |
falls back to AUTH_TOKEN |
WEBHOOK_URL |
Generic target for the Notification hook (ntfy, Slack, …) |
unset → no-op |
PROJECTS_ROOT |
Base dir for per-project roots / worktrees | ./projects |
CLAUDE_BIN / MISE_BIN / TMUX_BIN / FORGE_BIN / GIT_BIN |
Binary overrides | claude / mise / tmux / forge / git |
FORGE_VERSION |
Pinned forge version verified at spawn (Phase 2) | unset → skip check |
PROTECTED_BRANCHES |
Branches a direct push needs an approval to reach (Phase 2) | main,master |
Run
Apply migrations, then start the API:
export DATABASE_URL="sqlite:///$PWD/handler.db"
export AUTH_TOKEN="$(openssl rand -hex 32)"
alembic upgrade head
uvicorn handler.api.app:app --host 0.0.0.0 --port 8000
The API is just a client contract — everything below works with plain curl:
TOKEN="Authorization: Bearer $AUTH_TOKEN"
BASE="http://127.0.0.1:8000"
# Register a project and an agent
curl -s -X POST $BASE/projects -H "$TOKEN" -H 'Content-Type: application/json' \
-d '{"id":"leeworks-api","root_dir":"/srv/projects/leeworks"}'
curl -s -X POST $BASE/projects/leeworks-api/agents -H "$TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"api","working_dir":"/srv/projects/leeworks/api"}'
# Read an agent's checkmark and log
curl -s $BASE/projects/leeworks-api/agents/api/checkmark -H "$TOKEN"
curl -s $BASE/projects/leeworks-api/agents/api/log -H "$TOKEN"
# Answer a paused question, then resume the agent
curl -s -X POST $BASE/projects/leeworks-api/agents/api/answer -H "$TOKEN" \
-H 'Content-Type: application/json' -d '{"answer":"use Postgres"}'
curl -s -X POST $BASE/projects/leeworks-api/agents/api/resume -H "$TOKEN" \
-H 'Content-Type: application/json' -d '{}'
Control CLI
The handler command manages agent processes (the write side):
handler spawn --project leeworks-api --name junior --role junior --worktree feat/auth --task "add login"
handler list [--project leeworks-api]
handler attach --project leeworks-api --name junior
handler kill --project leeworks-api --name junior
# Phase 2 — forge workflow
handler forge-init --project leeworks-api # write + commit the role skills
handler approve --branch feat/auth --pr 12 # senior agent records its verdict
handler reject --branch feat/auth --note "fix X" # (project/agent from env in-session)
handler poll-ci [--project leeworks-api] [--watch] # backfill CI verdicts
spawn refuses any project whose working directory has no .mise.toml with a
[tasks.test] task — the verification gate is a hard requirement, not a convention — and
it also refuses to start if the project's credential_ref is configured but can't be
resolved, so a broken secret pointer fails fast instead of leaving an orphaned agent. It
resolves the working directory (a subdirectory or a fresh git worktree, always under the
project root), writes a per-agent .claude/settings.json wiring the hooks, resolves and
injects the project's credentials (see below), and launches a tmux session with the
agent's identity and DATABASE_URL injected into its environment. --role
(junior/senior/deploy) records which forge-workflow role the agent plays.
API reference
All routes require Authorization: Bearer <AUTH_TOKEN>. GET /health is unauthenticated.
| Method & path | Purpose |
|---|---|
GET /projects · POST /projects |
List / register projects |
GET /projects/:p/agents · POST … |
List / register agents (project-scoped) |
GET /projects/:p/agents/:name/checkmark |
The agent's current-state checkmark |
GET /projects/:p/agents/:name/log |
The agent's log history (paginated) |
POST /projects/:p/agents/:name/answer |
Backfill the operator's answer to an open question |
POST /projects/:p/agents/:name/resume |
Feed the answer back via claude --resume |
GET /shared/log |
Cross-project feed of entries explicitly marked global |
GET /shared/context · GET /shared/context/:key |
Read shared key/value facts |
PUT /shared/context/:key |
Write a shared fact — requires the shared-write token |
Hooks
Wired into each agent as python -m handler.hooks <event>:
Stop/SessionEnd— checkpoint. OnStop, runmise run test; on failure, returndecision: "block"with the output so the turn cannot end on red. Recordstests_status/tested_at;status = 'done'only ever accompanies a pass.PreToolUse— two jobs. AnAskUserQuestionis deferred: the question is persisted, the checkmark set topaused_for_input, and the tool call denied so control hands off to the async answer/resume flow. ABashcommand runninggit pushtriggers the push gate — tests first, then a throwaway image build (mise run build-image) — and is denied on the first failure.Notification— POSTs a small JSON payload toWEBHOOK_URL(no-op when unset). Never blocks the agent on delivery failure.
Hook identity travels via environment variables injected at spawn (HANDLER_AGENT_ID,
HANDLER_PROJECT_ID, HANDLER_AGENT_NAME, HANDLER_AGENT_ROLE, DATABASE_URL), since
hook stdin doesn't carry it; the wiring itself lives in the generated settings.json.
Forge workflow (Phase 2)
Handler doesn't give the operator forge commands. It configures forge for the agents
and lets them drive a role-based dev workflow themselves — the operator only sets a
project's credential_ref (and optionally a FORGE_VERSION pin).
- Three roles, three agents. A
junioragent writes the change and opens a PR; asenioragent reviews it and records an approval; adeployagent merges and ships it. Each is a separate agent with its own tmux session and working dir, so review is a genuine second context — not the author signing off on their own work. - Skills, committed into the repo.
handler forge-initwrites role skills (forge-junior,forge-senior,forge-deploy, plus an overview) into the managed repo's.claude/skills/and commits them, so the workflow travels with the code and is visible to humans.forgeitself is already authenticated inside each agent, so it works the same across GitHub/GitLab/Gitea/Forgejo/Bitbucket. - A hard approval gate. A merge or deploy command (
forge … merge,mise run deploy) — and a directgit pushto a protected branch (main/master, seePROTECTED_BRANCHES) — is denied unless a standingapprovedrecord exists for the current branch, made by a different agent than the one merging, and still pinned to the reviewed commit (pushing new commits invalidates a stale approval). Same block-on-failure mechanism as the test and push gates — the senior'shandler approveis what unlocks it, no agent can approve its own branch, and the protected-branch rule closes the "merge locally, push to main" path around it. - CI follow-through. When a push clears the local gates, Handler records the commit
with
ci_status = 'pending'. Thehandler poll-cipoller then asksforge ci listfor the runs tied to that commit and backfills the authoritative verdict — one interface, any forge, no inbound webhook.
Credentials — resolution, not storage (README 3.7)
The database never stores a raw token. A project's credential_ref is a pointer:
| Form | Meaning |
|---|---|
env:VAR_NAME |
read the value from an environment variable |
file:/path |
read (and strip) the value from a file |
cmd:some command |
run the command; its stdout is the value |
At spawn the control layer resolves the pointer and injects the value into that one
agent's environment as FORGE_TOKEN (plus the host-specific GITHUB_TOKEN /
GITEA_TOKEN / … when the remote is recognized). A repo-local git credential helper is
installed that hands the same value back for HTTPS push/pull — so one secret services both
forge and git, and the raw token lives only in the process environment, never on disk
or in the database.
Development
pytest # 106 tests, entirely on SQLite — no live claude/tmux/mise/forge/git needed
ruff check . # lint
# or, via the project's own mise tasks:
mise run verify # lint + test
The suite drives every API route through FastAPI's TestClient, exercises all four hook
types plus the approval gate, credential resolution, the skills generator, and the CI
poller, and runs a real alembic upgrade head per test so the migration path itself is
covered. The seams — control.tmux, control.forge, control.gitops, hooks.verify,
and control.spawn.resume — are the mock points that stand in for live
claude/tmux/mise/forge/git, and the drop-in points for wiring them up for real.
Project layout
src/handler/
config.py # env-driven settings, shared by every entrypoint
db/ # SQLAlchemy Core schema, engine, portable types, upsert, DAL
api/ # FastAPI app, auth deps, pydantic schemas, routes
control/ # CLI, tmux/worktree/settings-gen seams, spawn orchestration,
# forge/gitops seams, credentials, skills_gen, CI poller
hooks/ # Stop/SessionEnd, PreToolUse gate (push + approval), Notification
migrations/ # Alembic env + versions
tests/ # DB, API, hook, and control tests (SQLite)
docs/PLAN.md # full design + phased roadmap (the original plan of action)
Roadmap
Phase 1 (the MVP) is the control layer + API. Phase 2 (forge integration) is
implemented: credential resolution + injection, role-based forge-workflow skills, the hard
approval gate, and the CI-status poller — one interface across GitHub / GitLab / Gitea /
Forgejo / Bitbucket. Still ahead: Phase 3 a web UI, Phase 4 optional observability,
and Phase 5 open-source release. Details and design rationale live in
docs/PLAN.md.
License
MIT.