Skip to content

REST API

The public surface of a running harness. Management endpoints (/audit, /approvals, /plans, /jobs, /manifests, /eval, /usage, /memory, /documents, /artifacts) are in the Management API.

The full endpoint reference is at /reference/, rendered from the OpenAPI document of the release api.felix.run runs — every release attaches its openapi.json. Your own harness serves the same thing for the version you run: GET /openapi.json, rendered at /docs (Scalar). Both are behind auth in api_key and jwt modes unless the operator sets FELIX_DOCS_PUBLIC=true.

Examples use $BASE_URL — set it to your deployment:

export BASE_URL=http://localhost:8080

Requests carry a bearer token unless the harness runs with FELIX_AUTH_MODE=none:

export FELIX_KEY=sk-felix-local-…
curl -s "$BASE_URL/health" -H "authorization: Bearer $FELIX_KEY"

api_key mode also accepts Authorization: ApiKey <k> and X-Api-Key: <k>. See Auth.

Unauthenticated in every mode: the probes /health, /live, /ready, and everything under /.well-known/. /metrics is not public — its labels carry manifest and tool names — and neither are /docs and /openapi.json unless FELIX_DOCS_PUBLIC=true. See Auth.

Middleware runs request id → body limit → rate limit → auth.

Request id is outermost, so every response carries an x-request-id — including one rejected by the body limit or the rate limiter. Send your own in that header to have it echoed back; otherwise one is generated.

Condition Response
Body over 1 MiB, declared or streamed (12 MiB for POST /skill-library and PUT /skill-library/{name}/versions, which carry a whole skill bundle) 413 {"error":"payload_too_large"}
Per-tenant rate limit exceeded 429 {"error":"rate_limited"}
Missing or bad credentials 401 {"error":"unauthorized","reason":"missing_credentials"|"invalid_api_key"}
Manifest requires a scope the caller lacks 403
Unknown manifest 404 unknown_manifest:<name>
Manifest pinned and drifted 409
A secret:NAME cannot be resolved 503
Upstream model provider failed 502

thread_id on the wire is a suffix; the server prefixes the tenant id, so the real id is {tenant_id}:{suffix}. A suffix containing : or # is rejected with 400 invalid_thread_id. Omit it and the turn is stateless.

Method Path Purpose
GET /health Liveness — {"status":"ok","env":…,"version":…,"multi_region":false,"federation":null}
GET /live Liveness, named for what it is — the same static body as /health, no I/O
GET /ready Readiness. Probes the dependencies and answers 503 when one is down, so a pod with a dead database leaves rotation instead of being restarted
GET /metrics Prometheus text exposition. Authenticated — see Observability
GET /openapi.json OpenAPI 3.1 document
GET /docs Rendered API reference (Scalar)
curl -s "$BASE_URL/health" | jq

How a person gets a bearer token without an operator minting one. Both routes are public, and only while FELIX_GITHUB_CLIENT_ID is set; otherwise they sit behind auth like any other path. Setup and the rules (org → tenant, TTL, consent phishing) are in Auth. felix login and felix_client.github_device_login drive this for you.

Method Path Body Answer
GET /auth/methods none {github_device, github_redirect, google_redirect, bearer_required, github_signup}
GET /auth/github/authorize ?return_to=&tenant= 302 to GitHub (redirect sign-in)
GET /auth/github/callback from GitHub 302 back to return_to with #felix_login=ok or #felix_login_error=<code> (plus &login=<account> once GitHub said who signed in)
POST /auth/github/exchange none (the sign-in’s cookie) the same as /auth/github/token, once
POST /auth/github/device none {device_code, user_code, verification_uri, expires_in, interval}
POST /auth/github/token {device_code, tenant?} {access_token, token_type: "Bearer", expires_in, tenant, scopes, github_login}
GET /auth/google/authorize ?return_to= 302 to Google
GET /auth/google/callback from Google 302 back to return_to with #felix_login=ok or #felix_login_error=<code> (not_allowed for an account not on the list)
POST /auth/google/exchange none (the sign-in’s cookie) {access_token, token_type: "Bearer", expires_in, tenant, scopes}, once

/auth/methods is public in every auth mode and answers without starting anything, which is how a client decides whether to offer GitHub at all — probing /device would start a real flow and spend from its hourly budget. bearer_required is false only under FELIX_AUTH_MODE=none, where any bearer is accepted; a proxy that lets a caller’s own bearer stand in for its own lock must check it first. github_signup is off, or invite when accounts outside the mapped orgs can be invited (Signup); a harness older than the field omits it, which means off. github_login names who signed in, and is "" from /auth/github/actions and from Google. The three Google paths are public only while Google sign-in is configured, and google_redirect says so; they give a token only to the accounts on FELIX_GOOGLE_ACCOUNTS.

Show user_code and verification_uri to the person, then poll /token every interval seconds until it answers 200. A refusal is {error, message, interval?, tenants?, github_login?}, where github_login is the account refused, present once GitHub had said who it was:

Status error Do
428 authorization_pending Not approved yet. Wait interval (also Retry-After) and poll again
428 slow_down Same, at the new interval (or five seconds more if none is given)
400 expired_token, invalid_device_code The code is spent or expired. Start again
403 access_denied The person declined on GitHub
403 not_a_member, org_access_restricted, tenant_not_granted No configured org grants access (or the chosen tenant)
403 not_invited Signup is invite-only and this account, in no mapped org, is not on the list. Sign in with the invited one
409 tenant_ambiguous Orgs map to several tenants, listed in tenants. The flow is spent: start a new one passing tenant
429 rate_limited Too many flows started from this address. Retry-After says how long
502 github_unavailable GitHub failed or could not be reached. Try again
503 github_config_error The server’s OAuth app is misconfigured; the cause is in its log
start=$(curl -s -X POST "$BASE_URL/auth/github/device")
echo "$start" | jq -r '"Open \(.verification_uri) and enter \(.user_code)"'
curl -s -X POST "$BASE_URL/auth/github/token" -H 'content-type: application/json' \
-d "{\"device_code\": $(echo "$start" | jq .device_code)}"

Public only while FELIX_GITHUB_OIDC_AUDIENCE is set. A job with id-token: write posts its OIDC ID token, requested with that audience. Who may log in is the org’s actions block, and Auth has the setup and a workflow recipe. felix_client.github_actions_login and felix login --github-actions drive it.

Method Path Body Answer
POST /auth/github/actions {id_token, tenant?} {access_token, token_type: "Bearer", expires_in, tenant, scopes}
Status error Do
401 invalid_id_token The signature, issuer, audience or expiry did not check out. The reason is in the server’s log
403 workflow_not_granted No actions block admits this repository, ref, workflow, environment or event
403 tenant_not_granted The tenant passed is not one this workflow is granted
409 tenant_ambiguous The workflow is granted several tenants, listed in tenants. Retry the same ID token with tenant
502 github_unavailable GitHub’s signing keys could not be fetched. Try again

