---
doc:
  title: Commands of your own
guide:
  slug: tools
  brief: commands of the app, for an agent
  description: >-
    Tool entries in vocab.json, so the person's agent can act on an app with
    no page open: an entry's description, its argument schemas and $var
    bindings, the apply and query actions, what a deploy refuses, the page an
    answer is drawn in, and how the commands and command tools carry them.
---

# Commands of your own

An app's pages need somebody looking at them. Its commands do not. An entry in
`vocab.json` marked `"tool": true` gives the person's agent a command of the
app's own — log a run, read the leaderboard, close a shift — with nothing open.
This page is that entry: what it declares, how `$var` variables are bound and
typed, the two actions, what a deploy refuses, the page a command's answer is
drawn in, and how the `commands` and `command` tools carry all of it.

## Why an app carries commands at all

The graph tools (`graph_apply`, `graph_query`) can already write anything. What
they cannot do is describe what this app is for. A declared command gives the
agent the app's own vocabulary: one sentence the model chooses by, typed
arguments, and a template you wrote once so nobody has to remember that a run is
`jog{who, miles}` on a fresh entity. It is listed for everyone else in the space
too, so an app for four people is a command four people can use.

A new capability is a new component, not a new command. `graph_apply` already
writes anything the vocabulary declares, so something an app needs to keep, a
`rating`, a `loan`, a `shift`, is a line in its `vocab.json`, discoverable by
everyone through `graph_schema` the moment it deploys. Reach for a command only
when the app needs a verb that someone else's agent can call. Every command is
one more name anyone reading `commands` has to choose between.

## Every kind you declare is two commands already

You get the first two for nothing. Every component an app's `vocab.json`
declares is a kind of thing the app is about, and `app_deploy` gives each one a
command for adding one and a command for finding one again:

    vocab.json  { "$defs": {
                    "recipe": { "properties": {
                      "serves":  { "type": "number" },
                      "cuisine": { "type": "string" } } } } }

    →  add_recipe    title, body, alias, serves, cuisine
       find_recipe   words, serves, cuisine, limit

`add` creates an entity with that component on it, a `doc` for the title and
body, and an `alias` — a name the row answers to, so a later call reaches it
without having kept the eid. Only the `title` is required; a property nobody
names is a property nobody writes. `find` builds a query string over the same
properties: `words` is a full-text search over the title and body, each property
is an equality test, and every clause whose argument you leave out is left out
of the query — so `find` with no arguments returns everything of that kind.

**This is how an app is found again.** The person who asked for a recipe box in
one conversation says "add this recipe" in the next one, to an agent that has
never seen the app. `commands` is the one place every agent looks for what the
yaks apps can do, so the app is listed there whether or not anybody wrote a
command of its own.

Three ways to change that:

- **Opt out** — `"tools": false` at the top of `vocab.json`, beside `$defs`. The
  app declares its components and gets no generated commands at all.
- **Mark one as not a kind** — `"kind": false` on that component. A mark a row
  carries rather than a thing somebody adds — `starred`, `paid` — is nobody's to
  add, so it gets no commands while the rest of the file keeps its own.
- **Override** — declare a tool named `add_recipe` or `find_recipe` yourself.
  Your entry wins whole: your sentence, your arguments, your template. A
  hand-written command describes what the app is for; a generated one only
  describes what it stores.

A deploy regenerates them from `vocab.json` as it reads at that moment, so a
property added to a kind is an argument added to its two commands.

## The entry

A command is a `$defs` entry of `vocab.json` (or `vocab.yml`) marked
`"tool": true`, beside the components it writes — the same form a yaks package
declares its own tools in. The entry's key is the command's name, so a command
and a component never share one:

    { "$defs": {
        "jog": { "properties": {
          "who":   { "type": "string" },
          "miles": { "type": "number" } } },
        "log_run": {
          "tool": true,
          "description": "Log a run for the club leaderboard",
          "input": { "who":   { "type": "string" },
                     "miles": { "type": "number" } },
          "required": ["who", "miles"],
          "apply": { "entity": { "eid": "$run" },
                     "jog": { "who": "$who", "miles": "$miles" } } } } }

`app_deploy` is what puts it into service. The commands are replaced whole on
every deploy — a declaration holds no rows, so there is nothing to migrate;
remove an entry and the next deploy leaves the app without that command. A
`tools.json`, where commands were once declared, is refused at deploy, in the
sentence that says where each entry goes now.

## The name, and who may call it

