af HTTP/JSON API reference¶
The Agent Factory daemon exposes a small JSON API — a 1:1 mirror of the session
and task operations the af CLI performs — over a local Unix socket. It is
the same daemon core (#960 single-writer model) the TUI and af sessions /
af tasks commands already drive, reached over HTTP instead of the internal
net/rpc control socket, so the two surfaces can never diverge.
This page is a hand-written guide to the transport, auth, and envelope; the enumerated endpoint table is generated from the route catalog (see HTTP API reference). There is deliberately no OpenAPI/Swagger document in v1. To discover the surface from the command line without reading this file, run:
af api # human-readable catalog: socket path, auth model, every endpoint + a curl example
af api --json # the same catalog as JSON, wrapped in the shared {data,error} envelope
af api is read-only and local: it prints the catalog and the resolved socket
path but never dials the socket or starts the daemon. Its catalog is derived
from the daemon's actual route table, so it always matches what the server
serves.
Transport & socket path¶
The API is served over a dedicated Unix socket, not a TCP port:
$AGENT_FACTORY_HOME is where Agent Factory keeps its state. It resolves as:
- the
AGENT_FACTORY_HOMEenvironment variable, if set (with a leading~/~/expanded); otherwise - the default config dir,
~/.agent-factory.
So on a default install the socket is ~/.agent-factory/daemon-http.sock. af
api prints the resolved path for your environment.
The socket is created when the daemon starts (on demand whenever af runs and
there is work to host, or via an autostart unit — see
tasks.md). If the socket does not exist, the daemon
is not running.
Authentication¶
There is no token and no TCP port. Authentication is the filesystem:
- The socket is a Unix domain socket, reachable only from the local host — never the network.
- It is created with
0600permissions (owner read/write only), so only the user who owns the daemon process can connect. Group and other have no access.
This matches the model of the daemon's internal control socket. It is a
single-user, local-only API by design: anyone who can read the socket already
runs as your user and could drive af directly, so no additional secret buys
anything. Do not proxy this socket to a network interface.
Reaching the daemon from another machine
To drive the daemon from a different host — a remote TUI, or the
browser web client — don't proxy this socket. SSH to the host and run af
there, or expose the HTTP+token TCP listener to the network (it's on by
default on loopback; point listen_addr at a routable host:port). The
listener is plain HTTP — front it with a TLS-terminating proxy or a private
network. Both are covered in Remote daemon access.
Response envelope¶
Every response — success or failure, on every endpoint — is the same
{data, error} JSON envelope the CLI's --json flag emits, so the two surfaces
are byte-for-byte identical.
A success carries the payload under data with error: null:
A failure sets data: null and populates error.message:
{
"data": null,
"error": { "message": "agent-factory daemon is starting (restoring sessions); retry shortly" }
}
Both members always serialize (no omitempty), so a consumer can branch on
error === null without a presence check. Every response sets
Content-Type: application/json.
Status codes¶
| Status | Meaning |
|---|---|
200 OK |
Success. data holds the response payload; error is null. |
400 Bad Request |
The request body was not valid JSON, or it carried a field this daemon does not recognize (see Unknown fields). |
404 Not Found |
Unknown route (e.g. POST /v1/Nope). |
405 Method Not Allowed |
Wrong verb — RPC routes are POST-only; /v1/health is GET-only. |
413 Request Entity Too Large |
The body exceeded the 16 MiB cap. The request is rejected, never truncated-then-processed — the daemon is never reached. |
500 Internal Server Error |
The handler ran but returned an error (validation failure, not-found session, a disabled task refused by TriggerTask, etc.). error.message carries the detail. |
The status maps the transport outcome; a business-logic failure (e.g. "session
not found") is a 500 with a descriptive error.message, not a bare status.
Unknown fields¶
How the daemon treats a request field it does not recognize depends on whether the request identifies itself as an af client, because the same unknown field means opposite things to the two kinds of caller.
Hand-authored requests (curl, af api, your own scripts) are decoded
strictly: an unrecognized key is a 400. This is deliberate. An unknown key is
almost always a typo, and dropping it silently can widen what an RPC does — a
typo'd repo_idd leaves repo_id empty, which turns a one-repo Snapshot into an
all-repo Snapshot. Failing loudly is the safer answer:
$ curl --unix-socket ~/.agent-factory/daemon-http.sock \
-X POST http://af/v1/Snapshot -d '{"repo_idd":"typo"}'
{"data":null,"error":{"message":"malformed JSON request body: json: unknown field \"repo_idd\""}}
Requests carrying the X-AF-Client-Version header — which the af TUI and CLI
send automatically — are decoded leniently: unrecognized keys are ignored. The
daemon is upgraded independently of its clients, so a client newer than the daemon
legitimately sends fields the daemon has never heard of. Fields are additive and
never renamed, so ignoring an unknown one is always safe, whereas rejecting it
would turn every version skew into a hard failure.
The bundled web UI does not send the header and is decoded strictly. It is always served by the daemon it talks to, so it cannot be newer than that daemon and has no skew to tolerate.
Setting the header by hand is supported but simply opts you out of typo checking — it is not an authentication or trust boundary.
Note this lenient decoding only exists in daemons that ship with it. If a newer client talks to an older daemon, that daemon still rejects the newer field, and af clients report it as a version skew:
daemon is out of date and rejected the "tab_id" field this client sent —
restart it with `af daemon restart`
Restarting the daemon (so it matches the af binary on disk) is the fix.
Warm-up behavior¶
The daemon binds its sockets before it finishes restoring sessions. During that window:
GET /v1/healthanswers immediately (it is a pure liveness probe) — it does not wait for the restore.- State-dependent routes (session and task RPCs) return an error envelope with
the message
agent-factory daemon is starting (restoring sessions); retry shortly. Treat it as retryable: the daemon is alive; the same request succeeds once the restore completes.
Endpoints¶
The full, enumerated route table — every method, path, and request-body field —
is generated from the daemon's route catalog and lives in the
HTTP API reference. It cannot drift from the server:
the same catalog backs the mux, the af api command, and that page. Request-body
fields are the JSON keys of each RPC request struct; a route with no listed
fields accepts an empty body (-d '{}' or no -d at all).
GET /v1/health is the one non-POST route: a liveness probe (alias for the
internal Ping RPC) that answers even while the daemon is restoring sessions,
with response data of { "ok": true }.
Response shapes. These are not part of the generated request-field catalog,
so they are documented here. CreateSession returns { "instance": <session> };
Snapshot and ImportRemoteHookSessions return { "instances": [<session>…] };
ArchiveSession returns { "ok": true, "archived_path": "…" };
RestoreArchived returns { "ok": true, "worktree_path": "…" };
DeliverPrompt returns { "status": "started" | "sent" }; CreateTab
returns { "id"?: "<stable-tab-id>", "name": "<resolved-tab-name>", "tmux_name"?: "<tmux-session>" }
(id is the stable tab id minted by the daemon, which an older daemon may omit; tmux_name is the tmux session the tab was spawned under, omitted for a
web/vscode tab that owns no PTY; it normally tracks the name but diverges
after a rename, so read it from the response rather than re-deriving it);
CloseTab returns { "name": "<resolved-tab-name>" }; ListTasks returns
{ "tasks": [<task>…] }; UpdateTask returns { "ok": true, "task": <task> }
(the merged record); the rest return { "ok": true }. The task field of
AddTask is a full task object — the CLI/TUI build and validate it, and the
daemon re-validates and owns the write. UpdateTask instead takes a target id
and a FIELD-LEVEL update patch carrying only the fields to change (e.g.
{ "id": "ab12cd34", "update": { "enabled": false } }): the daemon merges the
patch onto the freshly-loaded record under its file lock and leaves every
unspecified field — and the scheduler-owned fields — as-stored, so a single-field
edit cannot clobber a concurrent edit another client made (#1700). See
tasks.md for the task shape.
UpdateTask, RemoveTask, and TriggerTask also accept an optional expect
object — { "enforce": true, "project_path": "/repos/alpha" } — asserting the
project the task was bound to when the caller authorized it. The daemon
re-checks it against the freshly-loaded record inside the same locked operation
and refuses the write if the task has since been re-bound, so a client that
checks scope in one request and mutates in another cannot act on a task that
moved projects in between. Omitting expect (or sending enforce: false) skips
the check, which is what a caller with no project context does — existing
clients are unaffected.
Examples¶
Health check:
curl --unix-socket ~/.agent-factory/daemon-http.sock http://localhost/v1/health
# {"data":{"ok":true},"error":null}
List every session (all repos):
Send a prompt into an existing session:
curl --unix-socket ~/.agent-factory/daemon-http.sock \
http://localhost/v1/SendPrompt \
-d '{"title":"fix-auth","prompt":"run the tests and report failures"}'
# {"data":{"ok":true},"error":null}
List tasks (no body needed):
Wrong verb → 405:
curl -i --unix-socket ~/.agent-factory/daemon-http.sock http://localhost/v1/ListTasks
# HTTP/1.1 405 Method Not Allowed
# {"data":null,"error":{"message":"method GET not allowed; use POST"}}
Oversize body → 413 (rejected, never processed):
head -c 20000000 /dev/zero | tr '\0' 'a' \
| curl -i --unix-socket ~/.agent-factory/daemon-http.sock \
http://localhost/v1/AddTask --data-binary @-
# HTTP/1.1 413 Request Entity Too Large
# {"data":null,"error":{"message":"request body exceeds 16777216-byte limit: …"}}
Relationship to the CLI¶
The HTTP API and af sessions / af tasks are two front-ends over one daemon
core. Prefer the CLI for interactive and scripting use — it handles daemon
startup, --repo resolution, and flag validation for you. Reach for the HTTP
API when you want to call the daemon from a language or tool without shelling
out to af, from inside an agent, or from a small local service. Both emit the
identical {data, error} envelope, so a consumer written against one reads the
other unchanged.