Skip to content

Runtime safety

Runtime safety controls act on agent execution while it runs, not on configuration. This page describes the controls of ADR-0041.

An agent can start to do damage after it is deployed. For example, it can send repeated messages to customers, call a toolkit with a leaked credential, or use a large part of the budget in a loop. Deleting the agent or editing its configuration is too slow, and it does not stop the runs that are in flight. An operator must be able to stop the execution immediately, at the correct scope, and start it again when the incident is closed.

A suspension has one of four scopes:

ScopeWhat it stopsWho can create it
agentEvery run of one agent in its projectA project admin of that project, or a platform administrator
projectEvery run in one project: agent chats, pipeline runs, index runs, direct toolkit runsA project admin of that project, or a platform administrator
toolkit_typeEvery call of one toolkit type (for example github) on the platformA platform administrator
platformEvery run on the platformA platform administrator, who must also type STOP

When a suspension is created, the platform does these steps:

  1. Admission refuses new runs. Every path that starts or continues a run checks the active suspensions before it writes anything: a chat turn, a continue or regenerate, a pipeline trigger or schedule, an index run, a direct toolkit run, a toolkit tool call, a desktop child turn, and the support assistant. A covered run is refused with HTTP 409 and the typed error execution_suspended. The error names the scope, but not the reason:

    {
    "error": "An administrator stopped this agent.",
    "code": "execution_suspended",
    "message": "An administrator stopped this agent.",
    "scope": "agent",
    "scope_key": "31",
    "suspension_id": 12
    }

    A scheduled pipeline run that is refused is recorded as failed with the detail skipped: an administrator suspended execution (execution_suspended).

  2. Runs in flight are cancelled. In the same database transaction that saves the suspension, the platform sets the desired state of each covered run to CANCELLED. This is the same cancel that the admin Tasks page uses. The worker stops the run at its next lease renewal. The cancelled_runs field of the suspension gives the number of runs it cancelled. A platform suspension cancels every run in flight. A toolkit_type suspension cancels every run in flight that holds a toolkit of that type, including toolkits of the run’s sub-agents: each run records its toolkit types when it is admitted. A run that is being admitted while the suspension is saved is either refused or cancelled: admission and the suspension are serialised in the database, so no run escapes both.

  3. Toolkit calls are refused at the worker. For the toolkit_type scope, the suspended types are frozen into the guardrail policy of each new run. The worker removes the tools of a suspended type from the agent’s tool set, and it refuses a call of that type with the code execution_suspended. Standalone toolkit tool calls of that type are refused at admission.

Each replica of the main service keeps a copy of the active suspensions. A change is sent to all replicas on the NATS subject elitea.rt.v1.safety.suspension, and each replica also reads the table again after 10 seconds at most, so a lost message does not extend the 10-second stop target. If a replica cannot read the table:

  • with a copy that it read before, it keeps that copy and tries again after one second;
  • with no copy (for example, just after it started), it refuses every run with HTTP 503 suspension_state_unavailable. It does not admit a run that a suspension could cover.

A suspension stays active until it is resumed. Resuming lets new runs start again. Runs that were cancelled stay cancelled; start them again if necessary.

  • An agent suspension cancels only the runs that were admitted after migration 0194, because older runs do not record their agent. The project and platform scopes cancel all runs.
  • A run admitted before migration 0201 did not record its toolkit types. A toolkit_type suspension reads them from the run’s stored input, which names only the run’s own toolkits, not those of its sub-agents. To be sure that such a run stops, also suspend its project or the platform.
  • A suspension, like a run cap, takes effect at the run’s next lease renewal, not instantly.
  • A cancelled run ends with the state cancelled, not with execution_suspended.
  • Desktop local agent loops run on the user’s computer. A suspension refuses their next cloud-side turn, but it does not stop a loop that is running locally.

Stop one agent. Open the agent, then select Stop agent next to Delete. Type the reason, then select Stop agent in the dialog. The page then shows Agent stopped with Resume agent. If the whole project is suspended, the page shows Project stopped.

Every scope. Open Admin › Kill switch. The page lists the active suspensions and refreshes every ten seconds. Select Suspend, then select the scope, the target, the reason, and who can resume it. To stop the whole platform, select the scope Platform and type STOP. Select Resume on a row to end that suspension. Turn on Show resumed suspensions to see the history.

API. The same operations are available with a personal API token:

Method and pathPurpose
GET /api/v2/admin/execution_suspensions?active=trueList the suspensions (filter project_id is optional)
POST /api/v2/admin/execution_suspensionsCreate a suspension of any scope
POST /api/v2/admin/execution_suspensions/{id}:resumeResume a suspension
GET /api/v2/elitea_core/execution_suspensions/prompt_lib/{project_id}List the suspensions of a project
POST /api/v2/elitea_core/execution_suspensions/prompt_lib/{project_id}Suspend an agent of the project, or the project
POST /api/v2/elitea_core/execution_suspensions/prompt_lib/{project_id}/{id}:resumeResume a suspension of the project

The body of a create is {"scope", "project_id", "application_id", "toolkit_type", "reason", "resume_requires", "confirm"}. Only the fields of the scope are necessary. project_id and application_id must be between 1 and 2147483647; any other value is refused with 400. A second suspension of the same target is refused with 409 already_suspended, and the answer includes the active suspension.

