Streaming replies
POST /v1/chats/{chatId}/messages has two modes:
| Request | Response |
|---|---|
Accept: text/event-stream |
A server-sent event stream: your stored message, then each agent reply as it is generated |
| anything else | 202 { message, responders } once your message is stored; replies arrive later via GET …/messages and the message.created webhook |
Event sequence
message.created ← the user message you just sent, as stored
reply.started (agent A) ← for each responder, in order
reply.delta* (agent A)
reply.completed (agent A) ← the final stored agent Message
reply.started (agent B)
reply.delta* (agent B)
reply.completed (agent B)
done ← stream endsResponders are serial: one reply completes before the next starts. A DM has one responder; a group has as many as were @mentioned, or one picked by the orchestrator (routing).
Each SSE frame's event: name equals the JSON type:
event: reply.delta
data: {"type":"reply.delta","replyId":"rpl_1","text":"Sure — "}
Event shapes
type StreamEvent =
| { type: 'message.created'; message: Message } // the user's message
| { type: 'reply.started'; agentId: string; replyId: string }
| { type: 'reply.delta'; replyId: string; text: string } // append to the reply with this replyId
| { type: 'reply.completed'; message: Message } // final agent message (persisted)
| { type: 'done' }
| { type: 'error'; error: { code: string; message: string; details?: unknown } };
type Message = {
id: string;
chatId: string;
sender: { type: 'user'; externalId: string } | { type: 'agent'; agentId: string } | { type: 'system' };
kind: 'text' | 'system';
content: string;
createdAt: string; // ISO 8601
};Treat reply.delta text as a preview; when reply.completed arrives, replace the preview with message.content — it is the canonical, stored reply. An error event uses the same { code, message, details? } shape as the error envelope.
With the SDK
messages.stream() returns an async iterable of StreamEvents.
const drafts = new Map<string, string>();
for await (const event of client.messages.stream(chatId, { content: 'Summarize my week' })) {
switch (event.type) {
case 'message.created':
render(event.message);
break;
case 'reply.started':
drafts.set(event.replyId, '');
showTyping(event.agentId);
break;
case 'reply.delta':
drafts.set(event.replyId, drafts.get(event.replyId) + event.text);
renderDraft(event.replyId, drafts.get(event.replyId));
break;
case 'reply.completed':
render(event.message); // final version
break;
case 'error':
showError(event.error.code, event.error.message);
break;
case 'done':
break;
}
}Raw HTTP
curl -sS -N -X POST "$AIBO_URL/v1/chats/$CHAT_ID/messages" \
-H "Authorization: Bearer $AIBO_USER_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-H 'Idempotency-Key: 5d0e7c1a-4a8b-4f0e-9c55-3f1b7d2a9e10' \
-d '{"content":"Summarize my week"}'EventSource can't send a POST body or an Authorization header, so in browsers read the stream with fetch() and a ReadableStream reader, splitting frames on blank lines.
Idempotency keys
Networks drop. To retry a send safely, include an Idempotency-Key header (≤ 128 chars, e.g. a UUID generated once per message the user typed). Retrying with the same key returns the original result instead of storing the message — and triggering replies — twice.
-H "Idempotency-Key: $(uuidgen)" # generate once per user message, reuse on retryGenerate the key when the user hits Send, not per HTTP attempt.
React Native and other non-streaming clients
React Native's built-in fetch does not expose a streaming response body, so SSE over fetch won't deliver events incrementally there. Options:
- Use a streaming-capable fetch (for example
expo/fetchin recent Expo SDKs) or an SSE library that supports POST and custom headers. - Fall back to non-streaming: omit
Accept: text/event-stream, take the202 { message, responders }, show a typing indicator for each responder, and pollGET /v1/chats/{chatId}/messagesuntil a message from each responder has arrived.
Either way, send the same Idempotency-Key on retries.