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.
The agent
A user-scoped agent, so each signed-in person gets their own conversations:
// 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' },
});
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:
// 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:
// 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:
// 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 for why decisions travel alone and all together.
The React side
Install the AI SDK’s client packages in app/:
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:
// 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:
// 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:
// 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: 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. Signatures for every call used here are in the ctx.agent() reference.