Commands of your own
An app's pages need somebody looking at them. Its commands do not. A
tools.json (or tools.yml) beside index.html gives the person's agent
commands of the app's own — log a run, read the leaderboard, close a shift —
with nothing open. This page is that file: what an entry 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, limitadd 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 column nobody names
is a column nobody writes. find builds a query string over the same columns:
words is a full-text search over the title and body, each column 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
tools.json.
Three ways to change that:
- Opt out —
"tools": falseat the top ofvocab.json, beside$defs. The app declares its components and gets no generated commands at all. - Mark one as not a kind —
"kind": falseon 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
add_recipeorfind_recipeintools.jsonyourself. 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
column added to a kind is an argument added to its two commands.
The file
tools.json sits at the app's root, beside vocab.json, and app_deploy is
what puts it into service. It is an object of entries, keyed by command name:
{ "log_run": {
"description": "Log a run for the club leaderboard",
"input": { "who": "text", "miles": "number" },
"apply": { "entity": { "eid": "$run" },
"jog": { "who": "$who", "miles": "$miles" } } } }The file is replaced whole on every deploy — a declaration holds no rows, so
there is nothing to migrate; delete tools.json and the next deploy leaves the
app with no commands at all. It is part of the app's source, not one of its
pages: GET /<app>/tools.json is a 404 on the web, the same as vocab.json and
worker.js, and a member reads it back with app_files.
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.
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.fyi/runs/
commands: log_run, leaderboard, sinceWho 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 appA 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.fyi/runs/
## yourname/recipes
add_recipe(title, body?, alias?, serves?) — Add a recipe to yourname/recipes
— Recipes, an app at yourname.yaks.fyi/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 four parts of an entry
description, input, exactly one action (apply or query), and an optional
view. 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
An object of argument names to types. Five types, the same ones a component's
columns use: text, number, bool, time, url. An argument name is
[a-z][a-z0-9_]{0,39}, like a command's, and "input": {} is a command with no
arguments. They become the JSON Schema commands prints and command fills
args from: number is a number, bool a boolean, and text, time and
url are strings — the last two carrying a description of the format wanted, so
a time argument asks for something like 2026-09-01 or 2026-09-01T10:00:00Z.
Every declared argument is required. There are no optional arguments and no
defaults: a variable with nothing to bind it would splice the word undefined
into your template. If something is genuinely optional, that is two commands.
(The two commands a kind gets are generated by the platform rather than written
by you, so those do let you leave an argument out — nothing breaks when a clause
is left out of a generated query, and commands marks them with a ?.)
An argument arrives as whatever the model sent and is converted to the type it
was declared as: "5" for a number becomes 5, "true" for a bool 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 text was declaredAn 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 column
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 — declare it in
log_run.inputThe 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": {
"description": "Log a run for the club leaderboard",
"input": { "who": "text", "miles": "number" },
"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": {
"description": "Log a run and a note about how it felt",
"input": { "who": "text", "miles": "number", "note": "text" },
"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": {
"description": "Delete a run somebody logged by mistake",
"input": { "run": "text" },
"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": {
"description": "The leaderboard: every run, with who logged it",
"input": {},
"query": ".jog!&.created!" },
"since": {
"description": "Runs logged since a date",
"input": { "since": "time" },
"query": ".jog!&.doc?&.created.at>=$since" },
"longest": {
"description": "Runs over a distance",
"input": { "miles": "number" },
"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 tools.json 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
accessdecides. Apublicapp accepts a write from an owner or editor of the space; anopenone 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.bynames 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. The whole
file 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:
tools.json: bad: screen — a tool says description, input, apply, query,
view; bad: $who names no input — declare it in bad.inputNothing 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 is an object with no keys but those five, a
descriptionthat is a sentence, and aninputwhose every type is one of the five types - exactly one action;
querya string,applyan object or a list of them - every
$varnames a declared input, or an entity the template creates - every component an
applynames is one the platform provides or this app'svocab.jsondeclares — the refusal names the component that is not declared and tells you to declare it invocab.jsonor use one of the platform's own viewis a page in this app's own files: relative, no.., ending.html
That last one is checked against the app's files, not against tools.json, so
the deploy is where a missing page is caught, and the message tells you what to
do:
tools.json: gone.html — a view names a page in this app's own files;
deploy the page beside index.htmlComponents are installed before commands, so a command may write a component declared by this very release.
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": { "description": "Every run so far",
"input": {},
"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
- The page sends
ui/initializeas a request — with anid, so a reply comes back; itsresult.hostContext.styles.variablesholds the MCP client's own theme, where it provides one. - Then
ui/notifications/initialized. - The answer arrives as
ui/notifications/tool-result, and the params'structuredContentis what the action returned —{rows: […]}for aquery,{entities, aliases}for anapply. An MCP client that forwards only the text sendscontentinstead, so fall back to reading that. - 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 tools.json
The run club, with the vocabulary it needs. vocab.json:
{ "$defs": {
"jog": { "properties": {
"who": { "type": "string" },
"miles": { "type": "number" },
"at": { "type": "string", "format": "date-time" } } } } }tools.json:
{ "log_run": {
"description": "Log a run for the club leaderboard",
"input": { "who": "text", "miles": "number", "at": "time" },
"apply": { "entity": { "eid": "$run" },
"doc": { "title": "Run by $who" },
"jog": { "who": "$who", "miles": "$miles",
"at": "$at" } } },
"leaderboard": {
"description": "Every run logged so far, with who logged it",
"input": {},
"query": ".jog!&.created!",
"view": "leaderboard.html" },
"since": {
"description": "Runs logged since a date",
"input": { "since": "time" },
"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 The guide.