A command's name is its whole name — `log_run`, not `runs__log_run`. It is not
an MCP tool and never appears in the connector's tool list. Two fixed MCP tools
carry every app's commands: **`commands`** lists what there is and **`command`**
runs one, with the app named beside the command rather than spliced into its
name.

    command { name: 'log_run', args: { who: 'Ada', miles: 5 } }

`args` is the command's own arguments, exactly as `commands` names them. `app`
goes beside them only where two apps you can reach have a command of the same
name.

An app page can ask `GET ./api/commands` for its own commands. The response is
an object keyed by command name, with each command's `description`, `input`,
`required` and `model` fields. It includes only commands this visitor may
discover and call, using the same access rule as `POST ./api/command`. A page
can use it to build its own command menu without copying that access rule.

**The tool list has to be fixed.** A directory such as OpenAI's snapshots
`tools/list` the day a connector is submitted and serves that snapshot forever —
only `tools/call` ever reaches the live server
(<https://developers.openai.com/plugins/deploy/app-review>). So this connector
lists the same names, in the same order, for every caller, signed in or not, and
a name that moved with what somebody deployed this morning could never be in
that snapshot. Commands are how an app's own actions reach an agent through a
tool list that cannot change.

The deploy reports the command names it registered:

    deployed yourname/runs v3: https://yourname.yaks.app/runs/
    commands: log_run, leaderboard, since

**Who sees them is who can reach the app**: every app in every space the caller
belongs to. A `public` or `open` app in a space they do not belong to is
reachable on the web but not by a command — a command names its app by slug, and
there is no space to resolve that slug against. Two apps in two of their spaces
can have a command of the same name; both are listed, and a call that names
neither app is refused, listing the candidates:

    log_run is a command of club/runs and yourname/runs — say which app

A name nobody has is refused with a list of the commands that do exist, since a
command list is a person's own and no model can have memorized it:

    no command add_run — your apps offer yourname/runs: log_run, leaderboard,
    since. commands lists them with their arguments.

`commands` groups them by app and lists each one's arguments the way `command`
accepts them, with a `?` on an optional argument. Its `app` argument — a slug,
or `<space>/<app>` where two spaces both have an app of that name — narrows it
to a single app; leave it out for everything you can reach:

    ## yourname/runs
    log_run(who, miles) — Log a run for the club leaderboard — Run club, an
      app at yourname.yaks.app/runs/

    ## yourname/recipes
    add_recipe(title, body?, alias?, serves?) — Add a recipe to yourname/recipes
      — Recipes, an app at yourname.yaks.app/recipes/

The listing carries the app's title, because a slug is not what the person
called it and a model chooses by words. Its `structuredContent` is
`{commands: [{at, name, title, description, readOnly, input, view?}]}` — `at` is
the app in the form `command` accepts, and `input` is the command's own JSON
Schema.

A deploy no longer changes anybody's tool list, and neither does joining a space
or trashing an app: the tools are the same tools they always were. Only a
platform release changes it, and then an MCP client that listed once is told to
list again (`notifications/tools/list_changed`, and a line on its next reply).
What a deploy does change is the pages a command's answer is drawn in — those
are MCP resources, and `notifications/resources/list_changed` announces it.

## The parts of an entry

`"tool": true`, a `description`, its arguments (`input` and `required`), exactly
one action (`apply` or `query`), an optional `view`, and an optional
`"model": true`. Any other key is refused by name rather than ignored, so a
mistyped key is an error at deploy time and not a command that quietly does half
of what was meant.

### description

Required, and the whole of what a model has to go on. It is what the model
chooses the command by, so write the sentence the person would say, not the
shape of the row: "Log a run for the club leaderboard", never "Writes a jog
component". The style of the platform's own tools is the one to copy — state
what it does, then when to reach for it: "Every run since a date, newest first.
Ask for it when someone wants the board." The app's title and address are
appended for you; do not write them in yourself.

### input and required

`input` is an object of argument names to JSON Schemas, one per argument, the
way a component declares its properties: `{ "type": "string" }`,
`{ "type": "number" }`, `{ "type": "boolean" }`, and whatever else JSON Schema
says — a `format`, an `enum`, a `description` the model reads. An argument name
is `[a-z][a-z0-9_]{0,39}`, like a command's, and a command with no arguments
leaves `input` out. Together they are the JSON Schema `commands` prints and
`command` checks `args` against.

`required` lists the arguments a caller must send. One it does not list may be
left out of a call, and then it takes its variable with it: a key holding
nothing but that variable is not written, a string it is spliced into is not
written either, and a clause of a query naming it drops out of the filter — so a
`find` with nothing to filter by reads everything. `commands` marks an optional
argument with a `?`.

An argument arrives as whatever the model sent and is converted to the type its
schema says: `"5"` for a `number` becomes `5`, `"true"` for a `boolean` becomes
`true`. What cannot be converted is refused by name, and nothing is written:

    miles is required
    miles is a number
    ready is true or false
    who is text                    ← an object where a string was declared

An argument nobody declared is dropped rather than refused — it binds no
variable, and refusing it would only teach the model to guess again.

### The action

Exactly one of `apply` or `query`. Neither, or both, is a refusal at deploy:

    log_run does one thing: apply (a bundle to write) or query (a filter
    line to read)

## Variables

`$arg` anywhere in the template — a key's value, a query string, an element of a
list — is a **variable**, the same `$var` that a write to the store and the
query grammar already use. An argument of that name binds it, and it becomes
that argument's value.

**A string that is nothing but a bound variable keeps the argument's own type.**
So `"miles": "$miles"` writes the number `5`, not the string `"5"`, and
`"ready": "$ready"` writes a boolean. This is the form to use for every property
that is not text.

**A variable inside a longer string is spliced in as text**, so a `title` of
"Run by $who" writes `Run by Ada`. In a `query`, a spliced value is
percent-encoded — a filter is written as a query string, and a title with an `&`
in it would otherwise read as the start of the next filter. `$$` is a literal
dollar sign.

**A variable nothing binds is an alias** — what `$run` has always meant in a
write to the store: the store creates an entity there, and every other `$run` in
the same call refers to that one. So one variable does both jobs, and which job
it does depends on whether an argument of that name arrived.

A variable that is neither an argument nor an entity the template writes is a
typo, refused at deploy:

    log_run: $when names no input and no entity here — declare it in
    log_run.input

## The apply action

`apply` is an entity bundle — one entity and the components written onto it — or
a list of them, exactly what `apply()` accepts on the page. A `$alias` creates a
new entity, and the answer reports the eid it was given. A new entity on every
call:

    "log_run": {
      "tool": true,
      "description": "Log a run for the club leaderboard",
      "input": { "who":   { "type": "string" },
                 "miles": { "type": "number" } },
      "required": ["who", "miles"],
      "apply": { "entity": { "eid": "$run" },
                 "doc": { "title": "Run by $who" },
                 "jog": { "who": "$who", "miles": "$miles" } } }

Two entities in one call, the second pointing at the first by its alias:

    "log_with_note": {
      "tool": true,
      "description": "Log a run and a note about how it felt",
      "input": { "who":   { "type": "string" },
                 "miles": { "type": "number" },
                 "note":  { "type": "string" } },
      "required": ["who", "miles", "note"],
      "apply": [
        { "entity": { "eid": "$run" },
          "jog": { "who": "$who", "miles": "$miles" } },
        { "entity": { "eid": "$said" },
          "doc": { "body": "$note" },
          "comment": { "target": "$run" } } ] }

There is one namespace for variables, so an alias and an argument must not share
a name: `$said` is the alias here because `$note` is already the argument. An
eid can be an argument, exactly because of that — put a bound variable where the
eid goes and the bundle patches the row that argument names, leaving the rest of
it alone. A bundle's own keys work in a template beside the components —
`entity`, `edges`, `tombstone`, `was` — so a command can add an edge or delete a
row:

    "drop_run": {
      "tool": true,
      "description": "Delete a run somebody logged by mistake",
      "input": { "run": { "type": "string" } },
      "required": ["run"],
      "apply": { "entity": { "eid": "$run" }, "tombstone": {} } }

The answer names what was written and carries the ids as `structuredContent`, so
the agent's next call — or a view's redraw — reads the row back by the eid this
one created:

    log_run: wrote 1 entity in yourname/runs: $run=4f3c…
    { "entities": ["4f3c…"], "aliases": { "$run": "4f3c…" } }

## The query action

`query` is a filter written as a query string, the same grammar boards and
`query()` use, and it returns the same listing a page gets:

    "board": {
      "tool": true,
      "description": "The leaderboard: every run, with who logged it",
      "query": ".jog&.created" },
    "since": {
      "tool": true,
      "description": "Runs logged since a date, or every run",
      "input": { "since": { "type": "string",
                            "description": "a date, like 2026-09-01" } },
      "query": ".jog&?doc&.created.at>=$since" },
    "longest": {
      "tool": true,
      "description": "Runs over a distance",
      "input": { "miles": { "type": "number" } },
      "required": ["miles"],
      "query": ".jog.miles>=$miles&?doc&limit=20" }

A row carries only the components its filter names, so name everything the
answer must show — `.jog` alone returns no titles, and `&?doc` asks for one
beside it. `.created` is how the board gets its bylines: a reference to somebody
the store knows returns `{eid, name}`, so the answer names who ran rather than
"someone". The answer counts the rows and returns them:

    leaderboard: 12 rows in yourname/runs
    { "rows": [ { "kind": "jog", "entity": { "eid": "4f3c…" },
                  "jog": { "who": "Ada", "miles": 5 },
                  "created": { "at": "2026-09-01T…",
                               "by": { "eid": "…", "name": "Ada" } } } ] }

## A command is a template, never code

Nothing in a tool entry runs. Filling in a template produces exactly the request
body a page's own `apply` or `query` would send, and it goes to the same `/api/`
endpoints that page uses, **as the person calling the command**. Three things
follow — the whole of the security model:

- **The app's `access` decides.** A `public` app accepts a write from an owner
  or editor of the space; an `open` one from anyone who can reach it. A viewer
  running a write command gets the same message the page would have shown them:
  "you can read this app but not change it — its owner can make you an editor".
- **`created.by` names them**, so `.created` on the rows a command wrote reports
  who ran it.
- **Nobody gets more through a command than they have on the page.**

Code that actually does something — calls an outside API, uses a secret,
computes an answer — belongs in a `worker.js`, not in a command.

## What a deploy refuses

`app_files` writes the file without reading it; `app_deploy` reads it. Every
tool entry is checked before anything is installed, and **every problem comes
back in one message** — an agent that could only fix one problem per deploy
would give up after the second:

    vocab.json: bad: screen — a tool says tool, description, input, required,
    apply, query, view, model; bad: $who names no input and no entity here —
    declare it in bad.input

Nothing is installed when it refuses: the commands the app already had keep
working exactly as before. What it checks:

- the command name and every argument name (`a-z`, `0-9`, `_`, starting with a
  letter)
- an entry has no keys but those eight, a `description` that is a sentence, an
  `input` whose every argument is a JSON Schema, and a `required` naming only
  its inputs
- exactly one action; `query` a string, `apply` an object or a list of them
- every `$var` names a declared input, or an entity the template creates
- every component an `apply` names is one the platform provides or this app's
  `vocab.json` declares — the refusal names the component that is not declared
  and tells you to declare it in `vocab.json` or use one of the platform's own
- `view` is a page in this app's own files: relative, no `..`, ending `.html`

That last one is checked against the app's files, not against `vocab.json`, so
the deploy is where a missing page is caught, and the message tells you what to
do:

    vocab.json: gone.html — a view names a page in this app's own files;
    deploy the page beside index.html

Components are installed before commands, so a command may write a component
declared by this very release.

## A command the app's own models may call

`"model": true` offers a command to the app's own models as a tool: when a
transcript in the app's store asks a model for a turn (see
[Models](/docs/models)), the commands marked this way are the tools it is
offered, and no others. The model's call runs as the person who asked for the
turn, held to what they could write on the page themselves.

    "set_mood": { "tool": true, "model": true,
                  "description": "Say how the smith feels",
                  "input": { "feeling": { "type": "string" } },
                  "apply": { "entity": { "eid": "$m" },
                             "mood": { "feeling": "$feeling",
                                       "of": "$session" } } }

