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}.

Anatomy of a tool node
{
  "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": true so 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.

FieldTypeDescription
{key}substitutionReplaced with the value of the top-level state key 'key'. Powered by Python str.format, so dict indexing like {obj[field]} also works.
scopepublic stateOnly non-internal keys are visible. Keys the engine writes with a __ prefix (loop counters, bookkeeping) are hidden from templates.
unknown keyno-opA placeholder with no matching key raises internally and is swallowed — the original string is passed through unchanged, never blanked.
recursiondeepRendering walks nested objects and arrays, so {placeholders} work anywhere inside params, at any depth.
How a template resolves
# 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"}
}
Because templating always produces a string, a numeric state value becomes its string form when interpolated into a larger string. Pass a value on its own (e.g. "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.

FieldTypeDescription
urlstringTarget URL. Supports {placeholders}. Missing url fails the node.
methodstringGET, POST, PUT, PATCH, DELETE, HEAD — case-insensitive.
headersobjectRequest headers.
queryobjectQuery-string parameters, appended to the URL.
bodyobjectJSON body. Sent as JSON for non-GET/HEAD methods only; ignored for GET/HEAD.
Node config
{
  "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"
}
Output shape
{ "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.

FieldTypeDescription
textstringMessage text. Supports {placeholders}.
webhook_urlstringPer-node override of the global webhook URL.
Output shape
{ "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.

FieldTypeDescription
tostringRecipient address.
subjectstringSubject line. Supports {placeholders}.
bodystringPlain-text body — sent as Resend's 'text'. Supports {placeholders}.
Output shape
{ "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.

FieldTypeDescription
secondsnumberSleep duration in seconds, hard-capped at 300.
Output shape
{ "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.

FieldTypeDescription
server_urlstringThe MCP server endpoint. Supports {placeholders}.
toolstringName of the tool to invoke on that server.
argumentsobjectTool arguments. String values support {placeholders}.
headersobjectExtra request headers, e.g. Authorization.
Output shape
{ "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.

FieldTypeDescription
HTTP_TOOL_BLOCK_PRIVATEenvSet to 1 to enable the guard. Default 0 (off) so localhost targets work in development.
blocked rangesis_private (10/8, 172.16/12, 192.168/16, …), is_loopback (127/8, ::1), is_link_local (169.254/16, fe80::/10), is_reserved.
resolutionThe 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 point http_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

FieldTypeDescription
namestringSlug referenced as custom:<name>. Pattern ^[a-z0-9][a-z0-9-_]{1,40}$ — lowercase, 2–41 chars. Unique per workspace.
labelstringHuman-friendly display name shown in the builder.
descriptionstringWhat the tool does; surfaced in the palette.
kind'http' | 'mcp'http: call your endpoint with JSON. mcp: tools/call against an MCP server.
endpoint_urlstringYour endpoint (http) or the MCP server URL (mcp). 1–2000 chars.
methodstringHTTP method for http-kind tools; stored uppercased.
headersobjectSent on every call — e.g. your service's auth token.
mcp_toolstringThe remote tool name to call. Required when kind is mcp.
input_schemaobjectJSON Schema. Arguments are validated against it before every call.
timeout_secondsnumberPer-call timeout, 1–600. Also the calling node's default timeout.
retry_countnumber0–5. The calling node's default retry count.
POST/api/tools

Register 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.

Register an HTTP agent
{
  "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
}
Register an MCP tool
{
  "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.

A node calling custom:score-lead
{
  "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 every custom:<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 required must be present
  • Keys declared in properties with a known type (string, number, integer, boolean, object, array) are type-checked
  • Booleans are rejected where a number or integer is expected (Python treats bool as 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
A validation failure
# 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).

FieldTypeDescription
allowed_workflow_idsstring[]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_argumentsobjectA 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.
A tool that only two workflows may call, and never with action=delete
{
  "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)
These are set on the tool via the same 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/call to mcp_tool (falling back to the tool name), returning the same { "result": ... } shape as mcp_call.

Testing a registered tool

POST/api/tools/{id}/test

Invoke the tool once with sample arguments — schema validation included, no retries. Always 200; the body reports whether the call itself succeeded.

Request → response
{ "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

GET/api/tools

Every registered tool, ordered alphabetically by name.

GET/api/tools/{id}

Fetch one tool by id. 404 if it does not exist.

PUT/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.

DELETE/api/tools/{id}

Remove a tool. Workflows still referencing it fail validation before their next run. Returns 204.

Register, test, and wire up by curl
# 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.