Reattach to a thread after a dropped stream or a page refresh.

curl -N "$BASE_URL/chat/stream/demo" \
-H "authorization: Bearer $FELIX_KEY" \
-H "last-event-id: 12"

Without Last-Event-ID (the header, or a ?last_event_id= query parameter for clients that cannot set headers) the stream opens with a single snapshot frame carrying the thread transcript. With one, it replays only the session events after that cursor as session_event frames. Either way it then tails the thread, sending : keep-alive comments while idle and closing after FELIX_STREAM_RESUME_IDLE_SECONDS — reconnect with the last id: you saw and nothing is lost.

A session_event carries id, seq, kind, role, content, name, and — on the messages that have them — tool_calls and tool_call_id. Those last two are what a tool card is built from: read tool_calls off an assistant message to open one per call, and match tool_call_id on the following tool_result to close it.

On an assistant message with readable reasoning, it also carries metadata: {"thinking": [{"type": "thinking", "thinking": str}, …]} — the same key the snapshot frame’s metadata already uses, so one function folds both. This is what lets a durable run’s reasoning reach a watching client while the run is still going: a durable stream carries no deltas, so before this the reasoning behind an assistant turn appeared only once the run had landed and the client re-read the snapshot. Only the readable text goes on the frame — a stored thinking block’s signature, and a redacted_thinking block’s opaque data, exist to be replayed back to the model provider, not shown, and stay in the log. The key is omitted entirely when a turn has nothing readable to show.

Run a turn and wait for the result.

curl -s -X POST "$BASE_URL/chat" \
-H "authorization: Bearer $FELIX_KEY" \
-H 'content-type: application/json' \
-d '{"manifest":"quick","messages":[{"role":"user","content":"What is 7 * 6?"}]}' | jq

Request (extra: forbid):

Field Type Default Notes
manifest string required Manifest name.
messages object[] [] OpenAI-shaped message dicts.
thread_id string | null null Suffix only.
model string | null null Mid-session override; allowlisted.
template string | null null Named template from spec.prompts.
template_args string[] []

A user message written /name args, where name is one of the manifest’s spec.prompts, is replaced by that prompt’s expansion (slash commands); /v1/chat/completions does the same.

Either messages or template must produce a message, else 400 messages_or_template_required. An empty manifest is 400 manifest_required. A model outside the allowlist is 400 model_not_allowlisted:<id>.

A message’s content is a string or a list of parts, in OpenAI’s shape. The same parsing applies to POST /chat, POST /chat/stream and POST /v1/chat/completions.

Part type Shape Notes
text (or no type) {"type":"text","text":"..."} Text parts are joined with newlines.
image_url / image {"type":"image_url","image_url":{"url":"...","detail":"low"}} url is an https: or data: URL. media_type defaults to image/png.
file {"type":"file","file":{"file_id":"..."}} An image uploaded with POST /files. A top-level file_id is also accepted.

A file part is stored in the session log as a reference, and the bytes are swapped in just before each model call. An inline data: URL is stored in the log whole, so a large image in a long thread grows the log it sits in, and full_replay reads that log back every turn. A file_id that resolves to nothing is dropped with a warning, and the turn still answers.

A part of any other type, or a file part with no file_id, is dropped with a logged warning.

A message can also carry images in a top-level attachments list instead of content parts: {"role":"user","content":"...","attachments":[{"url":"felix-file://<file_id>","media_type":"image/webp"}]}. url takes the same three forms: felix-file:// plus a file_id from POST /files, https:, or data:. This is the shape the session log stores, and the snapshot returns it on each user message as metadata.attachments, so a client can draw a thread’s images again after a reload. The web UI uses it: it fits each image to the 600 KiB upload limit in the browser (re-encoding to WebP, or JPEG where the browser cannot encode WebP), uploads it, and sends only the reference. A GIF over the limit is refused instead of flattened to its first frame.

Response:

{
"messages": [ ... ],
"final": { "role": "assistant", "content": "42" },
"thread_id": "default:my-thread",
"model": "claude-sonnet",
"leaf_id": "evt_...",
"approvals": []
}

approvals lists every gate the run asked a human about. Each entry is the approval_required frame a stream would have carried — approval_id, tool_name, args, rule_id, reason, thread_id, tool_call_id, expires_at — plus status: approved, denied, or expired when nobody decided before the deadline and the gate denied on its own. A plain response cannot report anything until the run is over, so a caller blocked on a gate still waits; to decide it meanwhile, find it with GET /approvals?thread_id=<thread_id> (see Approvals).

If the manifest sets spec.execution.mode: durable, POST /chat instead returns 202:

{
"status": "accepted",
"resume_token": "fib_...",
"fiber_id": "fib_...",
"expires_at": "2026-08-22T12:34:56Z",
"thread_id": "default:my-thread"
}

Poll it:

curl -s "$BASE_URL/chat/runs/$RESUME_TOKEN" -H "authorization: Bearer $FELIX_KEY" | jq

GET /chat/runs/{resume_token} returns {status, fiber_id, resume_token, expires_at, final, error, manifest_id}, plus webhooks: {<id>: "pending" | "delivered" | "dead"} when the run named any, or 404 run_not_found. Fibers checkpoint in Postgres and are resumed by the worker’s fiber_scheduler cron — so felix-scheduler must be running.

A durable manifest with spec.execution.webhooks: [ops] is announced to each named endpoint when the run reaches completed, failed, expired or dead. The worker’s webhook_delivery cron sends it within about a minute of the run ending:

POST /felix HTTP/1.1
content-type: application/json
user-agent: Felix-Webhooks/1
webhook-id: fib_...:ops
webhook-timestamp: 1790000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"run":{"error":"","expires_at":"...","fiber_id":"fib_...","final":{"content":"...","role":"assistant"},
"manifest_id":"support","resume_token":"fib_...","status":"completed"},
"tenant_id":"acme","thread_id":"acme:my-thread","type":"run.completed"}

type is run.completed, run.failed, run.expired or run.dead; run is exactly what GET /chat/runs/{resume_token} returns. Deliveries follow Standard Webhooks, so a receiver can verify with an existing library: the signature is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{body}")), where the key is the base64-decoded whsec_… secret, or the secret’s bytes otherwise.

