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:

EventWhenPayload
turnStartCopilot begins generating{ sessionId }
textDeltaNew text token generated{ delta: "text chunk" }
toolCallCopilot wants to call a tool{ callId, name, arguments }
turnEndGeneration complete{ reason, text }

Consuming Events

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:

ReasonMeaning
completeNormal completion
cancelledStopped by user/app
max_tokensHit token limit
errorSomething 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;
      });
    }
  }
}

Back to Concepts | Next: Quickstart