Your app's operations

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.

On this page

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() })
  • 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:

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

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:

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.