Manifest reference
Every field an out-of-process plugin may declare, and what each is refused for.
The manifest is the declarative half of an out-of-process plugin. It answers everything the parser and compiler need: what component types exist, what their inputs and outputs are, which branches they can take. Only execution crosses the process boundary.
Two things follow, and both are worth more than the flexibility given up:
- Parsing stays synchronous. No IPC round trip inside
parseFlow. - A spec's shape can be inspected without executing its author's code.
heddle validatereads this file and starts nobody's process.
{
"name": "guardrails",
"version": "1.0.0",
"capabilities": ["callModel"],
"components": [
{ "componentType": "Processor", "kind": "transform", "phase": "both" }
],
"tools": [
{ "name": "classify", "componentType": "Processor" }
]
}Top level
| Field | Required | Meaning |
|---|---|---|
name | yes | Written to component_plugin_name when a spec is serialised |
version | yes | Written to component_plugin_version |
command | no | How to start the process, as argv, resolved against the plugin's own directory |
capabilities | no | The reverse calls this plugin intends to make. Absent means none |
components | no* | The component types this plugin provides |
tools | no* | Tools this plugin contributes to the flow's registry |
files | no | Files this plugin puts in every node's workspace |
discoverTools | no | Whether the tool list has to be asked for rather than read. Off by default |
* A manifest must declare components, tools, or discoverTools. A tools-only plugin
is a real shape and the point of the kind, and an MCP proxy often declares none of the
three statically, because its tool list belongs to the server it fronts, so
discoverTools: true alone is enough.
discoverTools
Everything else here is data, so heddle learns what a plugin provides without running it,
which is what keeps heddle validate free and lets a spec be inspected without executing
its author's code. An MCP proxy cannot honour that, because a list committed to this file
is a copy of somebody else's registry as it stood when a build step last ran.
Declaring it says: start me, ask me once, take the answer. It is therefore refused unless
the operator opted in with --discover-tools. A manifest asking to be started is not
consent, and reading a manifest runs nothing.
{ "name": "mcp-proxy", "version": "1.0.0", "capabilities": [], "discoverTools": true }serve({}, {
listTools: async () => ({ tools: [{ name: 'search', path: './search' }] }),
});Discovery happens once, at load, before the registry is built, so Registry.lookup stays
synchronous. A discovered tool is checked by exactly the rules a declared one meets, and a
name colliding with one the manifest already declared is refused as the duplicate it is.
A submitted plugin may not discover. The server would have to start it to learn what it provides, on the endpoint callers reach for because it runs nothing.
When command is absent heddle supplies a default: a program carrying the execute bit is
invoked by path, and a .mjs or .js entry point is run under the host's node. Invoking
by path is the preferred form, because a sandbox binds the program it is given. Launch
a plugin as node plugin.mjs and the script is an argument rather than the program, so
the confined node cannot find it.
capabilities
A manifest asks; the host decides. A capability named here that the host does not grant fails the load, naming the capability. The operator's policy is not something a plugin discovers by probing at run time. A plugin that asks for nothing gets nothing.
"runTool" · "callModel" · "emitEvent" · "log"
Every reverse call is here, including the two that only say something. emitEvent and
log look harmless next to running a tool, and they are gated all the same, because what
they reach is somebody's screen.
components[]
| Field | Applies to | Meaning |
|---|---|---|
componentType | all | The string a spec writes. Must match [A-Za-z_][A-Za-z0-9_]*, unique within the manifest |
kind | all | node · transform · component · provider · middleware · store · encoder. Defaults to node |
inputs | node | Inputs to advertise when the spec does not declare them |
outputs | node | Outputs to advertise when the spec does not declare them |
branches | node | Branch names this component can take. Static by necessity |
schema | any spec-named kind, middleware, store | JSON Schema the component's configuration is checked against |
phase | transform | pre · post · both |
stream | provider | Whether this one can deliver an answer as it arrives. Off by default |
seams | middleware | Which seams this subscribes to. Required here, refused elsewhere |
protocol | encoder | The name a client asks for. Required here, refused elsewhere |
contentType | encoder | The response's content type. Required here, refused elsewhere |
inputs and outputs take { title, type, description?, default? }.
schema replaces the in-process validate() callback. A schema is data, so it can be
applied during parsing without starting the plugin.
branches is static because heddle's graph validator checks reachability before anything
executes, and a branch that only exists at run time is reported as unreachable.
stream is declared rather than negotiated because completeChat decides whether to
stream by looking for a streaming method, synchronously, before the process is
necessarily running. Off by default is the safe direction: a provider that says nothing
is only ever sent stream: false and never has to handle a mode it did not implement.
protocol must match [a-z0-9][a-z0-9-]*, since it is a token a client puts in a query
string rather than an identifier, and may not be heddle, which is heddle's own wire
format.
tools[]
| Field | Required | Meaning |
|---|---|---|
name | yes | What a spec writes and what the model is told. Must match [A-Za-z_][A-Za-z0-9_]* |
path | one of | An executable shipped beside the plugin |
componentType | one of | The component in this plugin that implements it |
description | no | Shown to the model |
inputSchema | no | The tool's parameters, as JSON Schema describing an object |
outputSchema | no | |
shadows | no | Whether this tool may take a name another source already provides |
path and componentType are exclusive and exactly one must be given: they are two
different answers to "how does this run", and a tool carrying both would leave the
precedence to whoever wrote the dispatch. A componentType is checked against the
components this same manifest declares.
A path is resolved and checked at load: it must exist, be a file, carry the execute
bit, and resolve inside the plugin's own directory. Containment is checked with
realpath, so a symlink pointing out of the directory is caught rather than followed. A
plugin needing a system binary ships a two-line wrapper.
shadows is off by default, and the default is the point. A collision is a load error
unless somebody says otherwise in writing, and a server refuses to let a submitted
plugin be that somebody, because a name a caller never wrote can capture calls made by
code they did not write either.
An inputSchema reaches the model's own tool block verbatim, and is serialised into
every request of every round. heddle caps it at 64 KB and 16 levels of nesting, and
refuses one whose declared type is anything but object. A tool takes named
arguments, so its schema has to describe them.
files[]
What a plugin ships besides its tools. Each entry lands in the workspace of every node in every run, before the flow starts.
{
"files": [
{ "path": "skills" },
{ "path": "reference/units.csv", "dest": "units.csv" }
]
}| Field | Required | Meaning |
|---|---|---|
path | yes | A file or directory inside the plugin's own directory |
dest | no | Where it lands, relative to the workspace root. basename(path) by default |
path follows exactly the rule tools[].path follows, and reuses the same code: resolved
against the plugin's directory, realpath'd on both sides, refused if it lands outside.
The same word for the same rule, because a second word would make one rule look like two.
Three things worth stating plainly:
- This is data, not a verb. heddle learns what a plugin puts in a workspace by reading
the manifest. It starts nothing, so
heddle validatestays free and a spec can be inspected without running its author's code.filesis not a seventh kind; nothing chooses it, the way nothing choosestools. - Always read-only. A plugin ships files; it does not get a writable channel onto the
operator's disk.
--mount …:rwexists and belongs to whoever runs heddle. - Top level, never on a component. Every component of a plugin runs against the same workspace, so on a component this would be read by nothing, and a manifest that writes it there is refused rather than ignored.
A plugin that declares files and nothing else is refused too, and the error says why:
that is a directory rather than a plugin, and --mount puts a directory in a workspace
without loading anybody's code.
A submitted plugin may not declare files. A plugin's files are read from the
directory it was installed in, and a plugin that arrived in a request is a module with no
directory, so there is nothing for them to resolve against. Files belonging to one run go
in that run's own request; files belonging to every run are
the operator's, with --mount.
What is refused, and why
The validator is deliberately strict, because it runs against data that may have arrived in an HTTP request and its failure is the first thing a plugin author sees.
- A
kindheddle does not recognise, listing the seven that exist. - A capability heddle does not serve, listing the four that exist.
seamson anything but a middleware, andprotocolorcontentTypeon anything but an encoder. A field that silently did nothing would leave an author believing their component was reachable.- An encoder with no
protocol, or none heddle could route, or one claimingheddle. - A component type declared twice, or one that is not a valid identifier.
- A tool with both
pathandcomponentType, or neither; a tool naming a component the plugin does not declare; two tools with one name. fileson a component rather than on the plugin; two files landing on one destination; more than 64 of them, since each is copied into every node's workspace; afilesarray that is the manifest's only content.