Skip to main content
router.run({...}) returns an async iterator of AgentEvents. The event shape is identical across providers — your rendering code doesn’t care whether Claude or Gemini produced the turn.

The event union

Event typeFieldsMeaning
texttext: stringPartial text delta. Stream these to your UI / stdout.
tool_callcall_id: string, name: string, arguments: objectModel is calling a tool. The router dispatches it automatically — this event is observational.
tool_resultcall_id: string, name: string, result: object, error?: stringTool returned. Log if you want; the model already has it.
finishstop_reason: string, session_id?: string, usage: objectTurn complete. Capture session_id to continue.
errormessage: string, errorType: stringTerminal failure. The iterator closes.

Handling every event

for await (const event of router.run({ provider, model, system, message, endUserId })) {
  switch (event.type) {
    case 'text':
      process.stdout.write(event.text);
      break;
    case 'tool_call':
      console.log(`\n[${event.name}] called`);
      break;
    case 'tool_result':
      if (event.error) console.error(`tool ${event.name} failed:`, event.error);
      break;
    case 'finish':
      console.log(`\n[done] ${event.stop_reason}`);
      console.log(`usage:`, event.usage);
      break;
    case 'error':
      console.error(`\n[error] ${event.errorType}: ${event.message}`);
      break;
  }
}

Under the hood — SSE

The router hits POST /api/v1/storage/sandboxes/{sandbox_id}/agents/run with Accept: text/event-stream. The server streams Server-Sent Events; the client parses frames into the neutral AgentEvent union. You don’t need to think about SSE. But if you want to bypass the router and talk to the endpoint yourself (for example, to proxy the stream through your own server to a browser client), the SSE parser helpers are exported:
import { iterateSseFrames, frameToAgentEvent } from '@copass/agent-router';

const resp = await fetch(url, { method: 'POST', headers, body });
for await (const frame of iterateSseFrames(resp)) {
  const event = frameToAgentEvent(frame);
  if (event) yield event;
}

Cancellation

Pass an AbortSignal on signal to cancel mid-stream:
const ac = new AbortController();
setTimeout(() => ac.abort(), 10_000);

try {
  for await (const e of router.run({ provider, model, system, message, endUserId, signal: ac.signal })) {
    // …
  }
} catch (err) {
  if (err.name === 'AbortError') console.log('cancelled');
}
Cancellation is cooperative — the server sees the dropped connection and stops billing the provider for further tokens.

Streaming to a browser

Three common patterns:
  1. Proxy the SSE through your server — cleanest. Your browser client gets a normal EventSource; the server keeps the API key.
  2. Expose a WebSocket — your server subscribes to the router and forwards events.
  3. Bypass the router on the server and use iterateSseFrames — if you need a different wire format than SSE.
All three keep COPASS_API_KEY server-side. The router SDK is designed to be imported in a server context, not shipped to the browser.

Tool calls, in detail

When the model calls a connected integration tool (e.g., github.list_issues):
┌──────────────────────────────────────────────────┐
│  router.run({...})                               │
└──────────────────────────────────────────────────┘


   tool_call { name: 'github.list_issues',
               arguments: { repo: 'owner/repo' } }      ← you observe this


   (server dispatches the GitHub tool against
    the sandbox's OAuth'd connection)


   tool_result { name: 'github.list_issues',
                 result: { issues: [...] } }            ← you observe this


   text "Here are your open issues: …"                  ← model resumes


   finish { stop_reason: 'end_turn', session_id: '…' }
You don’t implement the tool. You don’t need to know the schema. The integration provides the tools; the router dispatches them; your code just watches the events stream past.

Custom tools

If you want tools beyond the integrations — domain-specific functions, local I/O, business logic — register them in-process via AgentToolRegistry from copass-core-agents. The in-process backends pick them up; the hosted router path currently supports integration-provided tools only. See the Providers page for when to drop to an in-process backend.

Next steps

  • Multi-turn — the finish.session_id → next-turn loop.
  • Integrations — how the tools the agent calls got there.