--- title: "Building a Chat UI" description: "Build a chat interface using the universal event stream." icon: "comments" --- ## Setup ### List agents ```ts const { agents } = await client.listAgents(); // Each agent exposes feature coverage via `capabilities` to determine what UI to show const claude = agents.find((a) => a.id === "claude"); if (claude?.capabilities.permissions) { // Show permission approval UI } if (claude?.capabilities.questions) { // Show question response UI } ``` ### Create a session ```ts const sessionId = `session-${crypto.randomUUID()}`; await client.createSession(sessionId, { agent: "claude", agentMode: "code", // Optional: agent-specific mode permissionMode: "default", // Optional: "default" | "plan" | "bypass" model: "claude-sonnet-4", // Optional: model override }); ``` ### Send a message ```ts await client.postMessage(sessionId, { message: "Hello, world!" }); ``` ### Stream events Three options for receiving events: ```ts // Option 1: SSE (recommended for real-time UI) const stream = client.streamEvents(sessionId, { offset: 0 }); for await (const event of stream) { handleEvent(event); } // Option 2: Polling const { events, hasMore } = await client.getEvents(sessionId, { offset: 0 }); events.forEach(handleEvent); // Option 3: Turn streaming (send + stream in one call) const stream = client.streamTurn(sessionId, { message: "Hello" }); for await (const event of stream) { handleEvent(event); } ``` Use `offset` to track the last seen `sequence` number and resume from where you left off. --- ## Handling Events ### Bare minimum Handle these three events to render a basic chat: ```ts type ItemState = { item: UniversalItem; deltas: string[]; }; const items = new Map(); function handleEvent(event: UniversalEvent) { switch (event.type) { case "item.started": { const { item } = event.data as ItemEventData; items.set(item.item_id, { item, deltas: [] }); break; } case "item.delta": { const { item_id, delta } = event.data as ItemDeltaData; const state = items.get(item_id); if (state) { state.deltas.push(delta); } break; } case "item.completed": { const { item } = event.data as ItemEventData; const state = items.get(item.item_id); if (state) { state.item = item; state.deltas = []; // Clear deltas, use final content } break; } } } ``` When rendering, show a loading indicator while `item.status === "in_progress"`: ```ts function renderItem(state: ItemState) { const { item, deltas } = state; const isLoading = item.status === "in_progress"; // For streaming text, combine item content with accumulated deltas const text = item.content .filter((p) => p.type === "text") .map((p) => p.text) .join(""); const streamedText = text + deltas.join(""); return { content: streamedText, isLoading, role: item.role, kind: item.kind, }; } ``` ### Extra events Handle these for a complete implementation: ```ts function handleEvent(event: UniversalEvent) { switch (event.type) { // ... bare minimum events above ... case "session.started": { // Session is ready break; } case "session.ended": { const { reason, terminated_by } = event.data as SessionEndedData; // Disable input, show end reason // reason: "completed" | "error" | "terminated" // terminated_by: "agent" | "daemon" break; } case "error": { const { message, code } = event.data as ErrorData; // Display error to user break; } case "agent.unparsed": { const { error, location } = event.data as AgentUnparsedData; // Parsing failure - treat as bug in development console.error(`Parse error at ${location}: ${error}`); break; } } } ``` ### Content parts Each item has `content` parts. Render based on `type`: ```ts function renderContentPart(part: ContentPart) { switch (part.type) { case "text": return {part.text}; case "tool_call": return ; case "tool_result": return ; case "file_ref": return ; case "reasoning": return {part.text}; case "status": return ; case "image": return ; } } ``` --- ## Handling Permissions When `permission.requested` arrives, show an approval UI: ```ts const pendingPermissions = new Map(); function handleEvent(event: UniversalEvent) { if (event.type === "permission.requested") { const data = event.data as PermissionEventData; pendingPermissions.set(data.permission_id, data); } if (event.type === "permission.resolved") { const data = event.data as PermissionEventData; pendingPermissions.delete(data.permission_id); } } // User clicks approve/deny async function replyPermission(id: string, reply: "once" | "always" | "reject") { await client.replyPermission(sessionId, id, { reply }); pendingPermissions.delete(id); } ``` Render permission requests: ```ts function PermissionRequest({ data }: { data: PermissionEventData }) { return (

Allow: {data.action}

); } ``` --- ## Handling Questions When `question.requested` arrives, show a selection UI: ```ts const pendingQuestions = new Map(); function handleEvent(event: UniversalEvent) { if (event.type === "question.requested") { const data = event.data as QuestionEventData; pendingQuestions.set(data.question_id, data); } if (event.type === "question.resolved") { const data = event.data as QuestionEventData; pendingQuestions.delete(data.question_id); } } // User selects answer(s) async function answerQuestion(id: string, answers: string[][]) { await client.replyQuestion(sessionId, id, { answers }); pendingQuestions.delete(id); } async function rejectQuestion(id: string) { await client.rejectQuestion(sessionId, id); pendingQuestions.delete(id); } ``` Render question requests: ```ts function QuestionRequest({ data }: { data: QuestionEventData }) { const [selected, setSelected] = useState([]); return (

{data.prompt}

{data.options.map((option) => ( ))}
); } ``` --- ## Testing with Mock Agent The `mock` agent lets you test UI behaviors without external credentials: ```ts await client.createSession("test-session", { agent: "mock" }); ``` Send `help` to see available commands: | Command | Tests | |---------|-------| | `help` | Lists all commands | | `demo` | Full UI coverage sequence with markers | | `markdown` | Streaming markdown rendering | | `tool` | Tool call + result with file refs | | `status` | Status item updates | | `image` | Image content part | | `permission` | Permission request flow | | `question` | Question request flow | | `error` | Error + unparsed events | | `end` | Session ended event | | `echo ` | Echo text as assistant message | Any unrecognized text is echoed back as an assistant message. --- ## Reference Implementation The [Inspector UI](https://github.com/rivet-dev/sandbox-agent/blob/main/frontend/packages/inspector/src/App.tsx) is a complete reference showing session management, event rendering, and HITL flows.