The defaults
#| Default | What it does | To change it |
|---|---|---|
| Prompt caching | Each step reads the repeated part of the prompt, the instructions and the conversation so far, at the model's cache price. The SDK marks it on Claude models; GPT and Gemini models cache it on their own | cache: false (Claude) |
| Long conversations | As a call nears the ceiling of 160,000 tokens, or the model's smaller window, old tool results are cleared; if that is not enough, a brief stands in for the older messages | context: { maxTokens }, or context: false |
| Brakes | A run that keeps repeating its tool calls is told to change course, then stopped | brakes: false |
| Tool limits | After 60 seconds, the model reads that a tool call timed out and the call's signal aborts; the model reads at most 50,000 characters of a result |
timeoutMs and maxResultChars on the tool; 0 = none |
| Steps | A run makes at most 10 model calls | maxSteps |
| Retries | A call is retried only when Playcode shows nothing was charged, at most 2 times | - |
Hooks
#Hooks are your code at the points of a run. Each is optional, and a hook that returns nothing changes nothing:
| Hook | When | It can |
|---|---|---|
prepareStep(plan) |
Before each model call | Change the step's instructions, or narrow its tools |
beforeTool(call) |
Before a tool runs | Answer the call without running the tool |
afterTool(call, result) |
After a tool ran | Replace what the model reads of the result |
onStep(step) |
After each step | Watch it: log, count, alert |
const assistant = playcode.ai.agent({
model,
system: SYSTEM,
tools: { search, readPage, saveNote },
hooks: {
// The date stays right in a process that runs for days.
prepareStep: ({ index }) => ({
system: `${SYSTEM} Today is ${new Date().toISOString().slice(0, 10)}.`,
// Search first: the first step sees one tool.
...(index === 0 ? { tools: ['search'] } : {}),
}),
// Refuse writes in a read-only workspace.
beforeTool: (call) =>
call.name === 'saveNote' && readOnly
? { output: 'This workspace is read-only.', isError: true }
: undefined,
// Hide email addresses from the model.
afterTool: (call, result) => ({
output: result.output.replace(EMAIL, '[email]'),
isError: result.isError,
}),
onStep: (step) =>
log.info('ai step', {
model: step.model,
credits: step.usage.credits,
usageId: step.usageId,
}),
},
})
Calls a person approved, and calls that run again after a crash, skip beforeTool and afterTool. A rule that must hold for every call belongs in the tool itself.
An agent is a plain configuration, cheap to make, so a hook that needs one request's data can live in an agent made for that request. A durable agent is the exception: another process may continue its run, so its hooks read only what every process has.
Brakes
#The brakes watch for three loops: the same tool calls again and again, a tool that keeps failing, and the same text repeated. For the first two, the model is first told to change course; if it goes on, the run stops with finish: "stuck" instead of spending its budget on the loop. Repeated text stops the run at once. Turn them off with brakes: false only for an agent that repeats on purpose.
Long conversations
#A long conversation would send its whole history on every step, and fill the model's window. A fold starts once a call reads 112,000 tokens: 80% of the 160,000-token ceiling, after room for the answer. The run first clears old tool results, which needs no model call. If that is not enough, the run's own model writes a brief, a short summary that stands in for the older messages, and the newest messages stay as they were. A run folds at most 2 times. The brief is a paid call, inside usage and the budget, and a compaction event names it.
On a thread, a brief of the thread's earlier messages is kept, so the next run reads it instead of writing another. The thread itself keeps every message. A lower ceiling, such as context: { maxTokens: 60_000 }, makes each step cheaper on a long conversation.
Fallback models
#model takes a list, in order:
const support = playcode.ai.agent({
model: [firstChoice, fromAnotherMaker],
system,
tools,
})
The next model takes over only when a failure charged nothing and is the model's own: its maker stays down or busy after the retries, Playcode's daily ceiling for its maker is spent, or Playcode switched the model off or no longer offers it. The next model continues from the last completed step, with the same conversation, tools, steps and budget. A fallback event names the switch, and each step names the model that ran it (steps[i].model).
A run never switches for the workspace's balance, the app's budget or rate limit, a bad request, or a stream that broke after it started: every model would hit the same limit, or the call was already paid. Pick a fallback from another maker that reads what you send: a PDF needs a model whose card has accepts.pdf.
Sub-agents
#asTool() makes an agent a tool of another agent:
const researcher = playcode.ai.agent({
model,
system: 'You research suppliers. Answer with facts and their sources.',
tools: { search, readPage },
})
const buyer = playcode.ai.agent({
model,
system: 'You help the team pick suppliers.',
tools: {
research: researcher.asTool({
description: 'Researches a supplier and answers with the facts.',
}),
},
})
The calling model writes the sub-agent a task. The sub-agent reads nothing else of the conversation, and its answer is the tool's result. Its model calls are the caller's: they count in the caller's usage and budget, and what the caller has left caps the sub-agent. The caller's abort stops it. A sub-agent call has no tool time limit: the caller's signal and budget bound it. A subagent event carries its whole run. An agent whose tools need approval cannot be a tool. Put approval on the sub-agent tool instead: { ...researcher.asTool({ description }), approval: true }.
Spending limits
#| Limit | Where | Stops with |
|---|---|---|
maxSteps |
On the agent | finish: "max-steps". A brief and a sub-agent's calls are not steps of the run |
budget: { credits } |
On the agent: each run's own | finish: "budget". Checked before each step, so a run can pass it by one step: its model call, a brief before it, and the calls of sub-agents running at once |
maxOutputTokens |
On the agent | finish: "length"; it also bounds what one call can cost |
limits: { user: { creditsPerDay } } |
On the agent, a run or the chat endpoint | finish: "limit" |
| The app's monthly AI budget | None until you set one: ask Playcode's agent (Add AI to your app) | finish: "balance", with error |
Per-user limits
#limits: { user: { creditsPerDay: 1 } } caps what one end user may spend in a UTC day. The count lives in the agent's store and covers the agents on that store that set limits.user. Every model call of the user's runs counts: briefs, fallbacks and sub-agents too. Before each run and each step, the run reads what is left. When nothing is left, the run ends with finish: "limit" before its next model call.
- A user can pass the limit by one step's cost, once for each run they have in flight.
- A run with no
userhas no limit: passuseron every run you start for an end user. - What a run without
limits.user, or agenerate()call, spends is not counted. memoryStore()loses the counts on a restart.
What each user costs
#Pass user, your app's own id for the end user a call serves, on generate, run and start:
await support.run(message, { user: customer.id })
Every model call of the run then carries the user and the run's id, and Playcode keeps both on the call's usage record. Ask Playcode's agent what your app's AI cost per user, per run, per model or per day: it reads the appAiUsageGet operation of the Playcode API. An id is 1 to 64 characters of A-Z, a-z, 0-9, ., _, : or - - never an email or a name - and another shape is refused before any call.