This is the v0 interface contract the engine, Gizzi, allternit-api and the apps are built against.
/api/factory. allternit-api proxies them for Desktop, ai.allternit.com and m.allternit.com, and owns the approvals routes itself. allternit-api also serves the work-engine routes (mail, peers, steer, ledger, plans, leases, gate, vault and more) at /api/factory in process; they’re listed in Architecture. See Architecture for how the pieces fit, and the command reference for the CLI side.
1. Processes
- Engine:
allternit-factory(Rust,cmd/allternit-factory, crates underfactory/). It serves HTTP on127.0.0.1:3011(the port the old Rails Service had) and a Unix socket at~/.allternit/factory/factory.sock. State root:~/.allternit/factory/. Workspace ledger: the workspace’s.allternit/(unchanged layout). - Gizzi (
cmd/gizzi-code): the product CLI/TUI.gizzi <part> <verb> …runsallternit-factory <part> <verb> … --jsonand renders the result. It finds the engine binary in this order: next to thegizziexecutable, then$ALLTERNIT_FACTORY_BIN, then~/.allternit/bin/allternit-factory, thenPATH. If no engine is found, it says so with exit code 3 and tells the user how to install it. Never fall back silently. - allternit-api (
cmd/allternit-api): proxies/api/factory/*to the engine for Desktop, web and phone, except approvals (/api/factory/approvals*), which allternit-api owns because push and channels live there. It links the engine crates in-process for ledger and Gate access.
2. CLI contract (engine and Gizzi share it)
The four parts and their verbs are exactly SPEC §7. Rules:- Every command accepts
--json. With--json, stdout is one JSON document and nothing else. - Every mutation accepts
--dry-runand prints exactly what would change. - Exit codes:
0ok ·1refused by Gate ·2not found ·3transport broken / engine missing ·4timeout ·5needs a person ·64usage error ·70internal engine failure (HTTP 500, codeinternal). - On error with
--json, stdout is{"error":{"code":"refused|not_found|transport|timeout|needs_person|usage|internal","fact":"…","action":"…"}}. - A verb that’s specified but not implemented yet returns exit
2with codenot_foundand the fact"<part> <verb> is not built yet". Never pretend.
3. HTTP resources (/api/factory)
All JSON uses camelCase. Times are RFC 3339. IDs are opaque strings.
Agents
GET /api/factory/health→{ ok: boolean, service: 'allternit-factory', panes: boolean }GET /api/factory/agents?team=<name>→{ agents: Agent[], engine: { running: boolean, error: string|null } }.enginereports whether the pane engine could be read. When it couldn’t, the registry is shown unchanged and is never marked dead.GET /api/factory/agents/:id→AgentGET /api/factory/agents/:id/capture?lines=80→{ text: string, at: string }GET /api/factory/agents/:id/transcript?cursor=→{ chunks: {at:string,text:string}[], next: string|null }GET /api/factory/agents/:id/screen→{ ansi: string, revision: number, at: string }: the terminal agent’s visible pane screen, with colors.not_foundwhen the agent has no live pane.GET /api/factory/agents/:id/stream(SSE) →screenevents{ ansi, revision, at }each time the screen changes (the first at once), then onegoneevent{ reason }when the pane closes or can’t be read, and the stream ends. Live only, no event ids. allternit-api passes it through unbuffered with no request timeout. Desktop’s Code mode terminal tiles and the phone bot strip mirror panes with it.POST /api/factory/agents/:id/inputbody{ text?: string, keys?: string[] }→{ ok: true }: keystrokes into the pane, as if typed at the wall. Not a send: no delivery and no ledger record. UsePOST /sendto message the agent.GET /api/factory/teams→{ teams: Team[] }POST /api/factory/teams/:name/upbody{ preset?: string, on?: string, dryRun?: boolean }→{ plan: TeamPlanStep[], applied: boolean }POST /api/factory/teams/:name/down→{ stopped: string[] }
Orchestration
POST /api/factory/sendbody{ to: string /* address, agent id, or 'channel:<threadId>' */, text: string, queue?: boolean, threadId?: string, nodeId?: string, dagId?: string, idempotencyKey?: string /* or the Idempotency-Key header */, dryRun?: boolean }→Delivery. A repeatedidempotencyKeyreturns the first Delivery.dryRun→{ dryRun: true, plan: { to, via, threadId, records: string[], ifQueued?: string[] } }and writes nothing. A send that can’t be resolved returnsnot_foundand writes nothing. Vendor sends needthreadId. WhenthreadIdis given, the engine records the text as a thread message, andmessageIdnames it.GET /api/factory/deliveries?agent=&thread=&node=→{ deliveries: Delivery[] }GET /api/factory/events(Server-Sent Events). Event types:agent.state({agentId, state, pane}, live only, no SSE id),node.status({dagId, nodeId, cause, status}),delivery(a Delivery),approval.requestedandapproval.resolved(an Approval),proof.recorded({dagId, nodeId, outcome}),team.changed. Each has{ type, at, data }.- Ledger-backed events carry their ledger event id, and
Last-Event-ID(or?after=) replays from there. Without it, the stream starts at now. - Approval events through the allternit-api proxy have ids
<engineId>~apr-<approvalId>. They aren’t replayed, so re-readGET /api/factory/approvals?state=pendingon reconnect. - A user only receives their own approvals.
- Ledger-backed events carry their ledger event id, and
Peer routes (another computer’s engine)
A paired computer’s engine serves these on its peer port (serve --peer-port 3019, 127.0.0.1, forwarded over the mesh). An engine started without --peer-port starts serving them on 3019 once ~/.allternit/computer/paired.json appears (checked every 30 seconds), which is how a cloud computer that pairs itself after start takes peer calls. Every call needs Authorization: Bearer <peer ticket> for that computer. The ticket’s user is the caller, and a caller only reaches its own workspace and bots there (~/.allternit/factory/remote-work/<user>/<team>).
GET /api/factory/peer/hello→{ computerId, engine, user }POST /api/factory/peer/teams/:name/upbody{ files: Record<path, text>, bots: string[], preset?: string, repo?: { url, commit }, dryRun?: boolean }→{ plan, applied, results, root }.filesis the team folder and must includeteam.yaml; paths are relative, with no..(at most 200 files and 4 MiB). Only thebotsnamed start, here.repo.urlmust behttpsorssh; the workspace is a clone checked out atrepo.commit, with.allternit/excluded from git. A failed step answers 409 with the same body.POST /api/factory/peer/teams/:name/down→ likePOST /api/factory/teams/:name/down, in the caller’s workspace.POST /api/factory/peer/sendbody{ to: "bot@team", text, queue? }→{ to, state: "verified" | "queued", messageId?, depth?, reason? }POST /api/factory/peer/stopbody{ to }→{ stopped: [to] }GET /api/factory/peer/capture?to=bot@team&lines=25→{ to, session, lines, text }GET /api/factory/peer/agents?team=…→{ agents: [{ address, session, harness, live }] }GET /api/factory/peer/screen?to=bot@team→{ ansi, revision, at }GET /api/factory/peer/stream?to=bot@team(SSE) → the samescreen/goneevents asGET /api/factory/agents/:id/streamPOST /api/factory/peer/inputbody{ to, text?, keys? }→{ ok: true }
GET|POST /api/v1/computers/:id/factory-peer/<path>. :id is the computer’s id there or cloud-api’s pc_…. allternit-api gets the ticket, opens the mesh bridge to the computer’s peer port, and passes the answer back unchanged. A bot your engine started on another computer is recorded with runner: "remote:<computer id>"; its Agent.machine is that computer, and send, capture and down go there. While it is live its Agent.pane is { id: <session>, attachable: true }, and GET /agents/:id/screen, /stream and POST /agents/:id/input reach its pane there (allternit-api passes the stream through unbuffered), so terminal tiles mirror it like a local pane.
Workflows
GET /api/factory/templates→{ templates: TemplateSummary[] }, whereTemplateSummary { id, name, description, steps: number, params: number }GET /api/factory/templates/:id→TemplatePOST /api/factory/templatesbody{ fromRun: string, id?: string, force?: boolean, dryRun?: boolean }→{ saved: { id, name, steps: number, replaces: boolean, fromRun: { finished: boolean } } }. Saves runfromRun(a DAG id) as the workspace template<id>.json, the same conversion asgizzi workflows template save --from-run.iddefaults to the run’s title in lowercase with hyphens. A template with that id already in the workspace isrefused(403) unlessforce. An unknown run isnot_found.fromRun.finishedis false when some of the run’s steps hadn’t finished.dryRun→{ dryRun: true, plan }with the same fields, and writes nothing.POST /api/factory/runsbody{ template: string, intent?: string, params?: Record<string,string>, campaignId?: string, projectId?: string, team?: string, preset?: string, dryRun?: boolean }→{ dagId: string, campaignId: string, nodes: NodeCard[] }.dryRun→{ dryRun: true, plan: { template, campaignId, nodes: {nodeId, stepId, title, executor}[], records: string[] } }.intentis the person’s words. It becomes the campaign’s and root node’s## Intent, and fills the template param namedintentif there is one. GivecampaignIdorprojectId: withprojectId, the engine uses that project’s campaign and creates one if there isn’t one (Project = Campaign): idproject-<projectId>, objective =intent(elseProject <projectId>), owner = the caller, executor = the project’s worker bot (bot:project-worker-<projectId>). A dry run creates nothing.teamnames the team (team.yaml) whose bots fill the template’srole:steps, withpresetapplied. When the template assigns steps by role and no team is given, the workspace’s only team is used. With no team, or several, the run is refused asusage, and the fact names the teams to pick from. Dry-run nodes show each step’s resolved executor.GET /api/factory/dags/:dagId→Flow
Workspace
GET /api/factory/campaigns→{ campaigns: { id, projectId: string|null, title, intent, status, proven: number, total: number, needsYou: number }[] }.projectIdlinks a campaign to its project page (Project = Campaign).GET /api/factory/campaigns/:id/board→BoardGET /api/factory/nodes?assignee=<address>&status=<open|all>→{ nodes: NodeCard[] }: a bot’s own nodes across campaigns (the bot phone’s Work app). The defaultstatusisopen.GET /api/factory/nodes/:dagId/:nodeId→NodePagePOST /api/factory/nodes/:dagId/:nodeId/proof(multipart:line,file) →{ path, receiptId }
Approvals (owned by allternit-api)
GET /api/factory/approvals?state=pending→{ approvals: Approval[] }POST /api/factory/approvals/:id/resolvebody{ decision: 'approve'|'reject', note?: string }. Called in-app, with the session’s owner as actor.200→Approval(resolved).409when another surface answered first →{ error: { code: 'refused', fact: 'Already approved by <who> via <surface> at <time>', action: 'Nothing to do.' }, approval: Approval }.410when expired.- Push action endpoint and channel inbound hooks: internal to allternit-api.
UI events (allternit-ai, window CustomEvents)
These aren’t HTTP. They’re how the Factory surfaces in the app hand off to each other. Each one has one owner that listens.allternit:switch-mode{ mode: 'code' }: the shell (desktop and PhoneShell) switches modes.allternit:factory-attach-pane{ paneId, botId, originView }: the Code mode terminal wall focuses that engine pane (owner: the Desktop fold-in stream).allternit:factory-open-node{ dagId, nodeId }: the current page’s shared pane opens the Node pane. A bot thread hosts it in its own pane, and a project page in its pane workspace.
Errors
HTTP errors use status 400/403/404/409/502/504 with the body{ error: { code, fact, action } }, using the same codes as the CLI.