Self-test. The routine main.kill_switch_selftest runs once a day by default. It suspends a synthetic agent, checks that admission refuses it within the stop target of 10 seconds, resumes it, and checks that admission accepts it again. It does not stage a real run. It does not run while maintenance mode is on. Change its schedule, or turn it off, in Admin › Schedules & Tasks. Each pass writes an audit event. A successful pass sets the Prometheus gauge elitea_kill_switch_selftest_last_success_timestamp (Unix seconds). Alert when the gauge is older than two schedule periods.

The permission safety.suspend controls every route. Migration 0194 grants it to:

  • the platform roles super_admin and admin (the administration mode): every scope, on the admin routes;
  • the project role admin (the default mode), and each saved project admin role: the agent and project scopes of that project, on the project routes.

Each suspension has a resume rule:

resume_requiresWho can resume
any_admin (default)Any caller with safety.suspend on a surface that can see the suspension
governance_roleA platform administrator only
two_personA platform administrator who is not the person who created the suspension. The database also enforces this rule. For a suspension that the platform created (the circuit breaker), two different platform administrators: the first records an approval, the second resumes.

A project admin can resume only an any_admin suspension of their own project. A suspension made by a platform administrator for a project is visible on the project routes, but its rule can keep the project admin from resuming it.

Every create and resume is written to the audit trail (centry.audit_events) as part of the request record. The action is safety.suspension.created or safety.suspension.resumed, the entity type is execution_suspension, and the entity name is the scope and its key. Example:

{
"timestamp": "2026-10-28T09:15:02.418Z",
"user_id": 4,
"user_email": "ops@example.com",
"project_id": 7,
"event_type": "api",
"action": "safety.suspension.created",
"http_method": "POST",
"http_route": "/api/v2/elitea_core/execution_suspensions/prompt_lib/{projectID}",
"status_code": 201,
"is_error": false,
"entity_type": "execution_suspension",
"entity_id": 12,
"entity_name": "agent:31"
}

The self-test writes a schedule event with the action safety.kill_switch.selftest, for example the entity name refused=true resumed=true stop_latency_ms=3, and is_error set when a pass fails. The table elitea_runtime.execution_suspensions keeps every suspension with its reason, the user who created it, the time, the number of runs it cancelled, and who resumed it, when, and why. Rows are not deleted.

  • Decide who in your organization can stop and resume execution, and give safety.suspend only to those people. Remove it from project roles if only the platform team may stop agents.
  • Use two_person for platform suspensions if your change process needs a second approver to restart production.
  • Monitor elitea_kill_switch_selftest_last_success_timestamp and the safety.kill_switch.selftest audit events.
  • Keep the emergency stop runbook in your incident procedure, and practise it.
  • After you resume, start again the runs that were cancelled, if they are still necessary.

An agent can fail again and again without a person who sees it. For example, a tool starts to refuse every call, the model loops on the same failing tool, or the agent spends much more than usual. Each run fails, but new runs continue to start, and each run uses the budget and calls external systems. A person who reads a dashboard sees the problem too late. The platform must stop such an agent by rule, and keep it stopped until a person examines it.

The routine main.agent_breaker runs every minute. It reads the agent runs that ended since the last pass, and reads each run one time only. A run that ended less than 30 seconds before the pass waits for the next pass. Then the routine checks each agent against four rules:

RuleTrips whenDefault
error_rateMore than this percentage of the agent’s latest ended runs failed. The window is the latest 20 runs.On: more than 50 % of 20 runs
tool_failure_streakOne tool failed this many times in a row inside one run. A call of a different tool between two failures does not stop the count. A success of the same tool does.On: 5 failures
spend_per_hourThe agent’s model and metered tool spend in the last hour is more than this amount (USD)Off
runs_per_hourMore runs of the agent than this ended in the last hourOff

When a rule trips, the breaker writes a suspension of the source breaker at the agent scope. This is the same suspension that the kill switch uses:

  • admission refuses new runs of the agent with HTTP 409 execution_suspended, and the message The agent circuit breaker stopped this agent. An administrator must resume it.;
  • the runs of the agent that are in flight are cancelled in the same transaction;
  • the suspension records the rule, the observed value, the limit and the agent’s risk tier.

The breaker does not trip an agent that is already suspended, by any source. After a resume, the window starts again: runs admitted before the resume do not count. Its first pass after the upgrade reads only the runs that ended in the 10 minutes before. Older runs are not judged.

An agent with fewer ended runs than the window since its last resume is not judged on error_rate. A cancelled run does not count as a failure or as a run in the window.

A run cap stop is not a failure. A run that a run cap stopped (CAP_REACHED) does not count as a failure or as a run in the error_rate window. This applies to all caps: the wall time and spend caps that Main applies, and the tool call, model turn and records caps that the worker applies. A tool call that the worker refused because the run reached a cap is not a failure of that tool, so it does not add to tool_failure_streak. Thus an agent that does long tasks and often reaches its caps is not stopped by the breaker for that reason.

Who can resume depends on the agent’s risk tier:

Tierresume_requiresWho can resume
low, mediumany_adminA project admin of the project, or a platform administrator
highgovernance_roleA platform administrator
criticaltwo_personTwo different platform administrators. The first Resume records an approval and answers HTTP 202; the suspension stays active. A second platform administrator selects Resume to end it. The same administrator cannot do both steps (HTTP 403 second_person_required). The database also enforces this rule.

