Command
What a command is made of, how to name it, how its CLI face renders from JSON args, and the rules that keep it safe for an agent to call.
A command is a named operation a pack declares and the unit of Execute. Its CLI face and its JSON face are the same command under one Policy row, one Binding and one Adapter, so the name you give it holds on every surface a host shows. Give a pack 5 to 12 commands and write each CLI form to the agent-safe rules.
Anatomy#
The wire field verb_key and the manifest map verbs both mean command, since
HCP has no separate verb ontology.
| Part | Where | Example |
|---|---|---|
| Stable name | manifest verbs key; wire verb_key | plan, mail.list, setup.apply |
| Description | manifest | "Time-first options between two places." |
| CLI form | manifest cli[] | ["transit", "plan", "{{from}}", "{{to}}"] |
| Argument placeholders | {{name}} in cli[] | map to JSON args |
| Title (optional) | manifest title | "Searched the Inbox for “Laura”" |
| Approval view (optional) | manifest preview | The card a person sees before a gated write |
| Policy row | policy.json | operation, privacy.land, confirm |
| Adapter path | pack kind | spawn the wrap bin, or call in-process |
The twin face#
transit plan "A" "B" --json{ "pack_id": "transit", "verb_key": "plan", "args": { "from": "A", "to": "B" } }Only the Invoker differs, which makes the
twin face the command's identity (L2).
hcpx exec sends the JSON envelope from the command line for runtime operators
and tests, while Skills teach the domain bin. Its k=v pairs after -- become
args, and confirm=true or apply=true satisfies the confirm gate.
hcpx exec transit plan --json -- from=A to=BNaming#
Names are lower-case, dot-separated when a family is useful, and stable.
| Pattern | Use |
|---|---|
find plan show status follow | Public read set |
setup.status setup.apply setup.update | Installer family (HCP/1.1) |
connect | Establish Binding; needs pack.connect |
doctor capabilities validate | Health, discovery, and pre-flight checks; always read |
list get send create | Domain ops on a linked channel |
Dots group names without creating a hierarchy, so mail.list is one command.
A pack that needs more than 12 commands is usually two packs.
Each name is identical in every projection, and lint fails on drift:
| Face | Name |
|---|---|
| CLI | mail list (space grammar from cli[]) |
| JSON | "verb_key": "mail.list" |
| Skill | `mail.list` in the hot-path table |
| Host tool | hcpx exec mail mail.list in a shell, exec mail mail.list through the one hcp tool, or mail__mail_list in an in-process loop (pack id, two underscores, verb_key with dots as underscores) |
The CLI face may carry aliases in other languages, declared as manifest
metadata and never as separate commands, while the JSON verb_key stays
English.
Titles#
A host shows a person each step of a run, and title gives the words: live
while the command runs and done once it ran. The pack declares them because a
command line shows only flags and ids, and model text uses words the pack never
said.
"find": {
"description": "Mail in the Inbox matching a query or a sender.",
"cli": ["mail", "find", "--query", "{{q}}", "--from", "{{from}}"],
"title": {
"live": ["Searching the Inbox for “{{q}}”", "Searching the Inbox"],
"done": ["Searched the Inbox for “{{q}}”", "Searched the Inbox for mail from {{from}}", "Searched the Inbox"]
}
}Each form is one template or a list tried in order. The first template whose
placeholders all have a value wins, so a title never shows an empty slot, and
the last one in each list should have no placeholders, so a title always shows.
{{name}} is a call argument, and {{result.a.b}} is a field of the JSON
result that works only in done, as in "Read {{result.mail.subject}}".
Values are flattened to one line and cut to 60 characters, and a list of
strings is joined with commas.
| Call | live | done |
|---|---|---|
q=Laura | Searching the Inbox for “Laura” | Searched the Inbox for “Laura” |
from=anna@acme.example | Searching the Inbox | Searched the Inbox for mail from anna@acme.example |
| no arguments | Searching the Inbox | Searched the Inbox |
The SDK returns the words: commandTitle(pack, verb_key, args) while the
command runs and commandTitle(pack, verb_key, args, result) once it has a
result, or null when the pack declares no title and the host uses its own.
hcpx check reports a title that names an unknown argument, uses a result in
live, or has no plain fallback (M026).
RFC 0007 proposes titles and the reference SDK implements them. It asks hosts to show a declared title in place of the command line, keep the exact command one action away, and never replace the title with text the model wrote.
CLI projection#
Every command projects to a CLI form an agent can run safely (Projections):
<bin> <space…> [--flags] [--json]mail list --folder=inbox --json
mail send --to user@example.com --subject Hello --confirm --json{ "pack_id": "mail", "verb_key": "mail.list", "args": { "folder": "inbox" } }
{ "pack_id": "mail", "verb_key": "mail.send", "args": { "to": "user@example.com", "subject": "Hello", "confirm": true } }How cli[] renders#
The runtime substitutes placeholders from args, drops empty ones, and appends
the JSON flag.
| Declared | Args | Rendered |
|---|---|---|
["transit", "plan", "{{from}}", "{{to}}"] | { from: "A", to: "B" } | transit plan A B --json |
["mail", "list", "--folder={{folder}}"] | { folder: "inbox" } | mail list --folder=inbox --json |
["ledger", "transfer", "{{amount}}", "--to", "{{payee}}", "--memo", "{{memo}}", "--confirm"] | { amount: "2.80", payee: "demo-transit", confirm: true } | ledger transfer 2.80 --to demo-transit --confirm --json |
["ledger", "setup", "all", "-y"] | {} | ledger setup all -y --json |
The first token is the bin, and the arguments go to spawn as an array with no
shell in between, so a value with spaces stays one argument. A missing optional
value drops its token and the flag right before it, so an empty memo leaves
no dangling --memo. Without cli[] the runtime falls back to
wrap.argv_template, where {{verb}} expands to the command tail (mail.list
becomes list).
The JSON flag#
The runtime appends --json unless the pack or the command selects JSON
another way.
| Declared | Appended |
|---|---|
| Nothing | --json |
wrap.json_flag: ["-o", "json"] | -o json on every command |
A command's own json_flag | That value instead, for that command only; [] appends nothing, for CLIs that print JSON by default |
cli[] already selects JSON (--json, --json=…, --json name,url, or -o, --output, --format, -f followed by json) and the command has no json_flag | Nothing |
The JSON arguments always come last, and hcpx check probes render commands
the same way.
RFC 0004
proposes the JSON flag and the reference runtime implements it.
Exit codes#
| Code | Meaning |
|---|---|
0 | Success |
1 | Failure |
2 | Warning; used by doctor / status for degraded but usable |
127 | Bin not found or not allow-listed (runtime-side) |
A non-zero exit becomes { "ok": false, "error": <stderr or "exit N"> } on the
JSON face.
Agent-safe rules#
| Rule | Why |
|---|---|
--json or JSON-by-default stdout | Agents parse; humans still read |
| Non-zero exit on failure | Orchestrators branch correctly |
| No interactive prompts on the agent path | Agents hang or invent answers |
| Idempotent reads | Retries are safe |
Explicit writes: operation: write and clear names | No surprise side effects |
--confirm / --apply on gated writes, mirroring Policy confirm: true | The gate is visible on both faces |
| Stable space grammar | Skills stay teachable across versions |
doctor / capabilities for discovery | Beats hidden flags |
What is not a command#
A mega-command such as run or exec -- <argv> forwards arbitrary arguments
and behaves as a shell, which Execute does not cover. A PATH wrap over
allowedSubcommands is a bootstrap
to promote to named commands. "New message arrived" is a
Sense trigger such as new_item, while mail.list is a
command, and follow stays a read command however often an agent calls it.
Related#
- Execution: the pipeline a command passes
- Wrap a CLI
- Specification: Execute