Venom
RFC-0004venom-spec

Verb schema format (ZON) and CLI/MCP mapping

Field Value
Status Accepted
Authors commixon (commixon)
Created 2026-11-06
Accepted 2026-11-06, approved by the maintainer
Linear COM-203
Supersedes None
Superseded by None

History

  • 2026-11-07 (COM-398; small compatible normative addition, accepted through the COM-398 PR review): §3 adds the optional nullable member to every type descriptor kind, ratifying venom-std COM-390. It is legal in result, item and error data descriptors at any depth, but not under params. §7 says the digest includes it only when true. This is a compatible addition: descriptors without nullable keep their meaning and their digest.

Summary

A tool declares its verbs in a data-only ZON manifest. venom-std validates that manifest and generates the CLI, MCP tool catalog, Markdown/man pages, zsh/bash/fish completions and skill output from one normalized model. Handlers remain compiled Zig functions. This RFC defines schema format 1, its type system and mappings, with a worked venom-echo example.

Motivation

COM-213 (CLI generation) and the P0 venom-echo conformance tool (COM-216) need a shared declaration format. Separately maintained parsers, MCP schemas and documentation drift on defaults, errors and permissions. The ecosystem contract requires one schema and forbids dynamic plugins or embedded scripting. The wire protocol already defines dotted verb names, request envelopes, streams and stable exit codes; this RFC supplies the metadata those transports consume.

Design

1. Worked example: a toy echo tool

Save this complete manifest as verbs.zon:

.{
    .format = 1,
    .tool = "venom-echo",
    .summary = "A toy daemon for protocol conformance",
    .verbs = .{
        .{
            .name = "echo",
            .summary = "Return the supplied text",
            .params = .{
                .{
                    .name = "text",
                    .summary = "Text to return",
                    .type = .{ .kind = .string },
                    .required = true,
                },
                .{
                    .name = "style",
                    .summary = "Output casing",
                    .type = .{ .kind = .string, .choices = .{ "plain", "upper" } },
                    .default = "plain",
                },
            },
            .result = .{
                .kind = .object,
                .fields = .{
                    .{
                        .name = "text",
                        .summary = "Returned text",
                        .type = .{ .kind = .string },
                        .required = true,
                    },
                },
            },
            .errors = .{},
            .grant = .read,
            .streams = false,
            .examples = .{
                .{ .params = .{ .text = "hi" }, .result = .{ .text = "hi" } },
                .{ .params = .{ .text = "hi", .style = "upper" }, .result = .{ .text = "HI" } },
            },
        },
    },
}

This is the tool-specific fragment. The build merges standard verb bundles (section 7); this fragment alone is not a complete agent-contract binary. echo transforms text in memory and needs only read, never write.

$ venom-echo echo --text hi
{"text":"hi"}
$ venom-echo echo --text hi --style upper --json
{"text":"HI"}
$ venom-echo echo --json
{"error":{"code":"invalid_params.missing","message":"missing required param 'text'","data":{"param":"text"}}}
$ echo $?
2

Human rendering defaults to compact JSON for results, one value per line; errors use the message on stderr. A tool MAY supply a compiled human renderer, which MUST NOT affect validation, structured output or exit codes.

After the client’s hello, the second invocation sends and receives:

{"id":2,"verb":"echo","params":{"text":"hi","style":"upper"}}
{"id":2,"ok":{"text":"HI"}}

2. Manifest and normalized model

The manifest MUST be static ZON data, loaded at build time. It MUST NOT contain code, handler names to load dynamically, imports or executable expressions. A compiled Zig declaration MAY construct the same model; it MUST pass the same validator and produce identical generated artifacts. The build registers each handler against its exact wire name and fails for missing or extra handlers (including standard bundles).

Root members are format (integer, exactly 1), tool (RFC-0002 tool name), summary (nonempty string) and verbs (tuple of verb records). Unknown members at every level MUST fail generation. Duplicate names MUST fail. Strings MUST be valid UTF-8; names and summaries MUST NOT contain control characters. Summaries are plain text, escaped for each output format.

Verb member Meaning / default
name, summary Required wire name and nonempty description
params Field records in documentation order; default empty tuple
result Required type descriptor for final ok
errors Tool-specific error records; default empty tuple
grant Required: none, read, write, respond, files, spawn, admin
streams Boolean; default false
item Type descriptor; required exactly when streams = true
exit Default .{ .kind = .standard }; optional result status policy below
examples Tuple of {params, result} records; default empty tuple

