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.
status says what a person decided, never what became of it — an approved call can
still fail at the provider it was meant to change, or die before reaching it. So a
request also carries execution once something has run under it: state is
succeeded (every action the approval authorizes reached its system), failed with
the reason in summary, started while a call is mid-flight, or unknown where the
outcome cannot be confirmed. total_count is the actions the approval covers and
failed_count how many did not land, so a batch reads as “2 of 5 did not land” rather
than letting one success stand for the rest; recorded_count is how many have
reported at all. The field is absent until something executes, which is also what a
request decided before this record existed looks like — absence is never read as
success.
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.
Standing policies over REST and CLI
Humans and external clients can manage standing policies without running the action they authorize.GET /v1/approval-policies lists the active
organization’s policies; POST /v1/approval-policies creates one; and
DELETE /v1/approval-policies/:id removes one. A create request accepts
scope_type organization, job, workstream, or user, an optional matching
scope_id, decision (always_approve or always_require), optional
parameter_constraints, and an optional future expires_at.
Scoped API keys need approvals:read to list and approvals:decide to create
or delete policies. These are REST-only operations; there are no model-facing
policy-mutation tools. The equivalent CLI commands are
erdo approvals policies list, create, and delete.
Some older paused runs lack the saved approval identity needed to apply a decision
safely. Your answer is still recorded, but the run keeps its state and shows a
diagnostic explaining that the decision could not be applied. Review its existing
results before starting a new authenticated request. Repeating the decision does
not restart that run.
Exact review details stay on the card after you answer. For a bulk ad-copy edit,
open Details to see each changed slot’s old and new text, including pin changes.
The compact budget and attribution facts stay visible beside the proposal.
The card reports execution separately from approval: failed actions include their
reason and batch count, and a partial batch shows how many actions have reported.
It keeps checking a failed attempt during the 24-hour retry window and updates if
the retry succeeds. Once automatic checking stops, Check again reads the latest
record. Business measurements continue at their slower settlement cadence.
Creating a policy returns the effective rule. A live always_require rule is kept when a later request asks for always_approve; the CLI reports that mismatch and exits unsuccessfully, including with --json. A job has one rule per action: changing its live constraints reports a conflict, while an expired job rule can be replaced. Deleting a missing or other-organization policy returns not_found; it does not confirm a revocation.
Answer in the conversation
Use Approve run or Decline run on the card where the agent asked. Details shows the actions and their parameters. When standing rules are available, the menu beside Approve run lets you approve this run and allow future matching actions within the displayed bounds. The card stays in the conversation after you answer. It shows the run’s progress, failure or reported outcome; saving an approval does not mean the action succeeded. Activity provides another view of the same requests and decisions. You do not need to leave the conversation to answer. While your answer is being saved, the decision buttons are disabled. If saving fails, you can try again. If the approval was saved but the conversation could not resume, Try resuming again continues the approved run without submitting another decision. Expired and withdrawn requests have no decision buttons.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. A request leaves the pending state when it is decided or the run that raised it can no longer act on the answer:- 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, or the
linked job execution finished, failed, or was cancelled before anyone answered.
There is no longer any work for the answer to resume, so the request is marked
withdrawn. - Expired — a rare action can declare its own semantic deadline (a “today’s report” ask is moot tomorrow). A job or Workstream assignment waiting on a person also ends its unanswered run after seven days, so its card expires with that run. Standalone requests without a deadline remain pending.
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.
Declining with a reason stops the same ask coming back
When you decline, you can say why. The reason is optional, but it does two things a plain no cannot:- The same proposal is not filed again for seven days. If an agent, a monitor or a connected product proposes the same change on the same subject — pausing the same campaign, say — without stating a new reason, Erdo answers it with your decline instead of raising a new card. The proposer sees your words and the date. An agent carries on from there; a monitor reads the last week’s declines before it starts, so it knows what not to propose. A proposal that states a genuinely new reason (the facts changed) is a new ask and files as usual.
- Your words stay on the decision record. They appear as the decider’s
rationale beside the proposer’s own reason, on the decision report and in
list_decisions, so later reads can tell what was argued from what you said.
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.
When a request edits the wording of an ad that is already running, the headline is the
change itself rather than the name of the field being written. Each altered headline or
description appears as its current text followed by its replacement, the wording that
stays the same is reported as a count, and a request covering more than one ad opens
with how many ads it rewrites. Erdo reads the ad’s current wording from the ad account
at the moment the request is made, because an edit in place is the last point at which
the previous wording still exists. If every affected ad cannot be read, Erdo does not
file the request: approving a copy or destination edit without that snapshot would be
a blind approval and could not be resumed safely after the account became readable.
A change to which headline is pinned to a position counts as a change even when every
word stays the same, because a pin decides which headline Google has to show there. The
headline names the asset and the position it was pinned to, unpinned from, or moved
between, and a request that moves only a pin is never described as changing nothing.
The display path — the words after the domain in the address the ad shows, as in
example.com/tours/miami — is read the same way, because a searcher reads it before
deciding to click. A request that changes only the path still shows the old and new
segments rather than the name of the field being written.
An ad can run in more than one campaign at once, and editing it changes it in all of
them. Where that is the case the headline says how many campaigns the edit reaches and
names them, so a request touching a single ad is never read as the smallest kind of
change when it is not.
Erdo also remembers the wording it showed you. An ad edit replaces the whole set of
headlines or descriptions, so approving a request after somebody else has rewritten
that ad would put the wording you reviewed over theirs. When the ad has changed since
the request was made, Erdo declines to carry it out and says so instead: nothing is
written, and the change can be proposed again against the ad as it now reads.
That check covers only the parts of the ad the request would rewrite. A request that
changes headlines is still carried out after somebody edits a description it was
going to leave exactly as it was.
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.
Automatic settlement of an approval-declared effect currently supports the synced
Google Ads campaign metrics google_ads.cost_per_conversion, google_ads.clicks,
google_ads.average_cpc, google_ads.conversions and google_ads.cost. These
require a filter in the form campaign_id=123456789, using the connected account’s
actual campaign ID. Cost metrics use the account’s currency, not micros. Provider
conversions are not automatically the same as captured or qualified leads.
A free-text metric or campaign-name filter does not install a measurement reader.
An unsupported declaration remains visible as measurement_unavailable; it is
not evidence that the change worked. Page-funnel experiments use their existing
experiment measurement recipe and observations instead.
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. In a wide composer, the
approvals quick selector beside response depth switches between Ask by
default, Auto-approve safe actions, and Approve everything. In a narrow
conversation pane, both controls move into the Message options drop-up so
the message field keeps the available width. A relaxed approval mode remains
highlighted on that compact control, and the menu 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. A rejection can
carry a
reason (up to 2000 characters), which holds the same proposal back for
seven days — see Declining with a reason.
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. Where the request has an execution outcome the status reads
approved/landed or approved/did not land, so a decision that was never carried out
does not scan as a completed one. Use --json for the full payload, including
execution, 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, dates and — once something
has run under the approval — the outcome line with the failure reason where there is
one, 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.
