API REFERENCE

The chat stream

One request in, a stream of events out. Text arrives token by token, tool calls surface live, and write actions pause for approval.

POST/v1/chat

Request

Send JSON. The response is always text/event-stream; the server does not read your Accept header, but sending Accept: text/event-stream keeps intermediaries honest. Omit session_id (or send null) on the first turn, the server mints one and hands it back in the first event.

FieldTypeDescription
messagerequiredstringThe user turn. What the person typed. Missing from the body means a 422.
user_contextrequiredobjectThe end-user identity. Required by the schema even in open mode, where an empty object is accepted. In signed mode it must carry _ts and _sig.
session_idstring | nullContinue an existing conversation. Null or omitted starts a new one.
client_tzstringThe user's IANA timezone, e.g. Asia/Riyadh, max 64 characters. The agent reads times like "tomorrow at 9" in this zone. Omit it and the agent is told the zone is unknown, and will ask before scheduling rather than assuming UTC.
In signed identity mode the user_context must be signed, or the request is rejected with IDENTITY_SIGNATURE_REQUIRED. See the identity guide for the HMAC recipe.
Timezone. The model has no clock of its own — Agentifys tells it the current time on every turn. Send client_tz as a plain top-level field (the embedded widget does this automatically from Intl.DateTimeFormat().resolvedOptions().timeZone). Do not put it inside user_context in signed mode: that object is signed by your backend, and adding a field in the browser invalidates the signature. If your backend already knows the user's zone, signing a timezone field into user_context is better still — a verified zone outranks the browser's. With neither, the agent is told the zone is unknown and will ask before scheduling.

Send a message

bash
# -i prints the status line. A 401/402/403/429 failure is a JSON body,
# not a stream, and without -i it looks like a reply that never arrived.
curl -N -i https://agentifys.ai/v1/chat \
  -H "Authorization: Bearer era_your_project_key" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{
    "message": "Where is order 1043?",
    "user_context": { "id": "u_42", "name": "Sara" },
    "session_id": null
  }'

Failures before the stream

Auth, origin, rate limit, budget and session-ownership checks all run before the stream opens. When one of them rejects the request you get an ordinary JSON response with a non-200 status and no SSE at all — no error event, no [DONE]. A client that goes straight from fetch() to reading the body will find no data: lines and render a blank reply. Check the status first.

FieldTypeDescription
Invalid API key401Body is {"detail":"Invalid API key"}. The Bearer key is missing from the header value or matches no project. This is the one chat failure with no stable error code — every other one uses {"error":"CODE","detail":"..."}.
ORIGIN_NOT_ALLOWED403The Origin header is not on the project allowlist. Only enforced when the project has an allowlist configured.
RATE_LIMIT_EXCEEDED429Either the project-wide rpm/rpd ceiling or the per-user chat throttle. Carries a Retry-After header in seconds, and the same number in detail.
BUDGET_EXCEEDED402The monthly token budget for the project is exhausted. Checked before the turn starts, so it never arrives mid-stream.
IDENTITY_*401Signed-mode identity failures: IDENTITY_NOT_CONFIGURED, IDENTITY_SIGNATURE_REQUIRED, IDENTITY_SIGNATURE_INVALID, IDENTITY_SIGNATURE_EXPIRED, IDENTITY_ID_REQUIRED. On this endpoint all five return 401.
SESSION_NOT_FOUND403The session_id exists but belongs to a different project, or in signed mode to a different user. Deliberately opaque: it does not confirm whether the session exists. Note the status is 403, not 404.
Validation error422FastAPI schema rejection: message or user_context absent from the body, or the Authorization header absent entirely. The body is the FastAPI detail array, not an Agentifys error code.
BUDGET_EXCEEDED and RATE_LIMIT_EXCEEDED are HTTP responses only. They are never emitted as SSE error events, and no SSE event carries a machine-readable code field at all. Handle spend and throttle limits on the status code.

The SSE event contract

The response is a stream of Server-Sent Events. Each frame is a single line of the form data: {json} followed by a blank line. Payloads are never split across lines, so one data: line is always one complete JSON object. Read the type field to decide what to do. The stream always ends with a literal data: [DONE].

Frames carry no event: name and no id: field. Every frame is a default message event and the JSON type is the only discriminator. There are exactly eight event types.

session

Always the first event of the stream, emitted before the agent starts. Carries the session id that was created or reused. Store it and send it back as session_id on the next turn. A server-minted id is an opaque 22-character URL-safe token with no prefix and no structure — do not parse it.

text
data: {"type":"session","session_id":"kJ2mQ8vX1pR4nT7bW0cLdA"}
Sending a session_id that does not exist yet does not fail — it creates a session with exactly that id. So a value of your own choosing becomes a real session. In open mode the session id is the only thing guarding the transcript and any held write, so never derive it from something guessable such as a user id, an email or a counter. Let the server mint it.

text

