# Build an agent

> playcode.ai.agent(): a model that works in your app through your own functions - tools, how a run ends, its events, approvals and conversations.
> Source: https://playcode.io/docs/developers/sdk-agents - last reviewed 2026-10-17.

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.

## Your first agent

```ts
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:

```ts
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](/docs/add-to-your-app/send-email#who-receives-your-apps-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](/docs/developers/sdk-agent-controls#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](/docs/developers/sdk-agent-controls#brakes) |
| `approval` | A tool call waits for a person | See [Approvals](#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](/docs/developers/sdk-agent-controls#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:

```ts
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`:

```ts
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](/docs/developers/sdk-chat). 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:

```ts
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](/docs/developers/sdk-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](/docs/developers/sdk-chat#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](/docs/developers/sdk-examples#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](/docs/developers/sdk-examples#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](/docs/developers/sdk-durable-runs).
