Venom
RFC-0011venom-spec

Product-local commands in the generated CLI

Field Value
Status Accepted
Accepted 2026-11-07, explicitly approved by the maintainer
Authors commixon (commixon), with Claude
Created 2026-11-07
Linear COM-258; implementation COM-259 (venom-std), adoption COM-260 (venomx)
Supersedes None
Superseded by None

Summary

This RFC lets a product declare local commands: commands that run in-process in the product’s own binary, like the shared serve and mcp bootstraps, instead of being sent to the daemon. The first one is venomx attach. Local commands are declared in the compiled bootstrap extension that COM-254 introduced, so the closed format-1 manifest (RFC-0004) does not change. The generator includes them in CLI parsing, --help, Markdown and man docs, completions and the complete-schema digest. They are never daemon verbs or MCP tools, and an interactive command is left out of the skill. venom-std (COM-259) implements the generator and runtime; venomx (COM-260) moves attach onto them. The RFC also settles the question RFC-0007 section 8 deferred to COM-223: key-bound presentation actions inside a client need no generator extension.

Motivation

RFC-0004 section 7 lets only shared bundles dispatch locally (“product format-1 verbs always dispatch to the daemon”). RFC-0006 and RFC-0007 make venomx attach a presentation client in the same binary that is explicitly not a daemon verb. COM-223 therefore shipped it as an argv check in main.zig, in front of the generated CLI. The result breaks the agent contract’s one-schema rule (RFC-0001): venomx --help, venomx docs and the shell completions do not mention attach, its flags are parsed by hand, and its usage errors do not follow the generated --json and exit-code conventions.

More presentation entries are planned: raw attach (COM-224), venom-desk, and venom’s own TUI. Without a shared mechanism, each would add another hand-written intercept. RFC-0007 section 8 requires any shared generator extension for presentation clients to be specified and reviewed first; the maintainer chose to do that in COM-223’s review.

Waiting on this RFC: COM-259 (venom-std), then COM-260 (venomx).

Design

1. Example

venomx declares attach next to its manifest:

const model = cli.manifest.normalize(@import("verbs.zon"));
const App = cli.WithBootstrap(model, cli.manifest.examples(@import("verbs.zon")), .{
    .local = .{.{
        .name = "attach",
        .summary = "Attach this terminal to a session (its lowest-ID window) or one pane. Give exactly one of session or pane.",
        .interactive = true,
        .params = .{
            .{ .name = "session", .summary = "Session ID", .type = .{ .kind = .string } },
            .{ .name = "pane", .summary = "Pane ID", .type = .{ .kind = .string } },
            .{ .name = "history", .summary = "Scrollback rows to backfill per pane", .type = .{ .kind = .integer, .min = 0 }, .default = "10000" },
        },
        .result = .{ .kind = .object, .fields = .{} },
        .errors = .{
            .{ .code = "invalid_params.selector", .summary = "Neither or both of session and pane were given" },
            .{ .code = "not_found.session", .summary = "No such session" },
            .{ .code = "not_found.pane", .summary = "No such pane" },
            .{ .code = "denied.read", .summary = "The caller cannot read the target" },
        },
    }},
});

It registers the handler at run time:

const status = try App.run(gpa, argv, .{
    .stdout = out, .stderr = err, .environ = environ,
    .bootstrap = cli.Bootstrap.init(&daemon),
    .local = cli.Local.init(&presentation),
});

From a shell:

venomx attach --session s0
venomx attach --pane p3 --history 0
venomx attach --json
{"error":{"code":"invalid_params.selector","message":"give exactly one of --session or --pane","data":{}}}

venomx --help lists attach with the other commands. venomx docs documents it with “MCP tool: none (local command)” and “Interactive: needs a terminal on stdin and stdout”. venomx completions zsh completes attach --session. venomx mcp does not list it, and venomx skill does not mention it.

2. Declaration

The cli.WithBootstrap extension (COM-254) gains an optional member local: a tuple of local command records. A record has the same members as an RFC-0004 section 2 verb record, with these differences:

Member Rule
name Required. An RFC-0004 wire name. It MUST NOT equal a shared verb, a product verb, or another local command, and MUST NOT be a dotted prefix of a remote verb or have one as a prefix (CLI paths would collide). Shared reserved-name rules apply.
summary, params, result, errors, exit, examples As in RFC-0004, validated by the same normalizer
grant Optional; MUST be none if given, and normalizes to none. Metadata never authorizes (RFC-0004 section 2): any daemon request the command makes is authorized by the daemon in the normal way.
streams, item Not allowed. A local command does not emit RFC-0002 stream items.
interactive Optional boolean, default false. true means the command needs a terminal on stdin and stdout (section 4) and is for humans (section 3).

Unknown members MUST fail the build, as in format 1. The manifest file itself is unchanged: local commands exist only in the compiled extension, so format-1 consumers never see a member they don’t know.

3. Generated surfaces