A chunk of the reply, exactly as the model produced it. Many of these arrive in order. Concatenate the content values to build the full message. Two of these are written by Agentifys rather than the model: the summary line after an approved write, and the cancellation line after a declined one.

text
data: {"type":"text","content":"Order 1043 shipped "}

tool_start

The agent called a tool. Use it to show live tool status.

FieldTypeDescription
idstringThe provider tool-call id. Pairs this event with its tool_end and, for a held write, with the confirmation_required event.
namestringThe tool name as configured on the Tools page.
inputobjectThe arguments the model produced, unmodified.
text
data: {"type":"tool_start","id":"toolu_01A9","name":"lookup_order","input":{"order_id":"1043"}}

tool_end

The tool returned. Match it to its tool_start by id.

FieldTypeDescription
idstringMatches the tool_start.
namestringThe tool name.
resultobjectWhatever the executor returned. A failed tool still returns a result object, usually with an error field, and the turn continues.
text
data: {"type":"tool_end","id":"toolu_01A9","name":"lookup_order","result":{"status":"shipped","eta":"2026-09-02"}}
Platform meta-tools never appear in the stream. The internal field-collection tool (_era_save) and the four scheduling tools (schedule_task, list_scheduled_tasks, update_scheduled_task, cancel_scheduled_task) run in-process and emit no tool_start or tool_end, and are left out of stats.tools_used.

confirmation_required

A write action is held, waiting for the user to approve it. This event ends the turn — see Resuming a held write for how to release it. Show the display_name and input so the person can see what is about to happen.

FieldTypeDescription
idstringThe tool-call id of the held action.
namestringThe tool name.
display_namestringThe human label configured for the tool. Falls back to name when unset.
inputobjectThe exact arguments that will run if approved. Render these; this is the whole point of the pause.
confirmstringThe literal message to send back to approve. Currently __era_confirm__. Read it off the event rather than hardcoding it.
declinestringThe literal message to send back to cancel. Currently __era_decline__.
text
data: {"type":"confirmation_required","id":"toolu_01B4","name":"issue_refund","display_name":"Issue refund","input":{"order_id":"1043","amount":"42.00"},"confirm":"__era_confirm__","decline":"__era_decline__"}

stats

Usage for the turn, emitted immediately before the final done. Only on a turn that completes normally: a held write, an approved or declined write, and any failure all skip it.

FieldTypeDescription
input_tokensnumberPrompt tokens across every model call in the turn.
output_tokensnumberCompletion tokens across every model call in the turn.
tools_usedstring[]Tool names called this turn, in call order, one entry per call. A tool called twice appears twice. Not a count, and not deduplicated.
iterationsnumberModel round-trips used, starting at 1. The agent stops at 10.
text
data: {"type":"stats","input_tokens":812,"output_tokens":140,"tools_used":["lookup_order"],"iterations":2}

error

The turn failed. content is the only field: there is no code, no status, no structured detail. The stream then goes straight to [DONE] with no stats and no done, so any text already streamed is all the reply you get.

text
data: {"type":"error","content":"PROJECT_NO_API_KEY"}

There are four possible contents:

FieldTypeDescription
PROJECT_NO_API_KEYliteralEither the project has no usable provider key, or every key it has failed authentication during this turn (each key is marked "failing" as it is rejected). Add or fix a model key in Settings.
The AI provider is…proseFull text: "The AI provider is temporarily rate-limited. Please wait a moment and try again." The upstream model provider returned 429. Back off and retry the turn.
Agent error: …proseAn unhandled exception inside the agent, with the exception text appended. Treat the suffix as a debugging hint, never as something to parse or show verbatim.
An unexpected error…proseFull text: "An unexpected error occurred. Please try again." Raised by the stream wrapper when the agent generator itself blows up.

done

The turn completed. Carries a trace_id: 8 hex characters, which you pass to /v1/feedback to rate this reply. Not emitted on a failed turn, so do not treat it as the end-of-stream signal.

text
data: {"type":"done","trace_id":"9f3a1c7d"}

[DONE]

The final line, on every outcome. Not JSON. When you read it, stop reading and close the stream.

text
data: [DONE]

What arrives, and in what order

Only two events are guaranteed: session first and [DONE] last. Everything between depends on how the turn ends. An asterisk means zero or more.

text
normal turn        session -> text* -> (tool_start -> tool_end)* -> text* -> stats -> done -> [DONE]
held write         session -> text* -> confirmation_required -> done -> [DONE]
resume (approve)   session -> tool_start -> tool_end -> text -> done -> [DONE]
resume (decline)   session -> text -> done -> [DONE]
failed turn        session -> text* -> error -> [DONE]

Two consequences worth building for. done is absent on a failed turn, so close on [DONE], not on done. And stats only appears on a turn that runs to completion, so a usage counter wired to it will undercount held writes and failures.

Resuming a held write

A tool marked requires confirmation is never executed straight from the model. SSE is one-way and cannot block mid-stream for a click, so Agentifys stores the pending call against the session, emits confirmation_required, and ends the turn. Nothing further happens until you send another message on that session. A custom UI that renders the event but never sends the follow-up leaves the write held forever.