Many agents at the same time. When a model provider or a shared tool is down, many agents fail together. Stopping all of them would turn a short outage into a long one, because each agent then needs a person to resume it. If more agents than the storm limit (default 5) would trip on error_rate or tool_failure_streak in one pass, the breaker stops none of them on those rules. It raises the EliteaAgentBreakerStorm alert instead. The spend_per_hour and runs_per_hour rules still trip.

  • The breaker acts once a minute, on runs that ended. It does not stop a run in the middle of its first failures. Use run caps and project budgets for limits inside one run.
  • tool_failure_streak reads the run step records. A run whose worker records no tool steps is not checked on that rule.
  • spend_per_hour reads the gateway request log. Spend that the gateway does not attribute to a run is not counted. Each pass reads only the agents whose spend in the last hour is more than their own limit (the tier default, or the tighter override).
  • An agent’s limits apply to the agent in all its runs. The breaker has no limit per user.

Open Configuration › Circuit Breaker (permission configuration.governance). The section has:

  • Circuit Breaker On: on by default. Turn it off to stop all passes.
  • Error Rate Window (runs): the number of latest ended runs the error rate reads (5 to 200, default 20).
  • Storm Limit (agents per pass): default 5. 0 turns the storm limit off.
  • For each risk tier: Error Rate (%), Spend per Hour (USD), Same Tool Failing in a Row, Runs per Hour and Who Can Resume. 0 turns a rule off.

The routine reads the section at each pass, so a change applies within one minute. Change the routine’s schedule, or turn it off, in Admin › Schedules & Tasks. It does not run while maintenance mode is on.

One agent. The agent’s registry record can set tighter limits than its tier. It cannot set looser limits. Use the registry API with the field circuit_breaker:

Terminal window
curl -sS -X PUT "$ELITEA/api/v2/elitea_core/agent_registry/prompt_lib/12/31" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"circuit_breaker":{"error_rate_percent":30,"spend_per_hour_usd":5}}'

A value looser than the tier default is refused with HTTP 422 circuit_breaker_override_loosens, and fields names the looser limits. A smaller error_rate_window_runs is tighter. {} clears the override. Each change writes the security audit action agent.registry.breaker_override_changed.

Each trip writes, in the same transaction as the suspension:

  • the suspension row in elitea_runtime.execution_suspensions, with source = breaker, actor_id = 0 and the rule in detail;
  • one security audit event of the type ai.elitea.security.v1.agent.breaker, in the hash-chained security audit log. The audit export sends it to your SIEM.

Example event:

{
"type": "ai.elitea.security.v1.agent.breaker",
"subject": "agent/31",
"time": "2026-10-30T15:30:11.187Z",
"data": {
"action": "agent.breaker.tripped",
"principal_ref": "system:agent_breaker",
"tenant_project_id": 12,
"resource_type": "agent",
"resource_id": "31",
"reasons": [
"rule:error_rate", "observed:55", "threshold:50", "unit:percent",
"tier:high", "resume_requires:governance_role", "suspension:41",
"window_runs:20", "failed_runs:11"
]
}
}

The agent’s owners and the project admins get a notification with the rule and the observed value. Admin › Kill switch shows each breaker suspension with the label Circuit breaker, the rule, the observed value and the limit. The agent’s page shows Stopped by circuit breaker. The suspension API returns the same values in the field breaker.

The resume is written to the audit trail with the action safety.suspension.resumed. The first approval of a two_person resume is written with the action safety.suspension.resume_approved.

Metrics and alerts. elitea-main serves these Prometheus metrics on its metrics port (ELITEA_AUDIT_EXPORT_METRICS_ADDR, port 9464), also when the audit export is off. No series has a project or an agent label.

MetricTypeMeaning
elitea_safety_suspensions_active{scope,source}gaugeActive suspensions. Every replica reports the same count.
elitea_breaker_trips_total{rule}counterAgents the breaker stopped, by rule
elitea_breaker_storms_totalcounterPasses that stopped no agent because of the storm limit
elitea_breaker_pass_failures_totalcounterBreaker passes that failed

The Helm chart ships these alerts in a PrometheusRule wherever the Prometheus Operator API is available (main.safetyAlerts, on by default):

AlertSeverityFires when
EliteaPlatformExecutionSuspendedcriticalA platform suspension is active for 1 minute. The scheduled self-test is not included.
EliteaAgentBreakerStormcriticalA pass hit the storm limit
EliteaAgentBreakerTrippedwarningThe breaker stopped an agent in the last 10 minutes (label rule)
EliteaExecutionSuspensionsActivewarningAn agent, project or toolkit type suspension stays active for main.safetyAlerts.prometheusRule.activeFor (default 30 minutes)
EliteaAgentBreakerPassFailingwarning5 or more passes failed in 15 minutes
  • Paging and on-call stay with you. Elitea stops the agent and raises the alert. Route the alerts to your incident-management tool, and decide who is on call for agents. Elitea does not page a person.
  • Set the limits to match your agents. A low error-rate limit stops agents during short provider outages; a high one lets a broken agent run longer.
  • Set spend and runs limits for agents that can spend money or send messages, because they are off by default.
  • Decide who examines a stopped agent and who resumes it. For critical agents, keep two platform administrators available.
  • Before you resume, find the cause: read the run step records of the failed runs, and correct the agent, the tool or the credential.
  • Keep the Prometheus Operator, or scrape port 9464 with your own monitoring, so that the alerts can fire.

The worker limits each turn of a run, but a long autonomous run can continue for many turns. Without limits on the whole run, a looping agent can keep running, calling tools, reading records and spending for days.