grant names are enum literals in ZON. The daemon MUST enforce the grant before invoking a handler, including for MCP and local dispatch. Metadata never authorizes a caller. Unknown callers get at most read-only access; identity, resource scope, issuance and revocation belong to RFC-0005 (COM-204). none is explicit, never inferred from a missing declaration. Protocol feature capabilities from RFC-0002 are separate from grants.

Field records have required name, summary, type, plus optional required (default false) and default (absent by default). required = true and default together are invalid. Absence and explicit null differ: defaults fill only absent fields, then recursive validation runs. Optional fields without defaults remain absent; no implicit null is inserted. Examples MUST validate after default insertion; example results MUST validate against result. This format does not execute examples during generation.

3. Types and errors

Type descriptors are closed records selected by kind:

kind Additional members JSON Schema mapping
string Optional nonempty unique string tuple choices type: string, optional enum
boolean None type: boolean
integer Optional inclusive min, max type: integer, minimum, maximum
number Optional finite inclusive min, max type: number, minimum, maximum
null None type: null
array Required items descriptor type: array, items
object Required fields tuple of field records type: object, properties, required, additionalProperties: false

Every kind also accepts an optional boolean nullable (default false). A nullable descriptor accepts JSON null as well as its kind’s values; non-null values are validated exactly as without nullable. It maps to JSON Schema anyOf: [<kind's schema>, {type: null}], and generated documentation renders it as “ or null”. nullable is independent of field presence (§2): a required nullable field must be present but may be null; an optional one may also be absent. On a null kind it changes nothing.

nullable is legal in result, item and error data descriptors at any depth. It MUST NOT appear anywhere under params, including nested array items and object fields: §4’s CLI spelling cannot tell the string null from JSON null, and absence already expresses “no value” for an input. Generators MUST reject it there.

Only listed members and nullable are legal per kind. Bounds MUST be ordered; integers and integer bounds MUST be within ±9007199254740991 for lossless JSON interchange. Numbers MUST be finite. ZON enum literals are metadata only; wire enum values are strings. There are no unions, references, arbitrary maps or executable validators in format 1. Empty object fields are legal. Every object’s defaults apply recursively when that object is present.

Input validation is identical at CLI, MCP and daemon boundaries; the daemon is authoritative. Unknown fields, missing required fields, wrong types, invalid enum values and out-of-range values produce respectively invalid_params.unknown, .missing, .type, .enum, .range, with data.param a dotted field path (array indexes are decimal components). No string-to-number coercion occurs on JSON inputs. Invalid handler results or stream items produce internal.result, never success with invalid data.

Each errors record has code, summary, and optional data type descriptor (default empty object). Codes MUST start with an RFC-0002 class and have at least one refinement component, e.g. conflict.echo_busy. Components match [a-z][a-z0-9_]*. The generator documents class-derived exits; no per-error numeric override is permitted. Standard protocol, validation, authorization, transport, cancellation and internal errors are implicit; errors lists additional handler failures, not an exhaustive transport list.

exit = .{ .kind = .result, .field = "status" } permits successful CLI exit status passthrough. field MUST name a required top-level integer result field bounded 0–255. The status is used only on success; errors always use RFC-0002 section 7.2. MCP returns that status as ordinary result data. standard accepts no other members; result accepts only field.

4. Names and CLI mapping

Wire names MUST follow RFC-0002’s ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$. Choose dotted noun.action for grouped operations (pane.split, config.get, skill.install), and bare names for standalone operations (echo, events, doctor).

CLI canonical spelling splits dots into space-separated subcommands and replaces underscores within segments with hyphens: pane.set_title becomes pane set-title. The exact dotted wire spelling MUST also be accepted as one command token, for compatibility with RFC-0002 examples. Both routes call the same handler. A verb may be a prefix of another (skill and skill.install); a following non-option token selects a child, otherwise the prefix verb executes. Unknown command tokens are usage errors.

Parameter names match [a-z][a-z0-9_]*. Each top-level parameter becomes --name, underscores replaced with hyphens. All parameters are named: format 1 has no positional parameters, short aliases or implicit stdin.

  • Strings and numbers take a value, as --text hi or --text=hi. Values starting with - MUST use =. Integers use signed decimal digits; numbers use JSON number syntax. Non-finite and out-of-range values fail.
  • Booleans require --enabled=true or --enabled=false; there is no implicit negation alias. Null uses the literal null.
  • Arrays and objects take one JSON-encoded value, shell-quoted as needed. Repeated flags fail rather than silently overwriting or appending.
  • Options can follow any command token and precede or follow other options; required values consume their next token. -- ends option parsing; since there are no positional parameters, further tokens are usage errors.

Generators MUST reject mapped-name collisions, including global flag names json, socket, help, timeout and the reserved bridge verb. --json is a boolean switch on every command, --socket takes a path; --help prints generated help. These are client options, never params. A parameter named timeout is reserved for a future shared deadline policy; format 1 does not invent a deadline flag. Global options may precede the command. Unknown flags and malformed values exit 2; with --json they emit the standard error wrapper on stdout. Successful help with --json returns {"help":"..."} with the generated text as its string value.

Non-streaming --json emits only the ok payload, or {"error":...}. Streaming emits each item as NDJSON, suppresses the final successful ok, and emits an error wrapper if the stream fails (RFC-0002 section 7.3). The final ok is still validated and determines exit status. This means stream summaries are available on the wire but not in CLI NDJSON; generators MUST document this distinction. Interrupts use RFC-0002 cancellation.

5. MCP mapping

<tool> mcp is a generated MCP stdio adapter, with protocol negotiation provided by the MCP implementation. This mapping targets the tools model of MCP revision 2025-06-18 (including structured tool results). The tool catalog is fixed for the adapter’s lifetime; changing schemas requires a restart. Diagnostics go to stderr, never MCP stdout.

MCP tool names encode each _ as __ and each . as _d, leaving other characters unchanged. The reversible mapping avoids collisions between pane.split → pane_dsplit and pane_split → pane__split. Names longer than 128 bytes after encoding MUST fail generation. The tool’s own name is not prefixed: MCP server identity distinguishes binaries.

summary becomes description. inputSchema is the object formed from params; field summaries become property descriptions, defaults become JSON Schema default, and required fields populate required (including an empty array when none). The adapter MUST insert defaults itself: JSON Schema annotations do not perform insertion.

All successful structuredContent values use an object wrapper, so even scalar wire results have an object output schema. Non-streaming output is {"result": <ok>}. Streaming output is {"items": [<item>, ...], "result": <final ok>}. Generate a closed object outputSchema with those required properties and the corresponding type descriptors. The adapter also serializes the wrapper into a text content block. For the echo manifest, the generated catalog entry is:

{
  "name": "echo",
  "description": "Return the supplied text",
  "inputSchema": {
    "type": "object",
    "properties": {
      "text": {"type":"string","description":"Text to return"},
      "style": {"type":"string","enum":["plain","upper"],"description":"Output casing","default":"plain"}
    },
    "required": ["text"],
    "additionalProperties": false
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "result": {
        "type":"object",
        "properties":{"text":{"type":"string","description":"Returned text"}},
        "required":["text"],
        "additionalProperties":false
      }
    },
    "required":["result"],
    "additionalProperties":false
  }
}

