heddle
Plugins

Authoring a plugin

The in-process and out-of-process APIs, the context object, and a worked example of each kind.

Two APIs, one set of concepts. In process you export functions; out of process you serve verbs over a pipe. The kinds, the capabilities and the context object are the same either way, so this page pairs them.

In process

Default-export a plain object. Nothing to import: a plugin depends on heddle's shapes, not its code, which is how every shipped example is written.

export default {
  name: "my-plugin",
  version: "1.0.0",
  nodes: [],
  transforms: [],
  providers: [],
  middleware: [],
  encoders: [],
  tools: [],
};

For TypeScript, or an editor that will use it, @heddle-run/core exports definePlugin, an identity function that only adds types, so export default definePlugin({...}) is the same object with its shape checked. Take it or leave it; nothing at run time knows the difference.

name and version are written into a spec as component_plugin_name and component_plugin_version when one is serialised, so they are not decorative.

Out of process

Write a program that speaks the protocol. serve is provided for you whenever heddle chose the interpreter: a plugin submitted to a server gets the runtime prepended to its source, and a .mjs or .js entry point on disk gets the same runtime on the command line, as a data: URL that needs no file and so no sandbox grant. A program with the execute bit is run directly and speaks the protocol itself, which is the path for a plugin in another language.

serve({
  RegexNode: {
    execute: (input, ctx) => ({
      output: { matched: new RegExp(input.pattern).test(input.text) },
    }),
  },
});

Beside it, a manifest declaring what the program provides:

{
  "name": "my-plugin",
  "version": "1.0.0",
  "capabilities": [],
  "components": [{ "componentType": "RegexNode", "kind": "node" }]
}

See the manifest reference for every field.

stdout is the protocol. A plugin that prints to stdout corrupts the channel, so console.log is redirected to stderr for you. Writing to process.stdout directly is the one way to break this, and heddle reports the resulting parse error naming that cause.

The context object

Every handler receives a context. What is on it depends on the kind, because what a component may do follows from the component rather than from where it was written.

MemberAvailable toWhat it is
signalallAn AbortSignal, fired when heddle cancels the call or stops the plugin
runTool(name, input)node, transform, middlewareRun one of the flow's tools
callModel(request)node, transform, middlewareAsk this component's llm_config for an answer
emitEvent(name, data)allPublish plugin:<componentType>:<name> on the run's stream
log(level, message)allA line for whoever is watching the run
node / componentnode, transform, providerThe component's own spec fields
getWorkspace()nodeA directory this execution may write to, shared with its tools
partial(chunk)provider, streaming onlyOne piece of a streamed answer
runIdencoderheddle's identity for this run
seam, attempt, maxAttempts, admitsmiddlewareWhich seam is asking, and what it will honour

Cancellation is cooperative, as it is anywhere in Node: a handler that never reads ctx.signal runs to completion and heddle kills the process shortly after. One that does read it lets its process survive to serve the next call.

getWorkspace() deserves a note. A plugin can always make its own temp directory; what it cannot do is find the one its tools can see. A node's tool calls run in that node's workspace, on every run, sandbox or not, so a path from mkdtemp is somewhere neither the tools nor anything else will look. This returns the directory both sides share.

A node

A node occupies a slot in the flow's graph. It returns an output and, optionally, a branch.

// in process
nodes: [
  {
    componentType: "RegexNode",
    inferInputs: () => [{ title: "text", type: "string" }],
    inferOutputs: () => [{ title: "matched", type: "boolean" }],
    branches: () => ["hit", "miss"],
    createExecutor: (node, deps) => ({
      async execute(input, ctx) {
        const matched = new RegExp(String(node.pattern)).test(String(input.text));
        ctx.emitEvent("checked", { matched });
        return { output: { matched }, branch: matched ? "hit" : "miss" };
      },
    }),
  },
],
// out of process
serve({
  RegexNode: {
    async execute(input, ctx) {
      const matched = new RegExp(ctx.node.pattern).test(input.text);
      ctx.emitEvent("checked", { matched });
      return { output: { matched }, branch: matched ? "hit" : "miss" };
    },
  },
});