A run cap is the most that one agent run can use. There are five caps:

CapCountsEnforced byDefault
max_wall_secondsTime from the run’s original startMain, at each lease renewal0 (off)
max_tool_callsCalls of toolkit tools, MCP tools includedWorker, before each call200
max_model_turnsModel callsWorker, before each call0 (off)
max_spendModel and metered tool spend, in USDMain, every 30 seconds0 (off)
max_records_readRecords that the counting tools returnWorker, after each call0 (off)

max_wall_seconds is one platform-wide value. The other four have a value for each risk tier (low, medium, high, critical). The tier is the root agent’s tier from its registry record. An agent without a record, and an ad hoc chat, use the low tier. A nested agent or a pipeline node uses the caps of the root run, and its calls count against them.

Author caps. An agent author can set lower caps for one agent in the version’s meta.run_caps, for example {"max_tool_calls": 50, "max_spend": 0.5}. The lower value of the tier cap and the author cap applies. An author cannot raise a cap: a save with a value above the tier cap fails with HTTP 422:

{ "code": "RUN_CAP_ABOVE_TIER", "cap": "max_tool_calls", "tier": "high", "tier_cap": 80, "requested": 100,
"error": "meta.run_caps.max_tool_calls is 100, above the platform cap 80 for high tier agents: an agent can lower a run cap, never raise it" }

A value that is not a positive number in the cap’s range fails with RUN_CAP_INVALID.

Step limit. An agent’s step_limit still limits each agent loop. With max_model_turns, the lower of the two stops the run first.

What is counted.

  • Tool calls: every call of a toolkit tool or an MCP tool, in every agent and pipeline node of the run. Platform tools that are not toolkit tools (the planner, attachments, an agent called as a tool) are not counted. The toolkit calls that such an agent makes are counted.

  • Model turns: every model call of the run’s agents. Summary and compaction calls are not counted.

  • Records read: only these tools report a count. Other tools count 0.

    Toolkit typeToolCounted items
    jirasearch_using_jqlIssues returned
    githubsearch_issuesIssues and pull requests returned
    sqlexecute_sqlRows returned
    confluencesearch_pages, search_by_titlePages returned
    Every index toolkitsearch_indexDocuments returned
  • Spend: the run’s rows in the gateway request log. A model call costs its tokens at the price catalogue rate. A metered tool costs its recorded cost.

When a run reaches a cap. The run stops and ends with a typed error. A worker cap ends the run as failed. A Main cap (max_wall_seconds, max_spend) cancels the run with the reason cap_reached. The run’s event and the agent’s answer carry:

{ "code": "CAP_REACHED", "cap": "max_tool_calls", "retryable": false }

The answer shows a message that names the cap, for example “The run stopped because it reached the maximum number of tool calls an administrator set. The task may be incomplete. Check what it completed before you continue.”

Override. A user with the permission safety.override_cap can let the conversation continue past the cap one time. The platform administrators and the project admin role have this permission. The user selects Allow the next run past this cap on the stopped answer (or calls POST /api/v2/elitea_core/run_cap_overrides/prompt_lib/{project_id} with message_id). The next agent run of that conversation gets twice the cap that stopped the answer. The other caps do not change. One override is permitted for each stopped answer, and one unused override for each conversation. An unused override expires after one hour. After it expires, a user can give a new override on a different stopped answer in that conversation. A worker cap cannot be overridden until an administrator saves the per-tier caps (HTTP 422 tier_caps_not_saved). If an administrator removes the per-tier caps after an override on a worker cap, the next run does not use that override, because the worker does not get the caps. The override stays available until it expires.

  • max_wall_seconds takes effect at the next lease renewal, so a run can pass it by up to one renewal interval.
  • max_spend is checked every 30 seconds after the gateway records each call. A run can pass the cap by what it spends in that interval, plus the time to its next lease renewal. A single model call that is in progress is not stopped part way.
  • Spend is in USD, the currency of the price catalogue. A model without a price costs 0.
  • Records read counts only the tools in the table above.
  • Counters are per execution. A run that continues after an approval pause is a new execution, and its tool calls, model turns, records and spend start at zero. Its wall time counts from the original start.
  • Each new message in a conversation starts a new run with its own caps.
  • When a cap trips, a cancel that arrives later still ends the run with CAP_REACHED.

Under Configuration › Run Caps, a platform administrator (permission configuration.governance) sets:

  • Maximum Run Time (seconds): 0 (off, the default), or 60 to 604800 (7 days).
  • For each tier: Maximum Tool Calls, Maximum Model Turns, Maximum Spend (USD) and Maximum Records Read. 0 is no cap. Counts are whole numbers. Spend has at most four decimals.

