Skip to main content

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.
Approving a job’s ask after the original run has already finished re-runs the automation and applies the approved action — a late “yes” still does the thing you approved, rather than quietly doing nothing. That retry applies when an approval decision ended the original run. If the linked execution ended independently while its request was still pending, Erdo withdraws the request instead. Live executions and requests that are not tied to an execution continue waiting for a decision.

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 an occurrence_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.
Declining without a reason works exactly as before: the ask is refused once, and nothing holds a later identical proposal back.

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 open action_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 carry expected_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.