`$session` in such a command is the transcript the turn is in: here, the smith
whose mood it is. No argument binds it, so a model cannot be talked into naming
another transcript, and a command that uses it runs only in a model's turn:
called through `command`, it is refused. Every row such a call writes carries
the transcript as `created.via`, so a page can tell what the smith's own turn
wrote from anything a visitor wrote in the same shape.

## The view: a page the answer is drawn in

An entry may name a `view` — a page in the app's own files that the person's
agent renders the answer in instead of reading it out. Write it like any other
page, deploy it with the rest, and name it beside the action:

    "leaderboard": { "tool": true,
                     "description": "Every run so far",
                     "query": ".jog&.created",
                     "view": "leaderboard.html" }

Over the MCP connector the page is a resource at `ui://<space>/<app>/<file>`,
served as `text/html;profile=mcp-app`, and the `commands` listing names it as
that command's `view`. The link is carried there rather than on a tool's
`_meta`, because this app has no MCP tool of its own to attach it to — which is
the one thing the fixed tool list costs: an MCP client that renders a widget
from the tool it called finds nothing to read in the answer, so the agent
fetches the page and shows it rather than the client drawing it by itself. Only
a page a command named is readable at all: the app's other files are served on
the web, not through the connector, and asking for one returns `no resource`.

