Describe an operation
#import { createOperationRegistry, searchInput, searchOutput } from '@playcode/sdk/operations'
import { z } from 'zod'
const STATUS = ['NEW', 'CONTACTED', 'ARCHIVED'] as const
const LEAD = {
type: 'object',
properties: {
id: { type: 'integer' },
message: { type: 'string' },
status: { type: 'string', enum: [...STATUS] },
},
}
export const registry = createOperationRegistry()
registry.register({
resource: 'lead',
verb: 'Search',
effect: 'read',
access: 'owner',
description: 'Find or count leads, newest first. Read totalCount for a count. Returns items, totalCount and nextCursor.',
input: searchInput({ properties: { status: { type: 'string', enum: [...STATUS] } } }),
output: searchOutput(LEAD),
trusted: ['items.id', 'items.status'],
route: { method: 'GET', path: '/api/leads' },
})
registry.register({
resource: 'lead',
verb: 'Update',
effect: 'write',
access: 'owner',
description: "Change a lead's status. Use it after you contact a lead, or to archive one. Returns the updated lead.",
input: z.object({ id: z.number().int(), status: z.enum(STATUS) }),
output: LEAD,
trusted: ['id', 'status'],
emits: 'lead.updated',
route: { method: 'PATCH', path: '/api/leads/:id' },
})
registry.seal() // no door opens before
| Field | What it says |
|---|---|
resource, verb |
The name, noun first: leadSearch. Verbs: Create, Get, Search, Update, Delete, and actions such as Cancel, Send or Check |
effect |
read, write, or money |
access |
owner: the app's admins. user: a signed-in user, on their own data. public: anyone |
description |
What it does, when to use it, what it returns. Every door shows it; its first sentence titles its card |
emits |
The event a write lands, such as lead.updated |
consent, terms |
Money, a Delete and a send apply only under terms the person saw; terms names the dry run |
outward |
It sends outside the app, to a recipient or with content the caller sets |
trusted |
Answer fields no visitor can write: ids, enums, times |
protected |
A write only your pages run: accounts, sessions, admin rights |
A Search answers one page: first (50 by default, at most 100), after for the next one, and { items, totalCount, nextCursor }. Count with totalCount.
One executor
#The executor checks the door, the caller and the arguments. Then it calls your route through a transport you write, signed in as the person the call acts for, so your route's guards run too:
import { createOperationExecutor, memoryProposalStore, type OperationTransport } from '@playcode/sdk/operations'
const transport: OperationTransport = {
async send(request) {
const response = await fetch(`http://127.0.0.1:3000${request.path}${request.search}`, {
method: request.method,
headers: {
...request.headers,
'content-type': 'application/json',
authorization: `Bearer ${await callToken(request.principal, request.claims)}`,
},
body: request.body === undefined ? undefined : JSON.stringify(request.body),
signal: request.signal,
})
const body: unknown = await response.json().catch(() => undefined)
return { status: response.status, body, headers: response.headers }
},
}
export const executor = createOperationExecutor({ registry, transport, proposals: memoryProposalStore() })
callTokenis your short-lived credential for one call. Your route reads its claims withcallerOf(claims): who acts, and through which door.- A refusal comes in one vocabulary:
access_denied,not_found,validationwith itsfield,idempotency_conflict,idempotency_in_flight,rate_limited,consent_required,proposal_drifted,proposal_expired,proposal_declined,refused,internal. Your pages read it witherrorOfResponse(status, body). - A call may take 30 seconds; then its
signalaborts.
Give an assistant your operations
#operationTools() gives an agent four tools: SearchOperations, DescribeOperation, ReadOperation and WriteOperation. A new operation is a new ability, with no other code:
import { operationTools, UNTRUSTED_TEXT_PROMPT } from '@playcode/sdk/operations'
const assistant = playcode.ai.agent({
name: 'assistant',
model,
system: `You do the owner's admin work through the app's operations.\n${UNTRUSTED_TEXT_PROMPT}`,
tools: operationTools({
executor,
door: 'owner',
// From the run's own user and session, never from the model.
principal: async ({ user, session }) => {
if (!user || !session || !(await isLiveAdmin(user, session))) throw new Error('No live admin session')
return { kind: 'owner', user, session }
},
}),
store,
})
- A run keeps the user and the session that started it, as
agent.handler({ user, session })passes them (Build a chat with the SDK). After a sign-out, the run stops at its next call. - A read runs at once. A write pauses the run with
finish: "approval"until a person decides. - A tool result's
datais for your page, and no model reads it: a read's{ operation, args, totalCount }, a search's{ query, matches }, a write's{ operation, args, applied }. Show where an answer came from with it, not with the model's text. - A run asks a person about at most 3 changes and 3 sends (
perRun). Past that, it ends withfinish: "cap".
Each change waits on a card
#A write's card comes from the registry, the arguments and your app's own reads, never from the model's words:
import { operationCard } from '@playcode/sdk/operations'
const run = await assistant.get(runId)
if (run?.status === 'paused') {
for (const call of run.pending ?? []) {
const card = await operationCard({
executor,
door: 'owner',
principal: owner,
runId: run.id,
call,
sources: run.sources,
messages: run.thread ? await assistant.history(run.thread, { user: run.user }) : run.messages,
})
// card.lines: the card as plain text, in order.
}
}
- It shows the operation, the first sentence of its description and the arguments. An Update or a Delete shows the record as your app answers now; a send shows the recipient and the full content first.
- Build it each time you show it, and draw every value as plain text. Never focus Apply when a card arrives, and give it no shortcut.
- A consent card shows the terms your dry run answers. Apply applies that proposal once; if the record changed since, nothing changes (
proposal_drifted). A proposal lasts 60 minutes; one that moves money, 10 minutes.
Customer-written text is data
#A lead's message can hold instructions written to steer the model:
- Every string of an answer reaches the model inside a block,
<untrusted-5f3a9c1e source="lead.message">...</untrusted-5f3a9c1e>, unless its field istrusted. The code is random, so the text cannot close its block. UNTRUSTED_TEXT_PROMPTtells the model this text is data. Put it in your assistant's instructions as it is.- Once the conversation has read such text, each card says so, and marks an argument copied out of it, such as an email address.
- A
protectedwrite is refused by name on every assistant and token door.
What every write keeps
#- One key per call. An assistant's write sends
runId:callIdas itsidempotency-keyon every attempt. WithbeginIdempotent()andfinishIdempotent(), your route answers a repeat within 24 hours as it answered the first. - One event per change.
eventDraft()gives your route the event a write lands: who made it, through which door, the run, the arguments with secret-named fields redacted, and what an Update replaced.
Tokens and MCP
#operationsMcpHandler({ executor, principal }) serves the same four tools to MCP clients that hold your app's tokens, as a web handler. A read-write token's consent write still waits for the owner (consent_required).
Check it and prove it
#lintRoutes() checks every route your server serves: each is an operation or internal, and keeps the rules - an event on every write, consent where it is due, a bounded Search, guards that match its access.
operationsContract() from @playcode/sdk/testing proves your running app keeps its half: each operation reaches its route, guards refuse a visitor and a member, a repeat with its key replays once, and consent applies only under its proposal:
import { operationsContract } from '@playcode/sdk/testing'
import { it } from 'vitest'
const samples = {
leadSearch: { args: { first: 5 } },
leadUpdate: { args: { id: 1, status: 'CONTACTED' }, other: () => ({ id: 1, status: 'ARCHIVED' }) },
}
for (const check of operationsContract({ registry, transport, proposals, principals: { owner, visitor }, samples })) {
it(check.name, check.run)
}
proposalStoreContract() and idempotencyStoreContract() check your two stores.
Limits
#- The registry, the executor and the tools run in your app.
memoryProposalStore()lives in one process. - A change of many records takes several requests: each counts toward the run's cap.