Skip to main content

HTTP API

flux app run --serve <addr> exposes an agent over HTTP. Three surfaces share the one router:

  • Sessions — create a session, run turns against it over REST or SSE, read its usage.
  • A2A — the JSON-RPC agent-to-agent protocol, documented in A2A.
  • Webhook — a one-shot trigger that mints a session and runs a single turn.
# the built-in coding agent
flux app run --serve 127.0.0.1:3000

# a program's declared agent
flux app run support.flux --serve 127.0.0.1:3000 --yes

Every turn served here runs the same agent loop, through the same safety envelope, as a local flux run. Serving an agent does not widen what it may do — a request cannot approve its own actions, so a server deployment is normally paired with an explicit permission policy in .flux/config.toml.

Routes

MethodPathAuthPurpose
GET/healthexemptLiveness probe; returns ok.
GET/.well-known/agent-card.jsonexemptA2A discovery card.
GET/.well-known/agent.jsonexemptAlias of the above.
POST/sessionsper modeCreate a session.
GET/sessions/{id}per modeSession metadata.
POST/sessions/{id}/messagesper modeRun one turn, buffered.
GET/sessions/{id}/streamper modeRun one turn, streamed as SSE.
GET/sessions/{id}/usageper modeToken tiers and cost for one session.
GET/usageper modeToken tiers and cost across sessions.
POST/webhookper modeMint a session and run one turn.
POST/a2aper modeA2A JSON-RPC dispatcher.
GET/approvalsper modeEffects parked awaiting a human decision.
POST/approvals/{id}per modeDeliver one decision for one parked effect.

The multi-agent mount serves N agents from one server, keyed by path:

MethodPathAuthPurpose
GET/healthexemptLiveness probe.
GET/{agent_id}/.well-known/agent-card.jsonexemptThat agent's discovery card.
GET/{agent_id}/.well-known/agent.jsonexemptAlias of the above.
POST/{agent_id}/a2aper modeThat agent's A2A dispatcher.

It exposes A2A only — there is no per-agent session REST subtree. Each agent owns its own engine and event store, and an unknown agent_id is an indistinguishable 404. See Serving many agents.

/health and the discovery card are structurally exempt: they are registered outside the auth middleware rather than allow-listed by path string inside it, so no percent-encoding or double-slash trick can smuggle another route past the check. Everything else authenticates per the configured mode — see Server authentication & tenancy.

Sessions

Create a session

curl -X POST http://127.0.0.1:3000/sessions
{"id": "s_1", "model": "claude-sonnet-5"}

Read session metadata

curl http://127.0.0.1:3000/sessions/s_1
{"id": "s_1", "model": "claude-sonnet-5", "created_at_ms": 1753776000000}

An unknown id is a 404.

Run a turn

POST /sessions/{id}/messages takes {"input": "…"} and returns when the whole turn is done:

curl -X POST http://127.0.0.1:3000/sessions/s_1/messages \
-H 'content-type: application/json' \
-d '{"input": "which tests are failing?"}'
{
"text": "Two tests fail in flux-lang…",
"tool_calls": ["cargo_test", "read"],
"usage": {
"input": 4120,
"output": 380,
"cache_creation": 0,
"cache_read": 3900,
"reasoning": 0
}
}

tool_calls lists the operations the turn ran, in order. usage reports the five tiers shown above — input, output, cache_creation, cache_read, reasoning — rather than just input and output, because a caller pricing its own spend or measuring real context-window occupancy needs the cache and reasoning figures. It is null when the provider reported no usage.

Three tiers flux tracks internally are not on the wire, here or on the usage endpoints: the one-hour cache-write split, and audio input/output. They are all subsets of a tier that is reported, so no tokens go missing from a total — but a caller re-deriving cost from these numbers alone will price a 1h cache write as a five-minute one. The cost_usd the usage endpoints return is computed server-side from the full breakdown and does account for it.

Stream a turn

GET /sessions/{id}/stream?input=… runs the same turn and streams it as Server-Sent Events:

