Chat
Two endpoints: one returns the whole answer, one streams it as it is generated. Both run the same turn, cost the same, and produce the same conversation record.
POST /v1/chat
POST /v1/chat/stream
Both need chat:send.
Blocking or streaming
Use /v1/chat for anything that is not a live UI — a background job, a
Slack bot, an evaluation harness. One request, one JSON answer, no plumbing.
Use /v1/chat/stream when a person is watching a cursor blink. Tokens
arrive as they are produced, which makes a four-second answer feel immediate.
Send an Idempotency-Key on both endpoints. Without one, a timeout followed
by a retry is two billed turns and two conversation entries.
A single turn
curl -X POST https://api.agentency.com/v1/chat \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: turn-8f21c0" \
-d '{
"chatbot_id": 1,
"message": "Do you ship to Ireland?"
}'{
"object": "message",
"id": 42,
"conversation_id": 7,
"session_id": "api_9f2c7ab1…",
"response": "Yes — Ireland is covered by our standard EU shipping.",
"status": "answered",
"confidence_score": 88.1,
"sources": [
{
"source": "Shipping FAQ",
"collection": "Help centre",
"content": "We ship to every EU country, Ireland included…",
"relevance_score": 91.3,
"dataset_id": 3
}
],
"actions": [],
"usage": { "input_tokens": 41, "output_tokens": 88, "total_tokens": 129 }
}Worth knowing about each field:
session_id— send it back to continue this conversation. Omit it and the next message starts a new one.conversation_id— the conversation's id, forGET /v1/conversations/{id}and its messages.sources— what the answer was grounded in: each entry names the knowledge source (source), its collection, the passage (content) and how well it matched (relevance_score, 0–100). Empty means nothing relevant was retrieved, which is your signal that the answer is not from your content.confidence_score— confidence in the answer, 0–100: how strong the evidence was and how closely the reply sticks to it. How well the knowledge base matched the question alone isretrieval_confidence.actions— anything the bot triggered this turn, such as a handoff request or a CTA. See Actions.usage— tokens for this turn, so you can attribute spend per customer.
Keeping a conversation going
Pass the session_id you got back. History is kept server-side; you do not
need to resend previous messages. The request still accepts a
conversation_history array, but the server ignores it and rebuilds the
history from the conversation's stored turns.
curl -X POST https://api.agentency.com/v1/chat \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"chatbot_id": 1,
"session_id": "api_9f2c7ab1…",
"message": "How much is it?"
}'"How much is it?" only makes sense with the previous turn in view — that is what the session buys you.
You can only append to sessions the API started (api_…). Conversations that
began in the widget or on a messaging channel are readable through
GET /v1/conversations, but not writable from here: a visitor conversation
belongs to that channel. Sending another surface's session id is
422 VALIDATION_FAILED; an unknown api_ session is 404 NOT_FOUND.
Streaming
POST /v1/chat/stream answers with text/event-stream. The request body,
scope and billing are the same as POST /v1/chat; only the response differs.
Frames arrive in this order:
- Zero or more
event: statusframes while the turn routes, searches and starts generating. - Content frames: unnamed
data:lines whose JSON carries the next piece of the answer inchoices[0].delta.content. - At most one
event: final_textframe, when post-processing changed the answer that streamed. - Optional
event: options,event: mediaandevent: verdictframes, when the deployment enables them. data: [DONE]— the answer is complete. Keep reading.- One
event: metadataframe, always last, with the turn's ids, sources and usage.
A content frame is OpenAI-shaped, so an existing chunk parser reads it as is:
{
"object": "chat.completion.chunk",
"choices": [
{ "index": 0, "delta": { "content": "Yes — Ireland is" }, "finish_reason": null }
]
}finish_reason is always null: the stream ends with [DONE], never with a
finishing chunk. Lines starting with : (for example : ping) are
keep-alive comments sent while the model is silent; every SSE parser skips
them.
Every frame
| Frame | When | What to do with it |
|---|---|---|
event: status | While the turn works: stage is routing, searching, generating or working | Optional progress text, such as "Searching your content…" |
content (data:) | As the answer is generated | Append choices[0].delta.content to the reply |
event: final_text | Once, only when the stored answer differs from what streamed (an unverified link removed, a links block merged, redaction) | Replace the reply with content. Skipping it leaves text on screen that was never stored |
event: options | When quick replies are enabled: kind is clarify or did_you_mean, options holds 2–6 labels | Show the labels as buttons; send one back verbatim as the next message |
event: media | When the answer approved videos or images: items is a list of {type, url, title} | Render them below the reply |
event: verdict | When answer verdicts are enabled | The turn's answer classification, the same value the stored message carries |
data: [DONE] | Once, after the answer | The reply text is final. Do not stop reading |
event: metadata | Last | Keep it: query_id, session_id, status, confidence_score, sources, actions, media, verdict and usage (with cost_usd) |
Unknown event names can appear as the API grows; ignore them rather than failing.
curl -N -X POST https://api.agentency.com/v1/chat/stream \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: stream-8f21c0" \
-d '{"chatbot_id": 1, "message": "Do you ship to Ireland?"}'When the loop ends, answer is the reply exactly as stored and metadata
carries the same query_id, session_id, sources and usage the blocking
endpoint returns in its body (query_id is the message id).
The stream carries no conversation_id. Continue the conversation with the
session_id from metadata; when you need the conversation itself, look it up
with GET /v1/conversations/by_session/{sessionId}. The SDKs parse frames the
same way and hand you each one as { event, data, json }.
A reader that closes the stream at data: [DONE] never sees the metadata
frame, so it has no query_id to attach feedback to, no session_id to
continue the conversation, and no usage to bill against.
Retrying a stream
An SSE body cannot be replayed frame by frame, so a retry with the same
Idempotency-Key answers with a JSON summary of the original turn and
Idempotency-Replayed: true instead of a second stream. You still recover
query_id and session_id, and you are not charged twice.
When something goes wrong mid-stream
Problems before the first byte (a bad key, a missing scope, no credits)
arrive as an ordinary JSON error response with a status code, never as a
stream. Once the stream has started, the status is already 200, so the
trailing metadata frame carries the outcome instead:
error: "turn_persistence_failed"— the answer was generated but could not be recorded. The text you received is valid; there is noquery_idto attach feedback to. Treat it as a warning, not a failed request. TheIdempotency-Keyis released, so a retry runs a new turn.error: "chat_generation_failed"— generation failed after the stream opened. The content frames carried the chatbot's fallback message instead of an answer, then[DONE], then this frame. Nothing was stored, and theIdempotency-Keyis released, so you may retry.
Reading it back
Everything a chat turn produces is readable afterwards:
GET /v1/conversations/{id}/messages— the whole thread.GET /v1/messages/{id}— one message, including itssources. A stored message keeps its sources as compact references (dataset_id, scores on a 0–1 scale, and atitle/source_urlwhen known), not the passagesPOST /v1/chatreturned: keep the turn's response if you need those.
See Conversations.
Common mistakes
- No
Idempotency-Key. The single most expensive omission on the API. - Resending history yourself. The session already has it; extra context costs tokens and can confuse the answer.
- Ignoring empty
sources. That is the difference between "answered from your content" and "answered anyway". - Buffering the stream. A proxy that buffers turns streaming back into a slow blocking call. Disable buffering for this path.
- Stopping at
[DONE]. Themetadataframe comes after it. - Ignoring
final_text. The streamed text can differ from the stored answer; the frame is how you learn the stored one.
What's next
- Conversations — read, review and export threads.
- Actions — let a turn call your systems.
- Rate limits — chat has its own budget.