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:
- Widget — "Approve / Reject" buttons + an optional comment field;
- Telegram — an inline keyboard with two buttons, the callback calls
resolve_approval; - VK / Yandex Messenger — a text reply "yes" / "no" (no buttons, but the reply is recognized and resolves the approval).
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:
any/end_user— the buttons are rendered in the same chat (the end user decides for themselves);operator/admin— the buttons are not shown in the chat; instead the user receives "⏳ request forwarded to an operator", and the approval itself goes to the operator queue (see §23.6).
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 # необязательно
- telegram — the bot sends a card with inline buttons "Approve / Reject";
a separate long-poll loop resolves presses via
resolve_approval(the operator name = Telegram id — goes into the audit). - webhook —
POSTJSON of the approval request (all fields ofApprovalRequest) with an optional bearer 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": "Понял, оформляю возврат" }
]
}
user_request— the last user message;messages— the lastrecent_turnsturns (user + assistant);summary— a short LLM summary of the dialogue (whencontext.summary: true; then it replacesmessages).
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:
ProcessEvent::ApprovalResolved { request_id, tool, approved, comment, approver, timestamp }→logs/events.db;- the
kind: "approval"row in the message store.
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)
- A pending approval is stored in memory (
DashMap+ oneshot) — a server restart loses the pending approval. This is consistent with the fact that the whole turn is ephemeral; recovery after restart is future work (viastate_store/checkpoint). - The "safety dialogue" threshold (auto-approval of low risk by severity) is future
work; in v1 the policy is binary (
required: true/false).
23.10 Minimal adoption path
- Core:
ApprovalRequest/ApprovalResolved+ApprovalDecision+request_approval/resolve_approval(clone of the input-request mechanism). - The
approvalfield onTool+ globalapproval.required_tools+ gate inKernel::execute_tool. - Tool
human_approval+ registration in the registry. - Buttons in the widget + Telegram.
- Audit
ApprovalResolved. - Operator queue: registry of
pending_approval_info+GET/POST /v1/approvals- page
/approvals+ routing byapprover.
- page
23.11 Logical approvers (roles), quorum and physical approvers
Who exactly approves can be set in three ways (priority top to bottom):
approvers: [...]— physical approvers as an explicit list of channel ids (ym:<login>,tg:<id>,vk:<id>).role: finance— a logical role (dynamic).approver: any | end_user | operator | admin— routing.
Quorum (quorum) — how many distinct approvers must approve before
the action is performed. Any "no" rejects immediately:
any— one is enough (default);3(number) — a minimum of N distinct approvers;all— all participants of the role/list.
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.