# Build a chat with the SDK

> A chat your users talk to: an agent with a store, the chat endpoint agent.handler(), and useAgentChat() for the page - each user with their own conversations.
> Source: https://playcode.io/docs/developers/sdk-chat - last reviewed 2026-10-17.

A chat is an agent your users talk to on a page. The SDK gives you both halves: `agent.handler()`, one endpoint for the conversation, and `useAgentChat()`, the state and actions for the page. Each user has their own conversations in your app's database, and an answer goes on when the page reloads or the app restarts.

> [!TIP]
> A new Cloud app has all of this wired: the `/chat` page, its endpoint at `/api/ai/chat/`, and a store in the app's database. Start from it: [Add an AI chat to your app](/docs/add-to-your-app/add-a-chat).

## Do it yourself

1. Give the agent a `name` and a `store`, where its conversations and runs live. `memoryStore()` is enough to try it; for production, see [Your store](/docs/developers/sdk-durable-runs#your-store). In the sample, `model` is a model id you chose, and `findOrder` one of your tools.

   ```ts
   import { memoryStore, Playcode } from '@playcode/sdk'

   const playcode = new Playcode()

   export const support = playcode.ai.agent({
     name: 'support', // keep it across deploys
     store: memoryStore(),
     model,
     system: 'You are the support desk of Paper & Co. Be brief.',
     tools: { findOrder },
     budget: { credits: 0.25 },
   })
   ```

2. Recover runs that a restart cut, when the app starts and every 30 seconds. Without it, a conversation whose run was cut stays busy.

   ```ts
   await support.recover()
   setInterval(() => {
     support.recover().catch((error) => console.error('recover', error))
   }, 30_000)
   ```

3. Make the chat endpoint. `user` returns who is asking, from your app's own session. A request without a user is refused, because every message spends your credits.

   ```ts
   const chat = support.handler({
     user: async (request) => (await sessionOf(request))?.userId,
     limits: { user: { creditsPerDay: 1 } },
   })
   ```

4. Mount it at any path. It is a web-standard `(Request) => Promise<Response>`, so it runs on Node, Bun and edge runtimes:

   ```ts
   // Hono
   app.all('/api/chat/*', (c) => chat(c.req.raw))

   // Next.js, in app/api/chat/[...path]/route.ts
   export const GET = chat
   export const POST = chat
   ```

   In Express or NestJS, hand it a web `Request`: a new Cloud app's `backend-nest/src/ai/web-handler.ts` does this.

5. Draw the chat. `useAgentChat` gives the messages, the answer as it streams, each tool call's state and the calls that wait for approval. It has no styling: your components draw it.

   ```tsx
   import { useAgentChat } from '@playcode/sdk/react'
   import { useState, type FormEvent } from 'react'

   export function SupportChat() {
     const chat = useAgentChat({ url: '/api/chat', thread: 'support' })
     const [text, setText] = useState('')

     const submit = (event: FormEvent) => {
       event.preventDefault()
       void chat.send(text)
       setText('')
     }

     return (
       <div>
         {chat.messages.map((message) => (
           <div key={message.id} className={message.role}>
             {message.parts.map((part, index) =>
               part.type === 'text' ? (
                 <p key={index}>{part.text}</p>
               ) : part.type === 'tool' ? (
                 <small key={index}>{part.name}: {part.state}</small>
               ) : null,
             )}
           </div>
         ))}
         {chat.pending.map((call) => (
           <p key={call.id}>
             {call.name}
             <button onClick={() => chat.approve([call.id])}>Approve</button>
             <button onClick={() => chat.deny([call.id])}>Deny</button>
           </p>
         ))}
         {chat.error && <p role="alert">{chat.error.message}</p>}
         <form onSubmit={submit}>
           <input value={text} onChange={(e) => setText(e.target.value)} />
           {chat.status === 'running' ? (
             <button type="button" onClick={() => chat.stop()}>Stop</button>
           ) : (
             <button>Send</button>
           )}
         </form>
       </div>
     )
   }
   ```

   Without React, `createAgentChat({ url, thread })` from `@playcode/sdk/client` has the same state and actions: call `load()` first, then `subscribe` to changes and read `getState()`.

6. Sign in as a test user and send one message. You see the answer stream in. Ask Playcode's agent what the message cost: it reads your app's AI usage by user.

> [!WARNING]
> Keep the chat for signed-in users, and set the daily limit per user before you open sign-up to everyone. A chat that anyone can use spends your credits.

> [!COST]
> Each message uses credits at the model's price. With open sign-up, each new account has its own daily limit. Only the app's monthly AI budget caps them all together, and an app has none until you set one: ask Playcode's agent.

## The endpoint

| Route | Does |
|---|---|
| `POST /threads/:thread/messages` | Sends `{ text, files? }`, and answers the run's events as a stream |
| `GET /threads/:thread?before=&limit=` | A page of the thread's messages, and its run in progress |
| `GET /threads` | The user's threads, last updated first |
| `GET /runs/:run/events?after=` | The events after the last one the page saw, then the live ones |
| `POST /runs/:run/approve` | `{ approve, deny }`: a decision for every pending call |
| `POST /runs/:run/cancel` | Stops the run |

Every run it starts is durable, so an answer outlives the request that asked for it. A page that reloads reads the thread, finds the run in progress and catches up from its events. A thread id names that user's own thread: another user's thread of the same id is another conversation, so a fixed id per feature, such as `support`, is enough. Give each conversation its own id when a user needs several.

A message carries at most 10 files, up to 25 MB in all. Shrink photos first with `prepareImage(file)` from `@playcode/sdk/client`, then pass them to `send(text, files)`.

## Who approves

Through the endpoint, an approval belongs to the person in the chat: the page shows them **Approve** and **Deny**, and their conversation waits. That fits an action they confirm for themselves, such as a booking or a message sent in their name.

A decision that is yours or your staff's, such as a refund or a discount, is never `approval: true`: the customer would approve their own refund. Let the tool save a request in your database and tell the customer it waits for review. Decide it on an admin page of your app, and run the action there, once per request.

## More than one process

The endpoint's runs use the agent's `events` bus, which is this process's memory by default. With more than one process, pass a bus they share, and an executor that hands runs to your queue: see [Durable runs](/docs/developers/sdk-durable-runs).

## If it doesn't work

### 401 unauthenticated

`user(request)` answered no user. Check the session cookie or token reaches the endpoint.

### 404 not_found

The thread or run belongs to another user, or the path is not one of the routes above. A thread with no message yet answers 404 too, and the SDK's client shows it as empty.

### 409 thread_busy

The thread has an answer in progress, a call waiting for approval, or a run a restart cut. Wait, decide the call, or stop the run. A cut run frees the thread once `recover()` picks it up.

### 409 not_paused

The run was not waiting for a decision: another decision came first, from a second tab or a double click, or the run is running or ended. This decision ran nothing.

### 400 validation

An id in the path is not 1 to 64 characters of `A-Z`, `a-z`, `0-9`, `.`, `_`, `:` or `-`; the text or the files are over their limits; or a pending call has no decision.

### 413 request_too_large

The message is over 25 MB with its files. Shrink photos before you send them.

### 415 unsupported_media_type

A POST without `content-type: application/json`. The SDK's own client sends the header. If your own page posts with `fetch`, set it. The endpoint refuses other body types because a form or plain-text POST from another site would carry your user's cookies.

### 500 internal

`user(request)` returned an id the SDK refuses: an id is 1 to 64 characters of `A-Z`, `a-z`, `0-9`, `.`, `_`, `:` or `-`. Your server log names it.

### The run ends with finish "limit"

The user reached their daily limit. It resets at 00:00 UTC. Raise `creditsPerDay` if it is too low for your users.

## Limits

- A thread takes one run at a time.
- A message's text is at most 200,000 characters.
- `memoryStore()` and the default events bus live in one process's memory: a restart loses them.
- The chat endpoint of `@playcode/sdk` 0.5.0 takes a POST of any body type, so another site can post as your signed-in user. Use 0.6.0 or newer.