**Relative URLs work.** The connector prepends a `<base href>` pointing at the
app's own address — the same tag the app's pages are served with on the web —
and names that address in the resource's CSP metadata, so `./style.css` and an
image beside `index.html` load. A page carrying a `<base>` of its own keeps it.

**The frame cannot read `./api/` itself.** It is rendered off-origin with no
session cookie, so a `fetch` to the app's `/api/` endpoints arrives as nobody.
Its data arrives in the command's answer instead, and to redraw it makes a
`tools/call` of `command` back through the MCP client, which does carry who is
asking.

### The protocol, in the order it happens

1. The page sends `ui/initialize` as a request — with an `id`, so a reply comes
   back; its `result.hostContext.styles.variables` holds the MCP client's own
   theme, where it provides one.
2. Then `ui/notifications/initialized`.
3. The answer arrives as `ui/notifications/tool-result`, and the params'
   `structuredContent` is what the action returned — `{rows: […]}` for a
   `query`, `{entities, aliases}` for an `apply`. An MCP client that forwards
   only the text sends `content` instead, so fall back to reading that.
4. The page reports its height with `ui/notifications/size-changed`, whenever it
   changes.

Everything is `window.parent.postMessage` with a JSON-RPC envelope, and
`e.data.id` matches a reply to the request that asked for it.

