Skip to content

Extension Commands

Present an extension tool as an operator verb — declaration, nested paths, projected flags, and how exec differs from tool invoke.

For people running agent work7 pages in this section

An extension can present any of its tools as an operator-facing command:

compozy extension exec notes --cmd add --text "ship the beta" --tag release

A command is presentation metadata on a tool, not a second runtime. exec resolves the command descriptor and then performs exactly one POST /api/tools/ext__notes__add/invoke. Tool policy, approvals, risk gates, availability, and trusted_workspace apply unchanged — a command can never reach past a gate the equivalent tool invoke would enforce.

Declare a command

Add a command block to a tool registration. Everything else — the input schema, the handler, the risk class — is the tool declaration you already wrote.

compozysdk.Tool[addInput](
	extension,
	"add",
	compozysdk.ToolOptions{
		Description: "Store one note",
		InputSchema: addInputSchema,
		Command: &compozysdk.ExtensionCommandSpec{
			Verb:    "add",
			Summary: "Store one note",
			Example: `--text "ship the beta" --tag release --tag beta`,
			Flags:   map[string]string{"text": "text", "tag": "tags"},
		},
	},
	handleAdd,
)

TypeScript is the same shape:

extension.tool<AddInput>(
  "add",
  {
    description: "Store one note",
    inputSchema: addInputSchema,
    command: {
      verb: "add",
      summary: "Store one note",
      example: '--text "ship the beta" --tag release --tag beta',
      flags: { text: "text", tag: "tags" },
    },
  },
  handleAdd
);

flags maps a CLI flag name to a top-level field of the tool's input schema. The flag name and the field name do not have to match.

Nested paths and groups

A verb is a path whose segments are joined with /, the same hierarchy idiom the Host API uses for method paths:

Command: &compozysdk.ExtensionCommandSpec{
	Verb:    "list/recent",
	Summary: "List the most recent notes",
	Example: "--limit 3 --format markdown",
	Flags:   map[string]string{"limit": "limit", "format": "format"},
},

Declare the parent so it carries help text:

extension.CommandGroup("list", "Read stored notes")
extension.commandGroup("list", "Read stored notes");

Rules, all enforced at build and at manifest load:

RuleDetail
Maximum depth 2group/leaf. A third segment is rejected.
Groups are never executable--cmd list errors and lists the group's leaves; nothing reaches the tool runtime.
Every leaf is exactly one toolOne handler, one input schema, one flag set, one risk class.
No group-level flagsEach leaf declares its complete flag set, so no invocation inherits state.
Paths are unique per extensionA duplicate path fails the build, naming both tools.
A leaf cannot also be a group prefixlist and list/recent cannot both be leaves.
A declared group needs at least one leafLeafless groups, multi-segment group paths, empty segments, and leading or trailing slashes are rejected.

Groups may also be left implicit: the help tree still groups by prefix, just without a description.

Command paths are decoupled from tool IDs. list/recent backs ext__notes__list_recent, so the presentation tree can be reorganized without breaking the IDs agents call.

Projected flags

The daemon projects each mapped field into a typed flag. The accepted subset, after canonical $ref resolution:

SchemaProjection
string, boolean, integer, numberOne scalar flag.
["string", "null"] and other nullable scalarsThe optional scalar. An explicit null needs --input.
array whose items is one supported scalarA repeatable flag; each occurrence appends.
enum of the projected scalar typeChoices render in help; values are validated by the daemon on invoke.
default, minimum, maximumRender in help. The CLI never injects a default — the tool's own validation applies it.

Everything else is rejected with an error naming the field and pointing at --input: object, nested arrays, tuples and prefixItems, oneOf/anyOf/allOf/not, conditional schemas, multi-type unions beyond nullable, and unresolvable or cyclic $ref.

Conversion is fixed:

  • Booleans are presence-true; write --flag=false to send false explicitly.
  • An absent optional flag omits the field. It is never sent as null.
  • A schema-required field becomes a required flag, checked before the invoke leaves the CLI.
  • A conversion failure names the flag and the field it feeds.

These flag names are reserved for the host and rejected in a flags map: agent, approval-token, cmd, help, input, json, o, output, session, workspace.

Walk through the sample extension

sdk/examples/notes-commands is a complete public-SDK extension with one flat leaf, one declared group, and one nested leaf. Build and link it:

compozy extension dev ./sdk/examples/notes-commands

build generates the command block into the manifest — this is the real generated output:

[[resources.command_groups]]
  path = "list"
  summary = "Read stored notes"

[resources.tools.add.command]
  example = "--text \"ship the beta\" --tag release --tag beta"
  summary = "Store one note"
  verb = "add"
  [resources.tools.add.command.flags]
    tag = "tags"
    text = "text"

[resources.tools.list_recent.command]
  example = "--limit 3 --format markdown"
  summary = "List the most recent notes"
  verb = "list/recent"
  [resources.tools.list_recent.command.flags]
    format = "format"
    limit = "limit"

Discover

compozy extension commands notes

The human tree renders groups above their leaves; -o json and -o jsonl return a flat, path-keyed array with each leaf's projected flags, risk class, and approval_required.

compozy extension commands notes -o json

Run a flat leaf

compozy extension exec notes --cmd add --text "ship the beta" --tag release --tag beta

--tag is repeatable because tags is an array of strings. The two occurrences become {"tags":["release","beta"],"text":"ship the beta"}.

Run a nested leaf

compozy extension exec notes --cmd list/recent --limit 3 --format markdown

Ask for help

compozy extension exec notes --cmd list/recent --help

Help is generated from the descriptor, so it always reflects the installed extension:

Usage: compozy extension exec notes --cmd list/recent [flags]

List the most recent notes

Projected flags:
  --format  string, field=format, choices=text|markdown
  --limit  integer, field=limit, default=5, minimum=1, maximum=50

Refuse a group

compozy extension exec notes --cmd list
error: cli: command path "list" is a group and is not executable; available leaves: list/recent

No invocation reaches the runtime. A misspelled path prints the installed command list plus a did-you-mean suggestion computed from it.

Escape hatch: --input

When a tool takes a nested object, skip projection entirely:

compozy extension exec notes --cmd add --input '{"text":"ship the beta","tags":["release"]}'

--input and projected flags are mutually exclusive; using both is an error rather than a silent merge.

extension exec versus tool invoke

Both execute the same tool through the same daemon route.

compozy extension execcompozy tool invoke
Addressed byCommand path (--cmd list/recent)Tool ID (ext__notes__list_recent)
InputProjected flags or --input--input, --input-file, or stdin
HelpGenerated per command from the descriptorGeneric tool help
Round tripsDescriptor fetch, then invokeInvoke
Intended forOperators typing a verbAgents and scripts holding a tool ID

Policy is identical: an approval-gated command is refused through exec exactly as through tool invoke, with the same deterministic error, and succeeds after compozy tool approve with the same --approval-token. A command backed by an unavailable tool reports that tool's reason code.

Agents do not need exec. They already invoke ext__* tools natively; exec is the human skin over the same runtime.

Host flags

These are consumed by exec itself and never projected: --cmd, --input, -o/--output, --json, --workspace, --session, --agent, --approval-token, --help.

On this page