Build a chat with the SDK

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.

On this page

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. In the sample, model is a model id you chose, and findOrder one of your tools.

    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.

    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.

    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:

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

    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.

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.

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.