Action Approval

🔵 A mechanism that stops the agent and waits for a human decision before performing a sensitive action — a payment, a send, a change in the CRM, a deletion. Why it's needed and why you can't trust "the agent will ask on its own."


23.1 Why

An agent becomes more valuable the more sensitive data it touches and the more serious the actions it performs. But those very actions are dangerous: through prompt injection in data (an email, a website page, a document) the model can be forced to do something harmful — refund money, delete emails, send out a message.

If approval is left to the model's discretion ("a tool the agent calls on its own when it decides to"), then an attack will simply "forget" to call it. That's why in Agent OS approval is a mandatory gate in the kernel, not a request. The principle:

A cred authorizes the tool, approval authorizes the action. Together, not separately.

23.2 Two layers

Layer Who initiates Protects Bypass
Kernel gate the kernel, before tool execution sensitive tools always impossible — mandatory
human_approval (tool) the agent, on its own initiative arbitrary decision points possible (this is not a security boundary)

The first layer is the security boundary. The second is ergonomics ("ask a human how to proceed"), reusing the same mechanism.

23.3 Model

Request and decision

// событие, которое ядро рассылает каналу
ProcessEvent::ApprovalRequest {
    pid, session_id, user_id,
    request_id: String,
    tool: String,               // какой тул гейтится
    action: String,             // что увидит человек: «Списать 100 ₽ по счёту №7»
    details: Value,             // аргументы тула (или редakтированная выжимка)
    severity: String,           // low | medium | high
    approver: String,           // any | end_user | operator | admin
    timestamp,
}

// ответ человека
pub struct ApprovalDecision {
    pub approved: bool,
    pub comment: Option<String>,
    pub approver: Option<String>,   // кто нажал — для аудита/подотчётности
}

Policy

# в конфиге инструмента (tool.yaml)
approval:
  required: true        # гейтить каждый вызов
  severity: high        # low | medium | high
  timeout_secs: 600
  on_timeout: deny      # deny | allow (по умолчанию deny)
  approver: any         # end_user | operator | admin | any  (маршрутизация)
  quorum: any           # any | all | <число> — сколько апруверов должно одобрить
  role: finance         # логическая роль (динамическая, см. §23.10)
  # approvers: ["ym:a", "tg:123"]   # ИЛИ физические апруверы явным списком
  context:              # что показать одобряющему, чтобы он понял «почему»
    user_request: true  # последний запрос пользователя (по умолчанию true)
    recent_turns: 3     # сколько последних ходов диалога (0 = не показывать)
    summary: false      # вместо сырых ходов — короткое LLM-резюме диалога
  action: "Списать {{ input.amount }} ₽ со счёта {{ input.account }}"  # текст карточки
  when: "input.amount > 10000"   # гейтить только при истинном условии

Conditional gate (when). A MiniJinja expression over the tool's arguments (in scope — input). The gate applies only if the expression is true; otherwise the call proceeds without approval. This way small operations don't flood the operator. Both the bare form (input.amount > 10000) and the template form ({{ input.amount }} > 10000) are accepted. An invalid expression is treated as true (fail closed) — better to ask than to silently let it through.

Card text (action). A MiniJinja template over the arguments ({{ input.amount }}), replaces the default "Execute the tool action "X"?" An empty/broken value falls back to the default text.

# глобально, в server.yaml — не правя каждый тул
approval:
  required_tools: ["send_email", "refund", "delete_*"]   # glob-паттерны по имени тула
  default_timeout_secs: 600
  # необязательно: кастомный MiniJinja-шаблон рендера контекста одобрения
  context_template: |
    📝 Запрос: {{ user_request }}
    {% for m in messages %}{{ "👤" if m.role == "user" else "🤖" }} {{ m.content }}
    {% endfor %}

The policy for a call is resolved as follows: tool → server. If the tool has approval.required: true — its policy is used; otherwise if the tool name is in server.approval.required_tools — a policy with defaults is synthesized.

23.4 Lifecycle

LLM requested a tool-call
        │
        ▼
execute_tool: access check
        │
        ├── policy doesn't require approval ──▶ execute the tool
        │
        ▼
request_approval: emit ApprovalRequest, block (oneshot)
        │
        ├── channel renders the card ("Approve / Reject" buttons)
        │
        ▼
resolve_approval(request_id, decision)
        │
        ├── approved ──▶ execute the tool, result into the LLM loop
        ├── denied   ──▶ ToolResult{success:false}, the agent sees "rejected: reason"
        └── timeout  ──▶ per on_timeout (deny by default)

Approval blocks the turn (like request_attachment), but doesn't starve other sessions: the concurrency semaphore is held only around provider.stream, so waiting for approval doesn't occupy an inference slot.

23.5 Channels

For the ApprovalRequest event, interactive channels render a card with two buttons rather than text:

Ergonomics rule: approval arrives where the human already is, with context (action + details), in one tap, only at the sensitive moment.

Routing to an operator

The policy's approver field decides to whom to show the approval:

This breaks the coupling "the one the agent is talking to is the one who grants approval": a payment or a charge is approved by the responsible employee, not the client themselves.

Non-interactive channels (cron, email, webhook): there's no one to reply, so on_timeout kicks in — by default deny. For autonomous agents that need pre-authorized actions, use on_timeout: allow explicitly and deliberately (or move sensitive tools into a separate template without a gate).

