Using the library
Embed the engine with @heddle-run/core. Load, compile, validate and run a flow from your own program.
Everything the CLI and the server do runs on one engine, @heddle-run/core, and you can call
it directly. Three surfaces, one rule of thumb:
- The CLI (
heddle run) runs a flow on a machine, by hand or from a script. Start here; it is the shape most work wants. - The server (
heddle-server) serves the same flows over HTTP, streamed. For serving flows to clients, use it rather than wrapping the library in your own HTTP layer: the hard parts (streaming, draining, submitted code) are already done. - The library (
@heddle-run/core) embeds a flow in a program of your own: a worker that picks jobs off a queue, a test harness, a CI check, a CLI that is not this one.
Using the library is the one case where your project does depend on heddle, and the inversion the introduction describes is the document's, not the embedder's. The flow stays a plain Agent Spec document either way; what you are importing is the runtime, not abstractions the document needs.
npm install @heddle-run/core@heddle-run/core needs Node.js 18 or newer, and is ESM.
The minimal embed
Four calls: loadFlow reads the document, compile turns it into an executable graph,
validate refuses a graph that is not well-formed, and Runner walks it.
Scaffold something to run (npx @heddle-run/cli init my-agent gives you a flow.json and a
tools/ directory), or point at a project you already have. Then, in run.mjs:
import {
loadFlow,
compile,
validate,
Runner,
DEFAULT_RUNNER_OPTIONS,
FileRegistry,
SubprocessExecutor,
} from "@heddle-run/core";
const handleEvent = (event) => {
if (event.type === "node_start") console.error(`→ ${event.nodeName}`);
if (event.type === "tool_call") console.error(` tool: ${event.toolName}`);
};
const deps = {
toolRegistry: FileRegistry.create("my-agent/tools"),
toolExecutor: new SubprocessExecutor(),
eventHandler: handleEvent,
};
const flow = loadFlow("my-agent/flow.json");
const graph = compile(flow, deps);
validate(graph);
const runner = new Runner(graph, {
...DEFAULT_RUNNER_OPTIONS,
eventHandler: handleEvent,
});
const state = await runner.run(
undefined,
{ query: "What is the Open Agent Specification?" },
);
console.log(JSON.stringify(state.toData(), null, 2));export OPENAI_API_KEY=sk-...
node run.mjsWalking through it:
loadFlow(path)reads JSON or YAML (detected by extension) and resolves every$component_ref. A flow naming a plugin's component types needs the plugins at parse time:loadFlow(path, plugins); see plugins below.compile(flow, deps)builds the graph, constructing an executor per node from theDependenciesyou pass. Providers are constructed on first use, so compiling, and therefore validating, never reaches a credential.validate(graph)throws on a graph that is not well-formed: unreachable nodes, a branch with no edge, a data edge reading an output nothing declares. This is the second of the two validation passesheddle validateruns; the schema pass happens insideloadFlow.runner.run(signal, inputs, from?)walks the graph and resolves to the finalState.signalis an optionalAbortSignalfor cancelling the run from outside; the run's own timeout applies either way.inputsis what--inputis on the CLI: a plain object that becomes the run's starting state.fromresumes a checkpointed run and is normally left out; see Sessions.
The handler is passed twice on purpose. RunnerOptions.eventHandler receives the walk's
events (flow_start, node_start, …); Dependencies.eventHandler receives the ones
raised inside a node's execution (tool_call, token_delta, …). The two reach different
call sites, so give both the same function unless you want them apart.
Dependencies
Dependencies is what compile hands to every node executor it builds. Every field is
optional; compile(flow, {}) is legal and enough for a flow that calls no tools.
| Field | What it is |
|---|---|
toolRegistry | Where tool names resolve. FileRegistry.create(dir) discovers executables in a directory the way --tools-dir does, so the filename minus its extension is the tool's name. Compose several with composeRegistries([...]) |
toolExecutor | What runs a resolved tool. new SubprocessExecutor() is the stdin/stdout JSON protocol from Tools, with a 30-second default timeout; options add a sandbox, a workspace factory, and the tools to place in each workspace's bin |
eventHandler | Receives the events raised inside a node's execution. See the event stream |
plugins | A PluginRegistry from loadPlugins, providing custom component types |
middleware | A MiddlewareChain, for the seams inside a node (toolCall). Pass the same chain here and on RunnerOptions, since they reach different call sites |
stream | false asks providers for one buffered response instead of a token stream; the library's --no-stream |
allowEnvRefs | Whether $VAR in a spec's llm_config is resolved from the process environment. Defaults to true; a host running submitted specs sets it false |
defaultLlmKey, defaultLlmUrl | Fallback credential and base URL for a spec whose llm_config carries neither, which is what the server's default-credential flags feed |
createProvider | Replace how providers are constructed entirely. For tests and hosts; a custom model backend is better written as a provider plugin |
egress | An EgressPolicy restricting where specs you did not write may send model requests. Set it if you accept submitted specs; leave it out for your own, where it would refuse a local Ollama for nothing |
RunnerOptions
The second argument to new Runner(graph, opts). Spread DEFAULT_RUNNER_OPTIONS and
override:
| Field | Default | What it is |
|---|---|---|
maxIterations | 50 | Most node executions in one run, the guard against a cycling graph |
timeout | 300000 | Whole-run budget in milliseconds (five minutes) |
verbose | false | Log every node as it starts and completes |
maxNodeAttempts | 3 | How many times one arrival at a node may be attempted when middleware retries |
eventHandler | none | Receives the walk's events |
middleware | none | The MiddlewareChain consulted at the node and nodeError seams |
checkpoints | none | A CheckpointSink, somewhere to write the run's position, required for suspension and resuming. checkpointSink(...) builds one over a SessionStore; see Sessions |
durable | none | Write the position after every node, not only on suspension |
State
Runner.run resolves to a State, the run's final key–value state. state.toData()
returns it as a plain object, which is what the CLI prints. A run that went through a session has
heddle's own reserved keys in it (_chat_history, _resume); strip them with
withoutReserved(state.toData()) before storing or showing the result.
Failures are thrown, not returned: a RunError, LLMError, ToolError or one of their
siblings from @heddle-run/core. One of them is not a failure: RunSuspended, thrown when
middleware stops the run for a human. Check it with isSuspended(err) and treat it as
the run waiting, the way the CLI and server do.
The event stream
Both event handlers receive Event objects: a type plus the fields that type carries,
nodeName, state, delta, toolName, error, and so on. The built-in types:
| Type | When |
|---|---|
flow_start | The walk begins |
node_start | A node is entered, with its state |
node_complete | A node finished, with its output state |
node_error | A node failed; with middleware retrying, one per attempt |
tool_call | An agent or ToolNode invokes a tool, with toolName and toolArgs |
tool_result | That call returned, with toolResult |
token_delta | One piece of a streamed model answer, in delta |
plugin_log | A plugin said something via log, with level and message |
warning | Something recoverable worth knowing |
flow_complete | The walk finished, with the final state |
Plugins add their own, published as plugin:<componentType>:<name>, and isPluginEvent(type)
tells the two apart. Handlers are synchronous and should return quickly; a slow handler
stalls the run.
Concatenating token_deltas does not reconstruct the output. An agent that calls tools
streams every round it makes, including rounds whose text is discarded. The output is in
flow_complete. Same rule as the server's stream, which
is these events over SSE.
EVENT_CONTRACT_VERSION (currently 1) names the version of this contract. If you
persist events or ship them across a boundary, record it beside them; it moves when the
event model changes shape.
Plugins, middleware, and tools from plugins
The same objects the CLI's flags build are yours to build directly:
import {
loadFlow, loadPlugins, compile, composeRegistries,
FileRegistry, SubprocessExecutor, MiddlewareChain, parsePluginConfig,
} from "@heddle-run/core";
// what --plugin does: a .json path is a manifest, anything else an ES module
const plugins = await loadPlugins(["./gate.json"]);
const flow = loadFlow("flow.yaml", plugins);
const deps = {
plugins,
toolRegistry: composeRegistries([plugins.toolRegistry(), FileRegistry.create("tools")]),
toolExecutor: new SubprocessExecutor(),
};
// what --plugin-config does
deps.middleware = MiddlewareChain.build(plugins, deps, parsePluginConfig(['ApprovalGate={"tools":["refund"]}']));
const graph = compile(flow, deps);Build the chain after the rest of deps exists (it is built from them), assign it to
both deps.middleware and RunnerOptions.middleware, and only then compile, since the
executors read it when they are made. Call plugins.dispose() when you are done, so
out-of-process plugins are stopped.
Writing a plugin, as opposed to loading one, is the same either way; HeddlePlugin and
definePlugin are covered in Authoring a plugin.
Sessions from the library
Everything Sessions describes is exported: FileSessionStore, the
SessionStore interface, openTurn/closeTurn around a run, resumeTurn and
checkpointSink for durable runs and suspension. They are how the CLI and server do it,
and the pattern to follow is theirs: open a turn, run with a checkpointSink in
RunnerOptions.checkpoints, close the turn with the outcome, and on RunSuspended,
close nothing; the checkpoint holds the question.