import base64, hashlib, hmac
def verify(secret: str, headers: dict[str, str], body: bytes) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_")) if secret.startswith("whsec_") else secret.encode()
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split())
  • Dedupe on webhook-id. It is stable across retries, and a receiver may see one delivery twice.
  • Reject stale timestamps, a few minutes either way, so a captured delivery cannot be replayed.
  • Answer 2xx. Anything else, a redirect, or no answer within FELIX_WEBHOOK_TIMEOUT_SECONDS (10 s by default, at most 60) is a failed attempt: it is retried after 1m, 2m, 4m … up to 1h, and marked dead after FELIX_WEBHOOK_MAX_ATTEMPTS (8). The response body is ignored.
  • Check tenant_id on an endpoint registered for "*": any tenant can produce a run signed with its secret.
  • Dispatch on type. An endpoint can also receive skill.update_available events, under the same secret, when FELIX_SKILL_UPDATE_WEBHOOKS binds it to a tenant — see Skill import → Update notifications. Those carry a webhook-id of skill_update_<hex> and no run.

Same request body as /chat, streamed as Server-Sent Events.

curl -N -X POST "$BASE_URL/chat/stream" \
-H "authorization: Bearer $FELIX_KEY" \
-H 'content-type: application/json' \
-d '{"manifest":"quick","thread_id":"demo","messages":[{"role":"user","content":"Hi"}]}'

A streamed send that fails in the client — no answer, a proxy’s 5xx, a connection that dropped mid-reply — may or may not have reached the turn, and the client cannot tell which. Send an Idempotency-Key header and a resend of the same message never runs a second turn or appends a second user message:

curl -N -X POST "$BASE_URL/chat/stream" \
-H "authorization: Bearer $FELIX_KEY" \
-H 'content-type: application/json' \
-H "idempotency-key: $(uuidgen)" \
-d '{"manifest":"quick","thread_id":"demo","messages":[{"role":"user","content":"Hi"}]}'
  • The key is visible ASCII, at most 255 characters, with no { or }; anything else is 400 invalid_idempotency_key. It needs a thread_id in the body — a threadless stream writes no log a retry could be answered from — and is 400 idempotency_key_requires_thread_id without one. It is scoped to the tenant, the principal and the thread, and lives for FELIX_IDEMPOTENCY_TTL_SECONDS.
  • Mint one per message, not per request. Every resend of a message reuses its key, with a byte-identical body: the key is bound to a fingerprint of the body, and the same key with a different one is 422 idempotency_key_reused. Re-uploading an image hands back a new file id, so a client keeps what it sent and sends that again. An edited message is a new message, under a new key.

What a resend gets depends on where the first request is:

First request Resend gets
Still streaming 409 idempotency_in_progress. Nothing runs; reattach with GET /chat/stream/{thread_id}, as after any dropped stream.
Ended, having appended to the thread 200 with Idempotent-Replayed: true, and a replay instead of a turn (below).
Ended having appended nothing — it failed before its turn began, or the client left before the body was sent The key was released, so the resend runs normally.
Started a durable run A reattach to that run’s stream, with Idempotent-Replayed: true.

The replay is what the first request itself wrote to the thread — each event it appended is stamped with its origin, so a label, a steer or another client’s turn that landed meanwhile is not in it — in the shape a reattach uses, then that request’s error frame if it ended in one, then [DONE]:

id: 5
data: {"event":"session_event","data":{"id":"…","seq":4,"kind":"message","role":"user","content":"Hi"}}
id: 6
data: {"event":"session_event","data":{"id":"…","seq":5,"kind":"message","role":"assistant","content":"Hello!"}}
event: error
data: {"error":{"message":"…","type":"stream_error"}}
data: [DONE]

It is finite, carries no deltas, and runs nothing, so fold it with the code that folds a reattach’s session_event frames. A transient turn is torn down when its client disconnects, so a first request cut off mid-reply often replays its user message and nothing after it: the message landed once, and the conversation goes on with POST /chat/continue or a new message. The key is claimed after inbound auth, screening and the lease check, so a resend is judged as the first request was, and a refused one is never stored as the key’s answer.

POST /chat takes the header too, scoped to the principal alone: a resend after the first finished gets its stored response back with Idempotent-Replayed: true, one while it runs is 409 idempotency_in_progress, and a different body is 422 idempotency_key_reused.

chat-ui sends a fresh key with every message. When a send fails it puts the message back in the composer, and sending it again unchanged reuses the key, so the transcript shows what the first attempt left — once.

An agent with todo_write in its tools announces each change to its checklist, with the whole list:

data: {"event":"todo_updated","data":{"todos":[{"id":"1","content":"Reproduce the failure","status":"in_progress","active_form":"Reproducing the failure"},{"id":"2","content":"Fix it","status":"pending","active_form":""}]}}

The same list is on the session snapshot as todos — the last successful write on the current branch — so a client that reloads draws it without waiting for the next write, and a rewind shows the list that point in the conversation had.

When an approved plan ends plan mode mid-run, the stream says so, so a client can update its mode indicator without reloading:

data: {"event":"permission_mode_changed","data":{"mode":"default"}}

When an agent hands work to a child through the task tool, the stream marks where the child’s work begins and ends, so a client can render it apart from the parent’s:

data: {"event":"subagent_start","data":{"agent":"researcher","background":false}}
data: {"event":"subagent_end","data":{"agent":"researcher","outcome":"ok"}}

outcome is ok or error. Both arrive inside the task call — after its tool_start, before its tool_end. A background child sends only subagent_start, with background: true, its task_id, and the thread_id its transcript is written to (linked to the parent’s as parentSessionId); it finishes after the turn that started it, and its answer is read with task_result.

Almost every frame is a bare data: line, and the stream ends with a literal data: [DONE]. Two exceptions worth coding for:

  • id: accompanies structural frames (tool_start, tool_end, tool_execution_update, on_chain_end, done, aborted). It is the thread’s next session sequence — hand it back as Last-Event-ID to GET /chat/stream/{thread_id} after a dropped connection. Token-level frames carry no id:, which per the SSE spec leaves lastEventId unchanged, so a client always holds the last structural cursor.
  • event: error is used for the one error frame, emitted if the stream fails after the 200 has already been sent. Handle it with addEventListener("error", …); it will not arrive on onmessage.

Each payload is the same envelope:

{ "event": "text_delta", "type": "text_delta", "data": { ... }, "text": "derived" }

event and type always carry the same name. text is a convenience: data.chunk.content if present, else data.text or data.delta, else "".

