> ISPO SDK documentation. [HTML](https://ispo.ai/docs/documents/Expose_a_command.html) · [Documentation index](https://ispo.ai/docs/llms.txt)

# <a id="expose-a-command"></a>Expose a command

Use [commands](../variables/core.commands.md) to make one typed app operation discoverable and callable by the host and agents.

## <a id="runtime-and-authority"></a>Runtime and authority

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](http://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.

## <a id="checked-example"></a>Checked example

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

## <a id="effects-cancellation-and-recovery"></a>Effects, cancellation, and recovery

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.

## <a id="verify"></a>Verify

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.