Until an administrator saves a per-tier value, the worker applies its defaults (200 tool calls, no other worker cap), and Main does not send caps to the worker. Save the per-tier caps only after every worker runs this release. A worker of an earlier release refuses commands that carry caps.

  • elitea_runtime.execution_jobs.run_caps records the effective caps of each run, and override names the cap that an override raised. cancel_cap names the Main cap that cancelled a run.
  • elitea_runtime.run_cap_overrides records each override: the answer, the cap, who granted it, when, and the run that used it. lapsed_at records when an override that expired unused was closed.
  • Each override is a security audit event, in the same transaction as the override (excerpt):
{
"type": "ai.elitea.security.v1.agent.cap.override",
"data": {
"action": "agent.cap.override.granted",
"effect": "allow",
"tenant_project_id": 12,
"principal_ref": "user:7",
"execution_id": "0f6c…",
"resource_type": "chat_message",
"resource_id": "0190f2a6-7d1c-7cc1-9a35-3f4b4d2a1b11",
"decision_id": "41",
"reasons": ["cap:max_tool_calls", "raise:double", "ttl_seconds:3600"]
}
}
  • The request’s admin audit record has the action safety.run_cap_override.granted.
  • The metric elitea.runtime.run_caps.reached with the label cap counts the runs that Main stopped (max_wall_seconds, max_spend). The worker logs agent_run_cap_reached with the cap, the count and the limit for a worker cap.
  • The run’s step record has the CAP_REACHED error and its cap.
  • Choose caps above your longest legitimate run at each tier, including the time that people take to approve steps. Lower the caps for the high and critical tiers.
  • Give safety.override_cap only to the people who can decide that a run may use more. Review the agent.cap.override events.
  • Alert on elitea.runtime.run_caps.reached and on the worker’s agent_run_cap_reached log event if a stopped run needs attention.
  • Keep the price catalogue current, so that max_spend uses correct prices.
  • Use project budgets for spend across runs. A run cap limits one run only.

A sensitive tool, a pipeline review node or a pipeline node with interrupt_before or interrupt_after (a static pause) pauses a run until a person approves or continues the step. An auditor must be able to prove who approved each step, what the step was, and when. A record that only the chat window shows changes with the conversation, and it is not tamper-evident. A run that a schedule or a webhook starts has no person to approve a step. If such a run approves a step on its own, the approval control does not apply to automated runs.

Every approval is a ledger row. The table elitea_runtime.approval_ledger (migration 0211) records each event of each approval:

PhaseOutcomes
pausepaused: the run stopped at the step
noticeunattended_waiting: an unattended run waits, and the platform notified its owner
decisionapproved, rejected, edited, answered, other, timed_out, cancelled, superseded, unattended_failed, unattended_skipped

Each row records the actor (a user, or the platform), the tool, the toolkit, the digest of the tool arguments, the decision and the time. The arguments themselves are not recorded. Rows cannot be changed: a database trigger refuses an update.

The worker calculates the argument digest before it masks the arguments, and sends it with the pause: in the pause of the agent itself, and in display.argument_digest of a card of a parallel or fan-out run. For an agent, the digest is sha256: followed by the SHA-256 of the canonical JSON (keys in sorted order, no spaces) of the arguments that the tool runs with. For a pipeline node, the digest is the digest that the pipeline binds to the call. The masked arguments that the chat window shows are not the evidence, and the platform does not calculate a digest from them. Thus argument_digest is empty (NULL) in two cases:

  • a pause from a worker of an earlier release;
  • a static pause, which has no tool call and no arguments.

The platform writes a row on every path that pauses or decides a step:

  • a decision in the chat window, or with the continue API (a pause of the agent itself);
  • a static pause of a pipeline (interrupt_before or interrupt_after), and the continue of that pause (action continue, outcome approved). This includes a static pause in a pipeline that an agent calls as a tool;
  • a decision on one card of a parallel or fan-out run, with the per-interrupt decisions API;
  • a confirmation of a sensitive tool from the desktop app;
  • a Stop, a regenerate or a new message that ends a pause (cancelled or superseded);
  • the end of a run that leaves a card open (cancelled, reason run_ended);
  • the expiry of an unattended wait (timed_out).

The row is written in the same database transaction as the decision. If the row cannot be written, the decision is refused.

One winner. A step has one decision. When two people decide the same step at the same time, the platform applies one decision, and the other person gets HTTP 409. A person who answers after the step was decided, timed out or skipped also gets HTTP 409.

Every row is an audit event. In the same transaction, each row is written to the security audit outbox as one ai.elitea.security.v1.agent.approval event, at the next position of the hash chain of the project. These events are written for every run. They do not need the credential governance profile.

The chat window shows the ledger. The pending steps in the message metadata (hitl_interrupts) are a view for the UI. The ledger is the record. Each pending step in the chat window has a pause row, and each decided step has a decision row.

Unattended runs never approve. When a run that a schedule or a webhook trigger started gets to a step that needs approval, the platform does one of three things, set by on_approval_required:

ValueWhat happensLedger outcome
fail (default)The run ends with the error approval_required_unattended.unattended_failed
skipThe platform refuses the step. The model receives a tool result that says the step was skipped because the run has no approver, and continues without the step.unattended_skipped
notify_and_waitThe run pauses, and the run’s owner gets a notification. The owner can approve or reject the step (or continue a static pause) in the conversation. If nobody answers in time, the run ends with the error approval_timed_out, and the pause cannot be continued.unattended_waiting, then the decision or timed_out

No value approves a step. These pauses use fail, whatever the value is:

  • a skip of a step that has no refusal action. A static pause (interrupt_before or interrupt_after) has only the action continue, so an unattended static pause with skip uses fail, and the pause cannot be continued;
  • an unattended pause that also waits for a credential authorization;
  • a skip or a notify_and_wait pause of a run whose owner the platform cannot find. The platform cannot notify such a run’s owner, and it cannot continue the run as the owner. The run ends with the error approval_required_unattended, and the ledger row has the outcome unattended_failed and the reason approval_required_unattended.

