Gate a tool with guardrails
By the end of this tutorial you will have an agent whose refund tool is governed: a refund under $100 runs on its own, a larger one waits for a person, and you will approve one held refund and see the agent finish the job.
Six steps:
- Create the tool.
- Write the guardrail.
- Dry-run it.
- Attach it and bind the tool.
- Run a small and a large refund.
- Approve the held refund.
Every step is one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
-
A credential. A
nat_sk_…API key (or a session JWT from Auth) exported asNATURALI_TOKEN, and your client set up — the CLI, the SDK or plaincurlagainsthttps://api.naturali.ai/v1:export NATURALI_TOKEN=nat_sk_...export NATURALI_API=https://api.naturali.ai/v1 # curl examples only -
A working agent. That is what Your first agent generation builds. Arrive with both ids exported:
export PROJECT=proj_cT9LACJi0WypPf5Uexport AGENT=agent_8xS50HUYsaFDGH11The responses below come from an agent whose
instructionsread: "When asked to refund an order, call issue-refund once with the order id and the amount in US dollars, then report the result in one sentence."
Guardrails and approvals
are part of the Pro plan and above. On a lower rung, creating or evaluating a
guardrail and listing approvals answer 403 plan_feature_not_included. The plan
is the project owner's, not the caller's — see Pricing.
1. Create the tool
An http tool that issues a refund. It posts to
httpbin.org, which echoes the request back, so nothing real moves — in your
system this is the endpoint that pays out. The resolved parameters are
sent as the request body.
- CLI
- SDK
- curl
naturali create-tool \
--project-id "$PROJECT" \
--name issue-refund \
--type http \
--description 'Refunds an order. amount is in US dollars.' \
--parameters '{"type":"object","properties":{"order_id":{"type":"string"},"amount":{"type":"number"}},"required":["order_id","amount"]}' \
--execute '{"url":"https://httpbin.org/post","method":"POST"}'
const { data: tool } = await naturali.tools.createTool({
path: { project_id: PROJECT },
body: {
name: 'issue-refund',
type: 'http',
description: 'Refunds an order. amount is in US dollars.',
parameters: {
type: 'object',
properties: {
order_id: { type: 'string' },
amount: { type: 'number' },
},
required: ['order_id', 'amount'],
},
execute: { url: 'https://httpbin.org/post', method: 'POST' },
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tools" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "issue-refund",
"type": "http",
"description": "Refunds an order. amount is in US dollars.",
"parameters": {
"type": "object",
"properties": {
"order_id": { "type": "string" },
"amount": { "type": "number" }
},
"required": ["order_id", "amount"]
},
"execute": { "url": "https://httpbin.org/post", "method": "POST" }
}'
{
"id": "tool_NzCBgtWBAG7QkiaJ",
"project_id": "proj_cT9LACJi0WypPf5U",
"type": "http",
"name": "issue-refund",
"description": "Refunds an order. amount is in US dollars.",
"execute": { "url": "https://httpbin.org/post", "method": "POST" },
"guardrail_ids": null
}
export TOOL=tool_NzCBgtWBAG7QkiaJ
2. Write the guardrail
A guardrail's class is
one JSON Logic expression
over the call's arguments. This one answers A (execute) under $100 and C
(ask a person) otherwise. default_class: "C" means
anything the expression does not anticipate
also waits for a person.
- CLI
- SDK
- curl
naturali create-guardrail \
--project-id "$PROJECT" \
--name 'Refund sign-off' \
--document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 100] }, "A", "C"] }
}'
const { data: guardrail } = await naturali.guardrails.createGuardrail({
path: { project_id: PROJECT },
body: {
name: 'Refund sign-off',
document: {
default_class: 'C',
class: { if: [{ '<': [{ var: 'args.amount' }, 100] }, 'A', 'C'] },
},
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/guardrails" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Refund sign-off",
"document": {
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 100] }, "A", "C"] }
}
}'
{
"id": "guard_WwusNpQOzrgblxuA",
"project_id": "proj_cT9LACJi0WypPf5U",
"name": "Refund sign-off",
"version": 1,
"document": {
"class": { "if": [{ "<": [{ "var": "args.amount" }, 100] }, "A", "C"] },
"default_class": "C"
}
}
export GUARDRAIL=guard_WwusNpQOzrgblxuA
3. Dry-run it
POST /v1/projects/{project_id}/guardrails/{guardrail_id}/evaluate
returns the record a real call
would produce. Nothing executes and no approval
is filed, so this is where you check a document before it governs anything.
- CLI
- SDK
- curl
naturali evaluate-guardrail \
--project-id "$PROJECT" \
--guardrail-id "$GUARDRAIL" \
--args '{"order_id":"1002","amount":400}'
const { data: evaluation } = await naturali.guardrails.evaluateGuardrail({
path: { project_id: PROJECT, guardrail_id: GUARDRAIL },
body: { args: { order_id: '1002', amount: 400 } },
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/guardrails/$GUARDRAIL/evaluate" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "args": { "order_id": "1002", "amount": 400 } }'
{
"kind": "guardrail_evaluation",
"guardrail_id": "guard_WwusNpQOzrgblxuA",
"guardrail_version": 1,
"scope": "tool",
"class": "C",
"decision": "route_to_approval",
"guard_result": null,
"context_snapshot": { "args.amount": 400 }
}
Run it again with "amount": 40 and the answer is class: "A",
decision: "execute".
4. Attach it and bind the tool
Attach the guardrail to the tool, so the tool carries its gate to every agent it is bound to.
- CLI
- SDK
- curl
naturali update-tool \
--project-id "$PROJECT" \
--tool-id "$TOOL" \
--guardrail-ids "$GUARDRAIL"
await naturali.tools.updateTool({
path: { project_id: PROJECT, tool_id: TOOL },
body: { guardrail_ids: [GUARDRAIL] },
});
curl -sS -X PATCH "$NATURALI_API/projects/$PROJECT/tools/$TOOL" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{ \"guardrail_ids\": [\"$GUARDRAIL\"] }"
{
"id": "tool_NzCBgtWBAG7QkiaJ",
"name": "issue-refund",
"guardrail_ids": ["guard_WwusNpQOzrgblxuA"]
}
Then bind the tool to the agent.
- CLI
- SDK
- curl
naturali patch-agent \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--tool-bindings "[{\"tool_id\":\"$TOOL\"}]"
await naturali.agents.patchAgent({
path: { project_id: PROJECT, agent_id: AGENT },
body: { tool_bindings: [{ tool_id: TOOL }] },
});
curl -sS -X PATCH "$NATURALI_API/projects/$PROJECT/agents/$AGENT" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{ \"tool_bindings\": [{ \"tool_id\": \"$TOOL\" }] }"
tool_bindings is replaced wholesale, so send the full set when your agent
already has tools.
5. Run a small and a large refund
Ask for a $40 refund first.
- CLI
- SDK
- curl
naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--messages '[{"role":"user","content":"Refund order 1001: $40."}]'
const { data: small } = await naturali.agents.createAgentGeneration({
path: { project_id: PROJECT, agent_id: AGENT },
query: { wait: true },
body: {
messages: [{ role: 'user', content: 'Refund order 1001: $40.' }],
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$AGENT/generate?wait=true" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "messages": [{ "role": "user", "content": "Refund order 1001: $40." }] }'
{
"id": "gen_yFTEPDBfXGNyQTN9",
"status": "completed",
"output": {
"model": "glm-4.7-flash",
"content": "The refund for order 1001 in the amount of $40 has been processed successfully."
}
}
The guardrail answered A, so the tool ran with nobody involved. Now ask for
$400 — the same call with a different message:
- CLI
- SDK
- curl
naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--messages '[{"role":"user","content":"Refund order 1002: $400."}]'
const { data: large } = await naturali.agents.createAgentGeneration({
path: { project_id: PROJECT, agent_id: AGENT },
query: { wait: true },
body: {
messages: [{ role: 'user', content: 'Refund order 1002: $400.' }],
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$AGENT/generate?wait=true" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "messages": [{ "role": "user", "content": "Refund order 1002: $400." }] }'
{
"id": "gen_TBcvldVPdTcjWclu",
"status": "completed",
"output": {
"model": "glm-4.7-flash",
"content": "Refund request for order 1002 for $400 is pending approval with approval ID apr_diGdFF6EmxwsBlJ8."
}
}
export GENERATION=gen_TBcvldVPdTcjWclu
The generation completes: the tool did not run. Instead the agent got a
pending_approval tool result and an item was
filed on the approvals queue.
Read it:
- CLI
- SDK
- curl
naturali list-approvals \
--project-id "$PROJECT" \
--status pending
const { data: queue } = await naturali.approvals.listApprovals({
path: { project_id: PROJECT },
query: { status: 'pending' },
});
curl -sS "$NATURALI_API/projects/$PROJECT/approvals?status=pending" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"data": [
{
"id": "apr_diGdFF6EmxwsBlJ8",
"origin": "tool_call",
"status": "pending",
"proposed_action": {
"tool_id": "tool_NzCBgtWBAG7QkiaJ",
"action": "issue-refund",
"arguments": { "amount": 400, "order_id": "1002" }
},
"reasoning": "Customer requested refund for order 1002, amount $400.00",
"predicted_impact": "Full refund of $400.00 will be credited back to the customer's payment method.",
"generation_id": "gen_TBcvldVPdTcjWclu",
"agent_id": "agent_8xS50HUYsaFDGH11",
"policy_version": "guard_WwusNpQOzrgblxuA@1",
"expires_at": "2026-10-04T01:34:56.916Z"
}
],
"total": 1
}
export APPROVAL=apr_diGdFF6EmxwsBlJ8
proposed_action.arguments are frozen: approving executes exactly these, never
a value the model writes later. policy_version names the
guardrail version that held the call. An
item not settled by expires_at
can never be approved;
set expires_in on the guardrail document
to change the 24-hour window.
6. Approve the held refund
- CLI
- SDK
- curl
naturali approve-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL"
const { data: item } = await naturali.approvals.approveApproval({
path: { project_id: PROJECT, approval_id: APPROVAL },
body: {},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/approvals/$APPROVAL/approve" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
{
"id": "apr_diGdFF6EmxwsBlJ8",
"status": "approved",
"resolved_by": "user_7x9yZtYrtTbPXbx0",
"edited_arguments": null
}
Approving runs the tool with the frozen arguments — the guardrail is not asked again — and starts a continuation generation that tells the agent the outcome. It is linked to the generation that proposed the call, which is how you find it:
- CLI
- SDK
- curl
naturali list-generations \
--project-id "$PROJECT" \
--initiator-generation-id "$GENERATION"
const { data: continuations } = await naturali.generations.listGenerations({
path: { project_id: PROJECT },
query: { initiator_generation_id: GENERATION },
});
curl -sS \
"$NATURALI_API/projects/$PROJECT/generations?initiator_generation_id=$GENERATION" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"data": [
{
"id": "gen_5EHZBqKyhI6h6O23",
"agent_id": "agent_8xS50HUYsaFDGH11",
"initiator_generation_id": "gen_TBcvldVPdTcjWclu",
"status": "completed",
"stop_reason": "stop"
}
],
"total": 1
}
A completed continuation is the proof: the held refund was executed and the
agent has been told. Its
transcript opens with the
approval message it received, the tool's result inside — "Approval
apr_diGdFF6EmxwsBlJ8 … was approved. The action has been executed. Result: …" —
and ends with the agent's reply: "The refund of $400 for order 1002 has been
successfully processed."
Rejecting instead —
POST /v1/projects/{project_id}/approvals/{approval_id}/reject
with a reason — runs nothing
and tells the agent why.
What's next
- Guardrails — class
Bwith aguardruns a call on its own only while the guard holds, and hard-stops it with a tripwire otherwise. - Approvals → Edit, then approve — approve a smaller amount than the agent proposed, on the record.
- Project-scope guardrails — put a floor under every tool call in the project.
- Exceptions — the queue a tripwire or an expired approval lands in.
- Pause a run for a human decision — a fixed approval step in an orchestration, for a call that always needs a person.