curl -N 'http://127.0.0.1:3000/sessions/s_1/stream?input=which%20tests%20are%20failing%3F'
event: text
data: Two tests

event: tool
data: cargo_test

event: text
data: fail in flux-lang…

event: done
data: end
EventDataMeaning
textthe token textAn assistant text delta. Concatenate in order.
toolthe operation nameA tool call started.
errorthe error messageThe turn failed; done still follows.
doneendThe turn is over. Always last.

The stream is keep-alive'd, so an idle connection during a long tool call stays open. Note that text carries the delta only — the SSE path does not emit a usage summary, so read GET /sessions/{id}/usage afterwards if you need one.

Webhook

POST /webhook is the integration trigger: it mints a fresh session, runs one turn, and returns both. Use it for a CI hook or a bridged chat message — anything that arrives as a single external event with no conversation to continue.

curl -X POST http://127.0.0.1:3000/webhook \
-H 'content-type: application/json' \
-d '{"input": "release 0.34.0 failed on CI, triage it"}'
{
"session_id": "s_2",
"text": "The failure is in the plugin-pack signing step…",
"tool_calls": ["read", "grep"]
}

The returned session_id addresses the session for follow-up turns through the routes above.

Usage and cost

Both usage endpoints return the same per-model row shape:

curl http://127.0.0.1:3000/sessions/s_1/usage
{
"session_id": "s_1",
"models": [
{
"model": "claude-sonnet-5",
"calls": 3,
"usage": {"input": 4120, "output": 380, "cache_creation": 0, "cache_read": 3900, "reasoning": 0},
"cost_usd": 0.0192,
"subscription": false
}
]
}

cost_usd is null for a model with no pricing entry; subscription marks spend covered by a subscription rather than metered billing. GET /usage returns the same models array without the session_id key, rolled up across sessions. See Cost accounting for where the figures come from per provider.

Limits and errors

StatusWhen
401Authentication required or failed for the configured mode.
404Unknown session, or an unknown agent_id on the multi-agent mount.
408The request exceeded the response-production timeout.
429The caller exceeded request rate or live work, or completed provider usage tripped a call/spend circuit breaker. Retry-After and X-Flux-Limit identify when and which dimension may be retried.
413The request body exceeded the body cap.
500The turn failed.
503Principal mode only: the authentication backend was unreachable. Deliberately distinct from 401 — the token was never judged, so retrying later is the right response, not re-authenticating.

The body cap and timeout apply to the whole surface and are set at router build time, overridable per deployment with FLUX_SERVER_MAX_BODY_BYTES and FLUX_SERVER_REQUEST_TIMEOUT_SECS (positive integers; 0 or an unparseable value falls back to the default rather than disabling the bound, so the daemon is never accidentally left unlimited).

The timeout bounds how long a handler may take to produce a response, not how long a response body may stream. That distinction is what lets POST /sessions/{id}/messages be bounded — it holds the connection for a whole turn — while GET /sessions/{id}/stream returns its SSE response promptly and then streams for the life of the turn without being severed. When a buffered REST, webhook, or blocking A2A request reaches its deadline, Flux cancels the owning turn and waits for durable turn and child-work finalization before returning 408; a timeout cannot detach live work.

Streaming bodies have their own lifecycle bound: REST SSE uses a 256-event channel. A consumer that disconnects or stops draining cancels the request-owned turn; the producer finalizes a valid cancelled session instead of continuing approved effects or buffering token events without limit.

Request admission is keyed by authenticated principal in principal mode, by the configured shared realm in shared-secret mode, and by one loopback-development bucket in open mode. It covers every authenticated protected route, including session and usage reads; /health and discovery cards remain exempt. A request is charged once at that boundary. The same key's separate live-work cap covers REST turns, fresh-session webhooks, blocking/background A2A work, and long-lived SSE.

