Reference
Tool reference
Five built-in tools cover HTTP, Slack, email, pacing, and MCP — and the tool registry turns your own agents and services into first-class custom: nodes. Params template against run state, results land back in state for downstream nodes, and one SSRF guard governs every outbound call.
A tool node is one of the six node types the executor understands. It names its tool with tool_name, passes a params object, and writes whatever the tool returns into run state under an output_key (falling back to the node id). Downstream nodes read that key by name — both in expressions and in {placeholders}.
{
"id": "enrich",
"type": "tool",
"config": {
"label": "Enrich lead",
"tool_name": "http_request", // a built-in, or "custom:<name>"
"params": { "url": "https://api.example.com/enrich/{lead_id}" },
"output_key": "enrichment", // where the result lands in state
"timeout_seconds": 30, // optional node override
"retry_count": 2 // optional node override
}
}
Two rules apply to every tool, built-in or custom:
- Templating — every string value in
params(recursively, through nested objects and lists) has{placeholders}replaced from run state before the call. An unresolved placeholder leaves the string untouched rather than erroring. - Simulated mode — Slack and email degrade gracefully when their credentials are unset: the call succeeds, does nothing external, and its output reports
"simulated": trueso workflows stay runnable in development.
Templating params from state
Before a tool runs, the engine renders its params against the run's public state. State starts as the run input (both the whole object under input and each of its top-level keys spread alongside), then grows as each node writes its output_key.
| Field | Type | Default | Description |
|---|---|---|---|
| {key} | substitution | — | Replaced with the value of the top-level state key 'key'. Powered by Python str.format, so dict indexing like {obj[field]} also works. |
| scope | public state | — | Only non-internal keys are visible. Keys the engine writes with a __ prefix (loop counters, bookkeeping) are hidden from templates. |
| unknown key | no-op | — | A placeholder with no matching key raises internally and is swallowed — the original string is passed through unchanged, never blanked. |
| recursion | deep | — | Rendering walks nested objects and arrays, so {placeholders} work anywhere inside params, at any depth. |
# run input: {"lead_id": "L-88", "score": 72}
# after a 'classify' agent node writes output_key "tier"
"params": {
"url": "https://api.example.com/leads/{lead_id}", // -> .../leads/L-88
"body": { "score": "{score}", "tier": "{tier}" } // -> {"score":"72","tier":"warm"}
}
"score": "{score}") only when the receiving endpoint accepts a string; otherwise branch or transform it in a prior node.http_request
Generic HTTP client built on httpx. Follows redirects; 60-second network timeout. The method is uppercased; a request body is sent only for methods other than GET and HEAD.
| Field | Type | Default | Description |
|---|---|---|---|
| url | string | required | Target URL. Supports {placeholders}. Missing url fails the node. |
| method | string | GET | GET, POST, PUT, PATCH, DELETE, HEAD — case-insensitive. |
| headers | object | {} | Request headers. |
| query | object | {} | Query-string parameters, appended to the URL. |
| body | object | unset | JSON body. Sent as JSON for non-GET/HEAD methods only; ignored for GET/HEAD. |
{
"tool_name": "http_request",
"params": {
"method": "POST",
"url": "https://api.example.com/leads/{lead_id}/enrich",
"headers": { "Authorization": "Bearer sk-internal-token" },
"query": { "expand": "firmographics" },
"body": { "source": "ballast", "score": "{score}" }
},
"output_key": "enrichment"
}
{ "status_code": 200, "body": { ...parsed JSON... } }
// non-JSON responses: "body" is the response text, truncated to 5,000 chars
A non-2xx status is data, not a failure
http_request never fails the node on an HTTP error status — the code is returned as status_code for you to branch on, e.g. a condition node with enrichment['status_code'] == 200. Only transport-level problems — DNS failure, connection refused, timeout, a blocked SSRF target — raise and trigger the node's retries. (Registered custom: HTTP tools behave differently — see below.)slack_message
Posts a message to a Slack incoming webhook. The webhook comes from the webhook_url param if present, otherwise the SLACK_WEBHOOK_URL environment variable. With neither set, the call is a logged no-op. 30-second timeout; a non-2xx response from Slack does raise and fails the node.
| Field | Type | Default | Description |
|---|---|---|---|
| text | string | "" | Message text. Supports {placeholders}. |
| webhook_url | string | SLACK_WEBHOOK_URL | Per-node override of the global webhook URL. |
{ "sent": true, "text": "..." }
// without any webhook configured (simulated no-op):
{ "sent": false, "simulated": true, "text": "..." }
send_email
Sends a plain-text email through Resend when RESEND_API_KEY is set; a logged no-op otherwise. The body param maps to Resend's text field. The from address is EMAIL_FROM (default agentos@example.com). 30-second timeout.
| Field | Type | Default | Description |
|---|---|---|---|
| to | string | "" | Recipient address. |
| subject | string | "" | Subject line. Supports {placeholders}. |
| body | string | "" | Plain-text body — sent as Resend's 'text'. Supports {placeholders}. |
{ "sent": true, "to": "ops@example.com", "subject": "..." }
// without RESEND_API_KEY (simulated no-op):
{ "sent": false, "simulated": true, "to": "...", "subject": "..." }
delay
Pauses the branch for a fixed number of seconds. Useful for pacing external systems, spacing retriable side effects, and exercising parallelism or cancellation in tests. The value is capped at 300 seconds.
| Field | Type | Default | Description |
|---|---|---|---|
| seconds | number | 1 | Sleep duration in seconds, hard-capped at 300. |
{ "slept_seconds": 30 }
mcp_call
Calls a tool on any MCP server over the streamable-HTTP transport. The engine performs the full handshake for each call — initialize (protocol 2025-06-18), an notifications/initialized notification, then tools/call — carrying any Mcp-Session-Id the server returns across the subsequent requests. Both server_url and tool are required.
| Field | Type | Default | Description |
|---|---|---|---|
| server_url | string | required | The MCP server endpoint. Supports {placeholders}. |
| tool | string | required | Name of the tool to invoke on that server. |
| arguments | object | {} | Tool arguments. String values support {placeholders}. |
| headers | object | {} | Extra request headers, e.g. Authorization. |
{ "result": ... }
// preference order for "result":
// 1. the server's structuredContent, if present
// 2. otherwise the joined text content, JSON-parsed when it parses
// 3. otherwise the raw text
A tool result the server flags with isError raises and fails the node, as does any HTTP status ≥ 400 or a JSON-RPC error during the handshake. The SSRF guard below applies to the server_url exactly as it does to http_request.
The SSRF guard
Every outbound target — http_request, mcp_call, and both kinds of registered custom tool — passes through one guard before the request is made. When the environment variable HTTP_TOOL_BLOCK_PRIVATE is set to 1 (recommended in any shared or production deployment), the guard resolves the target host and refuses the call if any resolved address is private, loopback, link-local, or reserved. A host that cannot be resolved, or a URL with no host, is also refused.
| Field | Type | Default | Description |
|---|---|---|---|
| HTTP_TOOL_BLOCK_PRIVATE | env | 0 | Set to 1 to enable the guard. Default 0 (off) so localhost targets work in development. |
| blocked ranges | — | — | is_private (10/8, 172.16/12, 192.168/16, …), is_loopback (127/8, ::1), is_link_local (169.254/16, fe80::/10), is_reserved. |
| resolution | — | — | The host is resolved via getaddrinfo and every A/AAAA record is checked — a domain that resolves to a private IP is blocked too. |
Turn this on before you expose the platform
With the guard off, a workflow author (or a compromised upstream that controls a templated URL) could pointhttp_request at http://169.254.169.254/ or an internal service and exfiltrate its response through run output. Set HTTP_TOOL_BLOCK_PRIVATE=1 and keep genuinely internal endpoints behind an allowlisted egress proxy you trust.The custom tool registry
Beyond the built-ins, the registry turns any HTTP endpoint or any MCP tool into a first-class node. Register it once, then reference it in a tool node as custom:<name>. This is the primary way to bring your own agents and services into a workflow — the endpoint keeps running in your process; Ballast wraps it with checkpoints, retries, timeouts, gates, cost attribution, and audit.
Registration fields
| Field | Type | Default | Description |
|---|---|---|---|
| name | string | required | Slug referenced as custom:<name>. Pattern ^[a-z0-9][a-z0-9-_]{1,40}$ — lowercase, 2–41 chars. Unique per workspace. |
| label | string | = name | Human-friendly display name shown in the builder. |
| description | string | "" | What the tool does; surfaced in the palette. |
| kind | 'http' | 'mcp' | http | http: call your endpoint with JSON. mcp: tools/call against an MCP server. |
| endpoint_url | string | required | Your endpoint (http) or the MCP server URL (mcp). 1–2000 chars. |
| method | string | POST | HTTP method for http-kind tools; stored uppercased. |
| headers | object | {} | Sent on every call — e.g. your service's auth token. |
| mcp_tool | string | null | The remote tool name to call. Required when kind is mcp. |
| input_schema | object | {type:object} | JSON Schema. Arguments are validated against it before every call. |
| timeout_seconds | number | 60 | Per-call timeout, 1–600. Also the calling node's default timeout. |
| retry_count | number | 1 | 0–5. The calling node's default retry count. |
/api/toolsRegister an HTTP endpoint (kind http) or MCP tool (kind mcp). Returns 201 with the stored tool. 400 if the name is taken, or if kind is mcp without mcp_tool.
{
"name": "score-lead",
"label": "Score lead",
"description": "Scores an inbound lead 0-100.",
"kind": "http",
"endpoint_url": "https://my-agent.example.com/score",
"method": "POST",
"headers": { "Authorization": "Bearer sk-..." },
"input_schema": {
"type": "object",
"properties": { "company": { "type": "string" }, "employees": { "type": "integer" } },
"required": ["company"]
},
"timeout_seconds": 60,
"retry_count": 1
}
{
"name": "search-crm",
"kind": "mcp",
"endpoint_url": "https://crm.example.com/mcp",
"mcp_tool": "search_accounts",
"input_schema": {
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}
}
Referencing a registered tool
In a tool node, set tool_name to custom:<name>. At run start the executor loads every registered tool once; the node's params are rendered from state and become the request body (or MCP arguments). If the node does not set its own timeout_seconds or retry_count, it inherits the values from the registration — so you can tune reliability once at the tool and every referencing node picks it up.
{
"type": "tool",
"config": {
"tool_name": "custom:score-lead",
"params": { "company": "{company}", "employees": "{employee_count}" },
"output_key": "lead_score"
}
}
Broken references fail before a run, not during one
Graph validation checks everycustom:<name> against the registry, so deleting or renaming a tool that a workflow still uses surfaces as a validation error at trigger time. If a tool vanishes between validation and execution, the node fails with a clear "Registered tool <name> no longer exists" message rather than a mystery 404.Argument validation
Before any network call, arguments are checked against input_schema. Validation is deliberately minimal — required keys and primitive types — and any violation fails the node up front, so a bad payload never reaches your endpoint.
- Every key listed in
requiredmust be present - Keys declared in
propertieswith a knowntype(string,number,integer,boolean,object,array) are type-checked - Booleans are rejected where a
numberorintegeris expected (Python treatsboolas an int, so the guard excludes it explicitly) - Nested schemas, formats, enums, and constraints are not enforced — validate deeper inside your own endpoint if you need it
# node passes { "employees": true } against { "employees": {"type":"integer"} }, missing "company"
arguments failed schema validation: missing required argument 'company'; argument 'employees' must be of type integer
Runtime governance: allowlists & argument policy
A registered tool carries two governance controls, both enforced at run timeinside the executor — so hand-editing a workflow's JSON to slip in a tool node can't bypass them. A denial fails the node and is recorded as a first-class governance policy event (there is nothing to see in the audit-log of graph edits — the decision happens when the tool runs).
| Field | Type | Default | Description |
|---|---|---|---|
| allowed_workflow_ids | string[] | [] (any) | Which workflows may invoke this tool. Empty means any workflow; list the ids that are allowed. A call from a workflow not on a non-empty list is refused — recorded as policy_denied (policy: tool_permission). |
| blocked_arguments | object | {} | A per-argument value deny-list, { arg_name: [blocked values] }. A call passing a blocked value for that argument is refused — recorded as tool_argument_blocked. Enforced against the rendered params at call time. |
{
"name": "crm-write",
"kind": "http",
"endpoint_url": "https://crm.example.com/write",
"allowed_workflow_ids": ["9bc62f209c52", "af772b3602ef"],
"blocked_arguments": { "action": ["delete", "purge"] }
}
// a call with { "action": "delete" } fails:
// Argument 'action' is not permitted for tool 'crm-write' (governance)
POST /api/tools / PUT /api/tools/{id} body. Because enforcement is at execution time, changing a policy takes effect on the very next run without re-saving any workflow that uses the tool.HTTP vs MCP execution
The two kinds differ in how arguments travel and how errors are treated:
- http — for non-GET/HEAD methods the rendered params are sent as the JSON body; for GET/HEAD they become the query string. Unlike the built-in
http_request, a response status ≥ 400 does fail the node (with the status and a truncated body in the error). A JSON object response is returned as-is; anything else is wrapped as{ "result": ... }. - mcp — the rendered params become the tool arguments for a
tools/calltomcp_tool(falling back to the toolname), returning the same{ "result": ... }shape asmcp_call.
Testing a registered tool
/api/tools/{id}/testInvoke the tool once with sample arguments — schema validation included, no retries. Always 200; the body reports whether the call itself succeeded.
{ "arguments": { "company": "Acme", "employees": 240 } }
// →
{ "ok": true, "output": { "score": 87 }, "duration_ms": 214 }
// on any error (schema, transport, non-2xx) — still HTTP 200:
{ "ok": false, "error": "arguments failed schema validation: missing required argument 'company'", "duration_ms": 3 }
The test path runs a single attempt — it does not apply the retry count — so it is a faithful check of one live call against your endpoint and its schema.
Managing registered tools
/api/toolsEvery registered tool, ordered alphabetically by name.
/api/tools/{id}Fetch one tool by id. 404 if it does not exist.
/api/tools/{id}Update any subset of fields; only the fields you send change. Workflows referencing custom:<name> pick up the change on their next run. method is re-uppercased.
/api/tools/{id}Remove a tool. Workflows still referencing it fail validation before their next run. Returns 204.
# register
$ curl -s -X POST http://localhost:8000/api/tools \
-H "X-API-Key: aos_..." -H "content-type: application/json" \
-d '{"name":"score-lead","kind":"http",
"endpoint_url":"https://my-agent.example.com/score",
"input_schema":{"type":"object","properties":{"company":{"type":"string"}},"required":["company"]}}'
# -> 201 { "id": "3c9a1e02fb47", "name": "score-lead", ... }
# test one live call
$ curl -s -X POST http://localhost:8000/api/tools/3c9a1e02fb47/test \
-H "X-API-Key: aos_..." -H "content-type: application/json" \
-d '{"arguments": {"company": "Acme"}}'
# -> { "ok": true, "output": { "score": 87 }, "duration_ms": 214 }
# then reference it in a workflow node as tool_name "custom:score-lead"
Failure and retries
A tool that raises — unreachable host, blocked SSRF target, failed schema validation, a custom HTTP tool's non-2xx, an MCP isError, or an unknown tool_name — fails its node. The node then retries per its effective retry_count(node override, else the tool's registration, else 1) with exponential backoff, under its effective timeout_seconds (node override, else registration, else 120). If every attempt fails, the node fails and, unless a branch handles it, the run fails — resumable from its last checkpoint. Unknown built-in tool names are also rejected at validation time, before a run ever starts.