burgee
Concepts

Agent surfaces

One declaration projected into every form a caller reads — help, the --json envelope, --schema, an MCP server, completions — and a program that stops and says what to run instead of prompting an agent.

What it is

A burgee command is declared once, as data. Every surface a caller reads is projected from that declaration rather than written beside it:

SurfaceForWhat it is
--helpa personthe rendered manifest; --help --json is the same as a document. When every command that runs fits in 8 rows, each is listed by its full path with what it takes (config get <key>, not config). The root help ends with one line for agents naming --schema, --json and --explain
--jsona script, an agentone envelope on stdout: { ok, data, meta } or { ok: false, error }
--schemaan agent, before it callsthe program as data: commands, options, effects, the exit-code table, a JSON Schema per command, and one line naming --explain for where a value came from
--mcpan MCP clientthe same commands as MCP tools over stdio
completion <shell>a shella static script for bash, zsh, fish, pwsh or fig
--explain <option>anyone asking "why this value?"the configuration record for one option, on every command that runs and listed in its help

Your CLI is an agent tool has each surface in full: --schema and its character budget, what becomes an MCP tool, how tool arguments map back to flags, the client configuration, and completions. This page is the model behind them.

Why it exists

An agent driving a CLI through a shell pays in calls, tokens and misreads: it scrapes help text, guesses which output line is the result, and waits on a prompt nobody will answer. When each surface is written by hand beside the command, they drift — the help names a flag the schema does not, the MCP tool takes an argument the command refuses. Projecting all of them from one declaration leaves them nothing to drift from.

The rules

The envelope is one document on stdout. Success is {"ok":true,"data":<what run returned>,"meta":{"provenance":{…}}}, with meta.changed for an idempotent command. Failure is {"ok":false,"error":{"kind","code","message","hint"?,"fix"?,"usage"?}}, also on stdout, and stderr carries only {"error":{"kind","code","message"}}, one line, for a caller that reads stderr alone (Exit codes). --json counts only before a --. It counts wherever it is typed before that, including ahead of the command (demo --json config get user.name). The spellings other CLIs use count too: --format json, --format=json, --output json and --output=json are read as --json, unless the command declares an option of that name or one near it. In that case the option is the program's, and a near one makes the reading a guess, which is refused with a fix. Exit codes and errors has the failure side.

A refusal carries its next step. A failure has a fix to run, or, when there is none, usage: the failing command's usage line and options, or the commands that exist for a word the program does not know, at most 8 rows. An unknown command near a real one gets a fix that is the caller's own line with the word corrected. The agent recovers from the error it already read, not from a --help walk.

effects is required on burgee's own API. defineCommand refuses a runnable command without read_only, idempotent, non_idempotent or withheld. It becomes MCP's readOnlyHint, idempotentHint and destructiveHint, and withheld keeps a command out of tools/list. On burgee/commander and burgee/yargs, a command that declared nothing is listed as effects: 'undeclared' with no hints (maps effects to the hints an agent reads, never defaulting destructive to silence (N6) in mcp.test.ts).

An MCP result is the --json envelope, byte for byte. A tool call runs the command as a --json caller would, so an agent on MCP and one shelling out see one payload (returns the --json envelope as the tool result, byte for byte (N4)).

An agent is detected, not assumed to be a terminal. Five variables name one: AI_AGENT, CLAUDECODE, CURSOR_AGENT, CODEX_THREAD_ID and GEMINI_CLI. The first one set names ctx.agent. ctx.interactive is roundel's interactive(), the family's one rule for whether a person may be asked: a terminal on stdin, no CI, and none of those five, with FORCE_TTY=1 over all three. caique's prompts ask the same function, so a handler and a prompt never disagree about the same shell (one rule for whether a person may be asked (N12) in agent.test.ts). burgee loads roundel/terminal only when it is about to run a handler, so help, --version, --schema and --mcp never load it. Help's colour is an output decision and still reads stdout (the output policy has both).