Branch names must be static. heddle validates the graph for reachability before anything runs, so a branch that exists only at run time is reported as an unreachable edge. Out of process, declare them in the manifest as branches.

A transform

A transform hangs off Agent.transforms and sees the agent's messages: pre on their way to the model, post on the answer's way back. It returns pass, modify or reject.

serve({
  Processor: {
    apply: (messages, ctx) => {
      if (ctx.phase === "pre" && /ignore your instructions/i.test(messages.at(-1).content)) {
        return { action: "reject", reason: "prompt injection" };
      }
      return { action: "pass" };
    },
  },
});

reject is what makes a transform usable as a guardrail. In the pre phase heddle skips the model call entirely, so a blocked prompt costs nothing, and the agent returns transform_status: "rejected", which a builtin BranchingNode can route on.

A provider

A provider answers model calls itself, so a spec writes your component type where it would write OpenAiConfig. You are the endpoint, so you receive the whole request including model.

serve({
  AnthropicConfig: {
    chat: async (request, ctx) => {
      const res = await fetch(URL, {
        method: "POST",
        headers: { "x-api-key": ctx.component.api_key },
        body: JSON.stringify({ model: request.model, messages: request.messages }),
        signal: ctx.signal,
      });
      const body = await res.json();
      return { content: body.content[0].text, finish_reason: body.stop_reason };
    },
  },
});

ctx.component is the llm_config the spec wrote, which is where a key or a url the flow brought will be. heddle does not resolve $VAR for you. It will not read its own environment on your behalf, least of all when --safe has confined you precisely so you cannot read it either. Take a credential from your own component's fields, or from the environment the operator granted your process.

A plugin may not claim a type the SDK ships. A flow writing OpenAiConfig reaches heddle's own client whatever plugins are loaded.

To stream, add "stream": true to the component in your manifest and send each piece with ctx.partial:

chat: async (request, ctx) => {
  if (!ctx.stream) return await whole(request);
  for await (const piece of pieces(request)) ctx.partial({ content: piece });
  return { finish_reason: "stop" };
}

Three rules, and they are the protocol's rather than this helper's. Each partial is one chunk, and chunks accumulate. What you return is the last chunk, not a summary of the ones you sent. Returning {} is fine, and returning the whole answer again appends it twice. And your timeout is a silence budget that only restarts when you send something, so a provider that declares streaming and then buffers internally is killed exactly like one that hung.

A tool

A tool the plugin implements itself goes in serve's second argument, not the component map, because a tool name is a different namespace from a component type.

serve(handlers, {
  tools: {
    lookup: async (input, ctx) => ({ output: { found: await db.get(input.key) } }),
  },
});

Declare it in the manifest under tools, naming either a path (an executable shipped beside the plugin) or the componentType that implements it.

Lifecycle

serve's second argument also takes a shutdown hook:

serve(handlers, { shutdown: async () => pool.end() });

It runs when heddle asks the plugin to stop, before the process exits. It has about a second, and heddle kills what has not exited by then, so this is where a connection closes rather than where a backlog drains.

Reporting

emitEvent and log are how a plugin says anything at all. Neither is a duplicate of stderr, which out of process is capped and read only when the process fails: a plugin that works has no other way to be heard.

The difference between them is who the payload is for. log is heddle's shape, a level and a string, so every client can render it without knowing the plugin exists. emitEvent's data is the plugin's own shape, so only a client written against that plugin can read it. That is why log cannot carry data: the moment it could, it would be emitEvent under a worse name.

You never choose the whole event type. heddle publishes yours as plugin:<componentType>:<name>, which is what stops a plugin emitting flow_complete and telling every client watching that a flow it does not own has finished.