# Building a chat UI for an agent

> Expose an agent over HTTP from a function, stream its replies to the browser, render a chat with the AI SDK React hook, and surface tool approvals.

A chat interface for an agent is three functions and one React component. One function streams the agent's reply, one answers approval decisions, and one returns history so a conversation survives a page reload. The browser side uses the AI SDK's `useChat` hook, which already understands the stream the agent produces, and plain elements you can swap for whatever component library your app uses.

:::note{title="Version"}
Needs `@trayai/helix-sdk` and `@trayai/helix-cli` newer than 1.6.0.
:::

## The agent

A user-scoped agent, so each signed-in person gets their own conversations:

```typescript
// functions/_agents/assistant.ts
import { defineAgent } from '@trayai/helix-sdk';
import lookupOrder from '../_shared/tools/lookup-order';
import issueRefund from '../_shared/tools/issue-refund';

export default defineAgent({
  name: 'assistant',
  description: 'Answers the signed-in customer\'s questions about their orders.',
  model: 'claude-sonnet-4-5',
  auth: 'anthropicKey',
  instructions: 'Help the customer with their own orders. Use lookup_order before answering.',
  tools: [lookupOrder, issueRefund],
  memory: { scope: 'user' },
});
```

:::note{title="User scope under helix dev"}
A browser request to `helix dev` carries no signed-in user, so a user-scoped agent rejects it locally. While developing the UI, set `memory: { scope: 'project' }` and switch to `'user'` before you deploy, or exercise the agent from the terminal with `helix agent chat assistant --user <id>`, which does supply one.
:::

## Stream a reply

The browser sends the newest message and a session id. The function converts the message, hands it to the agent with the session, and returns the stream in the format `useChat` reads. Because the session already holds the earlier turns, the client never resends them:

```typescript
// functions/api/v1/chat.post.ts
import { z } from 'zod';
import { defineFunction, convertToModelMessages } from '@trayai/helix-sdk';
import type { UIMessage } from '@trayai/helix-sdk';

export const input = z.object({
  sessionId: z.string().min(1),
  message: z.object({
    id: z.string(),
    role: z.literal('user'),
    parts: z.array(z.any()),
  }),
});

export default defineFunction<typeof input>(async (ctx) => {
  const [turn] = await convertToModelMessages([ctx.input.message as UIMessage]);

  const stream = await ctx.agent('assistant').stream({
    sessionId: ctx.input.sessionId,
    messages: [turn],
  });

  return stream.toUIMessageStreamResponse();
});
```

`convertToModelMessages` and the `UIMessage` type are re-exported from `@trayai/helix-sdk`, so the function needs no `ai` dependency of its own.

## Load history and pending approvals

One endpoint serves both the conversation so far, for a reload, and the approvals waiting on the person, which the client checks after every reply. `session.messages()` returns model messages; this flattens them to the text parts a chat renders. `session.pendingApprovals()` names the id `approvalId`, so it is mapped to `id`, the name the approve endpoint and `generate()` use:

```typescript
// functions/api/v1/chat/history.get.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  sessionId: z.string().min(1),
});

export default defineFunction<typeof input>(async (ctx) => {
  const session = ctx.agent('assistant').session(ctx.input.sessionId);
  const messages = await session.messages();
  const pending = await session.pendingApprovals();

  const turns = messages.flatMap((m, i) => {
    if (m.role !== 'user' && m.role !== 'assistant') return [];
    const text =
      typeof m.content === 'string'
        ? m.content
        : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');
    return text ? [{ id: `h${i}`, role: m.role, parts: [{ type: 'text', text }] }] : [];
  });

  return {
    turns,
    pending: pending.map((p) => ({ id: p.approvalId, toolName: p.toolName, input: p.input })),
  };
});
```

## Decide on approvals

Approvals cannot be answered through the stream, and every pending approval has to be decided in one call. A separate function resumes the session with `generate()`, passing all the decisions at once, and returns the model's follow-up as plain text:

```typescript
// functions/api/v1/chat/approve.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  sessionId: z.string().min(1),
  decisions: z.array(z.object({
    id: z.string().min(1),
    approved: z.boolean(),
  })).min(1),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = await ctx.agent('assistant').generate({
    sessionId: ctx.input.sessionId,
    approvals: ctx.input.decisions,
  });
  return {
    text: result.text,
    pending: (result.pendingApprovals ?? []).map((p) => ({ id: p.id, toolName: p.toolName, input: p.input })),
  };
});
```

See [tool approval](/documentation/guides/tool-approval/) for why decisions travel alone and all together.

## The React side

Install the AI SDK's client packages in `app/`:

```bash
cd app && npm install ai @ai-sdk/react
```

`useChat` manages the message list and the stream. A transport tells it to send only the latest message plus the session id, matching the function above. The chat id doubles as the session id, so a client can mint one and use it from the first message. After each reply the component asks the history endpoint whether the agent paused; while anything is pending, the input is locked and a card per pending tool collects a decision, sent together once every card has one:

