# Host commands Status: current implementation reference ## Why A run that acts on the person's data — making a loom, writing a page — needs to reach the operations the host offers without the harness naming any of them. The host already describes those operations in its command manifest and already narrows them per run with a broker. Three generic tools put that in front of the model: `list_commands` shows the commands the host lets this run run, each as a typed signature; `run_command` runs one or a batch of them, as the agent, with write-class authority; and `run_read_command` runs broker-granted reads the same way. Which commands those are is the host's decision alone; nothing in cf-harness names a command. ## Host configuration The batch CLI accepts `--loom-commands-config /absolute/host-config.json` or `CF_HARNESS_LOOM_COMMANDS_CONFIG`. The file is supplied by the operator, read on the host, and never passed into the sandbox. Without it the command tools are absent, including when `--allow-tool` names one: naming one without the configuration is refused at argument parsing. ```json { "cliPath": "/opt/loom/src/bin/loom", "jobIdEnvVar": "LOOM_COMMAND_JOB_ID", "transport": { "kind": "broker", "queuePath": "/private/loom/run/command-queue" } } ``` Only the broker transport is accepted. The broker is what makes the grant mean something: it lists only the commands its scope admits, refuses every other command it is asked to run, and replaces whatever actor a request names with the `agent:` actor and run it was started for. A direct transport would leave the attribution to the configuration, so it is refused when the file is read. Each call runs the host's CLI over a cleared environment with `LOOM_PAGE_RPC_QUEUE` set to the queue, the same way the [authoring tools](LOOM_AUTHORING.md) and the [retrieval tools](LOOM_RETRIEVAL.md) reach the host: The optional `jobIdEnvVar` names the variable that carries the current host job id. The embedder supplies that id through `RunCfHarnessCliDependencies.commandJobId`, independently for each invocation; a `jobId` in the configuration file is ignored. Both discovery and execution receive the id in their cleared host environment when the variable and id are present. Without a supplied id the variable is omitted; the broker decides whether such a request is valid. The variable name must use uppercase letters, digits, and underscores, starting with a letter or underscore, and cannot replace `PATH` or `LOOM_PAGE_RPC_QUEUE`. Neither the model's arguments nor ambient process environment select the id. | Tool | Host command | | ------------------ | ---------------------------------------------------------------- | | `list_commands` | `loom command list --json` | | `run_command` | `loom command run --args-json - --json [--loom] [--expect]` | | `run_read_command` | The same invocation, with `--read-only` appended | `list_commands`, the first `run_command` of a run, and every `run_read_command` read the listing. Each call `run_command` or `run_read_command` sends to the host, a lone call or one call of a batch, is one `loom command run` process; a call refused before it is sent starts none. ## The run's catalog The tools read one catalog, held for the run: the host's listing is read the first time a tool needs it and kept for the rest of the run, so a run that calls many commands lists once. An explicit `list_commands`, and every `run_read_command`, reads the host afresh and holds what it read, so the read-only grant `run_read_command` checks is the host's current one. A read that fails is not held; the next call asks the host again. ## `list_commands` Takes an optional `detail`, naming up to sixteen commands. Returns: ```json { "outputId": "…", "status": "ok", "notice": "A command's effect is shown only where the host declares it; …", "entries": [ { "name": "people-discovery.dossier", "signature": "people-discovery.dossier(entity_id: string, limit?: integer = 50) -> {messages, records} [read, global]", "title": "Gather what is known about a person.", "description": "…", "readOnlyGranted": true } ], "hidden": 1 } ``` Each entry's `signature` is one line read from the command's argument schema (`src/loom-command-signature.ts`): - the parameters, required ones first and without `?`, optional ones with `?`, each group in the schema's order; a default follows `=`; a required input the host fills from the call's context (`x-source` in the manifest row) reads as optional, since a call may leave it out; - an enum reads `a|b|c`, a member that is empty or holds whitespace, `|`, a quote, a backslash, or a control character quoted and escaped as JSON; an array reads `T[]`, an object with declared properties `{...}` and one without `object`, a `$ref` the name it ends in, and a position nothing can satisfy (`false`, or an empty `enum`, `anyOf`, or `oneOf`) `never`; `(...)` is a schema that leaves its arguments open; - `-> {a, b}` names the fields the command's answer declares among its `outputs`, and is left out when it declares none; - the bracket holds the command's effect where the host declares one, then its target: the `scope` the host declares for it, as the host writes it (`global` where it declares none). A command whose target is `loom` takes a `loomId`; `run_command` passes the one a call names and infers none from the target. An entry carries `readOnlyGranted: true` where the broker grants read-only execution of the command, the grant `run_read_command` requires. A line is cut to 400 characters, its trailing parameters replaced by `…`. A command named in `detail` also carries its `inputSchema`: the host's schema with its `x-*` extension keywords removed, which every schema the model sees is, or `true`, open, where the host's is not an object or is larger than 16 KiB once those keywords are removed. A listing larger than the model bound keeps its first entries whole and the rest without description; a command named in `detail` is always kept whole. `omitted` counts rows that could not be read or fell past the catalog limit, and `compacted` the entries shown without description. A manifest row is left out, and counted as `hidden`, when its own declarations say an agent may not run it: - `origin: "pattern"` — a piece's verb, which an agent reaches through the piece it holds; - `developer: true`; - `actors` naming people only; - `origins_refused` holding `session`, or `origins_required` not holding it — a broker-stamped run reaches the command layer with origin `session`; - a `grant` — the command runs only over a person's consent grant. The filter is presentation. The broker's scope is what decides; a row the host declares nothing about is shown. An absent or unknown effect grants no read-only authority. The listing's `notice` says to assume such a command may change the person's data. A broker's `readOnlyGranted` stamp is separate from the command's declared effect and from the consent `grant` that hides a row. ## `run_command` Takes one call: ```json { "command": "people-discovery.dossier", "args": { "entity_id": "e-1" }, "loomId": "loom-0123456789abcdef", "expectedVersion": 3 } ``` or a batch of independent calls, each of the same shape: ```json { "calls": [{ "command": "…", "args": {} }, { "command": "…", "args": {} }] } ``` A batch carries one to sixteen calls, and one that names `command` as well as `calls` is refused. Each call's arguments are bounded at 16 KiB of JSON. Before anything is sent, each call is checked against the run's catalog: a command the catalog does not show is refused, so what a run can call is what it can see. The host's command layer checks each call's `args` against the command's schema; the broker refuses a command the host has withdrawn since the catalog was read. A batch's calls run concurrently, at most four in flight to the host at once, and one call's failure does not stop the others. ### A call's result Each call, alone or in a batch, comes back as one of: - `executed`: the command layer answered. `outcome` holds `ok`, `id`, `code`, `mayHaveLanded`, `completed`, and `bodyBytes`, each text cut to an identifier's length and `completed` to its first 32 operation ids, so the summary stays small beside the answer's measured `entry`. A refusal by the broker (`forbidden`) or by the command layer (`refused`) reads as `code: "not_granted"`, with the host's code beside it as `hostCode` and a `hint` telling the model to offer the command to the person in its result rather than retry it. An answer the command layer refused as `bad-args` — the args do not match the command's schema — carries the command's `signature`, the line `list_commands` showed, so the model can correct the call. - `unknown_command`: the catalog shows no command by that name, and nothing was sent. `suggestions` names up to three listed commands nearest the one called — names containing it first, then by edit distance — and `hint` points the model back to `list_commands`. - `failed_to_deliver` with `landed: "no"`: nothing was sent — `not_configured`, `invalid_input` (a malformed call), `batch_too_large` (more than sixteen calls), `cancelled`, or the catalog could not be read (`command_failed`, `malformed_payload`). In a batch, a malformed call, and a call reached after the turn was cancelled, come back as that call's result; the rest stop the whole tool call and come back in place of the batch. - `failed_to_deliver` with `landed: "unknown"`: the answer was lost after the command was sent, and the command may have taken effect. ### A batch's result ```json { "outputId": "…", "status": "batch", "results": [ { "status": "executed", "…": "…" }, { "status": "unknown_command" } ], "truncated": false } ``` `results` holds one result per call, in the calls' order, each in the shape the call alone would have returned, with its own `outputId`, minted in the calls' order. Three things differ from sending the calls one at a time. An executed call's summary is compact: `completed` is replaced by its count, `outcome.completedOmitted`, and `hint` is left out; the `signature` a `bad-args` refusal carries is kept. The summaries are charged against one output bound for the whole batch at their serialized size, in the calls' order, each beside the smallest form of every call after it; one that does not fit drops its `signature`, and then keeps only the outcome's `ok`, `code`, and `bodyBytes`, marked `summaryOmitted: true`, so later calls shrink first. The answers are measured in the calls' order against what the summaries leave: an answer that would have fit alone can be left out of its result, which is then marked truncated, and `truncated` says whether any was. And an answer without a label of its own takes the label of the batch's input as a whole, which covers every call's arguments: for a call whose own arguments carried less, that is the conservative choice. ### Authorization The tool's effect class is `write` for every call, a batch included, even when the host declares the command a read. Policy judges a tool call, so a batch is authorized or refused whole, as one write. Policy treats it as any other write: under `enforce-explicit` and `enforce-strict` a call runs only when the run's task is bound as a `direct-command` prompt slot, so a task that arrived as `context` — a request a pattern submitted, say — cannot invoke this tool. ## `run_read_command` Shares `run_command`'s inputs, one call or a batch, its catalog lookup, argument checks, output, and confidentiality measurement. Its effect class is `read`, so an enforcing context-bound task may invoke it. Before execution it reads the host's catalog afresh and requires, for each call, that the selected row has both `effect: "read"` and the broker's literal `readOnlyGranted: true`. A command the fresh listing does not show returns `unknown_command`; a changed or unknown effect, or an absent read-only grant, returns `not_granted` with `landed: "no"` and a `hint`, without issuing a command. At `enforce-strict`, `list_commands` and `run_read_command` are the narrow host-broker read exception for an explicitly `context`-bound task. Quoted or absent task authority still cannot invoke them, other strict read tools keep their existing gate, and `run_command` remains a write. The policy trace names this admission `cfc_enforce_strict_host_command_read`; invocation still checks the current broker grant rather than treating the policy admission as one. The invocation appends `--read-only`, outside the model's JSON arguments. A host that supports this tool must propagate that demand to its broker and refuse the call unless the command's current effect is `read` and the current job's grant permits read-only execution. This final check covers changes between discovery and execution. The broker supplies the catalog stamp; neither model arguments nor source content may supply it. `run_command` sends no read-only demand. ## The answer is Loom data The command's whole answer is measured the way a [retrieval](LOOM_RETRIEVAL.md) row is, as one row and through the same function: its own `ifc` label where it carries one, else the query's label (`labelForUnlabeledLoomRow`, published as deviation 10 in the [implementation profile](IMPLEMENTATION_PROFILE.md)), measured against the run's observation ceiling. - An admitted answer is shown to the model as the `entry`, its strings bounded, and held as a `document` referent with label source `command` and provenance naming the command, the agent actor, the loom, and the version the outputs report. A structured result that names the handle links a document minted from it. - A withheld answer shows none of its content: the model reads the `outcome` summary and the entry's `status` and `reasonCode`. - An answer that alone passes the output bound is left out and the result is marked truncated. The admitted answer's label is recorded as the run's model-context observation over the output channel; the label itself stays on the artifact. A batch records one observation, the join of its admitted answers' labels.