Streaming
Flapjack streams agent responses via Server-Sent Events (SSE). Learn the event types, ChatEvent union type, and how to handle each event.
Flapjack streams agent responses in real-time using Server-Sent Events (SSE). When you send a message, the response arrives as a sequence of typed events.
SSE Protocol
The response uses standard SSE format:
event: <type>
data: <json>
event: <type>
data: <json>
Each frame has an event line (the type) and a data line (JSON payload), separated by a blank line.
Event Types
| Event | Fields | When It's Sent |
|---|---|---|
meta | startedAt: string | First event. Stream metadata. |
token | delta: string | Each text chunk from the LLM. Concatenate to build the response. |
tool_call | tool: { id, name, arguments } | Agent decided to call a tool. |
tool_executing | tool_name: string | Tool execution has started. |
tool_result | tool_name, tool_call_id, result | Tool execution completed with a result. |
custom | kind: string, payload: unknown | Domain-specific event emitted by a tool. See Custom Events. |
auth_challenge | toolName: string, challenge: { provider, redirectUrl, description? } | A tool requires the user to complete an OAuth flow before it can succeed. See Auth Challenges. |
requires_action | toolCalls: ToolCall[] | Agent requests client-side tool execution. Respond with submitToolResults() or use the onToolCall callback. See Custom Tools. |
client_event | kind: string, payload: unknown | Event emitted by the Tensorlake runtime for client-side notifications (e.g. status updates, UI hints). Distinct from custom events, which are tool-defined. |
profile_switch_proposal | target: string, reason: string | Agent proposes switching to a different tool profile. The client should confirm before calling switchProfile(). |
done | ok, messageId?, content, usage? | Last event. Full final response text, persisted message ID, and optional usage stats. messageId is absent when the stream is stopped early. |
error | code, detail? | An error occurred. Stream may end. |
ChatEvent Type (SDK)
The SDK defines a discriminated union for all event types:
type ChatEvent =
| { type: 'meta'; startedAt: string }
| { type: 'token'; delta: string }
| { type: 'tool_call'; tool: { id: string; name: string; arguments: string } }
| { type: 'tool_executing'; tool_name: string }
| { type: 'tool_result'; tool_name: string; tool_call_id: string; result: unknown }
| { type: 'custom'; kind: string; payload: unknown }
| { type: 'auth_challenge'; toolName: string; challenge: { provider: string; redirectUrl: string; description?: string } }
| { type: 'requires_action'; toolCalls: ToolCall[] }
| { type: 'client_event'; kind: string; payload: unknown }
| { type: 'profile_switch_proposal'; target: string; reason: string }
| { type: 'done'; ok: boolean; messageId?: string; content: string; skipped?: boolean; reason?: string }
| { type: 'error'; code: string; detail?: string };
Raw SSE Wire Format
The raw SSE stream (when parsing directly without the SDK) includes additional fields that the SDK strips:
| Field | Event | Description |
|---|---|---|
usage | done | Token counts: model, total_input_tokens, total_output_tokens, cache_read_tokens, cache_write_tokens, compaction_input_tokens, compaction_output_tokens, total_response_time_ms, estimated_cost_usd |
stopped | done | true when the stream was interrupted via the stop endpoint |
messageId | done | Absent when stopped: true (message not persisted) |
If you need usage data for cost tracking, parse the raw SSE stream instead of using the SDK client.
Handling Events
Full Event Handler (SDK)
for await (const event of client.sendMessage(threadId, 'Hello!')) {
switch (event.type) {
case 'meta':
console.log('Stream started:', event.startedAt);
break;
case 'token':
process.stdout.write(event.delta);
break;
case 'tool_call':
console.log('[Calling tool]', event.tool.name);
break;
case 'tool_executing':
console.log('[Executing]', event.tool_name);
break;
case 'tool_result':
console.log('[Result]', event.tool_name, 'β', event.result);
break;
case 'custom':
console.log('[Custom]', event.kind, event.payload);
break;
case 'auth_challenge':
console.log('[Auth required]', event.challenge.provider, event.challenge.redirectUrl);
break;
case 'done':
console.log('\nFinal response:', event.content);
console.log('Message ID:', event.messageId);
break;
case 'requires_action':
console.log('[Client tools requested]', event.toolCalls.map(t => t.name));
break;
case 'error':
console.error('Error:', event.code, event.detail);
break;
}
}
π Copy as prompt
Write a handler for Flapjack's
sendMessageasync generator that processes all SSE event types: meta, token, tool_call, tool_executing, tool_result, custom, done, and error. Print tokens as they arrive and log tool activity.
Minimal Handler (SDK)
For simple use cases, you only need token and done:
let response = '';
for await (const event of client.sendMessage(threadId, message)) {
if (event.type === 'token') response += event.delta;
if (event.type === 'error') throw new Error(event.code);
}
// `response` now contains the full text
π Copy as prompt
Write a minimal Flapjack message handler that accumulates token deltas into a string and throws on errors.
Event Sequence
A typical stream looks like:
meta β token β token β ... β done
When server-side tools are involved:
meta β token β tool_call β tool_executing β tool_result β token β ... β done
When tools emit custom events:
meta β token β tool_call β tool_executing β tool_result β custom β token β ... β done
When client-side tools are involved (custom tools via tools option):
meta β token β requires_action β [submit results] β token β ... β done
When the agent proposes a profile switch (see Tool Profiles):
meta β token β profile_switch_proposal β requires_action β [client confirms] β token β ... β done
Multiple tool calls can happen in a single response. The agent may call up to 10 tools per turn.
Custom Events
Tools can emit domain-specific events that are forwarded to the client as custom SSE events. This allows embedding apps to stream structured data (suggestions, navigation proposals, auth challenges, etc.) alongside the AI response without encoding them inside tool_result payloads.
Emitting Custom Events from Tools
Include a _client_events array in your tool's output. Each entry has a kind (string identifier) and a payload (arbitrary JSON). The runtime strips _client_events from the tool result before passing it to the LLM, so they don't pollute the conversation context.
Webhook tools β Your webhook endpoint should return a JSON response containing _client_events. The control plane wraps webhook responses as { ok, status, output } where output is the response body as a string. The runtime automatically parses this JSON string and extracts _client_events:
// Your webhook endpoint returns:
{
"output": "Found 3 matching features.",
"_client_events": [
{
"kind": "suggestion",
"payload": {"name": "Auth", "description": "Add authentication", "icon": "Lock01"}
},
{
"kind": "navigation_proposal",
"payload": {"url": "/settings", "label": "Go to Settings"}
}
]
}
MCP tools β Return _client_events at the top level of your tool result dict. The runtime extracts them directly.
Wire Format
event: custom
data: {"kind":"suggestion","payload":{"name":"Auth","description":"Add authentication","icon":"Lock01"}}
Handling Custom Events (SDK)
Using the sendMessage async generator:
for await (const event of client.sendMessage(threadId, message)) {
if (event.type === 'custom') {
console.log(`Custom event [${event.kind}]:`, event.payload);
}
}
Handling Custom Events (React)
Using the useChat hook:
const { messages, sendMessage } = useChat(agentId, {
onCustomEvent(kind, payload) {
if (kind === 'suggestion') handleSuggestion(payload as FeatureSuggestion);
if (kind === 'auth_required') handleAuth(payload as AuthChallenge);
if (kind === 'navigation_proposal') handleNavigation(payload as NavigationProposal);
},
});
Or using the ChatPanel component:
<ChatPanel
agentId={agentId}
onCustomEvent={(kind, payload) => {
if (kind === 'suggestion') handleSuggestion(payload);
if (kind === 'navigation_proposal') handleNavigation(payload);
}}
/>
Event Ordering
Custom events from _client_events arrive immediately after the tool_result event for the same tool call. They are delivered in array order. Events emitted before a requires_action pause are delivered to the client before the pause β they survive the continuation loop.
Auth Challenges
When a tool requires the user to complete an OAuth flow (e.g. connect a GitHub account), it returns a special payload that Flapjack detects and emits as an auth_challenge event. The stream stays open so the UI can render a "Connect" CTA without killing the conversation.
How It Works
A tool opts in by returning a result with this shape:
{
"type": "auth_challenge",
"provider": "github",
"redirectUrl": "https://github.com/login/oauth/authorize?client_id=...",
"description": "Connect GitHub to read private repos"
}
The description field is optional. The detector also accepts the payload wrapped as a JSON string or inside an { output: "<json>" } envelope (common for webhook and delegated tools).
The auth_challenge event is emitted immediately after the tool_result event for the same tool call:
meta β tool_call β tool_executing β tool_result β auth_challenge β token β ... β done
Handling Auth Challenges (SDK)
for await (const event of client.sendMessage(threadId, message)) {
if (event.type === 'auth_challenge') {
// Render a "Connect <provider>" button with event.challenge.redirectUrl
console.log(`Connect ${event.challenge.provider}: ${event.challenge.redirectUrl}`);
}
}
Handling Auth Challenges (React)
The useChat hook exposes pendingAuthChallenge state and an onAuthChallenge callback:
const { messages, sendMessage, pendingAuthChallenge, clearAuthChallenge } = useChat(agentId, {
onAuthChallenge(challenge) {
// Optional callback β also available via pendingAuthChallenge state
console.log('Auth needed:', challenge.challenge.provider);
},
});
// Render inline CTA when pendingAuthChallenge is non-null
if (pendingAuthChallenge) {
return (
<a href={pendingAuthChallenge.challenge.redirectUrl}>
Connect {pendingAuthChallenge.challenge.provider}
</a>
);
}
Backwards Compatibility
- Old SDK + new server: Unknown event frames are silently dropped. No breakage.
- New SDK + old server: The
auth_challengecase is in place but the event never arrives.pendingAuthChallengestaysnull. - Existing tools: Only tools returning the exact
auth_challengeshape trigger the event. All other tool results are unaffected.
Parsing SSE Manually
If you're not using the SDK, parse SSE frames from the raw HTTP response:
const response = await fetch(url, { method: 'POST', headers, body });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const eventLine = frame.split('\n').find(l => l.startsWith('event:'));
const dataLine = frame.split('\n').find(l => l.startsWith('data:'));
if (!dataLine) continue;
const type = eventLine?.slice(6).trim();
const data = JSON.parse(dataLine.slice(5).trim());
// Handle { type, ...data }
}
}
π Copy as prompt
Write a manual SSE parser for Flapjack's streaming response. Read from a fetch response body, split on double newlines, extract event type and JSON data from each frame.
Next Steps
- SDK: Client β
sendMessagemethod reference - SDK: React β
useChathandles streaming automatically - API: Threads β raw endpoint details