Provider call and priced-spend thresholds are retrospective completed-usage circuit breakers, not exact prepaid caps. Flux charges each durable call fact after its exact owning turn/producer finishes, including calls that reported zero tokens, then rejects new work once the threshold is observed. Turns already admitted can finish and overshoot the threshold; max_inflight_per_principal bounds how many can do so concurrently. Exact turn attribution keeps overlapping requests for one session assigned to the right principal even when they finish or drop in reverse order. The in-process principal/realm buckets are cardinality-bounded and stale buckets are swept.

These controls are per replica. A multi-replica deployment must also enforce aggregate request, concurrency, and spend policy at an authenticated reverse proxy or shared control plane, using the same principal/tenant claim. Do not rely on client IP as the tenancy key behind a proxy. Limit rejections emit a secret-free flux_server_limit_rejections_total log signal and never include a bearer or shared-secret value.

In principal mode bearer introspection necessarily runs before the in-process request limiter: the verified principal selects the bucket. Protect the listener and introspection dependency with reverse-proxy/identity-provider arrival limits as well; Flux's per-principal limiter cannot shield that pre-admission authentication call from raw request floods.

Approvals

Only when the server was started with --remote-approval (see Topologies). Under any other approval posture both routes answer 501 with a body saying so, rather than an empty list — "nothing is waiting" and "nobody is ever asked" must not look alike.

GET /approvals returns everything the agent is currently blocked on, oldest first:

{
"approvals": [
{
"id": "ap_3f1c9a02b7d4e615_0",
"fingerprint": "…",
"tool": "write",
"subjects": ["report.txt"],
"summary": null,
"destructive": false,
"mutating": true,
"intents": {
"intents": [
{
"behavior": "filesystem_write",
"target": { "type": "path", "path": "report.txt" },
"role": "write_target",
"certainty": "certain"
}
]
},
"plan": null,
"waiting_secs": 4
}
],
"timeout_secs": 120
}

POST /approvals/{id} delivers one decision:

{ "fingerprint": "…", "decision": "deny", "reason": "not that path" }

decision is allow or deny; reason is optional and, on a denial, is passed to the model. fingerprint is required and must match — echo the opaque value returned by GET; do not reconstruct it. It canonically binds the complete effect, including structured intent targets and exact plan requirements, to the decision you were shown.

StatusMeaning
200Recorded. The waiting effect proceeds or is refused accordingly.
400decision was neither allow nor deny. Nothing was approved; the effect is still parked.
404No such parked request — already answered (including a replay), timed out, or its turn ended.
409The fingerprint names a different effect than this request. Nothing was approved, and the request stays parked.
410The run waiting on it is gone.
422The body is missing fingerprint or decision.
501This server is not running the remote-approval posture.

Three properties are load-bearing, and a client should be written expecting them:

  • An effect nobody answers is denied after timeout_secs (FLUX_APPROVAL_TIMEOUT_SECS, default 120). Silence is never an approval.
  • A decision is single-use. Answering removes the request, so a captured decision cannot be replayed onto a later effect.
  • A decision is bound to its effect. An allow you obtained for one request cannot be delivered against another, even an identical-looking one — that is what the id plus fingerprint pair is for.

⚠ Remote approval supports the single-operator server modes: a shared bearer token, or an open loopback listener. Router construction refuses it with per-request principal authentication. One deployment-wide queue in principal mode would otherwise let any authenticated principal list and answer every other principal's effects despite their session realms being isolated; that topology needs a separately authorized supervisor identity first. An unauthenticated non-loopback bind is still refused. Treat the shared token—and the ability to POST here—as equivalent to the agent's own authority.

Tenancy

In principal mode every session is tagged with the caller's realm, and the whole /sessions/{id}/* subtree is realm-guarded as one structural layer rather than per handler. Session ids are guessable (s_1, s_2, …), so this matters: guarding reads while leaving the write route open would be a cross-tenant read and write. GET /usage is realm-scoped in the same mode — one tenant cannot read another's spend.

Authentication always runs before the realm check, so an unauthenticated caller gets a 401 and never an existence signal.