session_progress
Three shapes. A turn started on a thread:
{"phase":"turn"}
Compaction fired mid-loop:
{"phase":"compaction","reason":"after_turn"}
Paired with each text_delta:
{"progress":{"type":"assistant_delta","kind":"text","delta":str}}
text_delta
A model token chunk
{"chunk":{"content":str},"delta":str}
on_chat_model_stream
Legacy — no longer emitted. A harness older than felix-run/felix#603 sent each chunk of a composite pattern’s streamed answer (parallel, plan_execute) a second time under this name, beside the text_delta. A client that reads both should ignore it once a text_delta has arrived, or draw the answer twice
{"chunk":{"content":str}}
tool_start
Before a tool runs
{"name":str,"input":dict,"id":str}
tool_execution_update
Right after tool_start
{"name":str,"id":str,"status":"running"}
tool_end
A tool returned. attachments is present only when it returned images, each a stored felix-file:// reference to fetch from GET /files/{id} (felix-run/felix#500)
{"name":str,"output":str,"id":str,"attachments"?:[{"url","media_type"}]}
tool_execution_update
Right after tool_end
{"name":str,"id":str,"status":"complete"}
tool_request
The client must run this tool
{"id","name","args","thread_id","transport":"client"}
approval_required
A gated tool is waiting on a human
{"approval_id","tool_name","args","rule_id","reason"?,"thread_id","tool_call_id"}
ui_request
The agent is asking the user something
{"request_id","kind","prompt","default","thread_id","options":[…],"metadata":{}}
steer
A queued steer was drained into the loop
{"content":str}
follow_up
A queued follow-up was drained after idle
{"content":str}
workspace_note
A run put an operator’s workspace edit in front of the model before its next call
{"path","op","bytes","to_path"?}
todo_updated
The agent rewrote its checklist (todo_write). Sent from inside the call, before its tool_end, and only for a write the harness accepted; the list is also the call’s arguments, and the snapshot’s todos holds the current one
{"todos":[{"id","content","status","active_form"}]}
subagent_start
A task call handed a job to a child agent. A background child (background: true) runs as a durable run on a thread of its own and sends no subagent_end. Neither frame carries the call id
{"agent","background","task_id"?,"thread_id"?}
subagent_end
A foreground child agent finished
{"agent","outcome":"ok"|"error"}
permission_mode_changed
An approved exit_plan_mode moved the thread out of plan mode mid-run. POST /chat/mode emits none; the snapshot’s permissionMode carries it
{"mode"}
max_turns
A react loop ran out of spec.recursion_limit steps with tool calls still pending. The session is recorded truncated and done says stop_reason: max_turns; this frame is the one that carries the limit
{"limit":int}
aborted
POST /chat/abort was observed
{"thread_id":str}
on_error
The run failed mid-stream. Not terminal — the stream still ends with done/[DONE], so a client that ignores it reports success
{"message":str}
on_chain_end
Penultimate frame. Its output is the harness’s in-process result object and reaches the wire as a Python repr string: there is nothing in it for a client to read. Usage rides on done
{"output": str}
done
Final frame before [DONE]. stop_reason is how the run ended — end_turn, tool_use, max_tokens, max_turns, stop_sequence, pause_turn, refusal, unknown — and is what /v1 maps to finish_reason. usage is the final model call’s block, absent when the provider reported none (and on a harness older than felix-run/felix#399). input excludes cached tokens, so the prompt is input + cacheRead + cacheWrite. Because that call’s prompt was the whole active branch, totalTokens is also how full the context window is
{"final": ChatMessage, "messages":[…], "stop_reason": str, "usage"?: {"input": int, "output": int, "cacheRead": int, "cacheWrite": int, "totalTokens": int, "cost": {…}}}

Three frames pause the run until something answers them out of band. The run is not failing — it is waiting.

A tool_request frame means the caller runs the tool. The harness blocks on a waiter keyed client:{thread_id}:{tool_call_id} and resumes when you post the result:

curl -s -X POST "$BASE_URL/chat/tool_result" \
-H "authorization: Bearer $FELIX_KEY" \
-H 'content-type: application/json' \
-d '{"thread_id":"demo","tool_call_id":"call_123","content":"total 4\n-rw-r--r-- README.md"}'
Field Type Default
thread_id string required
tool_call_id string required
content string | object | array ""
error bool false

Non-string content is JSON-serialized server-side. Returns {ok, signaled, thread_id, tool_call_id}; the loop then emits a normal tool_end.

Failing to answer stalls the run until the tool’s timeout — spec.client_tools[].timeout_seconds, default 120 s, max 3600 — after which the result becomes [error/timeout] client tool timed out.

An approval_required frame waits up to the rule’s ttl_seconds — five minutes when the rule sets none. Decide with POST /approvals/{approval_id}/decide — see the Management API.

Not answering is answering. On timeout the harness returns denied with the note timeout: the tool is refused and the run moves on. A client that keeps offering the choice past the deadline is asking about a decision already made, so show the deadline and stop accepting an answer at it. The same number is the grant’s lifetime — approving authorizes every byte-identical call to that tool, matched on a hash of the arguments, until it expires.

Poll GET /approvals as well as reading the stream. The frame is prompt on a streaming run, and since felix#232 a polled row carries thread_id like the frame does, so the two channels attribute a waiting approval the same way. See Approvals for how far that attribution goes. The poll is still what an operator console should be built on: it finds the approvals no stream was open for. A non-streaming POST /chat has no frame to read at all: poll GET /approvals?thread_id= while it blocks, and read approvals on its response for how each gate ended.

A durable run’s agent is in the worker while its stream is served by the API, so the in-process side event cannot cross. Since felix#246 the durable stream announces gates anyway — it reads the pending rows for the run’s thread and rebuilds approval_required from them, rather than forwarding a message over a bus. (A bus would be at-most-once, and here the message is the prompt: drop it and the run blocks its whole ttl_seconds before denying, with nobody ever asked. Rebuilding from the durable row also means a client that attaches after a gate fired still sees it.)

Three things to know before relying on it:

  • It needs the approvals:read scope, the same one GET /approvals needs. Without it a durable run still streams its transcript, status and answer, and says nothing about gates. thread_id is supplied by the caller and Felix does not bind a thread to a principal, so ungated this would let chat access alone read the tool names, arguments and gate reasons of any thread in the tenant.
  • Each gate is announced once per stream. GET /approvals answers “what is pending now”, so a row returns on every poll until decided; re-showing a prompt someone already answered is worse than showing it late. A decided or expired gate is never announced — both are history.
  • The poll remains the channel of record. A stream that was never opened, or that dropped before the gate fired, sees nothing. These frames are a convenience for a watching client, not a replacement for polling.

An approval request is never written to the session log, so the transcript tail added in felix#238 does not carry it — that is the gap this closes.

A ui_request frame asks the user a question. kind is select, confirm or input. Answer it:

curl -s -X POST "$BASE_URL/chat/ui" \
-H "authorization: Bearer $FELIX_KEY" \
-H 'content-type: application/json' \
-d '{"thread_id":"my-thread","request_id":"abc123","value":"option-a"}'
Field Type Default
thread_id string required — your thread id, the one the stream was opened on
request_id string, ≤ 64 chars required
value any null
cancelled bool false
note string ""

The default wait is 300 s; on timeout the agent sees cancelled: true with note: "timeout".

A prompt is answerable only on the thread that asked it, and a thread is scoped to your tenant, so knowing a request_id is not enough to answer someone else’s prompt. Send your own thread id — the suffix you opened the stream with, not the namespaced thread_id on the frame. An answer for a prompt on another thread is accepted (ok) and reaches nothing, the same as an answer for one that already timed out.

curl -s -X POST "$BASE_URL/chat/steer" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"thread_id":"demo","text":"Focus on the migration instead.","kind":"steer"}'

