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.
/v1/chatRequest
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.
| Field | Type | Description |
|---|---|---|
messagerequired | string | The user turn. What the person typed. Missing from the body means a 422. |
user_contextrequired | object | The 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_id | string | null | Continue an existing conversation. Null or omitted starts a new one. |
client_tz | string | The 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. |
user_context must be signed, or the request is rejected with IDENTITY_SIGNATURE_REQUIRED. See the identity guide for the HMAC recipe.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
# -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.
| Field | Type | Description |
|---|---|---|
Invalid API key | 401 | Body 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_ALLOWED | 403 | The Origin header is not on the project allowlist. Only enforced when the project has an allowlist configured. |
RATE_LIMIT_EXCEEDED | 429 | Either 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_EXCEEDED | 402 | The monthly token budget for the project is exhausted. Checked before the turn starts, so it never arrives mid-stream. |
IDENTITY_* | 401 | Signed-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_FOUND | 403 | The 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 error | 422 | FastAPI 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.
data: {"type":"session","session_id":"kJ2mQ8vX1pR4nT7bW0cLdA"}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.
data: {"type":"text","content":"Order 1043 shipped "}tool_start
The agent called a tool. Use it to show live tool status.
| Field | Type | Description |
|---|---|---|
id | string | The provider tool-call id. Pairs this event with its tool_end and, for a held write, with the confirmation_required event. |
name | string | The tool name as configured on the Tools page. |
input | object | The arguments the model produced, unmodified. |
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.
| Field | Type | Description |
|---|---|---|
id | string | Matches the tool_start. |
name | string | The tool name. |
result | object | Whatever the executor returned. A failed tool still returns a result object, usually with an error field, and the turn continues. |
data: {"type":"tool_end","id":"toolu_01A9","name":"lookup_order","result":{"status":"shipped","eta":"2026-09-02"}}_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.
| Field | Type | Description |
|---|---|---|
id | string | The tool-call id of the held action. |
name | string | The tool name. |
display_name | string | The human label configured for the tool. Falls back to name when unset. |
input | object | The exact arguments that will run if approved. Render these; this is the whole point of the pause. |
confirm | string | The literal message to send back to approve. Currently __era_confirm__. Read it off the event rather than hardcoding it. |
decline | string | The literal message to send back to cancel. Currently __era_decline__. |
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.
| Field | Type | Description |
|---|---|---|
input_tokens | number | Prompt tokens across every model call in the turn. |
output_tokens | number | Completion tokens across every model call in the turn. |
tools_used | string[] | Tool names called this turn, in call order, one entry per call. A tool called twice appears twice. Not a count, and not deduplicated. |
iterations | number | Model round-trips used, starting at 1. The agent stops at 10. |
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.
data: {"type":"error","content":"PROJECT_NO_API_KEY"}There are four possible contents:
| Field | Type | Description |
|---|---|---|
PROJECT_NO_API_KEY | literal | Either 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… | prose | Full 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: … | prose | An 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… | prose | Full 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.
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.
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.
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.
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.
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:
| Field | Type | Description |
|---|---|---|
__era_confirm__ | approve | The 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__ | cancel | The 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 else | drops it | The held call is discarded and your message is processed as a normal turn. Silently. Nothing tells the user the pending write went away. |
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.. ! ?. 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.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.
| Field | Type | Description |
|---|---|---|
No buffering | required | Write 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 compression | required | Do 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 timeout | raise it | A 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 heartbeat | be aware | The 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 resume | be aware | Frames 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. |
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.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.