mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 17:01:02 +00:00
370 lines
8.8 KiB
Text
370 lines
8.8 KiB
Text
---
|
|
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" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default)
|
|
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 item lifecycle plus turn lifecycle to render a basic chat:
|
|
|
|
```ts
|
|
type ItemState = {
|
|
item: UniversalItem;
|
|
deltas: string[];
|
|
};
|
|
|
|
const items = new Map<string, ItemState>();
|
|
let turnInProgress = false;
|
|
|
|
function handleEvent(event: UniversalEvent) {
|
|
switch (event.type) {
|
|
case "turn.started": {
|
|
turnInProgress = true;
|
|
break;
|
|
}
|
|
|
|
case "turn.ended": {
|
|
turnInProgress = false;
|
|
break;
|
|
}
|
|
|
|
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:
|
|
- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.).
|
|
- Use `item.status === "in_progress"` for per-item streaming state.
|
|
|
|
```ts
|
|
function renderItem(state: ItemState) {
|
|
const { item, deltas } = state;
|
|
const isItemLoading = 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,
|
|
isItemLoading,
|
|
isTurnLoading: turnInProgress,
|
|
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 <Markdown>{part.text}</Markdown>;
|
|
|
|
case "tool_call":
|
|
return <ToolCall name={part.name} args={part.arguments} />;
|
|
|
|
case "tool_result":
|
|
return <ToolResult output={part.output} />;
|
|
|
|
case "file_ref":
|
|
return <FileChange path={part.path} action={part.action} diff={part.diff} />;
|
|
|
|
case "reasoning":
|
|
return <Reasoning>{part.text}</Reasoning>;
|
|
|
|
case "status":
|
|
return <Status label={part.label} detail={part.detail} />;
|
|
|
|
case "image":
|
|
return <Image src={part.path} />;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Handling Permissions
|
|
|
|
When `permission.requested` arrives, show an approval UI:
|
|
|
|
```ts
|
|
const pendingPermissions = new Map<string, PermissionEventData>();
|
|
|
|
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 (
|
|
<div>
|
|
<p>Allow: {data.action}</p>
|
|
<button onClick={() => replyPermission(data.permission_id, "once")}>
|
|
Allow Once
|
|
</button>
|
|
<button onClick={() => replyPermission(data.permission_id, "always")}>
|
|
Always Allow
|
|
</button>
|
|
<button onClick={() => replyPermission(data.permission_id, "reject")}>
|
|
Reject
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Handling Questions
|
|
|
|
When `question.requested` arrives, show a selection UI:
|
|
|
|
```ts
|
|
const pendingQuestions = new Map<string, QuestionEventData>();
|
|
|
|
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<string[]>([]);
|
|
|
|
return (
|
|
<div>
|
|
<p>{data.prompt}</p>
|
|
{data.options.map((option) => (
|
|
<label key={option}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.includes(option)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelected([...selected, option]);
|
|
} else {
|
|
setSelected(selected.filter((s) => s !== option));
|
|
}
|
|
}}
|
|
/>
|
|
{option}
|
|
</label>
|
|
))}
|
|
<button onClick={() => answerQuestion(data.question_id, [selected])}>
|
|
Submit
|
|
</button>
|
|
<button onClick={() => rejectQuestion(data.question_id)}>
|
|
Reject
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 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 <text>` | 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.
|