kind: "steer" interrupts the remaining tool batch mid-run; kind: "follow_up" is delivered once the run goes idle — it has answered, or a terminal tool ended it — and the run takes another turn to answer it, tools and all, in the same stream. Drain behaviour is session.steering_mode / follow_up_mode (all or one-at-a-time): under all every follow-up waiting is answered in one turn, under one-at-a-time each in its own. A run that ends any other way (a budget, a truncated reply, the step limit, an abort) leaves its follow-ups for the thread’s next run. The queues are Redis lists, so this works across replicas.

A steer sent while no run is going is held for the next run on that thread and delivered at its start, right after that run’s own user turn — so “prefer metric units” shapes the next answer rather than being lost. Every held steer is delivered then, whatever steering_mode says, and it does not cancel that run’s tools. A steer or follow-up that arrives just after a run’s last check is kept for the next run the same way. queuedSteerCount on GET /chat/sessions/{thread_id} counts what is waiting.

When an operator changes a workspace file the agent is working on — in chat-ui’s side pane, say — the agent still holds what it read before. POST /chat/workspace/edited tells it.

curl -s -X POST "$BASE_URL/chat/workspace/edited" \
-H "Authorization: Bearer $FELIX_API_KEY" -H 'content-type: application/json' \
-d '{"thread_id":"demo","path":"notes/plan.md","op":"write","bytes":1204}'

The body is structured fields only — path, op (write, delete or rename), bytes (not on a delete) and to_path (a rename’s destination, and only then); the harness writes the sentence the model reads, so no client prose reaches it. With a run in flight the answer is status: "queued": the note is put in front of the model before its next call, without cancelling the tool calls in progress — unlike a steer — several edits to one path collapse to the last, and the stream emits workspace_note. With the thread idle it is status: "recorded" with an event_id: the note is appended at once and the next run reads it. Either way it lands in the log as a custom entry (role: "user", metadata.type: "workspace_edit", in_context: true), so a snapshot carries it. The route is lease-guarded like /chat/steer.

An agent whose file tools are the harness’s (read_file, write_file, …) works in a workspace on the harness — the manifest’s spec.workspace.scope, or the thread’s repository checkout when it has one — that a browser’s own files never reach. Three routes open it to a client, resolved exactly as the tools resolve it: by the manifest the thread last ran under, or manifest for a thread that has not run.

Method Path Notes
GET /chat/workspace/tree?thread_id=&limit=&manifest= {root_kind, scope, manifest, entries:[{path, type, bytes?}], truncated}. Recursive, symlinks neither followed nor listed, .git left out; limit 1–5000, default 2000.
GET /chat/workspace/file?thread_id=&path=&manifest= {path, bytes, sha256, encoding: "utf-8"|"base64", content}. 413 too_large past 512,000 bytes, 404 not_found, 400 not_a_file / invalid_path / reserved_path.
POST /chat/workspace/write {thread_id, path, content, expected_sha256?, manifest?} → {status, path, bytes, sha256, event_id}.
POST /chat/workspace/delete {thread_id, path, expected_sha256?, manifest?} → {status, path, event_id}. A file only: 400 not_a_file for a directory.
POST /chat/workspace/rename {thread_id, path, to_path, expected_sha256?, manifest?} → {status, path, to_path, sha256, event_id}. Never overwrites: 409 target_exists. Creates to_path’s folders.
POST /chat/workspace/delete_folder {thread_id, path, expected_count?, manifest?} → {status, path, files, event_id}. Recursive, never through a link; refuses the root, a tree holding .git, and one over 2,000 entries (409 too_many_entries). expected_count is the number of files the operator was shown: another count now is 409 workspace_changed with count, and nothing is deleted.
POST /chat/workspace/rename_folder {thread_id, path, to_path, manifest?} → {status, path, to_path, event_id}. Never over anything (409 target_exists), never into itself (400 invalid_path), same 2,000-entry cap.

The write, the delete and the rename are each conditional when expected_sha256 is sent: if the file is no longer what the client read, it is refused 409 {"detail":"workspace_changed","sha256","bytes"} and nothing is changed. Each also tells the agent, exactly as /chat/workspace/edited does (op write, delete or rename) — status is that note’s queued or recorded — so a client does not call both. It is lease-guarded like a steer.

Whether an agent works there at all is on its /v1/models entry: felix.workspace: {"tools": "server"|"client"|"both"|"none", "scope": …}. client means its file tools are the browser’s (local_*), and its files are not on the harness.

An agent can also ask to show the operator one of these files, with the open_file tool. The harness opens nothing: it checks the file is one GET /chat/workspace/file would serve and answers {"opened": "<path>", "kind": "file", "in": "side_pane", "size": N}, or a [tool error/…] refusal. A client that has a file pane opens what a successful result names, once per tool call id, and only for a call it watched finish — a tool_end frame, or a durable run’s tool_result session_event — never for one in a transcript it rebuilt from the session, or a reload would reopen every file the thread ever opened. The result is the whole signal on purpose: it reaches a durable run’s stream, where a side event cannot. chat-ui opens it in the thread’s side pane.

Method Path Body Notes
POST /chat/abort {thread_id} Sets a flag the loop polls each step; emits aborted, phase becomes aborted.
POST /chat/continue {thread_id, manifest, model?} Resumes after an abort or error with no new user message. 400 nothing_to_continue / already_complete.
POST /chat/mode {thread_id, mode} default plan accept_edits bypass — the conversation’s permission mode, from the next run. bypass needs approvals:bypass (403 missing_scope:approvals:bypass). Audited as permission_mode_change.
POST /chat/style {thread_id, manifest, style} The conversation’s output style, from the next run: one manifest declares, or null for its default. manifest is required with a style (422 manifest_required; 404 unknown_manifest), and a name it does not declare is 422 unknown_output_style. Answers {ok, thread_id, style}.
POST /chat/ask {thread_id, question} A side question: answered from the thread’s context by the agent it last ran under, with no tools, and added to nothing — no session event, no lease (so it works mid-run, and from a client that only watches). Answers {thread_id, manifest, status, answer, usage}, status being answered, not_in_context or withheld (a final-response judge refused it). 404 unknown_thread for a thread the harness has no record of.
POST /chat/thinking {thread_id, thinking_level} off minimal low medium high xhigh max. Persisted as a thinking_level_change event.
POST /chat/compact {thread_id, manifest, instructions?} Compact the transcript now.

