heddle

Bundles

Pack a flow and everything it runs with into one .heddle file anyone can run.

A flow is rarely just a flow. By the time an agent works it is a spec and a directory of tools, a plugin or two, a folder of files the tools read, and the settings its middleware runs with: five paths on your machine, held together by one shell command only you have. A bundle is that command made into a file:

heddle bundle spec.yaml --tools-dir tools --plugin plugin.json \
  --mount skills --input '{"query":"hello"}' -o agent.heddle

Send agent.heddle to anyone. Where heddle is installed, it runs with nothing else:

heddle run agent.heddle

What goes in

heddle bundle takes the same flags a run does, and packs what each names:

FlagWhat travels
<flow>The spec file, JSON or YAML
--tools-dir <dir>The tool executables, with their executable bits, read back exactly as a run reads a tools directory
--plugin <manifest>The plugin's whole directory. A manifest plugin may reference only what is inside its own directory, and that confinement rule is exactly what makes it shippable
--mount <src[:dest][:ro|:rw]>The file or directory, with where it lands and whether changes copy back
--plugin-config <Type>=<json>The settings, resolved now; a @file value is read at pack time, so only its contents travel
--input <json>A default input, recorded so the receiver's first run can be zero-configuration
--requires <json|@file>What the receiving machine must already have — checked before a run starts, never acted on. See below

The bundle is checked before it is written: the spec must parse, the graph must compile and validate, and every tool the flow names must be carried by --tools-dir or a --plugin. A bundle that packs is a bundle that runs. The failure your recipient would have hit is the failure you get now, on the machine that can fix it.

Running one

heddle run and heddle validate both take a .heddle where they take a flow. The rule for flags is: the bundle proposes, the command line disposes.

heddle run agent.heddle                            # the recorded input
heddle run agent.heddle --input '{"query":"mine"}' # yours wins
heddle run agent.heddle --mount ./more-data        # composes with the bundle's
heddle run agent.heddle --session support-42       # sessions work as always

A singular flag you pass overrides what the bundle recorded; a repeatable one composes with it, the bundle's entries first. A --plugin-config for a component type the bundle also configures replaces that type's settings entirely.

A bundle does not have to be a file you already have. An https:// address is fetched to a temporary file and opened the same way, and a bare name — no dots, no path separators — is shorthand for the library's copy of that entry:

heddle run https://heddle.run/library/coding-agent.heddle   # fetched, then run as above
heddle run coding-agent                                     # the same, spelled short

The disk is consulted first, so a file or directory that answers to the name keeps meaning itself. And only .heddle archives travel by URL: a bare flow file names tools and mounts by paths that did not come with it, so the archive is the form worth an address.

What is inside

A .heddle is a gzipped tar archive with a heddle.json manifest at its root. No custom container, no dependency acquired to read or write it; heddle carries its own ustar reader and writer over node:zlib. The practical consequence: you can inspect a bundle you were handed with tools you already trust, before heddle touches it.

$ tar -tzf agent.heddle
heddle.json
flow/spec.yaml
tools/
tools/bash.py
tools/read_file.py
plugins/skills/
plugins/skills/plugin.json
plugins/skills/bin/list_skills.py
mounts/0/
mounts/0/date-arithmetic.md

heddle.json names everything else, so the layout is data rather than convention:

{
  "format": 1,
  "name": "skills",
  "flow": "flow/spec.yaml",
  "tools": "tools",
  "plugins": ["plugins/skills/plugin.json"],
  "pluginConfig": { "RetryPolicy": { "maxAttempts": 3 } },
  "mounts": [{ "path": "mounts/0", "dest": "skills", "mode": "ro" }],
  "input": { "query": "what skills do you have?" }
}

format is the version. A bundle from a newer heddle is refused with an upgrade hint rather than half-read.

What deliberately does not travel

A bundle carries what an agent is, not what an operator decided. Four things are left out on purpose, and asking for them is asking the wrong file:

  • Credentials. A spec references a key as $ENV_VAR and it resolves on the machine that runs, never the one that packed. Nothing in a bundle can carry a secret unless you put one in a mounted file yourself, so don't.
  • Sandbox policy. --safe, --allow-read, --deny-net are the receiving operator's judgment about their own machine. A file that arrived in the mail does not get to make it.
  • Sessions. A conversation is machine-local state in ~/.heddle/sessions/. A bundle runs in a session (--session works as always); it does not bring one.
  • Discovery grants. A bundled plugin declaring discoverTools still needs --discover-tools from whoever runs it, because that flag means "you may start this plugin's process", and only the person running can grant it.

One thing cannot travel: an in-process plugin, a --plugin that is an importable module rather than a .json manifest. What it imports lives in the author's node_modules, so its closure is not something a directory walk can collect, and a bundle that shipped only the entry file would run on one machine in the world. heddle bundle refuses it and points at the manifest format, which is the shippable kind.

