Skip to main content

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:

  1. Create the tool.
  2. Write the guardrail.
  3. Dry-run it.
  4. Attach it and bind the tool.
  5. Run a small and a large refund.
  6. 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​

  1. A credential. A nat_sk_… API key (or a session JWT from Auth) exported as NATURALI_TOKEN, and your client set up — the CLI, the SDK or plain curl against https://api.naturali.ai/v1:

    export NATURALI_TOKEN=nat_sk_...
    export NATURALI_API=https://api.naturali.ai/v1 # curl examples only
  2. A working agent. That is what Your first agent generation builds. Arrive with both ids exported:

    export PROJECT=proj_cT9LACJi0WypPf5U
    export AGENT=agent_8xS50HUYsaFDGH11

    The responses below come from an agent whose instructions read: "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."

Included from the Pro plan

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.

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"}'
{
"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.

naturali create-guardrail \
--project-id "$PROJECT" \
--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.

naturali evaluate-guardrail \
--project-id "$PROJECT" \
--guardrail-id "$GUARDRAIL" \
--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.

naturali update-tool \
--project-id "$PROJECT" \
--tool-id "$TOOL" \
--guardrail-ids "$GUARDRAIL"
{
"id": "tool_NzCBgtWBAG7QkiaJ",
"name": "issue-refund",
"guardrail_ids": ["guard_WwusNpQOzrgblxuA"]
}

Then bind the tool to the agent.

naturali patch-agent \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--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.

naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--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:

naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--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:

naturali list-approvals \
--project-id "$PROJECT" \
--status pending
{
"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​

naturali approve-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL"
{
"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:

naturali list-generations \
--project-id "$PROJECT" \
--initiator-generation-id "$GENERATION"
{
"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​