On a skip, the platform is the actor of the decision. The ledger row has actor_kind system. On a card of a parallel or fan-out run, the card also names the platform: decided_by is empty and decided_by_system is true, and the DECIDED row of the card audit has system_actor true. The run’s owner is not the actor. The owner is recorded only as the owner of the run (unattended_run_policies.owner_user_id).

A failed expiry does not stop other expiries. The platform expires the waits of notify_and_wait runs in order of their expiry time. If the platform cannot expire one wait, it logs the error, ends that wait with the state FAILED and the failure code expiry_failed, and continues with the next wait. Later waits expire on time. The run of the failed wait stays paused until a person answers it or stops it.

Platform default. Open Configuration › Approvals (permission configuration.governance):

FieldValuesDefault
Unattended Default (approvals.unattended_default)fail, skip, notify_and_waitfail
Unattended Wait (hours) (approvals.unattended_wait_hours)1 to 72024

Per schedule and per trigger. Set on_approval_required on the schedule or on the trigger of a pipeline version. If you do not set it, the platform default applies. The settings read shows the value that applies.

Method and pathField
PUT /api/v2/pipeline_schedules/prompt_lib/{project_id}/{version_id}on_approval_required in the body
POST /api/v2/pipeline_triggers/prompt_lib/{project_id}/{version_id} (create or rotate)on_approval_required in the body

A save or a rotation without the field keeps the stored value. Any other value is refused with HTTP 400.

The platform saves the value in the same transaction as the schedule or the trigger. If the save fails, you get HTTP 500, and nothing changes: the schedule keeps its settings, and the trigger keeps its secret, so the secret that you have continues to work.

Each run keeps the value that applied when it started (table elitea_runtime.unattended_run_policies). A later change does not change a run that is in flight.

Read the ledger of a run:

SELECT created_at, path, phase, outcome, action, actor_kind, actor_user_id,
tool_name, toolkit_name, argument_digest, reason, audit_event_id
FROM elitea_runtime.approval_ledger
WHERE execution_id = '<execution id>'
ORDER BY id;

audit_event_id is the id of the audit event of the row. This is an example event of an approval:

{
"specversion": "1.0",
"id": "01a18ca9-bedb-75cc-a7a3-fa1b173dfca0",
"source": "elitea-main",
"type": "ai.elitea.security.v1.agent.approval",
"subject": "chat_message/6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"time": "2026-10-30T09:15:02.418Z",
"datacontenttype": "application/json",
"dataschema": "urn:elitea:security-audit:v1",
"partitionkey": "7",
"sequence": "00000000000000000412",
"ocsfclassuid": 6003,
"ocsfcategoryuid": 6,
"data": {
"tenant_project_id": 7,
"action": "agent.approval.approved",
"effect": "allow",
"reasons": ["path:root", "origin:manual", "guardrail:sensitive_tool", "action:approve"],
"principal_ref": "user:4",
"actor_chain": [],
"actor_fidelity": "attributed",
"execution_id": "4b7c2d1e9f0a4b3c8d7e6f5a4b3c2d1e",
"invocation_id": "interrupt-root-1",
"decision_id": "1873",
"resource_type": "chat_message",
"resource_id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"authorization_epoch": 0,
"revocation_epoch": 0,
"tool_name": "create_issue",
"toolkit_name": "jira",
"arguments_digest": "sha256:9c1f0e5b2d7a4c3e8f6b1a0d9e2c7b4a5f3e1d0c9b8a7f6e5d4c3b2a1f0e9d8c"
}
}

An event of the platform has the principal_ref system:elitea-main. The security audit validator refuses a value that looks like a credential (for example, a JWT or a Bearer value), a control character, or a value longer than 256 bytes. When a tool name, a toolkit name or an interrupt ID has such a value, the event records the digest of the value (sha256: and 64 hex characters) in its place. The approval is not refused because of the name. An unattended outcome has the reason approval_required_unattended, approval_skipped_unattended or approval_timed_out. To verify the chain, use the security audit verifier (see the evidence guide). To send the events to your SIEM, use the audit export.

  • A rejection in the desktop app occurs on the user’s computer and sends nothing to the server. The ledger has the pause row of the step, and no decision row.
  • A credential authorization pause (a toolkit that needs the user to sign in) is not an approval. It is not in the ledger, and on_approval_required does not apply to it.
  • When a parallel or fan-out run fails for a missing approver, the members that are already running continue until the worker sees the cancellation at its next lease renewal.
  • Pauses that started before migration 0211 have no pause row. A decision on such a pause is recorded.
  • Keep approvals.unattended_default at fail, unless a reviewed process needs skip or notify_and_wait.
  • Use notify_and_wait only when the owner of the schedule or trigger can answer before the wait expires.
  • Monitor elitea_runtime.unattended_approval_actions for rows with the failure code expiry_failed, and stop or answer those runs.
  • Send the agent.approval events to your SIEM, and alert on unattended_failed and timed_out.

A list of sensitive tools names each tool one by one. A new toolkit, or a new tool in a toolkit, does not ask for approval until someone adds it to the list. An agent that can delete records, post to people outside the organisation or move money can then act without a person.

Each tool is in one category:

CategoryMeaning
readReads data and changes nothing.
writeCreates or changes data. A tool that is not declared is write.
deleteDeletes data.
external_sendSends something that people outside the run see: an email, a chat post, a ticket or pull request comment.
paymentMoves money.
code_executionRuns code or a query that the model wrote.

The categories of the built-in tools are declared in one contract, libs/proto/contracts/tool-categories-v1.json. The worker compiles it in, and the main service reads the same file. The tools that are not read or write:

