Skip to content

Approval Flows

Some tools should not run without a human nod — PR merges, pod deletions, production SQL writes. Agentcy's approval system pauses the agent loop mid-stream, emits an approval_required event, and blocks on a Postgres record + Tokio oneshot until a decision arrives.

The concept is in Agent Loop — Approvals. This page is the operator and integrator view.

When approvals fire

A tool call requires approval when any of these is true:

  1. The ToolSpec.requires_approval = true.
  2. The tool's effect is write or destructive and the org default requires approval for that effect.
  3. A policy explicitly demands it (Rego rule).

The org defaults:

json
POST /api/v1/settings/security
{
  "approval_defaults": {
    "read":        "allow",
    "write":       "approve",
    "destructive": "approve"
  },
  "approval_timeout_secs": 300
}

The stream-level UX

event: tool_call_start
data: {"tool_call_id":"tc_01","name":"execute_connector_tool","connector":"kubernetes"}

event: approval_required
data: {
  "approval_id":"apr_01",
  "tool":"kubernetes.delete_pod",
  "args":{"cluster":"prod-us","pod":"checkout-6b21"},
  "effect":"destructive",
  "reason":"destructive tool requires approval (policy: default-destructive)",
  "expires_at":"2026-04-24T12:05:00Z"
}

(keepalives while we wait)
event: ping
data: {}

Your frontend should render this as a card with Approve / Deny / a reason input, and call:

bash
# Approve
curl -X POST "http://…/chat/conversations/$CONV/approvals/$APR_ID" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"approved":true}'

# Deny with a note
curl -X POST "http://…/chat/conversations/$CONV/approvals/$APR_ID" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"approved":false,"note":"we froze prod until Thursday"}'

The agent resumes immediately. On deny, the tool call errors with policy_denied and the LLM sees the note; it usually acknowledges and tries a safer path.

Timeout behavior

If no decision arrives before expires_at:

  • The pending record flips to expired.
  • The tool call errors with approval_timeout.
  • The loop emits message_end with finish_reason: "approval_timeout".
  • The UI can re-ask (a new conversation turn).

Default timeout is 300 seconds. Override per-conversation:

bash
curl -X PATCH "http://…/chat/conversations/$CONV" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"approval_timeout_secs":900}'

Escalation

For long-running tasks and channels (WhatsApp, Slack), a 5-minute timeout is often too short. Agentcy supports escalation:

json
{
  "escalation": [
    { "after_secs": 120, "notify": "slack:#ops" },
    { "after_secs": 300, "notify": "email:oncall@…" },
    { "after_secs": 900, "then": "deny" }
  ]
}

Set per-tool (in its ToolSpec) or per-conversation (via PATCH). The notify side uses the configured channel adapters.

Batch approvals

An agent might queue several tool calls behind an approval wall. Approve all at once:

bash
curl -X POST "http://…/chat/conversations/$CONV/approvals/batch" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"approval_ids":["apr_01","apr_02","apr_03"],"approved":true}'

Who can approve

By default, any user in the org can approve any pending call in their conversations. Tighten with a Rego policy:

rego
deny[msg] {
  input.action == "chat.approval.decide"
  input.resource.tool_effect == "destructive"
  not input.subject.role in {"admin","owner"}
  msg := "only admins can approve destructive tools"
}

Programmatic approvers

If you want a bot/agent to auto-approve certain classes of tool calls (e.g. reads from non-prod clusters), issue an API key with a scoped role and run a small subscriber on the activity feed:

python
import requests, os

TOKEN = os.environ["AGENTCY_API_KEY"]
BASE  = "https://your-agentcy/api/v1"

with requests.get(f"{BASE}/activity/stream?types=approval.requested",
                  headers={"authorization":f"Bearer {TOKEN}"}, stream=True) as r:
    for line in r.iter_lines():
        if not line.startswith(b"data:"): continue
        ev = json.loads(line[6:])
        if ev["resource"]["tool"] == "kubernetes.list_pods":
            requests.post(
                f"{BASE}/chat/conversations/{ev['conversation_id']}/approvals/{ev['approval_id']}",
                headers={"authorization":f"Bearer {TOKEN}"},
                json={"approved": True},
            )

Listing pending approvals

bash
curl "http://…/approvals?status=pending&limit=50" -H "authorization: Bearer $TOKEN" | jq

The UI's top-bar bell uses this.

Gotchas

  • Don't lose the approval_id. If your client drops the SSE stream between approval_required and the decision, reconnect with ?after= to re-receive it. Treat approval_id as a durable handle.
  • Approvals do not cache. Approving kubernetes.delete_pod{cluster=prod,pod=checkout-6b21} does not auto-approve the same tool next time. Use policies for "always allow X".
  • Denied calls still cost LLM tokens. The model has to re-plan. Write tight policies to prevent repeated dead-end tool calls.

Next

Built by AgentcyLabs. For in-house deployment or Agentcy Cloud (PaaS) access, visit agentcylabs.com.