> ## Documentation Index
> Fetch the complete documentation index at: https://docs.erdo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Approvals

> List and decide approval requests — actions an agent paused on, awaiting a human decision — over MCP, REST, or the CLI

# 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](/autonomy#risk-tiered-approval-gates--propose-the-irreversible).
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.

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.

## 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.

## 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.

| Field                   | What it tells you                                                                                    |
| ----------------------- | ---------------------------------------------------------------------------------------------------- |
| `action_headline`       | The outcome in one line — what changes if you approve, rather than which tool runs.                  |
| `action_items`          | The exact actions the decision covers, one entry each.                                               |
| `omitted_items`         | How many actions were left out of `action_items` when the request covers more than fits (see below). |
| `decision_class`        | The kind of business decision being made, such as `paid_media.campaign.budget` or `page.publish`.    |
| `subject_display_name`  | The human name of the thing being acted on — a page's title, a campaign name, an email recipient.    |
| `card_contract_version` | The version of this set of fields, which a renderer keys its expectations on.                        |

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 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.

## 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:

| Preset             | What still asks                                       | What flows                                            |
| ------------------ | ----------------------------------------------------- | ----------------------------------------------------- |
| Ask by default     | everything (no personal setting — the org's behavior) | —                                                     |
| Auto-approve safe  | `spend`, `destructive`                                | page work (`outbound`), the agent's own `bookkeeping` |
| Approve everything | nothing                                               | every class                                           |

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                    | Effect                                                                     |
| ------------------------ | -------------------------------------------------------------------------- |
| `once` (default)         | Approve just this one request.                                             |
| `always_this_job`        | Auto-approve this action for the rest of this job.                         |
| `always_this_workstream` | Create a standing policy for this action on this Workstream/Strategy only. |
| `always_org`             | Create a standing org-wide policy auto-approving this action.              |
| `always_user`            | Create a standing policy auto-approving this action for the deciding user. |

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

```bash theme={null}
# List pending requests (omit --status for all)
erdo approvals list --status pending
erdo approvals list --json                      # raw JSON
erdo approvals list --status pending --workstream brickell-lead-strategy

# Only approvals whose gated action acts on one typed subject resource
erdo approvals list --subject-type paid_media_campaign --subject-id 1234567890

# Read one request in full before deciding it
erdo approvals show <id>
erdo approvals show <id> --json                 # raw JSON

# Approve or reject a request
erdo approvals decide <id> --approve
erdo approvals decide <id> --reject
erdo approvals decide <id> --approve --scope always_user
erdo approvals decide <id> --approve --scope always_this_workstream

# Standing approvals carry parameter constraints that bound what the policy
# auto-approves. By default the narrowest of the request's scope options is
# used; pick a different one by index, or pass explicit constraints.
erdo approvals decide <id> --approve --scope always_org --option 2
erdo approvals decide <id> --approve --scope always_org \
  --constraints '{"spreadsheet_id":{"values":["1FftG..."]}}'

# Your personal auto-approve dial
erdo approvals settings                  # show the current setting
erdo approvals settings safe              # pages + bookkeeping flow; spend & destructive still ask
erdo approvals settings all               # nothing asks
erdo approvals settings reset             # clear back to the org default
erdo approvals settings --gates spend,outbound,destructive   # custom list
```

`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

| Tool                         | Description                                                                                                                                                                                                                                             |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `erdo_list_approvals`        | List approval requests, filterable by status, exact `workstream_slug`, and the typed subject resource the gated action acts on (`subject_resource_type` + `subject_resource_id`).                                                                       |
| `erdo_get_approval`          | Read one request in full by `id`: the headline, the itemized actions with the reason each was proposed and the parameters its action shows, the subject it acts on, the reason recorded for the ask, and the scope options a standing decision can use. |
| `erdo_decide_approval`       | Approve or reject a pending request so the paused run can continue (or be rejected). Takes `id`, `decision`, optional `scope`, and optional `parameter_constraints` for standing scopes (defaults to the request's narrowest scope option).             |
| `erdo_get_approval_settings` | Read your personal approval settings: the risk classes that still raise a card for runs you initiate in the active org.                                                                                                                                 |
| `erdo_set_approval_settings` | Replace your personal gate list — `["spend","destructive"]` auto-approves page work and bookkeeping; `[]` asks nothing; `null` clears back to the org default.                                                                                          |

## REST

| MCP Tool                     | REST Endpoint              | Method |
| ---------------------------- | -------------------------- | ------ |
| `erdo_list_approvals`        | `/v1/approvals`            | GET    |
| `erdo_get_approval`          | `/v1/approvals/:id`        | GET    |
| `erdo_decide_approval`       | `/v1/approvals/:id/decide` | POST   |
| `erdo_get_approval_settings` | `/v1/approval-settings`    | GET    |
| `erdo_set_approval_settings` | `/v1/approval-settings`    | PUT    |

```bash theme={null}
# List pending approvals
curl "https://api.erdo.ai/v1/approvals?status=pending" \
  -H "Authorization: Bearer YOUR_API_KEY"

# Read one request in full
curl https://api.erdo.ai/v1/approvals/<id> \
  -H "Authorization: Bearer YOUR_API_KEY"

# Approve a request (scope defaults to "once")
curl -X POST https://api.erdo.ai/v1/approvals/<id>/decide \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"decision": "approved", "scope": "once"}'

# Your personal auto-approve dial
curl https://api.erdo.ai/v1/approval-settings \
  -H "Authorization: Bearer YOUR_API_KEY"

curl -X PUT https://api.erdo.ai/v1/approval-settings \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"gates": ["spend", "destructive"]}'   # [] = ask nothing; null = org default
```

`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.
