Use commands to make one typed app operation discoverable and callable by the host and agents.
A command is one typed use case with two paths:
app button ── binding.run(input) ─┐
├─ same closure-private handler
iframe-action host call ──────────┘
agent-task host call ─────────────── native agent harness
commands.define(metadata, handler) returns immutable metadata plus local
.run(). commands.expose([...]) publishes one catalog and registers the
handlers. Its .ready() method separately reports that the exact loaded
command service can serve host calls.
Host calls never run in your visible app. The host bundles the module that
owns commands.expose([...]) — the exposure module graph — into a separate
command service under dist/ispo-command-service/ and executes it in a
hidden document under your project's origin and grants. That service has its
own build identity (commandBuildId), which moves only when a handler, the
exposure graph, or the SDK wire changes; UI-only edits leave it alone. The
service shares no memory with your app: it sees host-owned state through
ctx.sdk only, and a module-level cache that UI code fills is empty there.
There is no command entry in .ispo/project.json, authored catalog sidecar,
effect manifest, setEffects, or project-selected safe class. Generated
dist/ispo-project-commands* files are derived output; never edit them.
Included from the installed project-agent guidance: commands.md, “The Contract”.
Privileged work inside a command handler must use ctx.sdk so host invocation preserves its authority context. A command runs with its worker project's standing grants; defining or exposing it creates no permission. Its local .run() validates schemas but does not prove that host discovery, consent, or dispatch works.
This pure JSON command trims text. It has no privileged leaf and changes no stored data. Its literal metadata and schemas are discoverable at build time. Import the exposure module from the project entry and call ready() after its dependencies are ready.
import { commands } from '@ispo/sdk'
export const trimText = commands.define({
id: 'trim-text',
label: 'Trim text',
description: 'Remove leading and trailing whitespace from text.',
inputSchema: {
type: 'object', additionalProperties: false, required: ['text'],
properties: { text: { type: 'string', maxLength: 4000 } },
},
resultSchema: {
type: 'object', additionalProperties: false, required: ['kind', 'data'],
properties: { kind: { const: 'json' }, data: { type: 'string' } },
},
invocationMode: 'iframe-action',
resultChannels: ['json'],
}, (input) => ({ kind: 'json', data: input.text.trim() }))
export const exposure = commands.expose([trimText])
exposure.ready()
export function trimFromApp(text: string) {
return trimText.run({ text })
}
Repeating this pure example is safe. A real command's retry behavior depends on its leaves; wrapping a write in a command does not make that write idempotent. Cancellation or a timeout is not proof that a started effect was undone. Preserve host failure states, including uncertain outcomes, and reconcile durable state before retrying writes.
Schema validation failures need corrected input or metadata. An integrity-failed invocation needs a fresh admitted build and a re-selected command; another approval cannot repair stale build identity. Call ready() only when the separate command service can actually serve requests.
Typecheck, run the local example, wait for a fresh build, then discover and invoke the command through ISPO. Confirm the JSON result and reject invalid input. Test the host invocation as well as the app button: they execute in separate runtimes.