This is the v0 interface contract the engine, Gizzi, allternit-api and the apps are built against.
The engine serves these routes at /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 under factory/). It serves HTTP on 127.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> … runs allternit-factory <part> <verb> … --json and renders the result. It finds the engine binary in this order: next to the gizzi executable, then $ALLTERNIT_FACTORY_BIN, then ~/.allternit/bin/allternit-factory, then PATH. 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-run and prints exactly what would change.
  • Exit codes: 0 ok · 1 refused by Gate · 2 not found · 3 transport broken / engine missing · 4 timeout · 5 needs a person · 64 usage error · 70 internal engine failure (HTTP 500, code internal).
  • 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 2 with code not_found and 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 } }. engine reports 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 → Agent
  • GET /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_found when the agent has no live pane.
  • GET /api/factory/agents/:id/stream (SSE) → screen events { ansi, revision, at } each time the screen changes (the first at once), then one gone event { 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/input body { 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. Use POST /send to message the agent.
  • GET /api/factory/teams → { teams: Team[] }
  • POST /api/factory/teams/:name/up body { preset?: string, on?: string, dryRun?: boolean } → { plan: TeamPlanStep[], applied: boolean }
  • POST /api/factory/teams/:name/down → { stopped: string[] }

Orchestration

  • POST /api/factory/send body { 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 repeated idempotencyKey returns the first Delivery. dryRun → { dryRun: true, plan: { to, via, threadId, records: string[], ifQueued?: string[] } } and writes nothing. A send that can’t be resolved returns not_found and writes nothing. Vendor sends need threadId. When threadId is given, the engine records the text as a thread message, and messageId names 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.requested and approval.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-read GET /api/factory/approvals?state=pending on reconnect.
    • A user only receives their own approvals.

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/up body { files: Record<path, text>, bots: string[], preset?: string, repo?: { url, commit }, dryRun?: boolean } → { plan, applied, results, root }. files is the team folder and must include team.yaml; paths are relative, with no .. (at most 200 files and 4 MiB). Only the bots named start, here. repo.url must be https or ssh; the workspace is a clone checked out at repo.commit, with .allternit/ excluded from git. A failed step answers 409 with the same body.
  • POST /api/factory/peer/teams/:name/down → like POST /api/factory/teams/:name/down, in the caller’s workspace.
  • POST /api/factory/peer/send body { to: "bot@team", text, queue? } → { to, state: "verified" | "queued", messageId?, depth?, reason? }
  • POST /api/factory/peer/stop body { 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 same screen / gone events as GET /api/factory/agents/:id/stream
  • POST /api/factory/peer/input body { to, text?, keys? } → { ok: true }
Your own engine reaches these through allternit-api: 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[] }, where TemplateSummary { id, name, description, steps: number, params: number }
  • GET /api/factory/templates/:id → Template
  • POST /api/factory/templates body { fromRun: string, id?: string, force?: boolean, dryRun?: boolean } → { saved: { id, name, steps: number, replaces: boolean, fromRun: { finished: boolean } } }. Saves run fromRun (a DAG id) as the workspace template <id>.json, the same conversion as gizzi workflows template save --from-run. id defaults to the run’s title in lowercase with hyphens. A template with that id already in the workspace is refused (403) unless force. An unknown run is not_found. fromRun.finished is 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/runs body { 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[] } }. intent is the person’s words. It becomes the campaign’s and root node’s ## Intent, and fills the template param named intent if there is one. Give campaignId or projectId: with projectId, the engine uses that project’s campaign and creates one if there isn’t one (Project = Campaign): id project-<projectId>, objective = intent (else Project <projectId>), owner = the caller, executor = the project’s worker bot (bot:project-worker-<projectId>). A dry run creates nothing. team names the team (team.yaml) whose bots fill the template’s role: steps, with preset applied. 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 as usage, 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 }[] }. projectId links a campaign to its project page (Project = Campaign).
  • GET /api/factory/campaigns/:id/board → Board
  • GET /api/factory/nodes?assignee=<address>&status=<open|all> → { nodes: NodeCard[] }: a bot’s own nodes across campaigns (the bot phone’s Work app). The default status is open.
  • GET /api/factory/nodes/:dagId/:nodeId → NodePage
  • POST /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/resolve body { decision: 'approve'|'reject', note?: string }. Called in-app, with the session’s owner as actor. 200 → Approval (resolved). 409 when another surface answered first → { error: { code: 'refused', fact: 'Already approved by <who> via <surface> at <time>', action: 'Nothing to do.' }, approval: Approval }. 410 when 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.