# Your app's operations

> @playcode/sdk/operations: your app's features as operations. Its pages, an assistant and MCP clients call one route, and each change waits for a person.
> Source: https://playcode.io/docs/developers/sdk-operations - last reviewed 2026-11-02.

An operation is one feature of your app, written once as a route with a description. Your pages call the route as they do today. An AI assistant, an MCP client with one of your app's tokens, and your own code call the same route through one executor, which checks who may call it and makes each change wait for a person.

In the samples, `model` is a model id from `playcode.ai.models()`, and names such as `owner`, `store` and `callToken` are your app's own code.

> [!TIP]
> A new Cloud app has this wired: every backend route is an operation or marked internal, the owner's **Assistant** at `/assistant` works through them, and the **Activity** page lists every change.

## Describe an operation

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

```ts
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() })
```

- `callToken` is your short-lived credential for one call. Your route reads its claims with `callerOf(claims)`: who acts, and through which door.
- A refusal comes in one vocabulary: `access_denied`, `not_found`, `validation` with its `field`, `idempotency_conflict`, `idempotency_in_flight`, `rate_limited`, `consent_required`, `proposal_drifted`, `proposal_expired`, `proposal_declined`, `refused`, `internal`. Your pages read it with `errorOfResponse(status, body)`.
- A call may take 30 seconds; then its `signal` aborts.

## 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:

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

```ts
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 is `trusted`. The code is random, so the text cannot close its block.
- `UNTRUSTED_TEXT_PROMPT` tells 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 `protected` write is refused by name on every assistant and token door.

## What every write keeps

- **One key per call.** An assistant's write sends `runId:callId` as its `idempotency-key` on every attempt. With `beginIdempotent()` and `finishIdempotent()`, 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:

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