Thinking levels map to token budgets: off (none), minimal 128, low 512, medium 1024, high 2048, xhigh 8192, max 32000 — and to OpenAI reasoning_effort for OpenAI models.

Method Path Purpose
GET /chat/sessions?limit=&cursor= List sessions, newest first, one page at a time — {sessions:[…], items:[…], next_cursor}. limit is 1–500 (default 100); pass next_cursor back as cursor for the next page, and it is null on the last. Each row has id, createdAt, updatedAt, parentSessionId, sessionName, preview (first user message, masked, ≤120 chars) and manifest (the newest turn’s), the last two null when unknown (felix sessions backfill-previews fills preview for threads created before v0.11.2)
GET /chat/sessions/search?q=&limit= Postgres full-text search — {query, hits}
GET /chat/sessions/{thread_id} Authoritative snapshot
GET /chat/sessions/{thread_id}/export Active branch as JSONL (application/x-ndjson, attachment)
POST /chat/sessions/name {thread_id, name} — also appends a session_info event
POST /chat/sessions/label {thread_id, event_id, label}
POST /chat/sessions/feedback {thread_id, event_id, rating: "up" | "down" | null, note?} — rate an assistant turn
POST /chat/sessions/custom {thread_id, content, in_context, metadata, role}
GET /chat/history/{thread_id}?limit=&before_seq= Flat transcript, newest window — {thread_id, messages, events, oldest_seq, has_more}
DELETE /chat/history/{thread_id} Reset the session log

/chat/sessions/feedback rates an assistant message, and null clears the rating. An id the thread does not have is 404 unknown_event_id, and anything that is not an assistant message is 400 not_an_assistant_message. The current rating per event comes back on the snapshot as feedback ({rating, note, at}), and every change is also an audit event, turn_feedback, so GET /audit?event_type=turn_feedback lists them across the whole tenant. Nothing is written to the session log, so rating a turn does not move the thread’s leaf. In the web UI a thumbs-down asks what was wrong. With a note, it can also save the question as an eval case, judged against that note: a complaint becomes a regression test.

/chat/sessions/custom appends an entry from outside the conversation: a note, or an instruction from a plugin. role defaults to system, and in_context (default false) decides whether the model ever sees it. With in_context: true the entry enters the model’s context under the role you gave it, so a system entry steers every later turn and an assistant one reads to the model as something it said itself. The entry lands in the log as kind: "custom" with the flag in metadata.in_context. Both Felix clients draw it as a note that names its role and says whether the model read it, never as a message from either side.

GET /chat/sessions/{thread_id} is the contract a client hydrates from. Its keys are camelCase, deliberately:

{
"id": "default:demo",
"name": "Migration work",
"parentSessionId": null,
"createdAt": "…", "updatedAt": "…",
"phase": "idle",
"model": { "id": "claude-sonnet" },
"thinkingLevel": "medium",
"attached": false,
"locked": false,
"revision": 12,
"leafId": "evt_…",
"labels": {},
"transcript": [ { "id","seq","kind","role","content","timestamp","status","metadata",
"toolCallId","toolName","toolCalls","usage" } ],
"queuedSteer": [], "queuedSteerCount": 0,
"wake": { "fresh": true, "headSeq": 41, "endedOnAssistant": true, "pendingToolCalls": [] },
"activeRun": { "resumeToken": "fib_…", "status": "running", "expiresAt": 1760000000000 }
}

phase is one of idle, turn, compaction, branch_summary, retry, aborted. An idle phase with pending tool calls is reported as turn. A durable run writes no phase — its agent is in the worker — so the thread reads idle while one works; activeRun is that run, or null. Its resumeToken is what GET /chat/runs/{resume_token} takes.

Session event kinds: message, tool_call, tool_result, thinking, audit, compaction, model_change, thinking_level_change, branch_summary, custom, label, session_info.

GET /chat/history/{thread_id} returns the newest window of a thread, not the oldest, and is bounded at 5000 events read whether or not you pass limit. A long transcript therefore comes back truncated, with has_more: true. Page backwards by handing the previous response’s oldest_seq back as before_seq.

Two details decide whether paging works:

  • limit counts events read, not messages returned. The response filters out non-message kinds after the window is taken, so limit=50 can legitimately yield fewer than 50 messages. A limit below 1 is a 400 limit_must_be_positive.
  • oldest_seq is the window’s lower bound, not the first message’s seq. Those differ precisely when the filter dropped the oldest events in the window — so paging from the first message you can see would step over whatever was filtered and lose it.

messages and events carry the same array under two keys.

Not paginated, deliberately: GET /chat/sessions/{thread_id}/export returns the whole branch (an export that silently truncates is worse than a slow one), and the session snapshot above carries the transcript a reconnecting client rebuilds from.

A lease keeps two clients from driving one session at once. Each thread has one exclusive hold (mode: exclusive — the client driving it) and any number of observer holds (mode: shared — clients watching it read-only). Every hold has its own token and its own expiry.

curl -s -X POST "$BASE_URL/chat/sessions/lease" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"thread_id":"demo","holder_id":"tab-7","mode":"exclusive","ttl_seconds":300}'
Field Type Default
thread_id string required
holder_id string, ≤128 chars required
mode exclusive | shared exclusive
ttl_seconds float, 5–86400 300
token string | null null — required to renew

A grant answers {ok, renewed, token, mode, held_by_other, status, snapshot}. token is this hold’s own, and held_by_other says someone else is driving the thread.

  • exclusive is 409 lease_held while another holder has the exclusive hold. Observers never block it: a thread held only by observers is free to drive.
  • shared always succeeds, with a token of its own — never the exclusive one. On a thread another holder drives it returns 200 with held_by_other: true, and that hold is read-only. On a free thread it makes an observer-only lease. This is what a second tab falls back to when its exclusive acquire is 409.
  • Renewing is acquiring again with the hold’s token, and extends that hold and nothing else. The holder id alone is no proof — every status publishes the exclusive holder’s, and a duplicated browser tab copies its own — so a renewal without the token is 409 lease_held and returns no token. An observer’s renewal never extends the exclusive hold. A driver that wants to step down to watching renews in shared mode with its exclusive token.
  • Nobody is promoted. When the exclusive hold is released or lapses, observers stay observers. An observer that wants to drive acquires exclusive itself — chat-ui retries on every renewal while it watches, so a watching tab takes over within one renewal of the driver leaving.

Release with POST /chat/sessions/lease/release ({thread_id, token, holder_id?}). It drops the one hold the token names and leaves every other in place. The token is required: without it the answer is 403 token_required, and a holder_id sent alongside must be that hold’s holder (403 token_mismatch / not_holder otherwise). 409 lease_contended means concurrent changes on Redis kept the release from landing; nothing was released, so retry.

