Build an agent

An agent is a model with instructions and your app's functions as its tools. You define it once. Each run() is one turn: the model reads the message, calls your tools as it needs them, and answers, or stops at a limit you set.

In the samples, model is a model id you chose from playcode.ai.models(), and names such as db are your app's own code.

On this page

Your first agent

#
import { Playcode, tool } from '@playcode/sdk'
import { z } from 'zod'

const playcode = new Playcode()

const support = playcode.ai.agent({
  model,
  system: 'You answer questions about orders. Never promise a refund.',
  tools: {
    findOrder: tool({
      description: 'Finds an order by its number: status, items, tracking.',
      input: z.object({ number: z.string().describe('Like A-1042') }),
      execute: async ({ number }) =>
        (await db.orders.find(number)) ?? 'No order has that number.',
    }),
  },
  maxSteps: 6,
  budget: { credits: 0.2 },
})

const result = await support.run('Where is my order A-1042?')
console.log(result.text, result.finish, result.usage.credits)

The run reads the order with findOrder, then answers. It stops at whichever comes first: its answer, 6 model calls, or 0.2 credits.

Tools

#

A tool is one of your functions, described for the model:

  • description is what the model reads to decide when to call it. Say what the tool does and when to use it.
  • input is a zod schema or a JSON Schema. The SDK checks the model's input before execute runs. An input that does not match goes back to the model, which can fix it and call again.
  • execute(input, context) returns the result. A string reaches the model as it is; anything else, as JSON. A thrown error reaches the model as the tool's error, so throw one whose message says what to do next. The model can repeat it to your user: keep secrets and stack traces out of it.
  • context has signal (pass it to the fetches the tool makes), runId, toolCallId and idempotencyKey.
  • timeoutMs: past it, the model reads that the tool timed out, and the call's signal aborts. Pass the signal to the tool's work, or the work keeps running. The default is 60 seconds; 0 means no limit.
  • maxResultChars cuts a long result, with a note that names its size. The default is 50,000 characters; 0 means no cap.

A tool's name is its key in tools: 1 to 64 letters, digits, _ or -. The model can call several tools in one step, and the SDK runs up to 4 of them at once, so tools must be safe to run side by side.

idempotencyKey is the same for every attempt of one call, including a call that runs again after a crash. Key every write on it - a payment, a booking, an email - so a second attempt finds the first one's result instead of acting twice:

const sendReceipt = tool({
  description: 'Email the customer the receipt of an order they paid.',
  input: z.object({ order: z.string() }),
  execute: async ({ order }, { idempotencyKey }) => {
    const receipt = await receiptOf(order) // the customer's email and HTML
    const { status } = await playcode.email.send({
      to: receipt.email,
      subject: `Receipt for ${order}`,
      html: receipt.html,
      idempotencyKey,
    })
    return `Receipt ${status}.`
  },
})

Email to a customer arrives only once your app may send to anyone: see Who receives your app's email.

A tool can do whatever your code can: read your database, call any HTTP API or another SDK, send email through playcode.email, or run another agent (Sub-agents).

How a run ends

#

A run resolves with finish, the reason it stopped. These ends are results, not errors:

finish Means Next
stop The model answered Show text, or object with a schema
max-steps It still wanted tools at maxSteps Raise maxSteps, or make the task smaller
budget The run's budget is spent Raise it, or use a cheaper model
length The answer hit maxOutputTokens Raise it, or ask for a shorter answer
content-filter The model maker's safety filter refused Change the request
stuck The brakes stopped a loop See Brakes
approval A tool call waits for a person See Approvals
balance The workspace's credits, or the app's monthly AI budget, ran out; error says which Add credits, or raise or clear the app's budget
limit The end user's daily limit is spent See Per-user limits
aborted Your signal stopped it -

Anything else that fails throws: a request the model's maker refuses (PlaycodeApiError), a broken connection (PlaycodeConnectionError), a call the SDK cannot build (PlaycodeConfigError), or an error your own onEvent throws.

Events

#

onEvent receives every event of the run, in order:

