CLI Reference
Complete reference for all heddle CLI commands and options.
Global options
These are declared on heddle itself and may appear before or after the subcommand:
| Option | Description |
|---|---|
--verbose | Log every node as it starts and completes, plus tool arguments and results |
There is currently no --version flag. Use heddle --help to confirm the CLI is installed.
heddle run
Execute an Agent Spec flow.
heddle run <flow> [options]Arguments
| Argument | Description |
|---|---|
flow | Path to the flow file (JSON or YAML, detected by extension), a .heddle bundle made by heddle bundle, an https:// address of one, or a bare name from the library: heddle run coding-agent fetches and runs https://heddle.run/library/coding-agent.heddle. A file of the same name wins over the shorthand |
Options
| Option | Description |
|---|---|
--tools-dir <path> | Directory containing tool executables |
--input <json> | Input data as a JSON object. Becomes the run's starting state |
--format <name> | Read the flow through a named input format instead of resolving it from the file extension. json and yaml are builtin; any other name comes from a plugin |
--session [id] | Keep this run in a conversation on disk, and give the agent the turns before it. With no id, one is created and its id printed |
--session-dir <path> | Where sessions are kept (default $HEDDLE_SESSION_DIR, then ~/.heddle/sessions) |
--durable | Checkpoint at every node boundary, so a run that dies can be resumed. One store write per node, so it is asked for rather than implied |
--resume | Continue this session's unfinished run instead of starting a new turn |
--answer <json> | What to tell a run that stopped for a human, with --resume |
-i, --interactive | Open the terminal chat UI. Combine with --session to keep the conversation |
--plugin <module> | Load a plugin (repeatable). A .json path is a manifest and runs out of process; anything else is imported as an ES module |
--discover-tools | Let a plugin that declares discoverTools be started so heddle can ask what tools it has. Off by default: reading a manifest runs nothing, which is what keeps validate free |
--plugin-config <Type>=<json> | Configuration for a middleware component type (repeatable, once per type). @path reads the JSON from a file |
--max-node-attempts <n> | How many times one arrival at a node may be attempted when middleware retries. Whole number ≥ 1, default 3 |
--no-stream | Ask the model for one buffered response instead of a token stream |
--no-preflight | Start without checking the requirements a bundle declares. They are the author's description of your machine; this is how you say they are wrong |
--no-ask-env | Never ask for an environment variable the spec names and this shell does not have. Without it, a terminal is asked before the run starts |
--protocol <name> | Render the run through an installed encoder, one JSON frame per line on stdout, instead of the human progress output |
--mount <src[:dest][:ro|:rw]> | Put a file or directory in every node's workspace (repeatable). ro, the default, is a copy the run cannot carry back; rw copies changed files out again when a node finishes |
--workspace <dir> | Keep each node's workspace under this directory instead of a temporary one, so what the run produced is still there afterwards |
--mount-max-bytes <n> | Largest a --mount may be, in bytes. Default 67108864 (64 MiB) |
--mount-max-entries <n> | Most files and directories a --mount may hold. Default 4096 |
--no-mount-tools | Keep the tools out of the workspace's bin, so the only way to reach one is a call the model made. See what a tool can reach |
--safe | Run tools inside an OS sandbox |
--sandbox <backend> | Sandbox backend: auto (default), bubblewrap, or seatbelt |
--allow-read <path> | Grant sandboxed tools read access to a path (repeatable) |
--allow-write <path> | Grant sandboxed tools write access to a path (repeatable) |
--allow-env <name> | Forward an environment variable into the sandbox (repeatable) |
--deny-net | Block network access for sandboxed tools |
--no-stream is decided before the request goes out, not as a fallback. Use it for an
endpoint that cannot serve SSE or that bills streamed calls differently. Note that an
agent carrying a post transform does not stream regardless, since a token that has
reached a client cannot be recalled once the transform rejects the answer.
A spec writing api_key: $OPENAI_API_KEY reads that variable at its first model call. On
a terminal, run asks for the ones this shell does not have before the run starts rather
than failing several nodes in — hidden as you type, kept in this process only, and skipped
with enter. Nothing is asked without a terminal on stdin and stderr, so scripts and CI are
unaffected; --no-ask-env declines the question on a terminal as well. See
API keys.
--sandbox, --allow-read, --allow-write, --allow-env and --deny-net are each a
hard error without --safe, naming the offending flag. They tune a sandbox; they do not
switch one on. See Sandboxing.
--mount and --workspace are not on that list. A workspace exists on every run and
--safe decides only whether anything enforces its edges, so where a run's files come
from is a separate question from what confines them. Both are checked before the run
starts: a source that is not there, a destination that climbs out of the workspace, a
symbolic link anywhere in the tree, or two mounts landing on the same path is an error at
second zero rather than at whichever node reaches it first.
Examples
# Basic run
heddle run flow.yaml --tools-dir ./tools
# With input data
heddle run flow.json --tools-dir ./tools --input '{"query": "quantum computing"}'
# One turn of a conversation kept on disk
heddle run flow.yaml --tools-dir ./tools --session support-42 --input '{"query": "hello"}'
# Interactive chat, kept in the same conversation
heddle run flow.yaml --tools-dir ./tools --session support-42 -i
# Verbose logging
heddle --verbose run flow.yaml --tools-dir ./tools
# Confine tools to an OS sandbox
heddle run flow.yaml --tools-dir ./tools --safe
# Sandboxed, offline, with one writable output directory
heddle run flow.yaml --tools-dir ./tools --safe --deny-net --allow-write ./outOutput
The final state is printed to stdout as formatted JSON, unless --protocol is given, which
replaces it with a frame stream. Progress, tool activity, and errors go to stderr, so stdout
stays pipeable:
heddle run flow.json --input '{"query": "hello"}' | jq .result--input becomes the run's starting state as given. It is not checked against the start
node's declared outputs: malformed JSON fails immediately, but a key nothing declares is
carried and is readable, since an agent is sent its whole input state as JSON and {{key}}
substitution reads whatever the state holds. A declared key you omit is simply absent, and
a prompt referencing it keeps the placeholder.
Rendering the run in another protocol
--protocol <name> hands the run's events to an encoder and writes
its frames to stdout, one JSON object per line. The human progress output is replaced, not
merely redirected: the encoder is the only thing rendering the run.
heddle run examples/ag-ui/flow.json --plugin ./examples/ag-ui/encoder.json \
--protocol ag-ui --input '{"query": "hello"}'{"data":{"type":"RUN_STARTED","threadId":"5d4ed795-…","runId":"5d4ed795-…"}}
{"data":{"type":"STEP_STARTED","stepName":"start"}}
{"data":{"type":"STEP_FINISHED","stepName":"start"}}
{"data":{"type":"STATE_SNAPSHOT","snapshot":{"query":"hello"}}}
{"data":{"type":"STEP_STARTED","stepName":"end"}}
{"data":{"type":"STEP_FINISHED","stepName":"end"}}
{"data":{"type":"STATE_SNAPSHOT","snapshot":{"query":"hello"}}}
{"data":{"type":"RUN_FINISHED","threadId":"5d4ed795-…","runId":"5d4ed795-…"}}JSON Lines rather than the server's event:/data: framing, because SSE is how an HTTP
response body carries a name beside a payload and stdout is not one. The name is not lost:
each line is the frame, so event is present when the protocol names its frames and absent
when it carries the type inside data. --protocol heddle shows the named half, heddle's
own frames, with no plugin loaded at all:
{"event":"flow_start","data":{"type":"flow_start"}}
{"event":"node_start","data":{"type":"node_start","nodeName":"start","nodeType":"StartNode","attempt":1,"state":{"query":"hello"}}}The final state is not appended. With a protocol selected stdout is the frame stream, and
a pretty-printed object after it is a parse error for anything reading a line at a time.
Nothing is lost either: flow_complete carries the whole run state, so an encoder that wants
the answer already has it.
A protocol nothing provides is an error listing what is available; the encoder for one comes
from --plugin, and a plugin may not claim the name heddle.
--protocol and --interactive are refused together. The chat UI paints the answers in the
terminal, so there is no single stream of frames to render and no stdout left to write them
to. --session composes with --protocol, since a persisted turn is one ordinary run.
Sessions
Covered in full in Sessions; the flags in brief. --session keeps a
run in a conversation and gives the agent the turns before it. Each invocation is one
turn:
heddle run flow.yaml --session support-42 --input '{"query":"where is my order?"}'
heddle run flow.yaml --session support-42 --input '{"query":"and the second one?"}'A session is a directory: meta.json, a turns.jsonl appended to once per turn, and a
checkpoint.json present only while a run is unfinished. Ids are random unless you name one;
on the CLI a name you chose is fine, because the store is your own home directory. (The server
issues its own; see Server.)
Passing _chat_history yourself alongside --session is refused: the session is the
conversation, and heddle would have to pick one of the two to throw away.
Durable runs
--durable writes the run down at every node boundary. A process that dies mid-run leaves a
checkpoint, and --resume picks it up at the node it had reached. Nothing before that node
runs again.
heddle run flow.yaml --session support-42 --durable --input '{"query":"…"}'
# … the process dies …
heddle run flow.yaml --session support-42 --resumeIt costs one store write per node, which is why it is asked for rather than implied by
--session.
Stopping for a human
A middleware may suspend a run at a tool call or a node. The run stops, the question is written into the session, and the process can exit:
Stopped for a human: "ApprovalGate" is asking.
{ "tool": "refund", "arguments": { "amount": 4200 }, "question": "Approve refund?" }--resume --answer '<json>' continues it. The answer reaches the run as the result of the call
that was waiting, and nothing that already ran runs again: not the model call for that round,
not the tools already called in it. Calls the round never reached are made on the way back.
A suspension needs a session to be written into; a run without one is refused rather than stopped with no way back.
Interactive chat
-i opens the terminal chat UI. Alone, the conversation lasts as long as the terminal; with
--session it is kept, and reopening with the same id starts on the conversation so far.
Your message is bound to the first output declared on the flow's start node, falling back to query if it declares none. Type /exit or /quit to leave.
An in-flight run cannot be interrupted. Ctrl+C and /exit close the session, but a flow already executing keeps running until it finishes or hits the five-minute timeout.
heddle bundle
Pack a flow and everything it runs with into one shareable .heddle archive. This is the
flag reference; Bundles covers the format, what deliberately does not
travel, and how to treat a bundle you received.
heddle bundle <flow> [options]heddle bundle spec.yaml --tools-dir tools --plugin plugin.json \
--mount skills --input '{"query":"hello"}' -o agent.heddleWhoever receives the file needs nothing else:
heddle run agent.heddleOptions
| Option | Description |
|---|---|
--tools-dir <dir> | Directory of tool executables to ship. Read back exactly as heddle run --tools-dir would: flat, executables only, name = filename minus extension |
--plugin <manifest> | Plugin to ship, as a .json manifest (repeatable). The plugin's whole directory travels, and its confinement rule (everything it references resolves inside its directory) is what makes it shippable |
--discover-tools | Let a plugin that declares discoverTools be started, so the bundle can be checked against its tools |
--plugin-config <Type>=<json> | Component settings recorded in the bundle (repeatable). A @file value is read now, so only its contents travel |
--mount <src[:dest][:ro|:rw]> | File or directory to ship, landing in every workspace where a --mount would put it (repeatable) |
--input <json> | Default input recorded in the bundle; heddle run --input overrides it |
--requires <json|@file> | What the receiving machine must already have, as a JSON list of predicates. Checked before a run starts and reported all at once — never installed, fetched or executed. See requirements |
-o, --output <file> | Where to write the bundle. Default: <flow name>.heddle, named for the flow rather than the file, since flow files are usually all called flow.json |
What a bundle is
A .heddle is a gzipped tar with a heddle.json manifest at its root, so tar -tzf agent.heddle shows exactly what you were handed, no heddle required. The bundle is
checked before it is written: the spec must parse, the graph must hold, and every tool
the flow names must be carried by --tools-dir or a --plugin, so a bundle that packs
is a bundle that runs.
At run time the bundle proposes and the command line disposes: --input overrides the
recorded default, a --plugin-config for the same component type wins, and extra
--mount or --plugin flags compose with the bundle's. heddle validate agent.heddle
inspects one without running it.
What a bundle deliberately does not carry:
- Credentials. A spec references a key as
$ENV_VAR; it resolves on the machine that runs, never the one that packed. - Sandbox policy and session state.
--safeand--sessionare the operator's decisions about their own machine, and a file that arrived in the mail does not make them. - Discovery grants. A bundled plugin declaring
discoverToolsstill needs--discover-toolsfrom whoever runs it.
An in-process plugin (an importable module rather than a .json manifest) is refused:
what it imports lives on the author's machine, so a bundle cannot carry its closure.
Give the plugin a manifest and bundle that.
heddle doctor
Ask whether this machine has what a bundle needs, without running it.
heddle doctor <bundle-or-flow> [options]$ heddle doctor local-notetaker.heddle
local-notetaker cannot run here — 2 requirements unmet:
✗ whisper-cli not on PATH brew install whisper-cpp
✗ OPENAI_API_KEY not set for the notes step
✓ ffmpeg, node v22.17.0Everything missing is listed at once, with whatever the author wrote beside it, so a machine is fixed in one pass rather than one failure at a time. Exit status is 0 when every requirement holds and 1 when any does not, which is what makes it usable in CI — a workflow that packs a bundle can assert its runner can run one.
Options
| Option | Description |
|---|---|
--requires <json|@file> | The requirements to check, in the same JSON heddle bundle --requires takes. For a flow that is not packed yet, or to try a declaration before packing it. Refused against a bundle that records its own, which would check something other than what running it checks |
This runs nothing and installs nothing — see
requirements for why that is the
whole design rather than a missing feature. heddle run performs the same check before it
starts; on a terminal it pauses there — for the few requirements heddle has its own
install recipe for (whisper-cli, ffmpeg, the whisper models) it offers to run the fix
after showing it, and the rest you fix in another shell and press enter to re-check — and
anywhere else it fails with the same list. heddle doctor is how you ask before the
meeting rather than during it.
An environment variable is reported as set or not set. Its value is never printed.
heddle sessions
Inspect the conversations heddle has kept.
heddle sessions ls [--limit <n>] # most recently used first
heddle sessions show <id> [--json] # the transcript, or the stored record
heddle sessions rm <id> # delete it and everything in itAll three take --session-dir <path>. A session with an unfinished run is listed as
unfinished, and show says what it is waiting for.
heddle validate
Validate an Agent Spec component.
heddle validate <spec> [options]Arguments
| Argument | Description |
|---|---|
spec | Path to the spec file (JSON or YAML), or a .heddle bundle, validated with the tools and plugins it carries |
Options
| Option | Description |
|---|---|
--tools-dir <path> | Directory containing tool executables (validates tool references) |
--format <name> | Read the spec through a named input format instead of resolving it from the file extension |
--plugin <module> | Load a plugin (repeatable), so a flow naming its component types can be parsed |
--discover-tools | Allow a plugin declaring discoverTools to be started. Without it, such a plugin is refused at load rather than loaded without its tools |
What it checks
- Schema validation against the Open Agent Specification
- Graph compilation and structural validation, for flows
- That every tool the flow names resolves, against the composed registry, so this runs
when
--tools-diris given or when a loaded plugin contributes tools
No API key is needed. Providers are constructed on first use, so compiling a flow, and
therefore validating it, never reaches a credential. A spec whose llm_config names
$OPENAI_API_KEY validates with nothing in the environment.
A .json argument to --plugin is read as a manifest, which is data: validating a flow
that uses an out-of-process plugin starts none of that plugin's code.
One case worth knowing in CI. A spec that does not parse exits 1, so does an invalid
graph, and so does a tool the flow names that nothing provides. Exiting 0 while printing
the problem is the one outcome a validator must not have. What still exits 0 is a graph
heddle could not compile at all, reported as Graph validation skipped with the reason.
That skip is there so a check which could not run, usually because a node type's plugin was
not loaded, is not mistaken for a fault in the spec. Read the output as
well as the status, and treat Graph validation passed as the signal you want.
heddle init
Scaffold a new project.
heddle init <project-name>Creates a project directory containing a template flow.json and a sample tool in tools/. Existing files at those paths are overwritten.