The resume is an ordinary POST /v1/chat whose message is the token from the event, on the same session_id.

http
POST /v1/chat
Authorization: Bearer era_your_project_key
Content-Type: application/json

{
  "message": "__era_confirm__",
  "user_context": { "id": "u_42", "name": "Sara" },
  "session_id": "kJ2mQ8vX1pR4nT7bW0cLdA"
}

The two tokens are __era_confirm__ and __era_decline__, and every confirmation_required event ships them in its confirm and decline fields. Read them off the event.

javascript
const { text, held } = await send("Refund order 1043")

if (held) {
  // held.display_name and held.input are what the person approves.
  // The event carries the exact tokens; do not hardcode them.
  const approved = await askTheUser(held.display_name, held.input)

  // Same session_id, next turn. send() above already keeps it.
  await send(approved ? held.confirm : held.decline)
}

What each resume produces:

FieldTypeDescription
__era_confirm__approveThe held tool runs with the input from the event. The stream emits tool_start, tool_end, a one-line Agentifys-written summary as a text event, then done. No model call is made, so there is no stats event and the reply is not model-generated.
__era_decline__cancelThe held call is dropped and the stream emits one text event, the literal string "Okay, I've cancelled that action — nothing was submitted.", then done.
anything elsedrops itThe held call is discarded and your message is processed as a normal turn. Silently. Nothing tells the user the pending write went away.
The hold is matched by session, not by the call id. The resume message carries no reference to what it is approving, and the server executes whatever pending action that session holds. If you keep more than one approval on screen, the second approval can release the wrong write. A session holds at most one pending action, and a newer one replaces the older.
Typed replies also resolve a hold, which is why "anything else" above is narrower than it looks. Matching is case-insensitive and strips trailing . ! ?. The whole message approves if it is one of approve, yes, yes proceed, yes submit, confirm, confirmed, proceed, go ahead, ok, okay, do it, sure; it declines on decline, no, cancel, stop, no thanks, don't, do not, nope. Failing that, the first word decides: yes, approve, confirm, confirmed, proceed, ok, okay, sure, yep, yeah approve, and no, decline, cancel, stop, nope decline. So "Yes, go ahead!" approves the write. This is a convenience for typed conversations; your buttons should send the tokens, which are unambiguous.
If the model requests several confirmation-gated tools in one turn, only the first is held and the turn ends there. The other calls from that turn are discarded, not queued. Approving releases the one held action and nothing else.

Proxying the stream

Putting the endpoint behind your own server is the normal way to keep the project key off the browser. A proxy that treats the response like an ordinary JSON body will break streaming in ways that look like a slow or empty agent, so a few things have to be right.

Agentifys responds with Content-Type: text/event-stream, Cache-Control: no-cache and X-Accel-Buffering: no. Forward all three to the client unchanged.

FieldTypeDescription
No bufferingrequiredWrite each chunk through as it arrives. nginx needs proxy_buffering off (or it must honour the upstream X-Accel-Buffering: no); Node needs the response flushed per chunk rather than collected; serverless platforms that buffer a whole response cannot host this proxy at all.
No compressionrequiredDo not gzip or brotli the stream. A compressor fills its block before emitting, which holds tokens back until the turn ends and defeats the point of streaming.
Idle timeoutraise itA turn can run ten model round-trips plus tool calls. Set the read and idle timeouts on both proxy and client above your longest realistic turn: a 30-second default will cut replies mid-sentence.
No heartbeatbe awareThe server sends nothing between events. During a slow tool call the connection is silent, and anything that closes idle connections will close this one. There are no keepalive comment frames to lean on.
No resumebe awareFrames carry no id, and the server ignores Last-Event-ID. A dropped connection loses the rest of the turn with no way to pick it up.
Session history is written at the end of the turn. A connection that drops mid-turn is not recorded at all — neither the user message nor the partial reply reaches GET /v1/history. Resending the same message is the only recovery, and the conversation carries no memory of the cut-off attempt. But the tools that turn already called did run, and a retry gets a fresh idempotency key, so any write can happen twice. Retry freely for read-only turns; for anything that writes, ask the user first.
The browser EventSource API cannot be used against this endpoint. It only issues GET requests and cannot set an Authorization header. Use fetch with a ReadableStream reader, as in the JavaScript sample above.

Choice buttons

An agent can end a reply with a fenced code block tagged choices holding a JSON array of short strings. The system prompt asks for 2 to 6 of them; the renderer accepts 1 to 8 non-empty strings and falls back to plain text for anything else. It arrives inside the text events like any other markdown, so a custom UI has to strip the block out of the transcript before rendering, then draw the buttons itself. Clicking a button sends that option string as the next message.

A second fenced block, era-ui, carries in-page pointing instructions for the embedded widget. If you render your own transcript and do not handle it, strip it anyway, or a JSON blob will show up at the end of the reply. Both blocks are always the last thing in the message.