A call and successful response (after MCP initialization):

{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"{\"result\":{\"text\":\"hi\"}}"}],"structuredContent":{"result":{"text":"hi"}},"isError":false}}

Invalid MCP envelopes use JSON-RPC errors; an unknown tool is -32602. Once a known tool is selected, validation and execution failures return isError: true with a text block containing the JSON {"error":...} wrapper. Omit structuredContent on failure, since it would violate the success output schema. Preserve the Venom error code and data verbatim.

MCP tool calls do not provide an unbounded item channel. The adapter buffers streams to completion with hard limits of 1024 items and 1 MiB of compact JSON item bytes per call. Crossing either limit cancels upstream, discards the buffer and returns unsupported.stream_limit; it MUST NOT return a truncated success. These limits MUST appear in streaming tool descriptions. Unbounded events consumers SHOULD use events --json; MCP clients can cancel outstanding calls, which maps to upstream cancellation. Adapter connections MUST negotiate cancel; if unavailable, streaming tool calls fail with unsupported.capability before dispatch.

6. Documentation, completions and skills

Generators MUST order verbs lexicographically by wire name and preserve field, enum-choice and example declaration order. Markdown and man pages include every verb’s canonical CLI spelling, wire/MCP name, summary, parameter types/required/default/enum values, result and item shapes, grant, errors with exit codes, exit policy and examples. Man-page command paths use hyphens (venomx-pane-split); any filename collision MUST fail. User-supplied strings MUST be escaped for Markdown, roff and shell contexts.

zsh, bash and fish completions MUST derive the same subcommand tree and flags, including the dotted aliases and global options. They offer enum values and boolean literals, identify value-taking options, and omit already used flags. They MUST NOT contact the daemon, execute handlers, suggest secrets, or invent filesystem completion for plain string fields. Shell-specific registration is generated for the manifest’s tool name.