const result = await support.run(message, {
  onEvent: (event) => {
    if (event.type === 'text') page.write(event.text)
    if (event.type === 'toolCall') page.status(event.toolCall.name)
  },
})

The events are those of stream(): text, toolCall, toolResult, usage, step, compaction, fallback, subagent and done. A promise your handler returns is awaited where the run can wait: at a step, a usage report and a tool result.

The result

#
Field What it holds
text, object The answer; object is the checked answer of a schema on the agent
finish Why the run stopped
toolCalls, toolResults Every tool call of the run, and what the model was told of each
steps Each model call: its text, toolCalls, toolResults, usage, usageId and the model that ran it
usage, usageIds The whole run's tokens and credits, and one id per model call
model The model that gave the answer: the last one of a model list that ran
runId The run's id: every model call carried it, and the app's AI usage groups cost by it
messages What the run added to the conversation: your message, the model's turns and the tool results
pending, state When finish is approval: the calls that wait, and what resume() continues from
error When finish is balance: why Playcode stopped the run

Conversations

#

To keep a conversation yourself, pass the history and append each run's messages:

import type { AiMessage } from '@playcode/sdk'

let history: AiMessage[] = []
const first = await support.run('Where is my order A-1042?', { history })
history = [...history, ...first.messages]
const second = await support.run('And when will it arrive?', { history })

To let the SDK keep it, give the agent a store and pass a thread: Build a chat with the SDK. After a crash, run(null, { history }) continues a saved history from its last completed step, so no finished tool call runs again.

Approvals

#

A tool with approval waits for a person before each call - true for every call, or a check on its input:

const refund = tool({
  description: 'Refund an order, fully or in part.',
  input: z.object({ order: z.string(), amount: z.number() }),
  approval: ({ amount }) => amount > 100,
  execute: ({ order, amount }, { idempotencyKey }) =>
    payments.refund(order, amount, idempotencyKey),
})

const desk = playcode.ai.agent({
  model,
  system: 'You run the help desk of Paper & Co.',
  tools: { findOrder, refund },
})

const paused = await desk.run('Refund 150 euros on order A-1042')
if (paused.finish === 'approval' && paused.state && paused.pending) {
  // Store paused.state and show paused.pending to the person who decides.
  const approved = { approve: [paused.pending[0].id] }
  const done = await desk.resume(paused.state, approved)
}
  • The run stops at such a call with finish: "approval". The other calls of the same step have run.
  • state is JSON, signed with your secret key, so a changed state is refused. It is signed, not encrypted: it holds the conversation, the tool results and any files, so send it only where its reader may see them.
  • Every pending call needs a decision: approve: [id], or deny: [{ id, reason }]. The model reads a denied call as refused, with your reason.
  • The resumed run is the same run: its steps and its budget carry over.
  • Resume a state once. A second resume() of the same state runs the approved calls again, with the same idempotencyKey. A durable run applies a decision once: resumeRun(), on Durable runs.

With run() and resume(), your code decides who may approve, so a refund like this one belongs in your staff's tools. Through the chat endpoint, the person in the chat approves, and a customer would approve their own refund: see Who approves.

Boundaries

#

An agent can do only what its tools let it do, so the tools are its boundary:

  • Scope every tool to one end user. Take their id from your session, in code, never from the model's input.
  • Put approval on anything that sends data out of your app: an email, a payment, a web request.
  • Treat what a tool returns as untrusted. A document or an email can hold instructions written to steer the model; a scoped tool limits what they can do.
  • Never run code the model wrote on your app's server: it holds your secrets and your database.

For an agent that works across many documents, see A file system over your data.

Limits

#
  • An agent reads text, images and PDFs. It does not hear audio or watch video.
  • Tools that a model's maker runs itself, such as web search or code execution, are refused. A tool of yours can call a search API instead.
  • Playcode has no embeddings model, so no vector search. Give the agent file-style tools over your data instead, as coding agents work: A file system over your data.
  • MCP servers are not built in as tools. A tool's execute can call one through an MCP client library.
  • A run executes in your app, on its compute and credits. A schedule, such as a nightly run, is your app's own timer or queue: see Durable runs.