A variable joins the five only when it names an agent and nothing else. CURSOR_TRACE_ID does not qualify, because Cursor sets it in every integrated terminal and a person typing there would never be asked anything.

Never prompt an agent. burgee draws no prompt itself. A handler that would ask reads ctx.interactive and, when nobody can answer, calls ctx.actionRequired({ reason, message, next }): the run ends with exit 4 and an envelope carrying status: "action_required" and next[] — runnable commands with the program's name in front and the caller's --json carried. A prompt drawn with caique refuses instead of waiting and names the flag to pass (exit 2). Either way the caller gets a next step, not a hang (under --json: status, reason, message, runnable next[] carrying the caller's --json, hint; exit CANCELLED (4) in agent.test.ts).

Run it

tool.mjs
import { defineCommand, defineProgram, run } from 'burgee';

const program = defineProgram({
  name: 'tool',
  commands: [
    defineCommand({
      name: 'deploy',
      description: 'Deploy a build',
      effects: 'non_idempotent',
      examples: [{ command: 'tool deploy --target prod --yes' }],
      output: { cardinality: 'single' },
      options: {
        target: { type: 'string', required: true, description: 'where to deploy' },
        yes: { type: 'boolean', description: 'skip the confirmation' },
      },
      run: (ctx) => {
        if (!ctx.options.yes && !ctx.interactive) {
          ctx.actionRequired({
            reason: 'confirm',
            message: `deploying to ${ctx.options.target} needs confirmation`,
            next: [{ command: `deploy --target ${ctx.options.target} --yes`, when: 'once you have reviewed the plan' }],
          });
        }
        return { target: ctx.options.target, agent: ctx.agent ?? null };
      },
    }),
  ],
});

await run(program);

Nobody to confirm, so the program stops and says what to run:

CLAUDECODE=1 node tool.mjs deploy --target prod --json
{"ok":false,"status":"action_required","reason":"confirm","message":"deploying to prod needs confirmation","next":[{"command":"tool deploy --target prod --yes --json","when":"once you have reviewed the plan"}],"error":{"kind":"cancelled","code":4,"message":"deploying to prod needs confirmation"}}
{"error":{"kind":"cancelled","code":4,"message":"deploying to prod needs confirmation"}}
node tool.mjs deploy --target prod
action required (confirm): deploying to prod needs confirmation
next:
  tool deploy --target prod --yes    once you have reviewed the plan
{"error":{"kind":"cancelled","code":4,"message":"deploying to prod needs confirmation"}}

The next command, run by the agent:

CLAUDECODE=1 node tool.mjs deploy --target prod --yes --json
{"ok":true,"data":{"target":"prod","agent":"claude-code"},"meta":{"provenance":{"target":{"source":"flag","location":"--target"},"yes":{"source":"flag","location":"--yes"}}}}

A run that fails with nothing to fix names what the command takes, in the same envelope:

CLAUDECODE=1 node tool.mjs deploy --json
{"ok":false,"error":{"kind":"usage","code":2,"message":"missing required option --target","hint":"pass --target <value>","usage":{"command":"tool deploy [options]","options":[{"name":"--target <value>","description":"where to deploy (required)"},{"name":"--yes","description":"skip the confirmation"}]}}}
{"error":{"kind":"usage","code":2,"message":"missing required option --target"}}

What an agent reads before it calls, from the same declaration:

node tool.mjs --schema deploy --field inputSchema
{"type":"object","properties":{"target":{"type":"string","flag":"--target","description":"where to deploy"},"yes":{"type":"boolean","flag":"--yes","description":"skip the confirmation"}},"required":["target"],"additionalProperties":false}
node tool.mjs --schema deploy --field effects
"non_idempotent"

The same document is a clispec v0.3 document, so the call to try first and how much comes back are there too (the keys):

node tool.mjs --schema deploy --field example
{"args":["deploy","--target","prod","--yes"]}
node tool.mjs --schema deploy --field cardinality
"single"

Where the rules live

On this page