### A view, whole

    <!doctype html>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./style.css" />
    <ol id="board"></ol>
    <button id="today">today only</button>
    <script>
      let call = (method, params) =>
        parent.postMessage({ jsonrpc: '2.0', method, params }, '*')

      let asked = 1
      let request = (method, params) =>
        new Promise((resolve) => {
          let id = asked++
          addEventListener('message', function heard(e) {
            if (!e.data || e.data.id !== id) return
            removeEventListener('message', heard)
            resolve(e.data)
          })
          parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*')
        })

      let sized = () =>
        call('ui/notifications/size-changed', {
          width: document.body.scrollWidth,
          height: document.body.scrollHeight,
        })

      let draw = (data) => {
        board.replaceChildren(...((data || {}).rows || []).map((r) => {
          let li = document.createElement('li')
          li.textContent = `${r.jog.who} — ${r.jog.miles} miles` +
            (r.created ? ` · logged by ${r.created.by.name}` : '')
          return li
        }))
      }

      addEventListener('message', (e) => {
        let msg = e.data
        if (!msg || msg.method != 'ui/notifications/tool-result') return
        draw((msg.params || {}).structuredContent)
      })

      // The redraw: this app's own command, called back through the client.
      today.addEventListener('click', async () => {
        let { result } = await request('tools/call', {
          name: 'command',
          arguments: {
            name: 'since',
            args: { since: new Date().toISOString().slice(0, 10) },
          },
        })
        if (result && !result.isError) draw(result.structuredContent)
      })

      request('ui/initialize', { protocolVersion: '2026-01-26' }).then(() => {
        call('ui/notifications/initialized', {})
        sized()
      })

      new ResizeObserver(sized).observe(document.body)
    </script>

Expect the frame to be narrow: a view is drawn inside a conversation.

## One app's whole vocab.json

The run club: the component it keeps and the commands it offers, in one file.

    { "$defs": {
        "jog": { "properties": {
          "who":   { "type": "string" },
          "miles": { "type": "number" },
          "at":    { "type": "string", "format": "date-time" } } },
        "log_run": {
          "tool": true,
          "description": "Log a run for the club leaderboard",
          "input": { "who":   { "type": "string" },
                     "miles": { "type": "number" },
                     "at":    { "type": "string", "format": "date-time" } },
          "required": ["who", "miles"],
          "apply": { "entity": { "eid": "$run" },
                     "doc": { "title": "Run by $who" },
                     "jog": { "who": "$who", "miles": "$miles",
                              "at": "$at" } } },
        "leaderboard": {
          "tool": true,
          "description": "Every run logged so far, with who logged it",
          "query": ".jog&.created",
          "view": "leaderboard.html" },
        "since": {
          "tool": true,
          "description": "Runs logged since a date",
          "input": { "since": { "type": "string",
                                "description": "a date, like 2026-09-01" } },
          "required": ["since"],
          "query": ".jog&.created&.created.at>=$since",
          "view": "leaderboard.html" } } }

Two commands share one view — the connector lists the page once, and each draws
its own answer in it. Deploy that beside `index.html` and `leaderboard.html`,
and the club has three commands anywhere the person talks to their agent, doing
what they could do on the page and no more.

The whole guide, everything else an app can do, is at
<https://yaks.app/docs.md>.