GET /chat/sessions/{thread_id}/lease reports who holds the thread:

{
"locked": true,
"attached": true,
"holder_id": "tab-7",
"mode": "exclusive",
"observers": 1,
"observer_holds": [{ "holder_id": "tab-9", "expires_at": 1767225600000 }],
"expires_at": 1767225480000,
"token_hint": "Zx3k9q"
}

holder_id is the exclusive holder and is null when only observers hold the thread. locked means someone drives it, attached that anyone holds it. expires_at values are epoch milliseconds: the exclusive hold’s, or the last observer’s when there is none.

Leases are advisory unless a client opts in. The routes that drive a thread accept an X-Felix-Lease-Token header: POST /chat, /chat/stream, /chat/continue, /chat/abort, /chat/rewind, /chat/steer, /chat/tool_result, /chat/ui, /chat/compact, /chat/thinking, /chat/mode, /chat/style, /chat/sessions/name, /chat/sessions/label, /chat/sessions/custom, /chat/workspace/edited, /chat/workspace/write, /chat/workspace/delete, /chat/workspace/rename, /chat/workspace/delete_folder, /chat/workspace/rename_folder and DELETE /chat/history/{thread_id}. When the header is present, the request is refused unless it is the exclusive hold’s token:

409 detail Meaning
lease_read_only The token is an observer’s: this client is watching.
lease_held Another holder has the thread exclusively — this client’s hold lapsed or was never the driver’s.

Under the default, FELIX_LEASE_ENFORCE=advisory, a request without the header is not checked, so a script, the OpenAI-compatible surface and A2A are unaffected. That also means the header protects a client from its own mistakes, not the thread from a caller that leaves it off. /chat/fork only reads its source and /chat/sessions/feedback rates a reply without writing the log, so neither checks it; approvals are decided through /approvals, gated on the approvals:write scope.

FELIX_LEASE_ENFORCE=strict makes the lease binding on those same routes. A request without the header is then 409 lease_held while another holder has the thread exclusively. A thread nobody holds, or one only observers hold, still takes a request without a token, so scripts keep working whenever no client is driving; with the header the table above applies unchanged. /v1/chat/completions is checked too when it sends user, because user names the same thread a chat thread_id does ({tenant}:{user}): it is refused, with an OpenAI-shaped error, on a thread a chat client drives, and may send X-Felix-Lease-Token itself. A2A threads ({tenant}:a2a:{task}) and MCP calls cannot reach a chat thread and are not checked. Strict is still a consistency control between cooperating clients, not an authorization boundary: every caller in the tenant may acquire a lease.

A client refused the exclusive hold should therefore watch rather than go without one: take a shared hold and send its token, so the harness answers lease_read_only — a refusal that says “you are watching” — rather than lease_held under strict or, under advisory, nothing at all. chat-ui and the terminal both do.

Leases are Redis-backed, so they hold across replicas, and every change is one WATCH/MULTI transaction, so two replicas cannot both grant the exclusive hold. Without Redis they fall back to per-process state, where each replica can grant its own.

curl -s -X POST "$BASE_URL/chat/fork" -H "authorization: Bearer $FELIX_KEY" \
-H 'content-type: application/json' \
-d '{"thread_id":"demo","new_thread_id":"demo-branch","from_event_id":"evt_123"}'

new_thread_id must name a thread that does not exist yet: one with no events and no session metadata, and not the source thread itself. Otherwise the fork is refused with 409 {"detail": "thread_exists"} and nothing is written. Mint a fresh id per fork (chat-ui and the TUI use a random UUID). Two forks racing to the same new id leave exactly one fork.

POST /chat/rewind takes {thread_id, event_id, summarize?, instructions?, manifest?, restore_files?} and moves the active leaf back to event_id. Unless summarize: false (or session.branch_summary: false), the abandoned branch is summarized into a branch_summary event using the manifest’s model.

With restore_files: true, the rewind also undoes what the agent’s file tools did in the thread’s own workspace on the branch it abandons. Every write, edit, delete, rename and multi-edit those tools make is recorded: the file as it was before, and as the tool left it. The rewind puts each changed file back to its state before the branch’s first change. The answer gains files:

{
"ok": true,
"leaf_id": "…",
"files": {
"restored": ["src/app.py"],
"removed": ["notes/draft.md"],
"conflicts": ["README.md"],
"skipped": [],
"truncated": false
}
}
  • restored: written back as it was. A file the branch deleted or renamed away is recreated.
  • removed: deleted, because it did not exist before the branch (a new file, or a rename’s destination).
  • conflicts: left alone, because it is no longer what the branch’s last change left. Someone edited it, or a shell command rewrote it.
  • skipped: left alone, because its earlier state was not kept. This covers files over 512 KB, content past the tenant’s ceiling, and content the store refused.
  • truncated: the branch changed files more than 2,000 times. Only the first 2,000 changes were considered, and the rest can no longer be restored.

If the restore itself fails (the database or object store is unavailable), the rewind still happens and files is {"error": "unavailable"}. The records are kept, so a later rewind can still restore.

Every rewind settles the branch it leaves. With restore_files the branch’s changes are put back; without it they are kept as they are. Either way the records of that branch are then forgotten. A later restore undoes only what changed after the last rewind, so rewinding twice to the same point never reaches back past the first rewind.

Each restore is audited. It writes a workspace_restore audit row with the caller, thread, event and the four path lists, as file-pane writes and deletes do.

A few limits apply:

  • The thread’s own workspace only. A manifest whose spec.workspace.scope is tenant or deployment shares its files across threads. Undoing one thread’s branch there could remove a file another thread relies on, so those changes are not recorded.
  • Only the file tools are recorded. A shell_tools command’s writes are not, and neither are the operator’s file-pane edits. A file a command changed afterwards shows up as a conflict rather than being silently overwritten.
  • Rewinding onto another branch undoes the abandoned branch’s changes back to where the two branches split. It does not replay the target branch’s own changes.
  • Recording never blocks a write. If recording fails, the write goes ahead, the failure is counted as felix_file_checkpoint_failed, and that change cannot be restored. If it was a file’s first change on the branch, a restore puts back the file’s next recorded state.
  • Storage. Earlier contents are kept in the object store once per tenant, by digest, so a file changed step after step is not stored again each time. A tenant keeps at most FELIX_FILE_CHECKPOINT_MAX_BYTES_PER_TENANT (256 MiB); past that, changes are recorded but not kept. A file’s records in a thread are swept once FELIX_FILE_CHECKPOINT_RETENTION_DAYS (7 days) have passed since its last change there, however busy the thread stays. Deleting a thread does not delete them sooner, so a file the agent deleted can stay in the object store until then. FELIX_FILE_CHECKPOINTS=false records nothing.

