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:
descriptionis what the model reads to decide when to call it. Say what the tool does and when to use it.inputis a zod schema or a JSON Schema. The SDK checks the model's input beforeexecuteruns. 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.contexthassignal(pass it to the fetches the tool makes),runId,toolCallIdandidempotencyKey.timeoutMs: past it, the model reads that the tool timed out, and the call'ssignalaborts. Pass the signal to the tool's work, or the work keeps running. The default is 60 seconds;0means no limit.maxResultCharscuts a long result, with a note that names its size. The default is 50,000 characters;0means 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. stateis 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], ordeny: [{ 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 sameidempotencyKey. 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
approvalon 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
executecan 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.