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
- heddle calls your tool as a subprocess
- Input is sent as JSON via stdin
- Your tool processes the input
- 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.pyMake 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.jsonThe 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: stringEach 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 nameOne 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 -rfin 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-outEach 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 --safeA 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.