Control plane
July 1, 2026 · View on GitHub
Rename in flight: "control-plane" is being renamed to Control Hub (
agentbox hub) — seecontrol-plane-roadmap.md(milestone M1). This backlog stays as the historical record of what shipped under the old name; the code/CLI/config still saycontrol-planeuntil the M1 rename lands.
Status of the control plane: a portable service that holds the centralized concerns for boxes — git credentials (GitHub-App token leasing), permission state, the box registry/events — so boxes keep pushing / opening PRs while the user's laptop is off. Maintained live during implementation (per project convention).
Plan of record: ~/.claude/plans/design-a-new-approach-synthetic-bee.md.
The pivot (vs the first attempt)
The first attempt ran the relay on a dedicated always-on VPS with a long-lived fine-grained PAT. It worked but (1) billed a never-sleeping VPS for something mostly idle, and (2) pushed by reaching back into the box over the cloud SDK to make + download a git bundle — the part that resists a stateless/serverless deployment.
The current design is one portable control plane — the @agentbox/relay
core wrapped as a single Next.js + Postgres app (apps/control-plane). The
same code deploys to Vercel (managed) or self-hosts on a VPS (Postgres +
next start, via docker-compose). It is stateless per request (all state in
Postgres) and does no host execution. Git auth is GitHub-App leasing: it
mints 1-hour, single-repo installation tokens and leases them to boxes, which
push to GitHub directly — no bundle transfer, no SDK reach-back. That dedicated
always-on VPS (with its stored PAT) is removed. The laptop loopback relay
(agentbox-relay serve) is unchanged and shares the same core.
Architecture
cloud box --(forwarder, https)--> control plane (Next.js + Postgres)
| POST /events (per-box bearer) | Store (boxes/events/status/prompts)
| POST /rpc git.lease-token | GitHub App -> 1h repo-scoped token
| GET /rpc/status/:id (poll) | /admin/* (admin bearer, fail-closed)
laptop CLI --(register/answer, admin bearer)
box --(push directly with leased token)--> GitHub
- Store seam (
packages/relay/src/store/) — every relay handler talks to an asyncStore.MemoryStore(laptop + tests, wraps the historical in-memory structures),PostgresStore(hosted plane).pgis lazy-imported + bundler- external, so the laptop relay/CLI carry no pg. - Poll-based approvals (
permission.ts) —promptModeisblockon the laptop (in-process wait, unchanged) andpollon the hosted plane (parks a prompt row; the box polls/rpc/status/:id; the approved action runs there). - GitHub-App leasing (
github-app.ts,lease.ts) —git.lease-tokengated like push (agentbox/*auto, else approval); repo resolved from the box's REGISTEREDoriginUrl, never box params. In-boxgit pushleases + pushes directly whenAGENTBOX_GIT_LEASE=1(token in the remote URL for the push only, scrubbed after). - Hosted-plane handler (
core/handler.ts) — framework-agnostichandleRelayRequest(GenericRequest) -> RelayResponse; the Next.js app (apps/control-plane) is a thin Web-Request adapter over it. Rejects host-local RPCs (cp/download/checkpoint/docker git.push) — no host on the plane.
Phase status
- Phase 0 — Store seam (MemoryStore). Async
Storeover boxes/events/ status; handlers route through it; zero behavior change. Conformance suite. - Phase 1 — PostgresStore.
pg(lazy + external),makeStore(),migrate(); conformance green vs livepostgres:16. Laptop/CLI verified pg-free. - Phase 2 — Poll-based approvals. Prompt mailbox in both stores;
promptMode;202 {promptId}+GET /rpc/status/:id; ctl polls transparently. - Phase 3 — GitHub-App leasing + remove dedicated control-box.
github-app.ts,git.lease-token, in-box lease/push; deleted theagentbox control-boxcommand +sandbox-hetzner/control-box.ts. Therelay.controlPlaneUrlkey stays (now points at the hosted plane). The relay's legacy--control-boxadmin-bearer mode + its stored-PAT bundle-push (pushBundleWithPat/pushBundleToRemote) were later removed once the hosted plane + App leasing fully superseded them (see Cleanup below). - Phase 4 — Control-plane Next.js app (
apps/control-plane).handleRelayRequestcore + lean@agentbox/relay/control-planeentry; catch-all route handler; PostgresStore + App leaser from env; docker-compose + Dockerfile + README + Vercel notes. Verified:next build, live HTTP smoke vs postgres:16 (fail-closed admin gate, register/events persisted, agentbox/* lease, host-local -> 501). - Phase 4b — federation data layer (RemoteStore).
store-rpc.ts(applyStoreOpallow-list) +POST /admin/store+RemoteStore; conformance green. Remaining laptop wiring (autopause/queue over the store, docker cloud-only bypass, admin-CLI retarget) deferred. - Phase 5 — Box creation from the plane. The durable job queue
(
create_jobs, atomicFOR UPDATE SKIP LOCKEDclaim),POST /remote/boxes(202 {jobId}) +GET /remote/boxes/:id, thedrainCreateJobsworker (injectableCreateBoxFn), and theagentbox control-plane workercommand (--once/ loop; auto-loads the setup-written App creds; PostgresStore +GitHubAppLeaser+providerForCreate). The worker's productionCreateBoxFnis origin-clone seeding: lease an App token →git clonethe repo to a local temp dir → scrub the remote back to the bare origin → hand the checkout to the normalprovider.create({ workspacePath })→rm -rfthe temp dir. Full loop validated live end to end (enqueue → atomic claim → lease → clone →provider.create()→ jobdonewithresult.boxId— see below).- Scope: cloud providers only (matches §5 "the hosted plane creates cloud
boxes only"). Cloud providers seed the sandbox from the checkout (git bundle),
so the post-create temp-dir cleanup is safe. The docker provider is not
a valid plane target: it bind-mounts the workspace's
.gitas the box's persistent backing, so the worker'sfinallycleanup deletes the live gitdir out from under the container (the seeded files survive; in-boxgitbreaks). Docker boxes are created locally byagentbox create, never by the plane.
- Scope: cloud providers only (matches §5 "the hosted plane creates cloud
boxes only"). Cloud providers seed the sandbox from the checkout (git bundle),
so the post-create temp-dir cleanup is safe. The docker provider is not
a valid plane target: it bind-mounts the workspace's
- Setup CLI —
agentbox control-plane.setupruns the GitHub App manifest flow (localhost callback → browser → code exchange) and writes the deploy env + admin token;set-url/status. Tested e2e against a fake GitHub. - Phase 6 — Dashboard pages + docs sync. A token-gated App-Router
dashboard at
/(apps/control-plane/app/page.tsx+layout.tsx): a pure client view that reuses the admin-bearer auth (token insessionStorage, sent as the Bearer on every/admin/*fetch — no new server session), showing pending approvals (approve/deny via/admin/prompts/answer), the box registry, and recent events; polls every 4s. Coexists with the/[...path]API route handler (the catch-all is required, so/is free). Docs synced: new publiccontrol-plane.mdxreference page (+ nav entry,agentbox control-planeCLI commands incli.mdx,relay.controlPlaneUrlrow inconfiguration.mdx), a hosted-control-plane section indocs/host-relay.md, and the staleagentbox control-boxcommand references removed fromconfig/types.ts+host-actions.ts. (cloud-providers.mdalready had no control-box refs.) - Cleanup — remove the legacy
--control-boxrelay mode. The dedicated control-box (a VPS running the relay binary with a stored PAT) was the v1 approach; the hosted plane + App leasing replace it. Removed cleanly (AgentBox is unreleased): theagentbox-relay serve --control-boxflag + boot mode (bin.ts); thecontrolBox/adminToken/promptMode='poll'branches inserver.ts(the laptop relay is loopback-gated,/remote/*404s here — box creation is the Next.js plane's surface); thecontrolBox/githubTokendeps + thepushBundleWithPatPAT bundle-push and the gh--repo/cwd + git.fetch control-box branches inhost-actions.ts; the now-deadpushBundleToRemoteingit-pat.ts(its URL helperstoAuthedHttpsUrl/parseGitRemote/repoSlugFromRemotestay — leasing reuses them); andcontrol-box-admin.test.ts. Kept:relay.controlPlaneUrl(points at the hosted plane) and the plane's own always-on admin-bearer gating incore/handler.ts. Relay suite green (224 tests), CLI typechecks. - Turnkey setup — deploy + repo onboarding.
control-plane setupnow autogenerates the App name (agentbox-<rand>), and after creating the App runs a deploy step (interactiveselector--deploy vercel|hetzner|none), auto-set-urls + polls/healthz, and opens the App's repo-selection page.- Vercel (
deploy-vercel.ts): shells the logged-invercelCLI — link →vercel integration add neon --non-interactive(Postgres) → push env fromcontrol-plane.env→deploy --prod. Falls back to printed manual steps on failure. - Hetzner (
deployControlPlaneToHetznerin@agentbox/sandbox-hetzner+deploy-hetzner.ts): stock Ubuntucx23, cloud-init installs Docker + clones the public repo (controlPlaneCloudInit), firewall:22host-only +:80/:443open (controlPlaneInboundRules), secret.env+ a Caddy compose overlay scp'd,docker compose up -d --build; HTTPS athttps://<ip>.sslip.iovia Caddy + Let's Encrypt; persists~/.agentbox/control-plane/deploy.json. control-plane addauthorizes the current repo on the App; the agent launchers (claude/codex/opencode) call a sharedensureProjectRepoOnControlPlanethat checks install status (local App key or the newGET /admin/app/repo-installed/GitHubAppLeaser.isRepoInstalled) and prompts once per project when missing (repos.jsonack; unattended only warns). Relay suite 228 green.
- Vercel (
Live validation (2026-06-16)
The plane is deployed and validated on real infrastructure:
-
Deploy:
apps/control-planeon Vercel (madarcos-projects/agentbox-control-plane, Root Directoryapps/control-plane, monorepo built via turbo) + Neon Postgres (provisioned non-interactively viavercel integration add neon). Public URLagentbox-control-plane-two.vercel.app. -
Verified live:
/healthz(tables auto-migrated on Neon), fail-closed admin gate (401/200),register-box+/eventspersisted to Neon. -
Leasing live:
git.lease-tokenminted a real 1-hour GitHub-App installation token formadarco/agentbox-test-repo(Appagentbox-control-plane, installed on the repo) with the authed remote URL. -
Box-creation loop live (origin-clone, Hetzner): a Hetzner
cx23provisioned via cloud-init clonedagentbox-test-repointo/workspaceusing a plane-leased token (origin scrubbed back to the bare URL afterward), then was destroyed. -
Full worker loop live (queue → worker → box):
POST /remote/boxesenqueued a job on Neon (queued);agentbox control-plane worker --once --store <neon>atomically claimed it (claimedBy/startedAt), leased a 1h App token formadarco/agentbox-test-repo,git cloned it locally, scrubbed the remote, ranprovider.create()(seeded/workspacewith the repo content), and marked the jobdonewithresult.boxId+finishedAt. Proves enqueue → atomic claim → lease → clone →provider.create()→ completion end to end against the live Vercel+Neon plane. (Run with docker as a no-cost local target; see the Phase 5 docker caveat — the box was destroyed afterward.) -
Dashboard live: the token-gated dashboard deployed to the same Vercel plane (
/serves the admin-token form,/healthz+/admin/registrystill answer 200/401) — the page and the/[...path]API route coexist in the serverless env. -
Hetzner deploy path live (2026-06-16):
deployControlPlaneToHetznerran end to end against real Hetzner — firewall (:22host-only,:80/:443open),cx23VPS, cloud-init (Docker install + public-repo clone), secret.env+ Caddy compose overlay scp'd,docker composepulled postgres+caddy and invoked the app build. It exposed a pre-existing self-host image build bug (Vercel was unaffected — turbo there built deps in order): theapps/control-plane/Dockerfilebuilt the relay before its workspace deps emitteddist/. Fixed to build viaturbo run build --filter=@agentbox/control-plane, and added a root.dockerignore(hostnode_moduleswas being copied in, breaking native-dep arch). A clean-contextdocker buildnow goes 6/6 turbo tasks +next buildgreen. VPS + firewall destroyed after the run. Caveat: a full live green (Caddy + Let's Encrypt cert on<ip>.sslip.io+/healthz) needs the Dockerfile fix onoriginfirst (the VPS clones the public repo at--ref); validated locally pending that push. -
Git-backed Vercel deploy live (2026-06-16):
control-plane setup --deploy vercelnow builds the plane from GitHub via the Vercel REST API (no local upload), so it works from a global npm install. Live-validated: created a Git-connected project (madarco/agentbox, Root Directoryapps/control-plane), auto-provisioned Neon, upserted the App env, and agitSourceproduction build offeat/control-boxwent READY athttps://agentbox-control-plane.vercel.app—/healthz, the fail-closed admin gate, and the new/admin/app/repo-installedendpoint all answer. Ownership constraint: Vercel only connects a repo whose owner has the Vercel GitHub App; non-owners fork +--repo <fork>(Hetzner needs no ownership — it clones the public repo on the VPS). Replaced the old local-foldervercel deploy(monorepo-only, hit the Root-Directory bug). -
Vercel deploy made ownership-free + boot-safe (2026-06-16):
--deploy vercelis now tiered — if the deployer doesn't own--repoit auto-forks viaghand deploys the fork (github-fork.ts); if Vercel still can't connect (its GitHub App not installed on the account), it falls back to the Deploy Button (vercel.com/new/clone— clone + App install + Postgres in-browser) and then finishes via the API (upsert secrets + redeploy), so there's no manual paste. The control plane also boots with no secrets:lib/plane.tsanswers/healthzbefore building deps (reportingconfigured:{db,app,admin}) andbuildDepsno longer throws on a missing admin token; the handler returns a graceful 503 (not 500) for an unset admin token. Net: a bare deploy is reachable + never 500s, and the bare→set-secrets→redeploy sequence is always valid. (Hetzner stays ownership-free — it clones the public repo on the VPS.)
Security notes
- Token blast radius: leased tokens are per-repo, minimal perms
(
contents+pull_requestswrite), 1h, never persisted (in-memory cache only). Compromise of one box yields at most a 1h single-repo token; the plane holds only the App private key. - Repo-scope != branch-scope: an installation token can write any branch in
the repo. The lease gate auto-allows only
agentbox/*(decided with the user); any other branch needs approval. Branch protection on protected branches is the real backstop. - Lease gate == push gate, and the repo is always re-derived from the registered origin, never box params.
- Admin auth: the hosted plane gates
/admin/*+/remote/*on a constant-time admin-bearer, fail-closed (never loopback).
Verify
- Unit:
pnpm --filter @agentbox/relay test(Memory conformance, poll-prompt, github-app, control-plane-handler). Postgres conformance:AGENTBOX_TEST_DATABASE_URL=... pnpm --filter @agentbox/relay test postgres-storeagainst a disposablepostgres:16. - Self-host:
apps/control-plane->docker compose up --build(ornext start) withPOSTGRES_URL+AGENTBOX_RELAY_ADMIN_TOKEN;curl /healthz, admin 401/200, register a box,git.lease-token. - Live App round-trip (pending a real GitHub App on a test repo): register a
cloud box, push on
agentbox/*, confirm viagit ls-remote.