heddle
Plugins

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 validate reads 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

FieldRequiredMeaning
nameyesWritten to component_plugin_name when a spec is serialised
versionyesWritten to component_plugin_version
commandnoHow to start the process, as argv, resolved against the plugin's own directory
capabilitiesnoThe reverse calls this plugin intends to make. Absent means none
componentsno*The component types this plugin provides
toolsno*Tools this plugin contributes to the flow's registry
filesnoFiles this plugin puts in every node's workspace
discoverToolsnoWhether 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[]

FieldApplies toMeaning
componentTypeallThe string a spec writes. Must match [A-Za-z_][A-Za-z0-9_]*, unique within the manifest
kindallnode · transform · component · provider · middleware · store · encoder. Defaults to node
inputsnodeInputs to advertise when the spec does not declare them
outputsnodeOutputs to advertise when the spec does not declare them
branchesnodeBranch names this component can take. Static by necessity
schemaany spec-named kind, middleware, storeJSON Schema the component's configuration is checked against
phasetransformpre · post · both
streamproviderWhether this one can deliver an answer as it arrives. Off by default
seamsmiddlewareWhich seams this subscribes to. Required here, refused elsewhere
protocolencoderThe name a client asks for. Required here, refused elsewhere
contentTypeencoderThe 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[]

FieldRequiredMeaning
nameyesWhat a spec writes and what the model is told. Must match [A-Za-z_][A-Za-z0-9_]*
pathone ofAn executable shipped beside the plugin
componentTypeone ofThe component in this plugin that implements it
descriptionnoShown to the model
inputSchemanoThe tool's parameters, as JSON Schema describing an object
outputSchemano
shadowsnoWhether 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" }
  ]
}
FieldRequiredMeaning
pathyesA file or directory inside the plugin's own directory
destnoWhere 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 validate stays free and a spec can be inspected without running its author's code. files is not a seventh kind; nothing chooses it, the way nothing chooses tools.
  • Always read-only. A plugin ships files; it does not get a writable channel onto the operator's disk. --mount …:rw exists 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 kind heddle does not recognise, listing the seven that exist.
  • A capability heddle does not serve, listing the four that exist.
  • seams on anything but a middleware, and protocol or contentType on 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 claiming heddle.
  • A component type declared twice, or one that is not a valid identifier.
  • A tool with both path and componentType, or neither; a tool naming a component the plugin does not declare; two tools with one name.
  • files on 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; a files array that is the manifest's only content.