CategoryTools
external_sendGitHub comment_on_issue; GitLab and GitLab Org comment_on_issue, create_pr_change_comment; GitLab comment_on_pr; Bitbucket add_pull_request_comment; Azure DevOps Repos comment_on_pull_request; Aha! add_comment; Jira add_comments, update_comment_with_file; Figma post_file_comment; Slack send_message; Gmail (yagmail) send_gmail_message
deleteEvery delete_* tool, remove_index and OneNote onenote_delete_page
code_executionData Analysis pandas_analyze_data; Sandbox pyodide_sandbox, stateful_pyodide_sandbox; SQL execute_sql; BigQuery execute
paymentNo built-in tool yet

The tools of a custom MCP server take the category that the approver gave each tool when the connector request was approved (the pinned tools carry it). A tool without a category is write.

A tool call needs a human approval when one of these rules applies:

  • Platform rule. The administrator’s sensitive action tools name the tool. This is the list under Sensitive action authorization. It applies at every tier.
  • Policy rule. The approval policy names the tool’s category for the risk tier of the run’s agent.
  • Read untrusted, then act. The tool’s category is delete, external_send or payment, the rule is on for the risk tier of the run’s agent, and a tool returned a result earlier in the same turn. A tool result can contain an injected instruction, so an action after a read stops at a person. The rule is on by default at the high and critical tiers. See Untrusted content.

The rules only add approvals. An agent author cannot remove an approval that a rule requires. A toolkit setting cannot move a declared tool to another category.

The tier is the risk tier on the root agent’s registry record (a missing record is low). An agent, pipeline or sub-agent that the root agent calls uses the root agent’s tier, not its own.

The default policy is the ADR default:

TierCategories that need approval
lowNone
mediumNone
highdelete, external_send, payment
criticaldelete, external_send, payment
  • The policy applies only after an administrator saves Configuration › Approval Policy. Until then, only the platform rule applies, as in earlier releases. Save the section only after every worker runs this release: a worker from an earlier release refuses commands that carry the policy.
  • A tool that is not declared is write. A policy that names write asks for approval on every tool that is not declared, and on every MCP tool without a category. When you port a toolkit, declare the categories of its tools in the contract.
  • Direct tool calls from outside a run (the platform’s MCP endpoint) do not use the policy. They refuse every tool that is not read-only.
  • A change to the policy applies to the commands that the main service sends after the change, at once on the replica that saved it and within 15 seconds on the other replicas.

Under Configuration › Approval Policy, a platform administrator (permission configuration.governance) sets, for each tier, the categories whose tools need approval. The defaults are the table above.

In the same section, Tiers With the Read Untrusted, Then Act Rule selects the tiers whose runs use the “read untrusted, then act” rule. The default is high and critical. With the default policy, these tiers already ask for an approval of each delete, external_send and payment call, so the default adds no approval. The rule adds approvals only at a tier where you remove these categories from the policy, or where you add the tier to the rule. An empty list turns the rule off.

When a tool call pauses because of a rule, the pause carries the field approval_rule:

{ "source": "policy", "risk_tier": "high", "category": "external_send" }

The source is platform for the platform rule, policy for the policy rule, untrusted_read_then_act for the “read untrusted, then act” rule (with the category), and builtin for the cloud workspace’s own approvals (destructive commands, push and pull request). The platform rule is named when both rules apply. The field is on the hitl_interrupt and hitl_interrupts of the chat frame, so the decision record of the pause can name the rule.

  • Set each production agent’s risk tier, so the policy applies the correct row.
  • Decide which categories need approval at each tier. Add code_execution at high and critical if the model’s code or queries can reach production data.
  • Keep the platform’s sensitive action tools for single tools that need approval at every tier.

A rate limit at the LLM gateway counts the calls of a whole project. One agent that loops, or one user who runs many agents, can use all of the project’s calls. The other agents and users of the project then get 429 refusals.

A rate limit entry under LLM Governance can name agents (scope.agent_ids) or users (scope.user_ids). Such an entry is a throttle:

  • An agent throttle counts the calls of each named agent in its own counter. It applies to every model call that a run of that agent makes, also when the run starts from another project.
  • A user throttle counts the calls of each named user in its own counter. The counter is shared across the projects that the entry selects.
  • An entry that names agents and users counts each pair of agent and user.

A throttle adds to the project rate limit. It does not replace it. A call must pass the project rate limit, the agent throttle and the user throttle that apply to it. In each of these three groups, the narrowest entry applies.

The gateway checks the agent throttle first, then the user throttle, then the project rate limit. A call that one of them refuses does not use a call of the others. Thus an agent that retries after its 429 does not use the calls of the project. When the project rate limit refuses a call after a throttle counted it, the gateway gives the call back to the throttle. For a very short time between the count and the give-back, a different call to that throttle can see one call too many and get a 429. The gateway never admits a call above a ceiling because of this.

When a counter gets to its ceiling, the gateway refuses the call with HTTP 429, rate_limit_error and rate_limit_exceeded, and the Retry-After header gives the seconds until the minute ends. The message names the agent, user or project limit. A throttle refuses calls only. It does not suspend the agent or the user, and the next minute starts with a new counter.

