Events & Streaming
Real-time response handling in the Copilot SDK.
Why Streaming?
Without streaming, you wait for the entire response:
User sends message ──────────────────────────────────────► Response appears
(several seconds of waiting)
With streaming, you see tokens as they’re generated:
User sends message → R → es → pon → se → app → ears → word → by → word
This provides better UX and lets you process content incrementally.
Event Types
The SDK emits these events during a turn:
| Event | When | Payload |
|---|---|---|
turnStart | Copilot begins generating | { sessionId } |
textDelta | New text token generated | { delta: "text chunk" } |
toolCall | Copilot wants to call a tool | { callId, name, arguments } |
turnEnd | Generation complete | { reason, text } |
Consuming Events
Async iteration (recommended)
for await (const event of session.send("Hello")) {
switch (event.type) {
case "turnStart":
console.log("Starting...");
break;
case "textDelta":
process.stdout.write(event.delta);
break;
case "toolCall":
const result = await handleTool(event.name, event.arguments);
await session.submitToolResult(event.callId, result);
break;
case "turnEnd":
console.log("\nFinished:", event.reason);
break;
}
}
Callback style
session.send("Hello", {
onTextDelta: (delta) => process.stdout.write(delta),
onToolCall: async (call) => {
const result = await handleTool(call.name, call.arguments);
await session.submitToolResult(call.callId, result);
},
onTurnEnd: (result) => console.log("Done:", result.text)
});
Awaiting full response
// Just get the final result
const response = await session.send("Hello");
console.log(response.text); // Complete response
Event Flow Diagrams
Simple response (no tools)
send("What is 2+2?")
│
▼
turnStart
│
▼
textDelta: "The"
│
▼
textDelta: " answer"
│
▼
textDelta: " is"
│
▼
textDelta: " 4."
│
▼
turnEnd: { reason: "complete", text: "The answer is 4." }
With tool call
send("What's the weather in Paris?")
│
▼
turnStart
│
▼
textDelta: "Let me check"
│
▼
textDelta: " the weather..."
│
▼
toolCall: { name: "get_weather", args: '{"city":"Paris"}' }
│
├───► Your app: getWeather({ city: "Paris" })
│ │
│ ▼
│ submitToolResult(callId, { temp: 18, ... })
│
▼
textDelta: "It's"
│
▼
textDelta: " 18°C in Paris."
│
▼
turnEnd
Multiple tool calls
Copilot may call multiple tools in sequence or request them together:
send("Compare weather in London and Tokyo")
│
▼
turnStart
│
▼
toolCall: { name: "get_weather", args: '{"city":"London"}' }
│
├───► submitToolResult(...)
│
▼
toolCall: { name: "get_weather", args: '{"city":"Tokyo"}' }
│
├───► submitToolResult(...)
│
▼
textDelta: "London is 12°C, Tokyo is 22°C..."
│
▼
turnEnd
Building a Streaming UI
Terminal (simple)
for await (const event of session.send(prompt)) {
if (event.type === "textDelta") {
process.stdout.write(event.delta);
}
}
console.log(); // Newline at end
Web (with chunks)
// Server-side (Node.js)
app.post("/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
for await (const event of session.send(req.body.message)) {
if (event.type === "textDelta") {
res.write(`data: ${JSON.stringify({ delta: event.delta })}\n\n`);
}
}
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
// Client-side (browser)
const eventSource = new EventSource("/chat");
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.delta) {
outputDiv.textContent += data.delta;
}
};
React component
function ChatMessage({ prompt }) {
const [text, setText] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const stream = async () => {
for await (const event of session.send(prompt)) {
if (event.type === "textDelta") {
setText(prev => prev + event.delta);
}
if (event.type === "turnEnd") {
setLoading(false);
}
}
};
stream();
}, [prompt]);
return (
<div>
{text}
{loading && <span className="cursor">▊</span>}
</div>
);
}
Handling Partial Content
During streaming, you may need to process partial content (e.g., for syntax highlighting, markdown rendering):
let buffer = "";
let lastRendered = "";
for await (const event of session.send(prompt)) {
if (event.type === "textDelta") {
buffer += event.delta;
// Only re-render when we have complete sentences/blocks
if (buffer.includes("\n") || buffer.length > lastRendered.length + 50) {
render(buffer);
lastRendered = buffer;
}
}
}
// Final render
render(buffer);
Cancellation
To stop a streaming response mid-generation:
const controller = new AbortController();
// Start streaming
const streamPromise = (async () => {
for await (const event of session.send(prompt, { signal: controller.signal })) {
// Handle events
}
})();
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
await streamPromise;
} catch (e) {
if (e.name === "AbortError") {
console.log("Streaming cancelled");
}
}
Turn End Reasons
The turnEnd event includes a reason field:
| Reason | Meaning |
|---|---|
complete | Normal completion |
cancelled | Stopped by user/app |
max_tokens | Hit token limit |
error | Something went wrong |
if (event.type === "turnEnd") {
switch (event.reason) {
case "complete":
// Normal end
break;
case "max_tokens":
// Response was truncated, may need to continue
break;
case "error":
// Handle error
console.error("Turn failed:", event.error);
break;
}
}
Performance Considerations
- Buffer writes: Don’t update UI on every single delta if it causes jank
- Backpressure: If processing is slower than generation, events queue up
- Memory: Long responses accumulate in memory; consider chunked processing
// Debounced UI updates
let pending = "";
let updateScheduled = false;
for await (const event of session.send(prompt)) {
if (event.type === "textDelta") {
pending += event.delta;
if (!updateScheduled) {
updateScheduled = true;
requestAnimationFrame(() => {
outputElement.textContent = pending;
updateScheduled = false;
});
}
}
}