What a bundle needs from your machine

Some agents want more than heddle. local-notetaker records with binaries it does not ship, transcribes with a model file it does not carry, and writes up the notes with a key that is yours. A bundle can say so:

heddle bundle spec.yaml --tools-dir tools --requires '[
  {"binary": "ffmpeg", "hint": "brew install ffmpeg"},
  {"binary": "whisper-cli", "hint": "brew install whisper-cpp"},
  {"env": "OPENAI_API_KEY", "hint": "for the notes step"},
  {"file": "~/models/ggml-base.en.bin", "hint": "see the README for the download"},
  {"node": ">=22"}
]' -o agent.heddle

The list travels in heddle.json, and heddle run checks it before it starts anything — the same moment it checks that the tools resolve. Everything missing is reported at once:

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.0

That is the whole point of it being a list. Without one you fix a machine one failure at a time, and each failure arrives later than the last — the missing binary mid-run, the missing key at the first model call. heddle doctor agent.heddle prints exactly this without running anything, and exits non-zero when something is missing, so CI can ask the same question.

On a terminal, heddle run turns the report into a pause rather than an exit. A missing environment variable is asked for first, hidden as you type and kept for that run only — the same prompt a spec's $OPENAI_API_KEY gets. For a few well-known requirements — whisper-cli, ffmpeg, the whisper model files — heddle knows the fix itself and offers to make it, the exact commands on screen first, acting only on a typed y; every command and URL in that offer comes from a table versioned with the CLI, never from the bundle (see the callout below). Whatever is left waits: run the install in another shell, press enter, and heddle checks again; q stops with the failure CI would have seen. In a script or a pipeline nothing is asked, nothing is offered, and the refusal is immediate, so automation sees exit codes, never questions.

There are four kinds of requirement, and each is a pure observation of your machine:

KindHolds whenNotes
{"binary": "name"}the name resolves on $PATHa list means any one of them will do; an entry containing / is checked where it points, for the app-bundle paths a Mac needs
{"env": "NAME"}the variable is set and non-emptythe value is never read into the report — only whether it is set
{"file": "path"}the path existsa leading ~ is your home directory
{"node": ">=22"}the running Node satisfies itone >= against a version; heddle carries no semver library and will not pretend to. Anything else is refused when the bundle is packed

hint is free text shown beside the failure it explains. It is a string for a human to read — nothing passes it to a shell.

A requirement is a thing to look for, never a thing to run. There is deliberately no setup, install or preRun field, and the check itself installs, downloads and executes nothing: a .heddle can be downloaded from anywhere, and a bundle that ran commands on open would be remote code execution by design. When heddle run offers to install something, the declaration has only selected an entry from the CLI's own reviewed table — the command, the URL and the destination are heddle's words, shown to you and run only after your y. A bundle cannot author an install, and a hint is still a string for a human to read. Everything the table does not cover is yours to install, with your own package manager, after reading what the report said.

The declaration is the author's description of a machine they have never seen, so the operator keeps the last word: heddle run --no-preflight starts anyway. It is the same bargain as --discover-tools — the bundle declares, and the person at the keyboard decides.

Nothing breaks for bundles without the field, in either direction. A bundle that declares nothing is checked for nothing, and an older heddle handed a bundle that declares requirements ignores the field and runs as it always did — which is why adding it did not bump the format version.

Receiving one

A bundle is somebody else's code, packaged conveniently. Treat it exactly as you would a repository you just cloned. heddle's side of that bargain:

  • Extraction refuses path traversal, absolute paths, and symlinks or hard links anywhere in the archive, and caps what a bundle may unpack to, so opening one is safe.
  • Reading a plugin manifest runs nothing, so heddle validate agent.heddle inspects the whole bundle, spec and graph and tools and plugin manifests, without executing a line of it.
  • heddle doctor agent.heddle answers "would this work here?" the same way — by looking at your machine, not by touching the bundle's code.

Running it is running its tools, like any tools directory. The same advice applies, with more reason:

heddle validate agent.heddle        # look first
tar -tzf agent.heddle               # or with your own eyes
heddle run agent.heddle --safe      # then run it confined

Tools in a bundle are programs, and without --safe a tool is a subprocess with your whole environment, API keys included. For a bundle you did not build, --safe is not paranoia; it is the default posture toward executable mail. See Sandboxing.

Bundles to start from

The library is a set of ready-made ones — meeting notes, issue triage, docs Q&A, a CSV analyst, a changelog writer. Each is a directory in the repository that packs with one command, so they are worth reading as worked examples of the flags above as much as for running: one has no tools at all, one carries two tools and a folder of documents, one branches three ways.

Reference

The full flag table lives in the CLI reference.