The gateway knows the agent from the signed execution id of the call. It reads the agent of the run from the run’s admission record. The gateway knows the user from the signed user id of the call. A caller cannot set either value.

  • An agent id is an application id of one project. An entry that names agents must name exactly one project in scope.project_ids.
  • An entry can name at most 100 agents and 100 users.
  • A call that does not come from an agent run (for example, a chat without an agent) does not match an agent throttle. A call without a user does not match a user throttle.
  • The throttles fail open, as the project rate limits do. When the shared NATS counter is not available, or the gateway cannot read the agent of a run, the gateway admits the call and does not apply the throttle. It counts each such call.
  • The token ceiling applies to the call after the call that crossed it, as for every rate limit.

Under LLM Governance, a platform administrator (permission configuration.governance) creates a Rate limit entry. Set Requests per minute or Tokens per minute, and then fill Throttled agents (with one project in Projects) or Throttled users. Each id must be a positive whole number. The form shows an error and does not save a list with a different value. The main service refuses an entry that names an agent that is not in that project, or a user that does not exist. It applies the same checks to agent_ids and user_ids at the top level of data, because the gateway reads them there when the entry has no scope object or an empty one. See Budgets and LLM governance.

  • Each refusal writes a gateway log line governance: authored rate limit refused the request with the rule, the class (project, agent or user), the agent, the user and the counts.
  • The refused call is in the gateway request log with the status 429.
  • GET /governance/status on the gateway gives rate_limiter.refused and rate_limiter.degraded. degraded counts the calls that were admitted without a limit because the counter or the agent lookup failed.
  • Set throttles that are larger than the normal load of each agent and user, and smaller than the project rate limit.
  • Make sure that the agents and users that you throttle can retry after Retry-After.
  • Alert on a growing rate_limiter.degraded count. While it grows, no rate limit or throttle is in force.
  • To stop an agent, use the suspension, not a throttle.

A pipeline can call a tool from a toolkit node without a model. If the path to an external_send or payment tool has no hitl node, the pipeline sends the message or makes the payment without a person.

Configuration › Approval Policy › Tiers That Require a HITL Node in Pipelines names the tiers where the rule applies. Under the rule, every path from the pipeline’s entry point to a toolkit or MCP node that calls an external_send or payment tool must go through a hitl node. The check follows every edge: transitions, default outputs, decision and router routes, HITL routes, parallel branches, map workers and recovery routes.

A static pause also counts as approval. The run stops before each node that the pipeline’s interrupt_before list names, and after each node that its interrupt_after list names. A person resumes the run. Thus a tool node in interrupt_before is approved, and so is each node after a node in interrupt_after. A tool node in interrupt_after runs before the pause, so it is not approved.

The check also covers saved child pipelines:

  • A node that can run a saved child pipeline continues the path into the child. An agent node can run the child that it selects. An llm node can run each child that its tool_names give to the model.
  • The check examines the child again on each path that reaches such a node. A hitl node or a static pause before the node approves the child on that path. If there is no approval on the path, each path in the child must go through a hitl node.
  • A pipeline that an agent attaches as a tool, and that no node of a pipeline names, is checked from its own entry point.
  • The check goes down three levels of saved pipelines, as the worker does. A deeper composition, or one with a cycle, does not start.
  • Each child is checked at the tier of its parent if that tier requires the rule. If not, the child is checked at its own tier.

A pipeline that breaks the rule does not start. The start request returns HTTP 422 with the code PIPELINE_HITL_REQUIRED and a message that names the node, the tool, its category and the path that has no hitl node, for example:

Node "post" calls "send_message", which is a tool in the external_send category. The approval policy requires a hitl node on every path to such a tool for high-tier agents, and the path "draft" -> "route" -> "post" has none. Add a hitl node before "post".

The check runs each time the platform admits a run: a chat turn, an ad hoc chat turn, a schedule, a trigger, a regenerate and a resume after a pause. All of these freeze the agent version through one step, and the check is part of that step. Pipelines cannot be published (publish refuses them with pipeline_not_publishable), so the run admission is where the rule applies.

The default is off: no tier requires the node.

  • The check reads the pipeline’s toolkit and MCP nodes. A model node (llm or agent) that can call an external_send tool is covered by the policy rule at call time, not by this check.
  • The check uses the tier of the pipeline’s registry record, and of the registry record of each child pipeline. An ad hoc chat has no record and is low.
  • The check does not read the tools of a child agent that is not a pipeline. A pipeline that such an agent attaches as a tool is not checked when the run starts.
  • If the check cannot read a child pipeline, the run does not start.

Select the tiers under Tiers That Require a HITL Node in Pipelines. The hardening checklist recommends high and critical.

The refused start returns the error above. No run starts, so no run step is written.

  • Add a hitl node before each node that sends outside the organisation or pays, in pipelines at the tiers you select.
  • Set the tiers of pipelines on their registry records.

A human approval can pause a run inside a nested composition. Examples are an agent that calls an agent or a pipeline, and a pipeline that runs a saved pipeline. Each pause shows one approval card with the path of its callers. The worker keeps a checkpoint at each level. After an approval or a rejection, the paused level continues one time. Then each caller continues. When a person rejects, the tool is not called, and the caller reads the rejection result.

A pause can have a maximum of 3 delegations above it. The delegation depth limit under Configuration › Delegation also applies. The worker does not offer an attached pipeline that can pause in a position that it cannot resume. The worker writes a log entry with the reason pipeline.child_pause_unsupported and a detail code that names the pause kind. The HITL node page lists the pause kinds. To find the pipelines that the worker did not offer, search the worker logs for this reason.