heddle

Tools

Build custom tools in any language using stdin/stdout JSON.

Tools in heddle are standalone executables that communicate via JSON over stdin/stdout. Write them in any language. No SDK required.

How tools work

  1. heddle calls your tool as a subprocess
  2. Input is sent as JSON via stdin
  3. Your tool processes the input
  4. Output is returned as JSON via stdout

Creating a tool

Bash

#!/usr/bin/env bash
INPUT=$(cat)
QUERY=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('query',''))")
echo "{\"result\": \"You searched for: $QUERY\"}"

Python

#!/usr/bin/env python3
import sys
import json

args = json.load(sys.stdin)
query = args.get("query", "")

results = {"result": f"You searched for: {query}"}
json.dump(results, sys.stdout)

Node.js

#!/usr/bin/env node
const chunks = [];
process.stdin.on("data", (chunk) => chunks.push(chunk));
process.stdin.on("end", () => {
  const args = JSON.parse(Buffer.concat(chunks).toString());
  const result = { result: `You searched for: ${args.query}` };
  process.stdout.write(JSON.stringify(result));
});

Tool directory

Place your tools in a directory and reference it with --tools-dir:

tools/
├── web_search        # Must be executable
├── calculator
└── fetch_api.py

Make sure tools are executable:

chmod +x tools/*

A tool's name is its filename with the extension stripped, so fetch_api.py is declared in the spec as fetch_api. Two files that reduce to the same name, such as search.sh and search.py, collide, and only one of them is registered.

Tools from a plugin

A directory is not the only source. A plugin can contribute tools straight into the registry, either as executables it ships beside itself or as tools it implements in its own process, the latter existing nowhere on the filesystem. A flow names them exactly as it names a file-backed tool, and nothing downstream can tell the difference.

heddle run flow.json --tools-dir ./tools --plugin ./mcp-proxy/manifest.json

The registries are composed, and a plugin's tools go in weakest: a name from --tools-dir and a name a caller typed both beat a name a manifest bound in bulk. A plugin tool that collides at all is refused at load unless its manifest explicitly declared shadows, and a server refuses to let a submitted plugin be the one to declare it.

See Plugins for the manifest's tools field.

Declaring tools in a spec

Tools are declared inline as ServerTool components, not referenced by bare name. The name is what links the declaration to the executable in --tools-dir; description, inputs, and outputs are what the model sees when deciding whether to call it.

component_type: AgentNode
name: assistant
agent:
  component_type: Agent
  name: assistant-agent
  system_prompt: You are a helpful assistant.
  llm_config:
    component_type: OpenAiConfig
    name: openai
    model_id: gpt-4o
  tools:
    - component_type: ServerTool
      name: web_search
      description: Search the web for information
      inputs:
        - title: query
          type: string
      outputs:
        - title: results
          type: string
    - component_type: ServerTool
      name: calculator
      description: Perform mathematical calculations
      inputs:
        - title: expression
          type: string
      outputs:
        - title: value
          type: string

Each inputs entry becomes a parameter in the JSON Schema handed to the model. All of them are marked required; optional parameters are not yet expressible.

To run a tool directly, without an LLM choosing it, use a ToolNode.

Running heddle validate with --tools-dir checks that every declared ServerTool has a matching executable.

Execution details

  • Tool timeout: 30 seconds
  • Input arrives on stdin as a single JSON object; output must be a JSON object on stdout
  • Tools must exit with code 0 on success; a non-zero exit fails the call, with stderr included in the error
  • Malformed JSON on stdout fails the call
  • Empty stdout is treated as an empty object

When an agent calls a tool, a failure is reported back to the model as a tool message so it can retry or recover. A failure in a ToolNode fails the whole run.

By default tools run as subprocesses of heddle and inherit its full environment, including any API keys, and a tool can read and write anything the invoking user can. Treat a tools directory the way you would treat a directory of shell scripts you are about to run, and be especially careful with tools that execute commands or write files on the model's behalf.

The workspace

Every tool runs in a workspace: a directory of its own, which is also its working directory and the place it may write. It is $HEDDLE_WORKSPACE, and a tool is started inside it, so a relative path is a path in the workspace.

$HEDDLE_WORKSPACE/       # the tool's cwd, and its scratch
└── .heddle/bin/         # reserved: every tool, reachable by name

One workspace per AgentNode execution, shared by every tool call that agent makes. That is what lets an agent's tools hand each other files: write a CSV in one call, run a script over it in the next. A different agent in the same flow sees an empty one, and it is removed when the agent finishes.

Putting something in it

--mount puts a file or directory in every workspace, before the run starts:

heddle run flow.yaml --tools-dir ./tools \
  --mount ./skills \
  --mount ./data/report.csv:input.csv \
  --mount ./notes:notes:rw

<src>, then optionally :<dest> (where it lands, relative to the workspace root; the source's own name by default) and :ro or :rw.

ro is the default and is a copy. Every node gets its own; a run that edits one is editing its copy, and the original is untouched. Under --safe it is a real boundary and the write is refused outright.

rw is shared with the run. It is copied in when a node's scope opens and the files the node changed are copied back when it closes, so a later node sees what an earlier one wrote. Three rules worth knowing, because they are chosen rather than incidental:

  • Deletions do not propagate. A model's rm -rf in a scratch directory must not become deletion of your files. Write an empty file instead and say so.
  • Last writer wins, and the run is the last writer for the files it touched. If something else changed one while the run was using it, heddle writes over it and says which.
  • A copy-back that fails is reported, not raised. It happens as the node finishes, and the run's own error is the one worth having.

A plugin can put files in every workspace too, by declaring them in its manifest. That is how examples/skills-agent/ ships an agent with a folder of skills rather than a prompt full of them.

Over HTTP a caller has the same option without needing a path on the server. A request to a heddle-server started with --allow-request-code may carry a files array of { path, content }, and each entry lands in every node's workspace read-only, exactly where --mount would have put it. What differs is whose bytes they are: --mount names a directory the operator already has, and files carries the caller's own.

A bundle ships mounts too: heddle bundle --mount ./skills records the directory and its destination in the .heddle archive, and whoever runs the bundle gets it in every workspace without holding the path themselves.

Keeping it

heddle run flow.yaml --tools-dir ./tools --safe --workspace ./run-out

Each node's workspace goes in ./run-out/<node-name> and stays there. With --safe and no --allow-write at all, that is everything the run produced without granting a single writable path: the workspace was already writable, and now it survives.

A workspace is not confinement. Without --safe nothing stops a tool from writing elsewhere; what the workspace gives you is somewhere sensible for it to write by default, and somewhere its peers know to look. $HEDDLE_SANDBOX is the variable that says whether anything is enforcing the edges.

Safe mode

--safe runs each tool inside an OS sandbox instead: bubblewrap on Linux, Seatbelt on macOS, selected automatically.

heddle run flow.json --tools-dir ./tools --safe

A confined tool gets read-only system paths, a throwaway $HOME (so ~/.ssh and ~/.aws are unreachable), a private $TMPDIR, and only the environment variables named with --allow-env, so API keys in heddle's environment are not handed to tool code. Network access is allowed unless you pass --deny-net.

What --safe adds to the workspace is enforcement: the workspace becomes the only place a tool can write, and the directory heddle was launched from becomes readable and nothing more. Each AgentNode execution opens its own sandbox session over its own workspace, so every tool call is still its own container while all calls within one agent share the directory.

Widen the policy with --allow-read <path> and --allow-write <path>.

Plugins loaded with --plugin as an ES module are not sandboxed: the CLI imports the module into the heddle process, where it runs with full Node privileges. Only the tools it invokes via ctx.runTool are confined. A plugin loaded from a manifest is different: it runs in its own process with an empty environment, and is confined by the same sandbox as tools when one is configured.

See Sandboxing for the full policy, including which flags are a hard error without --safe.