<tool> skill renders UTF-8 SKILL.md deterministically, with YAML front matter name: <tool> and a safely quoted description from root summary. The body contains invocation conventions, the read-only default, a compact verb/grant table, per-verb parameters/defaults/results/errors, streaming limits, and CLI examples generated from examples. Example params become flags using POSIX shell quoting; examples MUST NOT contain credentials. Neither generation nor display executes example commands. Shared skill.install uses this exact content; target locations and installation policy are owned by RFC-0001 (COM-199). skill --json returns {"content":"<complete SKILL.md text>"}. The shared skill handler’s schema MUST describe that object; human output prints only content.

7. Shared verbs, digest and evolution

The compiler merges standard declarations with product declarations before validation. RFC-0002’s hello, cancel, ping remain implemented by proto; its versioned metadata is included in the model, not redefined by products. The standard agent-contract bundle supplies serve, mcp, skill, skill.install, doctor, and config.get|set|check|reload|schema; the event bundle supplies events. Their detailed behavior belongs to RFC-0001 and RFC-0003. Products MUST NOT override a shared wire name. Local bootstrap handlers (serve, mcp) dispatch in-process and MUST NOT be exposed as remote MCP tools or daemon requests. Shared bundle metadata identifies these locally; product format-1 verbs always dispatch to the daemon. Core hello and cancel are transport operations, not user MCP tools; ping and the other remote shared verbs use the normal mappings.

For RFC-0002 hello.schema, compute sha256: followed by 64 lowercase hex digits over canonical JSON of the complete normalized manifest, including the shared metadata and examples. Normalization expands all defaults above, represents enum literals as strings and tuples as arrays, and sorts verbs by wire name. An absent optional member remains absent except where this RFC defines a default. nullable is the exception: a descriptor includes it only when true, so adding it changed no existing digest. JSON object keys sort by UTF-8 byte order; arrays preserve order; there is no whitespace or trailing newline. Strings escape only quotes, backslashes and controls (controls as lowercase \u00xx). Integer values use decimal with no leading zero, negative zero becomes 0; non-integral numbers use the shortest round-trip decimal representation with lowercase e, no exponent plus sign or leading exponent zeros. This digest identifies the whole generated surface, including documentation; it is not an authorization token or a compatibility version.

A CLI MUST compare an advertised digest with its compiled digest and report a mismatch through doctor; normal calls continue with daemon validation. An absent digest is not itself an error. Documentation-only changes may change the digest. Tool releases MUST treat removed/renamed verbs, narrowed types, new required params, changed defaults, result changes and stronger grants as breaking API changes. Additive optional inputs with unchanged semantics are compatible. format changes only for incompatible manifest syntax/meaning; generators MUST reject unsupported versions rather than partially interpreting them. Shared declarations participate in the same validation and digest rules.

Agent-contract impact

  • Verbs: one declarative model generates every interface; dotted names align with RFC-0002. No new product behavior is introduced by this RFC.
  • --json / exits: preserves RFC-0002’s payloads, streams and exit table; defines validation and optional successful result status passthrough.
  • Events: events declares an item descriptor; its envelope, filters and replay remain RFC-0003’s responsibility. No new event types.
  • skill, doctor, config: shared bundles use the same schema; skill is generated, doctor reports digest mismatch, config’s runtime schema remains separate from this build-time verb manifest.
  • Grants: explicit per-verb requirement, enforced by daemon dispatch; read-only by default for unknown callers. RFC-0005 defines scope/identity.
  • Headless / OSC: generation and dispatch need no TUI. No change to OSC 7501 / OSC 133 ingestion or event conversion.

Alternatives

  • Only comptime Zig: convenient for handlers, but makes extracting the contract depend on compiling arbitrary code. Accept the equivalent model, while standardizing a data-only ZON interchange manifest.
  • JSON or YAML source: workable but duplicates the ecosystem’s ZON tooling; generated JSON Schema remains the MCP-facing representation.
  • Hyphenated wire names: obscures groups; dotted names already appear in RFC-0002. CLI spaces preserve familiar config get usage.
  • MCP progress notifications for each stream item: progress is not a typed durable item channel. Bounded buffering has explicit failure and leaves NDJSON available for long-lived subscriptions.
  • Do nothing / hand-maintain surfaces: violates the one-schema contract and allows completion and permission metadata to diverge from execution.

Open questions

  1. Naming: resolved by maintainer acceptance (2026-11-06): dotted wire names, spaced CLI paths, reversible MCP escaping.
  2. MCP streams: resolved by maintainer acceptance (2026-11-06): bounded buffering for v1; no new streaming transport is introduced.
  3. Shared verb semantics: deferred to existing COM-199 (RFC-0001) and COM-202 (RFC-0003); this RFC defines their integration, not their behavior.
  4. Grant scopes: deferred to existing COM-204 (RFC-0005); the manifest declares the required right, while the runtime checks resource scope.