Approvals
Some agent actions are sensitive enough to require a human decision before they run — sending an email, writing to a connected integration, and other side-effecting operations. When an agent hits one of these, it creates an approval request and the run pauses until a human approves or rejects it. Read-only work never needs approval: queries, reports, fetches, computations, and reads of connected systems proceed without a gate, including APIs that use POST to run a report. External side effects remain gated: native actions carry approval on the action itself, while generated code must classify the effect and name the concrete mutation before it can ask for approval. Every gated action also declares a risk class —spend, outbound,
destructive, or bookkeeping — and an organization in propose autonomy can
choose which classes still raise a card via the
autonomy_gates setting.
Unset, every gated action raises a card as it always has.
Each approval request records what the agent wants to do (action_key,
action_display), the run/thread/job it belongs to, its status
(pending, approved, rejected, withdrawn, or expired), and when it was
created and decided.
When an action belongs to a Workstream/Strategy, the request also carries workstream_id. This
enables an exact operating view without parsing job names or action text.
Approving or rejecting a request resumes the paused agent run: an approval lets the
action proceed, a rejection terminates it. The same surface is available over the
CLI, MCP, and REST.
A pending ask waits until it’s no longer needed
An approval request does not age out just because it’s been sitting for a while — if you’re away for a week, your pending decisions are still there when you get back. A request leaves the pending state only when it is genuinely no longer needed:- Decided — you approve or reject it.
- Superseded — the agent proposes a newer version of the same action on the same subject, which folds onto the existing request (see below) rather than piling up.
- Withdrawn — the automation that raised it was disabled or deleted, so there’s
nothing left to run. The request is marked
withdrawn. - Expired — only for the rare action that declares its own semantic deadline (a “today’s report” ask is moot tomorrow). Most actions declare none, so most requests never expire. “Publish this page” has no deadline and waits indefinitely.
Repeated proposals dedupe
If an agent proposes the same action on the same subject — say pausing the same ad group — while an earlier request for it is still pending, Erdo does not file a second request. It folds the re-proposal into the existing one and bumps anoccurrence_count, so a repeatedly-attempted action shows up once as
“proposed N× since <date>” rather than as a stack of identical cards. The
newest attempt’s run is the one your decision resumes. The count and first-seen
date are preserved across resolutions, so you can see how persistently an action has
been retried.
The subject an approval acts on
Most approvals are about a specific thing — a page about to be published, a record about to be written, an ad group about to be paused. When the action names that thing, the request carries it as a typed subject:subject_resource_type (for example
artifact) and subject_resource_id. Both are absent when the action has no resolvable
subject.
The point of the typed subject is that you don’t have to parse action_display to know
what’s being changed — you can resolve the resource and show the real thing before you
decide. For an approval to publish a page, that means fetching the artifact and rendering
or previewing it, so the decision is made against the page itself rather than a
description of it. action_display remains the human-readable summary; the subject is the
machine-readable handle to the object underneath it.
What the request already tells you
A decision needs to be legible in the moment you make it, and the gated action’s raw input is the wrong place to learn what it does: that payload is shaped for the system being written to, so a client reading it has to infer business meaning from provider field names — and infers it wrong the day a payload changes. Every request therefore carries a small set of derived card fields, computed before the response is sent and identical wherever the approval is shown, so no caller ever has to openaction_input to work out what approving would do.
Each entry in
action_items describes one exact action: what it does, the why its
producer recorded for it, its own decision_class, and params_compact — a flat map of
the few parameters worth showing on a card. A request that covers a single action has
exactly one entry; a request covering several has one per action, ordered the way the
headline describes them, so an approver reading “3 ad groups paused, 1 budget raised”
finds the items in that order.
action_items is bounded at 20 entries. A response that grew with the size of the
request would make a list of pending approvals arbitrarily large, and an itemization
nobody can read is not a detail view either — so anything past the cap is reported
honestly as a count in omitted_items (“and 43 more”) rather than silently truncated.
params_compact only ever carries the parameters a gated action publishes as safe to
show, never a dump of its input: a card is read by whoever can see the approval, and
guessing which of a provider’s fields are safe or meaningful is exactly how a card ends
up leaking one field and omitting the one that mattered. An action that publishes no
such parameters ships an item with none, and its what and why still carry the
decision.
subject_display_name is absent rather than guessed. When the action names no
resolvable subject — or a multi-action request’s actions disagree about theirs — the
field is simply not there, and the right fallback is the typed
subject_resource_type / subject_resource_id above, or nothing at all. A plausible
name derived from an id is worse than no name, because it reads as fact.
decision_class follows the same rule on a request covering several actions: it is
present only when every action shares one class. Eight ad-group pauses are genuinely
one kind of decision and say so; a page publish and a budget rise that merely arrived
together are not, so the request classifies itself as neither rather than borrowing the
first action’s label.
What the change is expected to do
A request may also carryexpected_effect: what the agent proposing the change said it
would move, written before you answer. It is an object naming the metric, the
direction it should go, the target it should reach or the min_change that would
count as it having worked, the horizon_days to judge it over, and optionally a
filter narrowing it to one campaign, page or segment and a baseline note saying
what the agent believes the number is today.
The field is absent on most requests, and that is the honest answer rather than a
gap: a permission fix or a bookkeeping correction has no business effect anything could
measure, and a metric invented to fill the field would be scored later as if somebody
had meant it. Where it is present it is stated as the proposal was made — nothing
re-derives it afterwards from what happened.
Auto-approve — your personal approval settings
Standing policies bound what they auto-approve tightly (one page, one recipient list), which is right for a single grant — but if you spend your day in Erdo, clicking approve on live-page edit after live-page edit, a per-page grant never catches up. Personal approval settings are the dial for that: which risk classes still raise a card for agent runs you initiate in this organization, regardless of the org’s autonomy mode. The setting is a list of classes that still ask. Everything else runs without a card — same consent as approving, delivered in advance — and still recorded in your Activity feed and the run’s history. In Settings → Approvals you pick between three presets and can tune the exact class list:
Three guarantees never change with this setting: a standing “always require”
policy on an action outranks it (a rule you or your admin wrote deliberately is
never silently widened); an agent’s explicit
request_approval always
reaches a human; and an action nobody has classified fails closed and always
asks. The same standing rules you create from a card’s “always allow” are
listed beside the dial, so the two layers read as one system.
The setting is personal to you and to this organization — it follows neither you
into other orgs nor your teammates into this one.
You don’t have to leave the chat to change it: the approvals quick selector
in the chat composer (next to the effort selector) switches between Ask by
default, Auto-approve safe actions, and Approve everything in one click,
shows your current mode on its pill, and links through to the full rules in
Settings → Approvals. A custom class list set on the settings page shows as
Custom there.
Deciding an approval
A decision carries a scope that controls how broadly the approval applies:
Scope only applies to approvals; a rejection always applies once.
For approvals attached to a workstream, Erdo’s approval cards default their standing option to
always_this_workstream; widening it to the organization remains an explicit choice.
A standing policy always carries parameter constraints — the bounds on what it
auto-approves, such as a specific spreadsheet or recipient. Each request comes
with pre-computed constraint bundles (scope_options, visible in
erdo approvals list --json), ordered from most to least specific. When a
decision arrives without explicit constraints, the narrowest bundle is used;
the decide response reports whether a standing policy was created.
CLI
erdo approvals list prints one row per request: a short id, status, action key,
display, and creation time — plus a trailing ×N when the action was re-proposed
and deduped. Use --json for the full payload, including occurrence_count and
first_proposed_at.
erdo approvals show prints the request the way a decision is made against it: the
headline first, then the status, decision class, subject, and dates, then the reason
recorded for the ask, then a numbered list of the actions it covers with each one’s
reason and shown parameters, and finally the scope options — numbered so the number you
read is the one you pass to erdo approvals decide --option N.
MCP tools
REST
GET /v1/approvals/:id is the drill-in read. It returns one request with everything a
list row carries plus the reason recorded for the ask and the scope options a standing
decision can use — the list stays sized for scanning, and the detail read is what a
decision is made against. An id belonging to another organization comes back 404
rather than 403, so an id can never be probed for existence.
Add workstream_slug=<slug> to the list query for an exact Strategy/workstream view.
Add subject_resource_type=<type> and/or subject_resource_id=<id> to narrow the
list to approvals whose gated action acts on one typed resource — for example, every
decided approval that touched one paid-media campaign. The filter matches the subject
stamped on the request at creation (each response row carries it as
subject_resource_type / subject_resource_id); approvals whose action declares no
typed subject never match a non-empty filter, so filtering only ever narrows what you
would otherwise see.
