CLI
The same contract and managed runtime tools that drive HTTP, MCP and agent
surfaces can also drive a command-line program. createCli composes their
commands with explicitly local binary commands in one router and help tree.
Managed commands — myapp generate "a fox" --wait,
myapp models list --json | jq … — run through the same validation, auth gate
and error model as every other managed surface (HTTP ≡ MCP ≡ agent ≡ CLI,
ADR 0014).
It exists for what the other three surfaces cannot do: a generation kicked off
with Bash(run_in_background) that notifies on exit, a SKILL.md that shells
out in one line, a pipeable terminal command.
Installation and types
Install stitchkit and zod for a CLI. The public stitchkit/cli runtime and
declarations do not require ai, the MCP SDK or Bun globals. Node consumers can
use strict NodeNext resolution with skipLibCheck: false and ordinary Node
types; the package keeps its required dependencies separate from optional SDKs.
CLI runtime tools use the one runtime-tool declaration: identity, input and output
schemas, handler, and explicit CLI exposure. SDK presenters are the adapter’s extension
of that declaration (the fourth type argument of RuntimeToolDefinitionWithOutput, or
defineRuntimeTool in stitchkit/tools), which requires the corresponding peers. A typed
definition returned by defineRuntimeTool or createRuntimeToolFactory can be
passed directly to both CLI and SDK mounts.
Keep transport-specific present on that typed SDK definition, rather than on
an inline object in CliConfig.runtimeTools. CLI never executes those presenters;
its neutral configuration does not validate SDK presentation. Native CLI command
presentation remains on defineCliCommand({ present }) and retains its inferred
output type.
For a CLI-only managed tool, use the neutral generic construction contract to infer the handler from its schemas:
import type { RuntimeToolDefinitionWithOutput } from 'stitchkit/cli'
import { z } from 'zod'
const input = z.object({ text: z.string() })
const output = z.object({ size: z.number() })
const measure = {
name: 'measure', description: 'Measure text',
identity: { serviceName: 'text', action: 'measure', method: 'POST' },
transports: ['CLI'], input, output,
handler: ({ input }) => ({ size: input.text.length }),
} satisfies RuntimeToolDefinitionWithOutput<typeof input, typeof output>
Pass measure to runtimeTools. RuntimeToolDefinition, the heterogeneous
registration type, accepts it unchanged, and types an inline handler written directly
in runtimeTools by the parsed object (use the construction type above to type the
input from the schema). Execute it through createCli or createCliInvoker so
validation remains on the canonical path.
Exposure is opt-in
Unlike MCP and agent — where an endpoint with no expose is a tool by default —
a method becomes a CLI command only when its expose lists 'CLI'. Adding
the CLI never silently turns your existing API tools into shell commands.
{ method: 'POST', path: '/', desc: 'Generate media', tool: { name: 'generate' },
expose: ['CLI', 'MCP', 'AGENT'], input: GenerateInput, output: Generation } // CLI + MCP + agent
{ method: 'GET', path: '/models', desc: 'List models', tool: { name: 'list_models' },
expose: ['CLI'] } // CLI only
{ method: 'GET', path: '/search', desc: 'Search' } // HTTP + MCP + AGENT — NOT CLI
A fresh contract shows zero CLI commands until methods opt in — that is the
design, not a bug. A pathless runtime definition follows the same rule: it must
explicitly include 'CLI' in transports; the undefined default remains
['MCP', 'AGENT']. The command name is the tool name — tool.name if set, else
a verb-aware name from the method + prefix (list → list_widgets, get →
get_widget), not a literal prefix_key.
A minimal CLI
stitchkit ships no binary — you write the executable and point your app’s bin
at it:
#!/usr/bin/env node
// src/cli.ts
import { createCli } from 'stitchkit/cli'
import { catalogService, generateService } from './services'
await createCli({
name: 'myapp',
version: '1.0.0',
services: [catalogService, generateService],
resolveAuth: () => resolveToken(process.env.MYAPP_TOKEN), // lazy, at most once
})
createCli({ signal }) and its wait loop honor an explicit caller
AbortSignal. Stitchkit does not install a process-global SIGINT handler for
ordinary CLI programs: if desired, the application binds SIGINT to an
AbortController and passes its signal. Aborting wait stops polling only; it
does not cancel the underlying job.
// package.json
{ "bin": { "myapp": "./dist/cli.js" } }
stitchkit/cli pulls in neither the MCP SDK nor ai, so a CLI binary needs no
MCP/agent peer dependencies.
Pathless managed commands
Use runtimeTools when an application operation has no HTTP path but must keep
the canonical operation identity, context, lifecycle/RBAC, hooks and
introspection. It can share one definition with MCP and Agent while opting into
CLI explicitly:
import { createCli } from 'stitchkit/cli'
import { defineUploadTool } from 'stitchkit/tools'
import { z } from 'zod'
const uploadInput = defineUploadTool({
name: 'upload_input',
description: 'Upload one local input file',
identity: { serviceName: 'jobs', action: 'uploadInput', scope: 'user' },
output: z.object({ url: z.url() }),
transports: ['MCP', 'AGENT', 'CLI'],
upload: (path, context) => uploadFile(path, context.signal),
})
await createCli({
name: 'myapp',
version: '1.0.0',
runtimeTools: [uploadInput],
})
services and runtimeTools may each be static arrays or factories receiving
the resolved identity. A runtime-only CLI is valid; stitchkit/cli still pulls
in neither MCP nor AI peers. Contract/runtime name collisions and reserved
option fields fail through the same checks before managed dispatch.
Native binary commands
Login, self-update, diagnostics, integration setup and shell completion belong
to the executable, not to HTTP/MCP/Agent. Define them with defineCliCommand:
import { createCli, defineCliCommand } from 'stitchkit/cli'
import { z } from 'zod'
const login = defineCliCommand({
name: 'login',
description: 'Store credentials for later managed commands',
input: z.object({ token: z.string() }),
output: z.object({ configured: z.boolean() }),
handler: async ({ input }) => {
await saveToken(input.token)
return { configured: true }
},
})
await createCli({
name: 'myapp',
version: '1.0.0',
commands: [login],
resolveAuth: loadStoredIdentity,
services: (identity) => createRemoteServices(identity),
})
Native commands receive only typed input, parsed global options and the
configured stdout/stderr writers. They reuse help, argv/stdin parsing,
validation, dry-run, error envelopes and exit mapping, but deliberately have no
fake service/action/scope/method identity, lifecycle or tool hooks and never
appear in MCP/Agent manifests.
A native command with a declared output may also own its final terminal presentation and successful process status. Both callbacks receive the exact Zod output type and run only after output validation; help, dry-run and failed validation never invoke them:
const doctor = defineCliCommand({
name: 'doctor',
description: 'Inspect local health',
input: z.object({}),
output: z.object({ status: z.enum(['ok', 'degraded']) }),
handler: () => ({ status: 'degraded' }),
present: ({ result, options }) =>
options.json ? `${JSON.stringify(result)}\n` : `STATUS ${result.status}\n`,
exitCode: (result) => result.status === 'degraded' ? 1 : 0,
})
present returns the exact stdout bytes, which Stitchkit writes once. Without
it the canonical JSON output is unchanged. exitCode classifies a successfully
validated result and must return an integer in 0..255; failed ToolResult
envelopes and the application-wide exitCodes mapping remain authoritative for
failures. A throwing callback, invalid status or non-string presenter becomes a
normalized INTERNAL_SERVER_ERROR, never partial success output. Void native
commands cannot declare either callback.
--version, a selected native command and its command help run before
resolveAuth, services, context or runtime-tool factories. Top-level help is
also credential-free when managed surfaces are static. A dynamic factory must
resolve identity to discover its command names; its collisions are checked at
that resolution boundary. If eager global collision proof matters, keep the
surface static.
Calling commands
<app> <command> [positional] [--flags]
Arguments are coerced to the contract schema’s types — every argv token is a string, the schema says what it should be:
| Zod field | CLI |
|---|---|
z.string() |
--name "box" or a positional |
z.number() |
--count 3 → 3 |
z.boolean() |
--active (presence) / --active false / --no-active |
z.literal(1) / z.literal([1, 2]) |
--level 1 → 1 |
z.literal(true) |
--enabled / --no-enabled |
z.literal('007') |
--code 007 → "007" |
z.enum([...]) |
--size large |
z.array(z.string()) |
--tag a --tag b → ["a","b"] |
z.object({...}) |
--opts '{"k":"v"}' (JSON) or --opts.k v |
z.record(z.string(), z.unknown()) |
--data '{"code":"007"}' or --data.code=007 → "007" |
.optional() / .default() |
not required |
Use the inline := form to assign a whole JSON value without scalar coercion:
myapp upsert --productId:=null --count:=null
myapp upsert --productId null # the literal string "null"
myapp upsert --productId:='"null"' # the same string, written as JSON
null is accepted only when the unchanged command schema permits it. Omitting an
optional field leaves it absent; it does not imply null. The same syntax works
for application global options and dotted leaves (--settings.note:=null). A
JSON assignment supplies the whole field, so an array uses one assignment such as
--tags:='["a","b"]'; mixing it with repeated element flags or another assignment
is refused. Invalid JSON is a usage error before the handler runs. Framework
options such as --json retain their existing grammar. An exact declared field
name ending in : retains its existing inline spelling.
Primitive literals use the same coercion through optional, nullable and default wrappers and as
array elements. A literal set whose members have different primitive types stays lexical because
argv has no type tag: for z.literal([7, '007']), --value 007 remains the string "007" rather
than being guessed as the number 7. The unchanged Zod schema makes the final admission decision.
Object and record JSON keeps the same behavior when a CLI discovers a tool through MCP. Restored
propertyNames, minProperties and maxProperties constraints still run before dispatch; CLI
coercion does not execute application transforms.
A dotted leaf follows the leaf schema, including through optional/nullable wrappers and restored
MCP object guards. A declared number or boolean is coerced; a string stays byte-for-byte lexical.
For a free z.record(z.string(), z.unknown()), argv provides no JSON type tag, so the dotted
value also stays a string: 007, +007, 1e3, true and false keep those spellings.
Use the whole-object JSON form when a free record value must be a JSON number or boolean. This
avoids silently changing identifiers while keeping z.record(..., z.number()) and
z.record(..., z.boolean()) explicitly typed.
After a bare boolean flag, the next token is its value when it is exactly
true or false — --active false turns it off, as it reads. Any other word
there stays a positional, and -- makes a literal false one too. The same
holds for a short alias, a framework flag such as --json false and an
application option.
Every declared non-array option is single-use, including framework and
application globals. Bare, separate-value, inline, negative and short-alias
forms of a command option count as the same name: --active=false --active,
--active false --no-active and -f --active are argument errors even when
the values agree. Duplicates are refused before command execution. Array
options accumulate across long and short forms; unknown non-dotted passthrough
options retain their raw-list grammar. Tokens after -- are literal data.
Command --help and -h keep their diagnostic precedence over malformed
operation arguments; application globals are validated before routing. Help is a
request, not a setting, so repeating it in any spelling (-h -h, -h --help) asks once
and never errors.
Without presentation configuration, positional arguments fill non-boolean
fields in declaration order, so myapp generate "a fox" is
--prompt "a fox". A piped value fills the first required unset field:
echo "a fox" | myapp generate.
Automatic stdin routing skips TTYs and waits at most 250 ms for the first byte. An empty open pipe or EOF without data leaves the field unset, so normal schema validation reports the missing field instead of waiting indefinitely. Once any byte arrives, the reader collects the entire stream until EOF, including chunks separated by long pauses; it decodes UTF-8 after collecting all bytes and trims surrounding whitespace. Read errors or a close before EOF fail the invocation. Explicit arguments and commands without required unset fields never read stdin.
A producer that takes longer than 250 ms to start must supply its value as an
argument (for example, myapp generate --prompt "$(slow-producer)") or the
application must provide an explicit CliConfig.stdin reader that waits for it.
That hook is not wrapped in the availability timeout. - remains literal data,
not a special stdin marker. JSONL stream/batch commands own their input and wait
for lines as usual; MCP/serve and long-running handlers have no new deadline.
A token is an option only when it is a long form (--name, --name=value,
--) or - followed by a letter. Anything else that starts with - is data:
myapp note a "- item" fills a positional, and after an option that expects a
value the next token is that value — --grep -foo, --count -5. The next
token is refused as a value only when it is itself an option, so --grep --json
still reports that --grep requires a value. A value that cannot be read as
its field’s type is refused naming the flag and what was typed:
--tail expects a whole number, got "abc".
For a stable shell grammar, declare the default command, short aliases and the
exact positional fields on createCli:
await createCli({
name: 'myapp',
version: '1.0.0',
services,
commands: [doctor],
defaultCommand: 'logs',
optionAliases: {
logs: { f: 'follow', n: 'lines' },
},
positionals: {
logs: ['target'], // `lines` is option-only
doctor: [], // no argv positionals
},
})
Now myapp, myapp --json and myapp logs --json select logs;
myapp -f -n 100 --target api maps to
logs --follow --lines 100 --target api. A leading non-option token remains an
explicit command so typos stay loud rather than becoming ambiguous default
positionals; use an explicit myapp logs api when passing positionals. Leading
framework globals may precede that explicit command. Top-level --help, -h
and --version never execute the default, and top-level help marks it.
Aliases are command-local, one ASCII letter and validated against the resolved
command schema. -f / -f=false / -f false are boolean forms; values accept -n 100 and
-n=100. Arrays accumulate across short and long forms. -h is reserved,
bundles such as -fn, attached values such as -n100, --no-f and unknown
short flags are rejected. Canonical --no-follow remains available.
A trailing list
When the LAST declared positional is an array field, it takes every remaining token, each coerced by the array’s element type:
positionals: { handoff: ['to', 'files'] } // files: z.array(z.string())
myapp handoff proj a.md b.md → { to: 'proj', files: ['a.md', 'b.md'] }
myapp handoff proj a.md → { to: 'proj', files: ['a.md'] }
One token is a one-element list, not a scalar, so the parsed shape never depends
on how many a caller happened to pass. Command help marks it:
Usage: myapp handoff <to> <files...>. The flag form still works
(--files '["a.md","b.md"]', or a repeated --files) — but passing both forms
in one call is an argument error rather than a silent merge.
Only the trailing position is variadic. An array declared anywhere else in the
list keeps taking exactly one token (a JSON array), and its help stays <tags>,
so the usage line always says which field is the list.
positionals replaces automatic schema-order selection only for the named
command. An empty array disables argv positionals. Fields remain available as
long/short options and stdin still fills the first required unset field with the
same schema-aware coercion. Unknown, duplicate or boolean targets and a required
positional after an optional/default positional fail when that command surface
resolves. Native dispatch retains its lazy credential-free boundary; dynamic
managed policies validate when their identity-dependent surface resolves.
The advertised schema is never mutated — a CLI call validates against the exact same Zod schema an HTTP or MCP call does.
Global flags
| Flag | Effect |
|---|---|
--json |
Compact success/error JSON records for scripts |
--wait |
Block-poll an async result to a terminal state |
--wait-timeout <s> |
Override the --wait timeout |
--output-dir <dir> |
Download result media into a directory |
--quiet |
Suppress non-essential stderr output |
--dry-run |
Print the resolved call without executing |
--help, -h |
Usage — top-level or per-command flag table |
--help <text> |
List only the commands matching a substring |
--count-by <field> |
Count records per distinct value — see Aggregate views |
--sum <f> [--by <g>] |
Total a numeric field, optionally grouped |
--sort <field> |
Order records by a field, largest first |
--ascending |
Flip --sort to smallest first |
--top <n> |
Keep the n leading entries of the view asked for |
--table <a,b> |
Render named fields as an aligned table |
stdout carries the result; structured errors and progress go to stderr. With
--json, a success or structured failure is exactly one compact,
newline-terminated JSON record on its respective stream; progress and CLI usage
diagnostics remain ordinary stderr text. This keeps stdout pipeable and
2>/dev/null clean. The process exit code carries the error class (0 ok,
VALIDATION_ERROR → 1, UNAUTHORIZED → 2, FORBIDDEN → 3, NOT_FOUND → 4,
…) — override per app with exitCodes.
A narrower question than “all of them”
On a discovered surface --help is the only way to learn what exists, and that
can be two hundred commands. At that size the list stops being an answer: it
scrolls past a person and costs an agent the same context an unfiltered result
would. So there is a question between “one command” and “all of them”:
myapp --help broadcast # also: -h broadcast · help broadcast · --help=broadcast
Commands matching "broadcast" (3 of 205):
broadcast_send Send a broadcast to every subscriber
broadcast_cancel Stop a running broadcast
announce_publish Publish an announcement as a broadcast
The description is searched as well as the name, because the word someone knows
is often in the sentence rather than the name — announce_publish above is
matched that way. The count says what was left out.
No match is an exit code, not an empty success: 0 over an empty list reads
as “there are none”, which is a different statement from “none of these”. It
exits with whatever NOT_FOUND maps to (4 by default). Bare --help is
unchanged, and --help=false still means what it always did — the reserved
boolean’s negation — so one value never carries two meanings.
Application global options
--json and friends above are the framework’s. An application usually has
globals of its own — which identity key to use, which checkout a call speaks
for, which profile — and they belong to no single operation:
await createCli({
name: 'myapp',
version: '1.0.0',
globalOptions: z.object({
caller: z.string().optional().describe('Identity key file'),
root: z.string().optional().describe('Checkout the call speaks for'),
}),
resolveAuth: (globals) => loadIdentity(globals.caller),
context: (auth, globals) => ({ auth, root: globals.root }),
runtimeTools: (auth) => catalogFor(auth),
})
myapp --root /srv/app handoff_read --handoffId u
myapp handoff_read --root /srv/app --handoffId u # the same call
These flags are lifted out of argv wherever they stand — before or after the
command name — validated against the declared schema, and kept out of every
operation’s arguments: handoff_read above receives { handoffId: 'u' } and
nothing else. The values reach resolveAuth(globals), context(auth, globals)
and a native command’s globals. An invalid value is an argument error naming
the flag; a name that collides with a framework option, or with a field of any
command, is refused at startup rather than shadowing it silently. Past a bare
-- every token is a literal, so a positional value that reads like a global
survives intact.
Both help levels list them under Application options:.
When the managed surface cannot resolve
A CLI whose commands come from a running server declares them with a factory,
and that factory needs an identity. When resolveAuth fails — the server is
down, the key file is missing — the commands it would have named are unknown to
the CLI, but the native ones are not:
$ myapp --help
myapp 1.0.0
...
Commands:
serve Run the server
Managed commands are unavailable: UNREACHABLE: socket closed
Help still lists what does not depend on identity and says, in one line, why the
rest is missing. Calling a name the CLI cannot resolve answers with that same
refusal and the exit code its error class declares through exitCodes — never
Unknown command, which would claim the name does not exist when the truth is
that it could not be looked up. A native command and its help still run: they
never needed an identity. Identity is still resolved at most once per
invocation, failure included.
Per-command help derives the positional form from the same resolved policy as
the argv parser. For example, a required action and optional profile render as
Usage: myapp skill <action> [profile] [--flags]; the argument table also shows
<action> | --action and [profile] | --profile. Boolean fields remain flags.
Declared aliases render beside their canonical options, for example
-n, --lines.
The argument table and Application options: show bounds from their JSON Schema,
for example <integer> [>=1, <=50], <string> [length >=1, length <=12], and
<value…> [items >=2, items <=4]. Numeric > and < are exclusive, while >=
and <= include the endpoint; a zero limit is displayed too. Only limits the
schema declares are shown: the safe-integer range every z.int() carries is the
type, not a bound, so a bare integer prints <integer>, .min(1) prints >=1 and
.positive() prints >0. Unbounded fields keep just their type and description. Bounds on an array’s items are not bounds
on the array itself.
Composed schemas retain their meaning: any of: and one of: separate
alternative branches with |, while all of: joins constraints with &.
For example, a nullable bounded string renders
<value> [any of: string [length >=1] | null]. These labels describe the input;
the same contract schema still validates the call.
--wait — background-friendly generation
--wait polls an async result until it is done. It is generic — the core knows
nothing about “generations”: you say how to read the poll target, which command
to re-call and when it is done.
await createCli({
name: 'myapp',
version: '1.0.0',
services,
wait: {
generate: {
tool: 'get_generation',
poll: (r) => (isRecord(r) && typeof r.id === 'string' ? { id: r.id } : null),
done: (r) => isRecord(r) && ['COMPLETED', 'FAILED'].includes(String(r.status)),
failed: (r) => isRecord(r) && r.status === 'FAILED',
},
},
})
failed is optional. When it matches either the initial result or a later poll,
polling stops and the CLI emits WAIT_FAILED on stderr with a non-zero exit;
the terminal payload is retained under details.result. failed is checked
before done, so overlapping predicates fail closed. A failed poll tool call
keeps its own error code, and an elapsed deadline remains TIMEOUT.
# foreground
myapp generate "a fox" --wait --output-dir ./out
# background — frees the agent; a notification fires on exit
myapp generate "a fox" --wait --json > result.json &
Aggregate views
The CLI’s audience is agents, scripts and jq, so output is JSON. That settles
the encoding; it does not settle whether the answer to “how many items per
status” should be every item. Measured on a live server, one ordinary question:
| call | characters returned |
|---|---|
| the listing (98 records) | 34 750 |
--count-by status |
~90 |
An agent pays for every one of those characters in its context window, and | jq does not help: the bytes have been read into the conversation by the time
jq sees them. So the aggregate is computed on the result, before anything is
written.
myapp item_list --count-by status # { "active": 33, "idle": 33, "stopped": 32 }
myapp item_list --count-by status --top 2 # the two largest groups
myapp item_list --sum messages # 4753
myapp item_list --sum messages --by status # one total per status
myapp item_list --top 5 --by status # same view, written the other way round
myapp item_list --table id,status # the one human-facing shape
Two words, one each: --by groups, --sort orders. That leaves --top a
single meaning everywhere — the n leading entries of the view you asked for —
so the question a CLI actually gets asked composes out of the parts:
myapp item_list --top 5 --sort messages --table id,messages # the five biggest, as a table
myapp item_list --sort messages --top 5 --json # the same five, as records
myapp item_list --sort name --ascending # ordered the other way
Groups come back largest first for the same reason. A record that carries no value for the sort field sorts last in both directions: it is not the smallest, it is not on the scale at all, and letting it lead an ascending list would answer a question nobody asked.
Three rules worth knowing before you rely on them:
- A field the result does not carry is an argument error. A group of zero over a misspelled field is indistinguishable from a true empty answer, and the caller reads it as data. The message names the fields that are there.
- An aggregate needs a collection — the result itself when it is an array, or the single array field of a result object. An aggregate over a scalar, or over an object with two array fields, is refused rather than guessed.
- Without a view flag the output is byte-for-byte what it was. The flags are
reserved CLI behaviour like
--json; they never reach a tool argument. - Ordering and grouping do not mix.
--sortwith--by,--count-byor--sumis refused rather than given a second meaning.
A failed call still reports its own error and exit code. An aggregate over an error is not an answer to the question that was asked.
Named profiles
A CLI that talks to a deployed server needs an address and a credential per
environment, and the way a person picks one is a name: --profile prod.
globalOptions gives the flag a home and resolveAuth(globals) gives it a
resolution point. The rule that makes the mechanism safe is easy to write the
wrong way round, because the unsafe version reads as kindness:
the named profile does not exist, but exactly one profile is configured — use it.
That is correct exactly while a single profile exists. The day a second appears it is a command run against the wrong deployment, with nothing in the output to say so. A profile named explicitly and not found is a refusal, never a substitution. Substitution survives only where it cannot be wrong: no name was given at all and exactly one profile exists — and even then it is announced on stderr. The distinction has to be drawn at resolution; one step later, “prod” and “prod by default” are the same string.
createCliProfileStore is that rule, plus the twenty lines every consumer of
this shape writes:
import { createCliProfileStore } from 'stitchkit/cli'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
const profiles = createCliProfileStore({
directory: join(homedir(), '.config/myapp/profiles'),
schema: z.object({ url: z.url(), token: z.string().min(1) }),
createHint: (name, path) => `write ${path} with {"url","token"} for "${name}"`,
})
await createCli({
name: 'myapp',
version,
globalOptions: z.object({ profile: z.string().optional() }),
resolveAuth: (globals) => profiles.resolve(globals.profile).value,
services,
})
Files are written 0600 in a 0700 directory — and a profile file other users
can read is refused with the chmod that fixes it, because it holds a
credential.
Distribution and self-update
createCli ships no executable, and that is right — but the step after the
executable is not application logic either. It is the same problem for every
consumer, with the same three traps:
- The installer cannot parse the manifest. A
curl … | shruns on a machine where nothing is installed yet, includingjq. So the installer is generated from the manifest, server-side, with the URL and digest already substituted — it parses no JSON at all. - Replacing a running binary is a rename, not a write. Anything else can leave a half-written executable on someone’s PATH when the connection drops.
- The digest covers the decompressed bytes — the file that will actually be executed, not the archive that was transferred.
The framework owns the manifest shape, the installer generation and the update primitive. The application owns where the assets live, which platforms it publishes and who may download them.
import {
CliBuildManifestSchema, assertCliPublishable, renderCliInstaller,
selectCliBuildAsset, checkCliUpdate, applyCliUpdate,
} from 'stitchkit/cli'
// Publishing: refuse to republish one version from a different commit —
// otherwise everyone who already installed it never receives the fix.
assertCliPublishable(previous, next)
// Serving: omit `asset` and one script covers every published target, selecting
// by uname at run time — otherwise that dispatch is the last hand-written piece
// of the install path, and every publisher writes the same x86_64 → x64 table.
renderCliInstaller({ manifest, binaryName: 'myapp' })
// Or pin one target explicitly:
renderCliInstaller({ manifest, asset: selectCliBuildAsset(manifest, target), binaryName: 'myapp' })
// Checking: bounded, at most once per interval, silent on any failure.
const check = await checkCliUpdate({ manifestUrl, currentVersion, lastCheckedAt })
if (check.status === 'outdated' && check.asset) {
// Replacing is always an explicit command, never a side effect of a check.
await applyCliUpdate({ asset: check.asset })
}
checkCliUpdate has four answers, not three: skipped, current,
outdated and unknown. “Could not ask” is not “up to date” — collapsing them
is how a tool goes quiet about its own staleness for months. It never throws,
and a command still exits with the code it earned.
Carry the build stamp inside the binary (CliBuildStampSchema,
formatCliBuildStamp) so the tool can say what it is rather than leaving the
reader to infer it from behaviour.
A manifest inside your own network
The check goes through the same SSRF boundary every outbound fetch does, so a
manifest served from 10.x, localhost or a .internal name is refused and
the answer is unknown. For a self-hosted deployment that is a wrong answer
dressed as a careful one, and the refusal says so — it names the field that
lifts it rather than reading like a timeout:
refusing to fetch an internal host — the manifest endpoint is not public.
Set `allowPrivateHosts` if it is your own deployment; the download keeps its
own setting, because an asset URL comes from the document.
const check = await checkCliUpdate({ manifestUrl, currentVersion, allowPrivateHosts: true })
Set it from whether your endpoint is private, not as a blanket switch, and
leave applyCliUpdate at its default. The two are not the same decision: the
manifest URL is configuration you wrote, while an asset URL arrives inside a
document the endpoint served, and a document is exactly what an SSRF boundary
exists to distrust.
Publishing a complete version
publishCli is an opt-in publisher for a local application-owned distribution
directory. It commits every target and its version manifest before atomically
replacing the public manifest.json. It ships from its own evolving entrypoint,
stitchkit/cli/publish: release infrastructure follows how distributions are
published, so it is not held to the stable promise of stitchkit/cli
(ADR 0245). The
manifest, signatures, installer and updater it writes for stay in
stitchkit/cli. Your application selects the CLI source identity and admits
that snapshot at each phase:
import { publishCli } from 'stitchkit/cli/publish'
const result = await publishCli({
storageRoot: '/srv/myapp/cli',
name: 'myapp', version: cliVersion, commit: selectedCliCommit,
baseUrl: 'https://downloads.example.com/cli/',
targets: [{ platform: 'linux', arch: 'x64' }],
signal,
admit: ({ phase, identity, signal }) => assertSelectedSource({ phase, identity, signal }),
build: ({ target, stamp, signal }) => compileSelectedSource({ target, stamp, signal }),
})
// result.outcome: 'published' | 'existing'; result.manifest is ready to serve.
The builder returns Uint8Array or ReadableStream<Uint8Array> containing the
executable. Every target receives the same immutable { version, commit, builtAt } stamp; carry it inside the binary. The publisher owns staging,
gzip assets, paths and manifests. Callbacks receive the cancellation signal and
run under the publication lock. Admission phases are prepare, each target’s
build, commit, and promote; validate the selected source through commit
and promotion. A backend release can explicitly select an unchanged older CLI
identity. The helper never substitutes a different commit or rebuilds an
existing version under a new identity.
Assets live at <version>/<name>-<platform>-<arch>.gz, with an immutable
<version>/manifest.json and a public manifest.json. A verified repeat keeps
the exact bytes and builtAt, requires the same target set and base URL, and
returns existing without invoking the builder. A version committed before a
failed promotion is recovered and promoted without rebuilding. Conflicting
same-version commits, downgrades, unsafe components, corrupt manifests and
assets are refused. Version/name/target components are validated before any
build; storageRoot is absolute and its existing parent must be trusted.
All limits are finite positive safe integers; limits overrides these defaults:
| Field | Default | Enforced boundary |
|---|---|---|
maxTargets |
16 | Requested and verified target count |
maxManifestBytes |
256 KiB | Read and written manifest bytes |
maxAssetBytes |
256 MiB | Builder and decompressed executable bytes |
maxCompressedBytes |
256 MiB | Archived asset bytes |
maxDirectoryEntries |
128 | Version layout, root scan and commit space |
maxStoredVersions |
16 | Verified publisher-owned history |
lockTimeoutMs |
10,000 | Wait to acquire the lock |
timeoutMs |
600,000 | Whole publication deadline |
retention defaults to 2 and keeps the current and previous published versions.
It must be at least 2 and less than maxStoredVersions (at least 3), leaving a
recovery slot. Older versions are pruned only after their own identity, exact
layout, raw size and digest are verified. Runtime folders and foreign semver
directories remain untouched. A full or unverifiable store refuses further
publication; repeated completed candidates stay inside the owned history cap.
Cancelled or failed incomplete staging is removed only while its root and
directory generations still match. The shared asynchronous gzip decoder also
bounds updater expansion by the declared raw size and configured byte cap.
The publisher rejects symlink roots/versions/assets, non-regular files and multiply linked files, and checks held-lock, root and prior-pointer generations before commit and promotion. This is cooperative publication under a trusted parent directory. Portable path checks do not provide an atomic comparison with a hostile process replacing ancestors between syscalls. Durability uses file and directory sync on POSIX; Windows publication is refused. A deadline stops callback/stream waits and blocks late promotion; trusted callbacks remain responsible for cancelling their own subprocesses and external effects.
A failure after an immutable commit reports that the publication outcome must
be checked. A failure after public-pointer rename may already have published;
AtomicFilePublicationError.published remains available when the atomic writer
reports that boundary. Read the durable manifest and retry the admitted same
identity to recover; an error does not promise rollback. Optional signing and
trust reuse the Ed25519 contract below. The generated shell installer checks
raw digest and size; the updater checks manifest authorship when given a trust
root.
Proving who built it — signing the manifest
The asset digest proves the bytes that arrived are the bytes the manifest named. It proves nothing about who named them: the manifest and the assets come from one origin, so whoever can replace one can replace the other. Authorship needs a key the build carries and the server never holds.
// Publishing — the only side that holds a private key.
const signature = signCliManifest(manifest, { keyId: 'release-2026', privateKey })
publish({ ...manifest, signature })
// Installing — the trust root is compiled into the binary. One fetched at run
// time from the same origin as the manifest would prove nothing.
const trust = { keys: { 'release-2026': PUBLISHED_KEY } }
const check = await checkCliUpdate({ manifestUrl, currentVersion, trust })
if (check.status === 'outdated' && check.asset) {
await applyCliUpdate({ asset: check.asset, manifest: check.manifest, trust, backupPath })
}
The signature covers {name, version, commit, builtAt, assets[]} with every
asset’s digest, so the chain closes on the file that will execute rather than
on the document describing it; moving an asset to a new URL does not invalidate
it, changing what an asset contains does.
Five verdicts, and unenforced is the one that earns its keep: a build with no
pinned key keeps updating, and the fact that nothing was checked is visible
rather than assumed. missing, unknown-key and invalid are distinguished
because they need different answers — a key you forgot to pin is not a forged
signature.
A bad verdict does not produce a fifth status. outdated is an instruction to
install, and a manifest that failed its signature has not established that there
is a newer build worth installing — only that a document claims one. The check
answers unknown with the verdict in its reason, so it is never mistaken for a
network failure. applyCliUpdate checks again and refuses before the
download: a signature verified after the bytes are on the machine produces a
cleanup problem rather than a refusal.
A way back — backupPath and rollbackCliUpdate
Rolling back is a property of updating, not of the application:
const applied = await applyCliUpdate({ asset, targetPath, backupPath })
// …the new build turns out to be wrong. `backupSha256` is absent when there was
// nothing to keep — a first install — and that is the case with nothing to roll
// back to:
if (applied.backupSha256) {
rollbackCliUpdate({ targetPath, backupPath, expectedSha256: applied.backupSha256 })
}
The copy is taken between “the new bytes verified” and the replacement — earlier would preserve a binary about to be replaced by a download that then failed its digest, later has nothing left to copy — and it carries the target’s file mode, because a backup that cannot be executed is not a way back. A first install has nothing to keep, which is not a failure.
expectedSha256 is required, not optional. A rollback that installs whatever
happens to be at the backup path is a second install of an unverified binary,
and the day it is used is the day nobody is in a position to check.
Run it before it replaces anything — verify
A digest proves the bytes are the ones published, not that they load. A tool
that updates itself unattended — a daemon — and installs a build that does not
start takes its own updater down with it, and every machine is then repaired by
hand. verify is the step between “digest matched” and “target replaced”:
await applyCliUpdate({
asset, targetPath, backupPath,
verify: async (candidate, signal) => {
const child = Bun.spawn(['bun', candidate, 'version'], { signal, stdout: 'pipe' })
const printed = (await new Response(child.stdout).text()).trim()
if ((await child.exited) !== 0 || printed !== check.version) {
throw new Error(`printed ${printed}, expected ${check.version}`)
}
},
verifyTimeoutMs: 15_000, // default 30 000
})
The verified bytes are written beside the target as an executable candidate
with the target’s extension, and verify gets its path — a path rather than a
command, because a JavaScript bundle is started through its runtime. It runs
before the backup and the replacement. If it throws or outlives
verifyTimeoutMs (its signal aborts at the deadline), the candidate is
removed, the target and any earlier backup are untouched, and the update is
refused with the reason as its cause. The target is written from the checked
bytes in memory, never from the candidate file, so nothing the candidate did
while it ran can change what is installed.
Two release tracks
assertCliPublishable takes one manifest or every manifest already
published:
assertCliPublishable([stableManifest, betaManifest], next)
With a single argument and two tracks the check is silently useless: 1.2.3
goes to beta from one commit and to stable from another, the function is called
with the track being published and sees no conflict, and the person who
installed the beta is told they are current forever. Nothing on their machine
looks wrong.
The channel itself is not an argument of the framework, and will not become
one. The framework has no model of how your URLs are built and should not: an
asset’s address comes from the manifest, and the manifest’s address belongs to
your server. A channel is two documents at two addresses — which of them a build
consults is your decision, and assertCliPublishable is told about all of them
rather than taught the shape of any.
One operation per line — createCliInvoker
The most common way an agent drives a CLI is not one command: it is a stream — a JSON line per operation, an answer per line — and a resumable batch of the same. The mechanics contain nothing product-specific, and until now the framework left the one piece that cannot be written outside it: running an already-parsed call.
import { createCliInvoker, defineCliStreamCommand, defineCliBatchCommand } from 'stitchkit/cli'
const invoker = await createCliInvoker({ name: 'myapp', services })
const outcome = await invoker.invoke('create_item', { title: 'hello', tags: ['a'] })
// { ok: true, exitCode: 0, data: { … } } — nothing written, nothing exited
invoke runs the same pipeline a typed command runs — the same validation, the
same lifecycle gate, the same hooks — and returns the handler’s validated
output with the exit code the printed path would give, from the same table.
data is typed unknown: the surface is resolved at run time from services and
runtime tools, so there is no compile-time name to key a result type on. Narrow
it with the contract’s own output schema. That table used to
live inside the function that prints, so the only way to learn a code was to
print it.
Without this, a stream loop had to re-spawn the binary per line: arguments
serialised back into --flag value strings, nested objects pushed through
JSON.stringify into one argv slot and parsed again on the other side, the
result read back out of stdout text. Three conversions of data the framework was
already holding, plus a process start measured at 0.15 s — thirty seconds for a
two-hundred-line manifest before any work begins. The ban on nesting one stream
inside another was a consequence of that child process, not a rule anyone wanted.
Native commands in the stream
A native command is in the invoker’s surface when — and only when — it declares
output:
const describe = defineCliCommand({
name: 'describe', description: 'Describe the catalogue',
input: z.object({ model: z.string() }),
output: z.object({ model: z.string(), ratios: z.array(z.string()) }),
handler: ({ input }) => ({ model: input.model, ratios: ['1:1', '16:9'] }),
})
await createCliInvoker({ name: 'myapp', services, commands: [describe] })
// invoke('describe', { model: 'flux' }) → { ok: true, exitCode: 0, data: { … } }
A command that declares output?: never prints itself and returns nothing, so
there is no result to hand back; it answers NOT_FOUND and stays on the command
line, where stdout exists. Your stream command is one of these, which is why a
stream still cannot invoke itself.
On this path present is not called — it is stdout formatting, and there is no
stdout — while exitCode is applied through the same function the command line
uses, so a script branching on the code sees one answer from both. Anything the
handler writes comes back as written rather than interleaving with your own
output:
const outcome = await invoker.invoke('describe', { model: 'flux' })
outcome.written // { stderr: 'warming the cache\n' } — absent when nothing was written
Mounting the loop
await createCli({
name: 'myapp', version, services,
commands: [
defineCliStreamCommand({ name: 'jsonl', invoker }),
defineCliBatchCommand({ name: 'batch', invoker, checkpointPath: '.myapp-batch.json' }),
],
})
They are factories, not framework-owned names. Reserving jsonl and batch
would take two names out of a namespace that belongs to the application — one
that already has a batch command would either fail at startup or find its own
command silently shadowed — so you mount them under whatever you call them, like
any other command of yours.
Each line is { id, command, args }; each answer is the invocation result plus
that id. A line that is not JSON, or has no id, is answered and the
stream continues: a stream that goes quiet on one line leaves its consumer
unable to tell which request it lost. Answers go to stdout, one object per line,
and nothing else does.
Resuming a batch
The checkpoint records, per id, a digest of the line that produced the recorded answer, and is written after every line by atomic rename — a checkpoint that only survives a clean finish protects against exactly the case that does not happen.
A re-run replays what already ran. A line whose content changed under the same id is refused: replaying it would report success for an operation nobody ran, and re-running it would repeat a paid call. Both answer a question nobody asked.
What stays yours: what makes a repeat safe (an idempotency key derived from the line id), which operations a batch may contain, and how much of it runs at once. That is product knowledge, and the framework has no business guessing it.
Commands discovered from a running server
A CLI compiled from contracts carries the surface of the build it was compiled from. One built from discovery carries the surface the server has right now — which matters, because a long-lived MCP client freezes schemas at connect time and then refuses the server’s own newer fields:
const discovered = await mountConnections([
defineMcpClientConnection({
name: 'api',
transport: { url },
token: () => key,
transports: ['CLI'], // the opt-in: this server's tools are commands
limits: {
discovery: { timeoutMs: 30_000, maxResponseBytes: 4 * 1024 * 1024 },
call: { timeoutMs: 120_000 },
},
}),
])
await createCli({ name: 'myapp', version, runtimeTools: discovered, commands: [...] })
transports is where the opt-in belongs: a whole server becomes a set of
commands, and a connection without it still contributes nothing to the CLI —
exposure stays explicit, as it is everywhere else in the framework.
One unconvertible schema does not take the connection down. The tool
is skipped and named (onSkippedTool, or a stderr line by default), so a
surface of two hundred tools is not lost to one.
A discovered schema is checked locally, except for its unions. The arguments are
coerced and parsed against the schema the server advertised, so a missing field or a
wrong scalar is refused before anything is sent. A union in that schema is not judged
here: a server’s presentation may join a discriminated union into one object whose
shared field is an unlabelled anyOf (a stitchkit server with flattenUnionInput
does), and judged against that, a wrong value could only be refused with every branch
listed. The value goes to the server, which holds the original schema, and its refusal
names the one wrong field — blocks.0.data.artifactId: expected string rather than
No union branch matched — branch 1 … and 6 more branches.
MCP connection limits follow the operation phase. initialize, the initialized
notification, tools/list and legacy endpoint discovery use limits.discovery;
tools/call uses limits.call. A mounted call’s handshake still uses discovery
limits. Each logical operation has one deadline, including initial transport
negotiation and response-body reading. Legacy endpoint readiness has a discovery
deadline; the lifetime of an established SSE stream does not.
Omitted phase fields inherit the existing connection-level timeoutMs and
maxResponseBytes, whose defaults are 30 seconds and 1 MiB. Values must be positive
safe integers; timeoutMs must also fit the runtime timer range. The byte ceiling
counts raw UTF-8 body bytes, including SSE framing, regardless of Content-Length.
Pending legacy calls also count comments and wrong-id frames. budget.maxTools
and budget.maxSchemaBytes remain separate mount limits.
Only the initial initialize can negotiate HTTP-to-legacy fallback on 400, 404
or 405. A failed tools/call is not replayed. Caller cancellation preserves its
original reason for in-process observers.
Expected local failures produce safe tool errors: CONNECTION_TIMEOUT (exit 7),
CONNECTION_RESPONSE_TOO_LARGE (exit 1), UNAUTHORIZED (exit 2), FORBIDDEN
(exit 3) or CONNECTION_REQUEST_FAILED (exit 1). Deadline and response-limit
failures include fixed operation/phase, the applicable bound and measured
observedReadBytes. The public ConnectionTimeoutError and
ConnectionResponseTooLargeError classes carry the same diagnostics. Raw URL,
credential, body, identity and stack stay with the original in-process cause in
onToolError / afterToolCall; tool errors produce no process console dump.
Unknown throws remain INTERNAL_SERVER_ERROR. A timeout is not proof that a
write did not happen: verify the remote outcome before repeating an operation.
A remote refusal keeps its code. A failed tools/call must keep three things:
the code (so exitCodes can map it instead of every remote failure exiting 1),
the message the operator needs, and the error’s own class — a plain Error is
an unexpected error to the runner, which would print a code frame of the framework
bundle before the JSON failure and scrub it to INTERNAL_SERVER_ERROR.
A tools/call response with isError: true and a structured { error, details } body is relayed as the contract error it
is, on every transport; anything else fails as UPSTREAM_TOOL_ERROR carrying
what the server did send. Not INTERNAL_SERVER_ERROR, because nothing of ours
broke.
The CLI writes this refusal to stderr, leaves stdout empty and exits with the
mapped nonzero error code (1 for an unmapped code). A successful response may
contain an ordinary field named error; its payload alone does not establish a
tool failure.
On the CLI a discovered command prints the answer, not the envelope.
tools/call returns { content: [...], structuredContent? }, and an agent mount
needs exactly that — the parts are what a model is shown. The CLI is different in
kind, because the handler’s value is what gets printed, piped and aggregated:
handed the envelope, --count-by status groups the content parts and answers
no record carries the field "status" — available: text, type. So the CLI
transport unwraps, and only it: structuredContent when the server sent one, a
lone text part when it parses as JSON, its text when it does not, and null when
there are no parts at all — a tool without an output contract answers that way,
and the _meta beside an empty content is protocol metadata, never the answer.
Several parts, an image or audio pass through whole — picking one of many would
be inventing an answer.
Auth parity
A scoped command is guarded by the same createAuthHook your HTTP server uses —
pass it as lifecycle, and inject the identity through context so
resolveFromContext can read it:
const authHook = createAuthHook({ /* resolve, resolveFromContext, rules */ })
await createCli({
name: 'myapp',
version: '1.0.0',
resolveAuth: () => resolveIdentityFromToken(process.env.MYAPP_TOKEN),
context: (identity) => ({ user: identity }), // resolveFromContext reads this
lifecycle: { beforeHandle: authHook }, // same policy; HTTP wires it as authorize
services,
})
Without lifecycle, a scoped command runs unguarded — the scope check lives
entirely inside the createAuthHook result, so with no hook wired in there is
nothing to enforce a method’s scope. This matches the MCP / agent surfaces
exactly (ADR 0014): on every tool
transport the auth gate is opt-in, so a contract with scoped methods must be
given a lifecycle (and context identity) to be protected. The gate only
fails closed once the hook is present but resolveFromContext is missing —
then a scoped call has no identity and is rejected.
Typed context
Use createToolkit<AppContext>() to type the injected context against your
app’s context shape — the tool-side mirror of createImplement
(ADR 0017):
const tools = createToolkit<{ user: User }>()
await tools.createCli({
name: 'myapp',
version: '1.0.0',
services,
context: (identity) => ({ user: identity }), // checked against { user: User }
})
Remaining boundary
File-upload (multipart) contract endpoints remain CLI-invisible, the same as
on MCP/Agent: their wire body is not a JSON tool form. Model file-oriented
application behavior as a managed pathless command, or binary-only behavior as
a native command. Streaming (SSE) output is not yet piped to stdout.