The Python client takes the same flag: await client.rewind(event_id, restore_files=True).

The next message appends as a child of the new leaf, so a rewind followed by a send creates a branch. GET /chat/sessions/{id} still returns every event on the session, across all branches, and reports the active one only as leafId. To render the active branch, start at the leaf and follow each event’s metadata.parent_id back to the root. That is the path the model’s context is built from. Cutting the list off at the leaf works only while the thread has a single branch: after an edit, the abandoned reply sits between the branch point and the new leaf in seq order.

model is the manifest name.

curl -s "$BASE_URL/v1/chat/completions" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"model":"quick","messages":[{"role":"user","content":"hi"}]}' | jq
Method Path Notes
GET /v1/models Bundled and published manifests as OpenAI model objects, each with a felix block: providerModel, contextWindow, starters (always a list, empty when the manifest declares none), commands ([{name, description, argumentHint}], the prompts a person can run as /name; always a list), outputStyles ([{name, description}]; always a list), defaultOutputStyle (a name, or null) and greeting ({headline, subtitle}, or null)
POST /v1/chat/completions stream: true emits chat.completion.chunk frames, a final chunk carrying usage, then data: [DONE]

The optional user field becomes the thread suffix ({tenant}:{user}). Errors come back in OpenAI shape — {"error": {...}} — including manifest_drift, content_filter and model_gateway_error.

Tool-call history round-trips the way an OpenAI SDK sends it: an assistant message’s tool_calls (function.arguments as a JSON string) and the tool messages that answer them by tool_call_id reach the model together. Arguments that are not a JSON object are a 400 invalid_request_error naming the call, tool_calls[0]: arguments are not valid JSON.

POST /a2a speaks A2A JSON-RPC for agent-to-agent calls. Peers declared in spec.peers become peer__{name} tools on the calling agent, and hop depth is bounded by limits.max_peer_hops.

Images. message/send takes images as FileParts carrying their bytes. type and mime_type are accepted as spellings too.

{"kind": "file", "file": {"bytes": "<base64>", "mimeType": "image/png"}}

An image-only message is valid. mimeType is optional and read from the bytes when absent. A FilePart is held to the /files upload rules: png, jpeg, gif or webp by its bytes, at most 600 KiB. It is stored like an upload, under the tenant’s attachment quota. A FilePart that fails the rules, or that carries a uri instead of bytes, refuses the message with -32602 rather than being dropped. The image is then screened like any user turn’s. If the agent’s model route is text-only and no vision route is set, the message gets -32602 naming the route. A refused message’s task reads failed on tasks/get.

A peer’s answer is read from its artifacts, or else its status message. Image FileParts in it reach the calling agent as tool images: checked and capped before screening reads them, then stored and screened as an untrusted tool’s are.

Method Path Notes
POST /mcp JSON-RPC: initialize, tools/list, tools/call. params.manifest selects the agent.
GET /mcp Human-readable info

A tools/call runs the manifest’s tool the way the agent’s own loop does: through the governance stack, and around it the plugin before_tool / after_tool hooks and the manifest’s pre_tool_use / post_tool_use hooks, with a tool_call (or policy_deny) audit row. A hook that refuses the call answers it with isError: true and the hook’s message; a tool that raises is named by its error code, never by its exception’s text.

Tool inputSchema is generated from each tool’s argument schema. A remote MCP tool that already supplies JSON Schema is forwarded verbatim through raw_input_schema, preserving its descriptions and enums.

Images.

  • Server side. A tool that returns images (a browser screenshot, an image tool’s result) answers tools/call with an image content block for each, as base64. The limits:
    • at most four images;
    • each must be an image by its bytes;
    • a stored image is sent only if the same call made it, read back under the caller’s tenant, since an MCP client cannot resolve a felix-file:// reference;
    • a URL is never passed on.
  • Client side. Felix reads a remote server’s image content blocks as tool images. They are stored and capped, and screened as an untrusted tool’s are, which means they are quarantined when content_screening.image_model is unset.

Starts a durable run from a signed event, for a trigger the operator registered in FELIX_TRIGGERS (setup). No Authorization header: the sender signs the body with the trigger’s secret, as Standard Webhooks or GitHub specify. The body must be UTF-8 JSON of at most 256 KiB.

Status Body When
202 {"status":"accepted","run_id"} The run is queued. Watch it with GET /chat/runs/{run_id}, or let the manifest’s spec.execution.webhooks announce its end.
202 the same, with idempotent-replayed: true A delivery already accepted. For Standard Webhooks the same webhook-id; for GitHub the same signed body, whatever X-GitHub-Delivery says.
200 {"status":"ignored"} The event type is not one the trigger runs for (not in events, or GitHub’s ping). Remembered like an accepted delivery.
401 invalid_signature No trigger has this id, or the signature is missing, stale or wrong, or the delivery id is malformed. One answer for all of them, so ids cannot be probed; the reason is in the server log.
409 delivery_in_progress / delivery_id_reused The same delivery is being handled now, or a webhook-id came back with a different body.
413 payload_too_large
422 invalid_payload Not UTF-8 JSON.
429 rate_limited The trigger’s per-minute limit is spent. Not remembered: a retry may succeed.
503 trigger_unavailable The trigger cannot start its run: its secret did not resolve or is too short, its manifest is missing, refuses the trigger’s principal, or does not list trigger_event.

The run’s user turn is the trigger’s prompt. The payload arrives only when the model calls trigger_event, which returns the event type, delivery id and the body as sent, marked as third-party data.

Method Path Notes
GET /.well-known/agent-card.json Built from FELIX_DEFAULT_MANIFEST
GET /.well-known/jwks.json {"error":"not_configured","keys":[]} until FELIX_JWKS_PUBLIC is set

POST /internal/sessions/{session_id}/events lets a queue consumer write a tool result back onto a session. It requires the X-Felix-Consumer-Secret header matching FELIX_CONSUMER_SHARED_SECRET. With the secret unset the route is available only when FELIX_AUTH_MODE=none outside production; otherwise it returns 503 consumer_shared_secret_required. Content is screened, so a hostile payload gets 422 content_screening_denied.

session_id is the full, tenant-prefixed thread id as it appears in the queue envelope ({tenant}:{suffix}, or {tenant}:fiber:{id} for a durable run) — not a bare suffix. It must belong to the tenant the consumer’s own credential names, or the write is refused with 403 thread_not_in_tenant. Every other route composes a thread id from a client suffix and the caller’s tenant; this one receives one already built, so it checks ownership instead.

There is no request header that selects a variant. When a canary is active, the harness picks a side deterministically from a hash of tenant, thread, manifest and the two version numbers, so a given thread stays pinned to one variant for the life of the rollout. The resolved variant is reported on GET /manifests/{name}. See Manifests.