Surface Local command
CLI parsing and --help Yes. Same flag syntax, validation, defaults and global options as remote verbs (RFC-0004 section 4).
Markdown and man docs Yes, sorted with every other verb. “MCP tool: none (local command)” as for serve; interactive commands also get “Interactive: needs a terminal on stdin and stdout”.
Completions (zsh, bash, fish) Yes
Complete-schema digest Yes. The normalized record has grant: "none", interactive, and dispatch: "local". Remote product verbs gain no member, so their digests do not change.
App.schema (daemon dispatch) No. The daemon never binds a handler for it. A request naming it fails like any unknown verb (unsupported, exit 10).
MCP No. tools/list omits it; tools/call rejects it as it rejects serve.
Skill Non-interactive commands appear in a “Local commands” section. Interactive commands MUST be omitted: agents use verbs and events, not presentation clients (RFC-0006 agent-contract impact, RFC-0007 section 9).

The hello.schema digest a daemon advertises is computed from the same complete model, so a CLI/daemon pair built from the same sources still matches.

4. Dispatch and outcomes

After parsing and validating parameters, the runtime calls the registered local handler in-process, with:

  • the command’s wire name and its validated parameter object, with defaults inserted (borrowed for the call);
  • the selected socket options and the environment (the same values remote dispatch would use);
  • stdout, stderr, and the interruption flag.

For an interactive command, the runtime first checks that stdin and stdout are terminals. If not, it fails with unsupported.terminal (exit 10) without calling the handler. A daemon is never started for a local command; the startup hook (COM-254) applies only to its eligible remote verb.

The handler returns one of:

  • result: a value that MUST validate against result. The runtime renders it as for a remote ok: --json prints it, human mode prints it unless it is an empty object.
  • failure: {code, message, data}. The code MUST be declared in errors or be a shared class code (invalid_params, unavailable, cancelled, …). The runtime renders it exactly like a remote error, including --json and the RFC-0002 class exit code.
  • status: an exit code. This is used when the command has already reported to the user itself, for example after leaving a full-screen terminal UI. Codes follow the RFC-0002 class table.

An interactive handler owns the terminal while it runs and MUST restore it before returning, so the runtime’s output lands on the normal screen. Before entering the UI it SHOULD return failures (not found, denied, unavailable) as failure outcomes, so --json callers and scripts get the standard error object.

The build MUST fail if a declared local command has no handler, or if Local is registered without the declarations it serves (mirroring RFC-0004’s missing/extra handler rule). Registering no Local is allowed only for a product that declares no local commands.

5. Presentation actions (RFC-0007 section 8)

Key-bound actions inside a presentation client (scroll, focus, window choice, selection, detach) are not commands of the generated CLI. They have no wire name, no flags and no daemon dispatch, and they never cross a process boundary. Those that change daemon state MUST already be schema verbs sent with the client’s own identity, as RFC-0007 section 8 requires. RFC-0007 also requires the local actions to be typed named actions shared by bindings and in-client command entry; that is product code. No shared generator extension is required for them. A product SHOULD document them in the local command’s help text or its README. Generating them from data is left as an open question.

6. Compatibility

  • Format 1 and its consumers are unchanged; no manifest file changes.
  • Products without local commands keep identical generated output and digests.
  • A product that adds a local command changes its digest. doctor’s schema-skew check (RFC-0004 section 7) reports that until the daemon is restarted from the same build, as for any schema change.
  • Adding a local command is a compatible change. Removing or renaming one, or changing its parameters incompatibly, follows RFC-0004’s breaking-change rules.

Agent-contract impact

  • Verbs: no new daemon verbs. A local command is a CLI command from the same verb model, like serve.
  • --json / exits: local commands use the generated parsing, --json error objects and RFC-0002 exit classes. New shared error unsupported.terminal (exit 10).
  • Events: none.
  • skill: interactive local commands are omitted; non-interactive ones are listed as local. doctor: unchanged (the digest covers local commands).
  • Config: none.
  • Capability grants: local commands declare grant = none and are not authorized by metadata. Every daemon request they make is checked by the daemon with the caller’s resolved identity (RFC-0005).
  • Headless / OSC: non-interactive local commands work headless; interactive ones refuse without a terminal instead of misbehaving. No OSC impact.

Alternatives

  • Add a dispatch member to format-1 verb records. This puts local behaviour into the interchange manifest that other tools read, and needs a format bump or a normative RFC-0004 change. COM-254 already chose compiled extensions for local bootstrap metadata; this RFC follows it.
  • Keep per-product intercepts in main() (COM-223 as shipped). This costs nothing now, but every presentation client would bypass help, docs, completions and the standard error rendering. That violates the one-schema rule.
  • Make attach a daemon verb. RFC-0006 and RFC-0007 rule this out: the stream needs the caller’s terminal, and the daemon must not drive a TUI.
  • List interactive commands in the skill with a “humans only” note. The skill exists to tell agents what to run, and RFC-0007 forbids recommending agent attachment, so leaving them out is the safer rule.
  • Do nothing. attach stays undiscoverable from generated documentation, and COM-224, venom-desk and venom repeat the intercept.

Open questions

All three were resolved by maintainer acceptance on 2026-11-07, as proposed.

  1. Mutually exclusive parameters. Resolved: the handler validates “exactly one of” constraints and declares an invalid_params.* error (section 1). A generic one_of constraint would be a separate RFC-0004 change.
  2. Terminal check error. Resolved: unsupported.terminal (exit 10). COM-260 moves venomx attach from its interim exit 2 to this code.
  3. Documenting presentation actions. Resolved: deferred. Key-bound actions stay in product documentation (section 5) until a second presentation client needs a shared, typed action table.