---
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 has capabilities that 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<string, ItemState>();

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 <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.