Delegation to a subagent. When an agent launches another agent as a subagent (an in-process child with a parent link — subagent_tool, while for the engineer it's a static delegate), the child process's approval is forwarded into the parent's chat: the card appears where the human is, and is approved inline (the kernel sends the request along the parent chain too, relay_to_ancestor_streams). Therefore, for real work on another agent, use delegation with parent connection rather than an HTTP launch (agent_run → /v1/inbound): there the caller is lost, and the approval goes to timeout → deny.

23.6 Operator approval queue (inbox)

Operator approvals are visible and resolved in the admin UI (page /approvals) and via the HTTP API:

Endpoint Description
GET /v1/approvals 🔒 list pending approvals (queue)
POST /v1/approvals/:request_id 🔒 resolve: { "approved": true, "comment": "…" }

The queue is a Kernel::list_pending_approvals() (registry of pending_approval_info, populated on request_approval, cleared on resolution/timeout). The resolution resolve_approval wakes the blocked turn, and the operator name is written to the audit (from the RBAC principal) — that very "human name" on the action.

A scoped operator (templates: [...]) sees and resolves only approvals for their own templates; a full admin sees all. Unknown/expired request_id → 404.

Notifying operators in messengers

Besides the web queue, requests can be delivered to operators directly in a messenger / webhook — then approval can be done by pressing a button without opening /approvals:

# server.yaml
approval:
  required_tools: ["refund", "delete_*"]
  operator_notify:
    - type: telegram
      chat_id: -100123456789      # чат операторов
      token_env: TG_OPERATOR_TOKEN
    - type: webhook
      url: https://ops.example.com/approvals
      token_env: OPS_WEBHOOK_TOKEN   # необязательно

Secrets — only via token_env (from .env), as everywhere in the platform.

Request context

"Tool + arguments" is not enough for the approver — they need to understand why the agent is doing this. Therefore the request (ApprovalRequest.context / field context in the queue) is accompanied by dialogue context:

{
  "user_request": "Верни деньги за заказ №7",
  "summary": "Клиент просит вернуть деньги за заказ, агент оформляет возврат",
  "messages": [
    { "role": "user", "content": "Верни деньги за заказ №7" },
    { "role": "assistant", "content": "Понял, оформляю возврат" }
  ]
}

Controlled by approval.context.{user_request, recent_turns, summary} in the tool policy (see §23.3). The context is rendered everywhere: in widget/Telegram cards, in the text response of VK/Yandex, in the /approvals queue and in operator_notify.

Rendering is configured by the global MiniJinja template approval.context_template (variables: user_request, summary, messages). Empty = default rendering (summary with prefix 📝, or Запрос: … + lines with 👤/🤖 icons, up to ~200 characters per line). On template error — fallback to default.

23.7 Tool human_approval

An agent-initiated request for a decision (layer 2):

kind: human_approval
name: ask_before_send
description: "Спросить человека, отправлять ли рассылку."
prompt: "Отправить клиенту?"
timeout_secs: 600

Call parameters: action (what to show), details (JSON context), timeout_secs. Returns { approved, comment } to the LLM loop.

23.8 Audit

Every decision is recorded:

This gives a "human name" on every sensitive action: who, when, what and by what decision approved it. Without this, approval is just a pause.

23.9 Limitations (v1)

23.10 Minimal adoption path

  1. Core: ApprovalRequest/ApprovalResolved + ApprovalDecision + request_approval/resolve_approval (clone of the input-request mechanism).
  2. The approval field on Tool + global approval.required_tools + gate in Kernel::execute_tool.
  3. Tool human_approval + registration in the registry.
  4. Buttons in the widget + Telegram.
  5. Audit ApprovalResolved.
  6. Operator queue: registry of pending_approval_info + GET/POST /v1/approvals
    • page /approvals + routing by approver.

23.11 Logical approvers (roles), quorum and physical approvers

Who exactly approves can be set in three ways (priority top to bottom):

  1. approvers: [...] — physical approvers as an explicit list of channel ids (ym:<login>, tg:<id>, vk:<id>).
  2. role: finance — a logical role (dynamic).
  3. approver: any | end_user | operator | admin — routing.

Quorum (quorum) — how many distinct approvers must approve before the action is performed. Any "no" rejects immediately:

Roles (logical approvers)

A role is a named set of participants + quorum, stored in DuckDB (logs/approval_roles.db) and managed via the API without redeployment:

Endpoint Description
GET /v1/approvals/roles 🔒 list roles
POST /v1/approvals/roles 🔒 create/update role {name, quorum, members, enabled}
DELETE /v1/approvals/roles/:name 🔒 delete role
POST /v1/approvals/roles/:name/members 🔒 add participant {member}
DELETE /v1/approvals/roles/:name/members/:member 🔒 remove participant

Hired an employee → POST .../members {"member":"ym:new_login"} — we don't touch the agent or YAML. Fired → DELETE. On invocation, the gate resolves the role into the current list of participants and quorum; role not found/disabled → fail-closed (timeout → deny).

Participants are delivered by channel prefix via operator_notify (ym: → Yandex Messenger, tg: → Telegram with buttons, vk: → VK); each participant responds in their own messenger, and the core collects decisions until quorum.