```tsx
// app/src/components/AgentChat.tsx
import { useEffect, useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

interface Pending {
  id: string;
  toolName: string;
  input: unknown;
}

export function AgentChat({ sessionId }: { sessionId: string }) {
  const [draft, setDraft] = useState('');
  const [pending, setPending] = useState<Pending[]>([]);
  const [decisions, setDecisions] = useState<Record<string, boolean>>({});

  const { messages, sendMessage, setMessages, status } = useChat({
    id: sessionId,
    transport: new DefaultChatTransport({
      api: '/api/v1/chat',
      prepareSendMessagesRequest: ({ messages, id }) => ({
        body: { sessionId: id, message: messages[messages.length - 1] },
      }),
    }),
  });

  // One effect covers both the first load and the moment after each reply:
  // `status` is 'ready' on mount and again whenever a stream finishes.
  useEffect(() => {
    if (status !== 'ready') return;
    fetch(`/api/v1/chat/history?sessionId=${encodeURIComponent(sessionId)}`)
      .then((r) => r.json())
      .then(({ turns, pending }) => {
        if (messages.length === 0 && turns.length > 0) setMessages(turns);
        setPending(pending);
        setDecisions({});
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [status, sessionId]);

  const allDecided = pending.length > 0 && pending.every((p) => p.id in decisions);

  async function submitDecisions() {
    const res = await fetch('/api/v1/chat/approve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        sessionId,
        decisions: pending.map((p) => ({ id: p.id, approved: decisions[p.id] })),
      }),
    });
    const { text, pending: next } = await res.json();
    if (text) {
      setMessages((prev) => [
        ...prev,
        { id: `a${Date.now()}`, role: 'assistant', parts: [{ type: 'text', text }] },
      ]);
    }
    setPending(next);
    setDecisions({});
  }

  return (
    <section style={{ display: 'flex', flexDirection: 'column', height: 600, border: '1px solid #ddd' }}>
      <div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
        {messages.map((m) => (
          <p key={m.id} style={{ textAlign: m.role === 'user' ? 'right' : 'left' }}>
            {m.parts.filter((p) => p.type === 'text').map((p, i) => <span key={i}>{(p as { text: string }).text}</span>)}
          </p>
        ))}

        {pending.map((p) => (
          <div key={p.id} style={{ border: '1px solid #e5b800', padding: 12, margin: '8px 0' }}>
            <strong>The assistant wants to run {p.toolName}</strong>
            <pre style={{ fontSize: 12 }}>{JSON.stringify(p.input, null, 2)}</pre>
            <label>
              <input type="radio" name={p.id} onChange={() => setDecisions({ ...decisions, [p.id]: true })} /> Approve
            </label>
            <label style={{ marginLeft: 12 }}>
              <input type="radio" name={p.id} onChange={() => setDecisions({ ...decisions, [p.id]: false })} /> Deny
            </label>
          </div>
        ))}

        {pending.length > 0 && (
          <button type="button" disabled={!allDecided} onClick={submitDecisions}>
            Send decisions
          </button>
        )}
      </div>

      <form
        style={{ display: 'flex', gap: 8, padding: 12, borderTop: '1px solid #ddd' }}
        onSubmit={(e) => {
          e.preventDefault();
          if (!draft.trim()) return;
          sendMessage({ text: draft });
          setDraft('');
        }}
      >
        <input
          style={{ flex: 1 }}
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder="Ask about an order"
          disabled={pending.length > 0}
        />
        <button type="submit" disabled={status !== 'ready' || pending.length > 0}>
          Send
        </button>
      </form>
    </section>
  );
}
```

Mount it with a session id. A fresh id per visit starts a new conversation; a stored one resumes it:

```tsx
// app/src/pages/Support.tsx
import { useState } from 'react';
import { AgentChat } from '../components/AgentChat';

export function Support() {
  const [sessionId] = useState(() => localStorage.getItem('support-session') ?? newSession());
  return <AgentChat sessionId={sessionId} />;
}

function newSession() {
  const id = `ses_${crypto.randomUUID().replaceAll('-', '')}`;
  localStorage.setItem('support-session', id);
  return id;
}
```

Because the agent is user-scoped, a session id only ever resolves to the signed-in user's own conversation. A stale id from another account never reaches someone else's history: locally it addresses an empty session, and on the platform store the call is refused as not found, so treat that error as "start a new conversation".

## Listing past conversations

For a sidebar of earlier chats, add a function that calls `agent.sessions()` and render the result. It returns the calling user's sessions, newest first, with `messageCount` and `pendingApprovals` counts for a badge:

```typescript
// functions/api/v1/chat/sessions.get.ts
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  return ctx.agent('assistant').sessions({ first: 20 });
});
```

## What to expect in production

- **Streaming** follows the rules in [streaming](/documentation/guides/streaming/): the first bytes reach the browser before the run finishes, and the function timeout still applies to the whole run.
- **Identity** comes from the signed-in user, so `scope: 'user'` works with no extra code once deployed. Locally, see the note under the agent definition above.
- **Cost** per turn is not on the stream result. Read it from a `generate()` call, or log it in the agent's execution trace.

The app layout and build are covered in [frontend apps](/documentation/guides/frontend/). Signatures for every call used here are in the [`ctx.agent()` reference](/documentation/reference/context/agent/).

---

Canonical: https://helix.tray.ai/documentation/guides/agent-chat-ui/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt