Tools
Capabilities an agent can call — an HTTP endpoint, an MCP server, your own client, or a pipeline of the three.
Overview
A tool is what an agent calls out to. It is registered per
project and attached to an agent through its
tool_bindings — by reference, or inline for a one-off.
Gate a tool with guardrails
binds an http tool by reference.
type | What it calls |
|---|---|
http | An HTTP endpoint you name, with placeholders, headers and a body mode. |
mcp | An MCP server, optionally scoped to an allowlist of its tools. |
client | Nothing server-side: the run pauses and your code supplies the result. |
pipeline | An ordered sequence of other tools, wiring each step's input from earlier results. |
This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Tools.
Data Model
Tool
| Field | Type | Description |
|---|---|---|
id | string | Public tool ID (tool_ prefix). |
project_id | string | The owning project. |
name | string | Human-readable label. |
description | string, nullable | |
type | http | client | mcp | pipeline | The tool's execution kind. Defaults to http. |
parameters | object | JSON-Schema-shaped parameters the tool accepts. |
execute | object, nullable | HTTP execution config (type: http only): url (required, may carry {param} placeholders), method, headers, body_mode. |
output_mapping | object, nullable | JSON Logic mapping applied to the raw result — see output_mapping. |
preset_parameters | object, nullable | Parameters pinned on the tool, on every call it makes, whatever its type. A pinned key is removed from the schema the model sees, and its value wins over one the model or a direct caller supplies. Values accept {{context:<key>}} — see Per-call context in preset_parameters. |
mcp | object, nullable | MCP server config (type: mcp only). |
actions | string[], nullable | Allowlist of MCP actions exposed (type: mcp only). |
denied_actions | string[], nullable | Denylist of MCP actions (type: mcp only). |
context_keys | string[], nullable | Which context keys may reach this tool as headers. null forwards every key the caller supplied. See Per-user credentials. |
pipeline | object, nullable | Step definition (type: pipeline only). |
guardrail_ids | string[], nullable | Guardrails attached at the tool scope, governing this tool wherever it is used. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
Secrets and per-call context in headers
A tool's own headers — on execute for http, on mcp for MCP — accept two
kinds of reference, resolved at call time rather than stored resolved:
{{secret:<id>}} pulls a secret, and {{context:<key>}} pulls a
value from the call's tool_context. A static credential belongs in the first; a
per-user one in the second.
Per-call context in preset_parameters
{{context:<key>}} also resolves inside preset_parameters values, not just
headers — so a pin can be the caller's own value instead of one fixed when
the tool was created. This is how a tool pins a parameter to "the one account
this run may act on" rather than a value baked in at write time:
{ "preset_parameters": { "account_id": "{{context:accountId}}" } }
The resolved value is retyped to the parameter's declared schema type — a
tool_context entry is always a string, but a parameter typed integer gets
an integer. A key missing from the call's tool_context fails the call with
MISSING_TOOL_CONTEXT_KEY, the same fail-closed behavior as a missing header
reference, rather than sending the literal placeholder through.
{{secret:<id>}} is not resolved in preset_parameters — only in
headers. A secret belongs in a header a tool's own endpoint reads, never in
a parameter value the model or the tool call log can see.
Per-user credentials reach a tool as a header
A tool endpoint often needs to act as the person the run is for, not as the project: one user's calendar, one user's orders, one user's account on a third-party API. Putting that credential in the tool's own headers cannot express it — those headers are fixed at write time and shared by every call.
The credential travels per call instead, in the tool_context a caller attaches
when it starts work. The runtime forwards each entry as a request header on
every http/mcp tool call the agents make, named X-Naturali-Context-
followed by your key verbatim:
X-Naturali-Context-ocaToken: usr_token_V1StGXR8Z5jdHi6B
Two properties are worth reading twice. The key is appended verbatim — no
character is re-cased and no separator is inserted, so ocaToken and
oca_token are two different keys producing two different headers. And the
prefix is always applied: a caller cannot name an arbitrary header, which is
what stops caller-supplied data from overwriting a credential the tool
configured for itself.
Read the header case-insensitively. HTTP field names are case-insensitive and
HTTP/2 sends them lowercased, so your endpoint will usually see
x-naturali-context-ocatoken however you spelled the key:
// Node / Express / Koa — incoming header names are lowercased for you
const ocaToken = req.headers['x-naturali-context-ocatoken'];
Landing the value in Authorization
Most targets want the credential in the header they already use, not in a
prefixed one. The tool says so, with a {{context:<key>}} reference in its
own headers — the tool knows the header shape its endpoint expects, and the
caller only supplies the value:
Authorization: 'Bearer {{context:ocaToken}}'
A reference is legal in headers and nowhere else, url included: a context
value comes from the caller, so it must never steer the outbound request. If the
key is missing from the tool_context at call time the tool call fails, rather
than sending Bearer with nothing after it and coming back as an opaque 401
several steps from the mistake.
Confine it with context_keys
By default every key in the bag goes to every tool the agent can call,
including endpoints you do not control. context_keys is the allowlist that
stops that: set it to the keys a tool legitimately needs, and a credential meant
for one endpoint stops egressing to the rest of the tool set. [] forwards
none, and null (the default) forwards all.
A key the tool consumes through a {{context:...}} reference in its own headers
is substituted either way — the tool declared that header itself.
Create such a tool — a per-user credential in Authorization, and nothing else
in the bag reaching it:
- CLI
- SDK
- curl
naturali create-tool \
--project-id proj_V1StGXR8Z5jdHi6B \
--name oca \
--type mcp \
--mcp '{"url":"https://mcp.example.com/sse","headers":{"Authorization":"Bearer {{context:ocaToken}}"}}' \
--context-keys ocaToken
const { data: tool } = await naturali.tools.createTool({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
name: 'oca',
type: 'mcp',
mcp: {
url: 'https://mcp.example.com/sse',
headers: { Authorization: 'Bearer {{context:ocaToken}}' },
},
context_keys: ['ocaToken'],
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/tools \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "oca",
"type": "mcp",
"mcp": {
"url": "https://mcp.example.com/sse",
"headers": { "Authorization": "Bearer {{context:ocaToken}}" }
},
"context_keys": ["ocaToken"]
}'
Then supply the value when you start the work: tool_context is a field on a
session (forwarded on every call in that session), on a single
agent generation, on a direct
call-tool request, and — write-only — on an
eval run and on a
trigger, which is what
puts an agent built this way on a schedule: a firing has no caller to carry a
credential, so the trigger holds the bag, and a {{secret:...}} reference in it
keeps the value in the secret store.
The headers are worth trusting only if the endpoint also authenticates the
request as coming from your naturali deployment — a shared secret in the tool's
own headers, mTLS, or a network restriction. Anyone who can reach the endpoint
can set an X-Naturali-Context-* header by hand.
When a tool does not resolve
An mcp binding is resolved at the start of every turn by listing the server's
tools. When that listing does not arrive — the server unreachable or refusing
the credential, a {{secret:…}} in the URL or headers that does not resolve —
or arrives and leaves nothing to attach, the binding is dropped and the turn
runs without it. One flaky server does not take an agent down.
The generation still completes, carrying no error. The model is told which of its tools are unavailable for the turn, and instructed to say so plainly rather than claim it lacks the capability — but it is not told why. The reason is operator-grade and would become text the model may repeat to an end user, so it goes to the activity feed instead. An expired token therefore reads to your end user as a data source that could not be reached, not as a product limitation, and not as a cause the model guessed at.
The record is an
activity entry of kind
tool_resolution_failed, severity warning, naming the binding, the reason and
the generation. Nothing else reports it: poll or alert on
?kind=tool_resolution_failed.
An actions allowlist that excludes every tool the server returns drops the
binding too, and says so in detail.reason — a binding that legitimately
matches nothing is indistinguishable from a broken one until you read it.
Zero tool calls is not proof a restriction held
A generation that completes without calling a tool looks identical whether
actions withheld the tool or the credential behind it was dead. Asking the
agent for the forbidden thing and watching it decline does not tell the two
apart. The activity feed does: an entry for that binding means it was dropped,
and the refusal you observed says nothing about the allowlist. No entry means
the binding attached what the allowlist leaves, and the model had those tools
to call.
Calling a tool directly
POST /v1/projects/{project_id}/tools/{tool_id}/call
invokes a tool without an agent — the fastest way to check that a tool's URL,
headers and mapping are right before binding it. http, mcp and pipeline
tools are supported; a client tool answers 422, because there is no agent run
to pause and nobody to hand the call to. For an mcp tool, action names the
MCP tool to invoke, and an action outside the tool's actions allowlist is
rejected before any outbound request is made.
It is also what tells a broken tool from a model calling a working one badly —
see Debug a failed run.
The request body also takes its own tool_context, narrowed by the tool's
context_keys allowlist like any other caller — there is no session or
generation behind this call, so session_id, actor_id and actor_external_id
are dropped from the bag rather than forwarded.
A direct call is a run, like every tool call
(Runs and the allowance). On Free, once the
account has used its runs for the month, it answers 403 plan_limit_reached
with resource: "runs" and calls nothing. A negative credit balance does not
refuse it: a tool call spends no credit. Not in the generated spec.
client tools pause the run
A client-type tool has no server-side execution: when the model calls it the
generation stops with requires_action and the pending call
on it. Your code does the work and posts the result back — to
…/agents/{agent_id}/generate/{generation_id}/tool-outputs
for a one-shot run, or
…/sessions/{session_id}/tool-outputs
inside a session — and the run continues from where it stopped.
Run tools in your own code
walks the pause,
the submit
and the resumed run.
output_mapping sees the result and the input
output_mapping's JSON Logic is evaluated over
{ output: <raw result>, input: <merged input> }, for every tool type. So
{ "var": "output.text" } extracts a bare scalar instead of returning the whole
envelope, and { "var": "input.title" } echoes back a field of the request that
produced the response — which is how you carry a value through a call that does
not return it. For a pipeline tool it runs after the pipeline's own output
mapping, over that mapping's result.
http execution always sends a body, even for GET
An http tool's resolved parameters are sent as the request body regardless
of execute.method. Most servers ignore a body on a GET request, but some
reject it outright; if a GET tool call fails against a target that behaves
this way, that is usually why — switching the target's own route to accept
POST, where one is available, sidesteps it.
Examples
- CLI
- SDK
- curl
naturali create-tool \
--project-id proj_V1StGXR8Z5jdHi6B \
--name get-weather \
--type http
const { data: tool } = await naturali.tools.createTool({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { name: 'get-weather', type: 'http' },
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/tools \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "get-weather", "type": "http" }'