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

# Approval gates

> How the platform pauses an agent or workflow step until a human signs off — and how policy decides which steps need a gate.

export const PageMeta = ({audience, readingTime, prereqs, level}) => {
  const items = [audience && ({
    key: "audience",
    label: "AUDIENCE",
    value: audience
  }), readingTime && ({
    key: "time",
    label: "READ",
    value: readingTime
  }), level && ({
    key: "level",
    label: "LEVEL",
    value: level
  }), prereqs && prereqs.length > 0 && ({
    key: "prereqs",
    label: "PREREQS",
    value: prereqs.join(" · ")
  })].filter(Boolean);
  if (items.length === 0) return null;
  return <div className="gv-page-meta" role="group" aria-label="Page metadata">
      {items.map(item => <div className="gv-page-meta__item" key={item.key}>
          <span className="gv-page-meta__label">{item.label}</span>
          <span className="gv-page-meta__value">{item.value}</span>
        </div>)}
    </div>;
};

export const FeatureTable = ({columns, rows, featuredRow, caption, variant}) => <div className={`gv-feature-table ${variant ? `gv-feature-table--${variant}` : ""}`}>
    <table>
      {caption && <caption className="gv-feature-table__caption">{caption}</caption>}
      <thead>
        <tr>
          {columns.map(col => <th key={col.key} scope="col">
              <span className="gv-feature-table__header">{col.label}</span>
            </th>)}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => {
  const rowClass = [i === featuredRow ? "gv-feature-table__row--featured" : "", row.chosen ? "gv-feature-table__row--chosen" : ""].filter(Boolean).join(" ");
  return <tr key={i} className={rowClass}>
              {columns.map(col => {
    const v = row[col.key];
    if (variant === "tradeoff" && Array.isArray(v)) {
      const pill = col.key === "pros" ? "gv-feature-table__pill--pro" : col.key === "cons" ? "gv-feature-table__pill--con" : "";
      return <td key={col.key}>
                      {v.map((item, j) => <span key={j} className={`gv-feature-table__pill ${pill}`}>{item}</span>)}
                    </td>;
    }
    return <td key={col.key}>{v}</td>;
  })}
            </tr>;
})}
      </tbody>
    </table>
  </div>;

export const Def = ({term, type, defaultValue, required, children}) => <>
    <dt className="gv-def-list__term">
      <span className="gv-def-list__name">{term}</span>
      {type && <span className="gv-def-list__type">{type}</span>}
      {required && <span className="gv-def-list__required">required</span>}
      {defaultValue !== undefined && <span className="gv-def-list__default">
          default <code>{defaultValue}</code>
        </span>}
    </dt>
    <dd className="gv-def-list__description">{children}</dd>
  </>;

export const DefList = ({children}) => <dl className="gv-def-list">{children}</dl>;

export const ArrowItem = ({children}) => <li className="gv-arrow-item">
    <span className="gv-arrow-item__arrow" aria-hidden="true">{"↳︎"}</span>
    <span className="gv-arrow-item__content">{children}</span>
  </li>;

export const ArrowList = ({children}) => <ul className="gv-arrow-list">{children}</ul>;

export const BrandCard = ({eyebrow, title, href, icon, badge, cta, featured = false, horizontal = false, children}) => {
  const classes = ["gv-card", featured && "gv-card--featured", horizontal && "gv-card--horizontal"].filter(Boolean).join(" ");
  const inner = <>
      {icon && <span className="gv-card__icon" aria-hidden="true">
          <Icon icon={icon} />
        </span>}
      <div className="gv-card__main">
        {(eyebrow || badge) && <div className="gv-card__meta">
            {eyebrow && <Eyebrow label={eyebrow} tone="muted" />}
            {badge && <span className="gv-card__badge">{badge}</span>}
          </div>}
        <h3 className="gv-card__title">{title}</h3>
        {children && <div className="gv-card__body">{children}</div>}
        {cta && <span className="gv-card__cta">
            {cta}
            <svg className="gv-card__cta-arrow" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <line x1="7" y1="17" x2="17" y2="7" />
              <polyline points="7 7 17 7 17 17" />
            </svg>
          </span>}
      </div>
    </>;
  return href ? <a className={classes} href={href}>
      {inner}
    </a> : <div className={classes}>{inner}</div>;
};

export const Eyebrow = ({label, tone = "default"}) => <div className={`gv-eyebrow gv-eyebrow--${tone}`}>
    <span className="gv-eyebrow__bar" aria-hidden="true" />
    <span className="gv-eyebrow__label">{label}</span>
  </div>;

<Eyebrow label="CONCEPT" />

<PageMeta audience="Developers, operators" readingTime="5 min" prereqs={["Agents", "Workflows"]} level="Concepts" />

Some actions an automation can take are too consequential to dispatch without a human looking. Charging a card above a threshold. Sending a mass email. Granting elevated workspace access. **Approval gates** are how the platform pauses an [agent](/concepts/agents) or [workflow](/concepts/workflows) step until a human signs off, and they are the only surface where the runtime itself owns the review queue. By the end of this page you will know what an approval is, who can decide it, what the decision is bound to, and how to model "ask before doing this" without building a queue yourself.

## An approval, in the wild

When a step reaches a gate the runtime opens an *approval record* and pauses the session. The record names the action that wants to dispatch and the payload that would be sent. An operator reviews and decides.

```json approval.json theme={null}
{
  "id":         "apr_01H...",
  "agent_id":   "agt_01H...",
  "run_id":     "run_01H...",
  "status":     "pending",
  "action":     "send_email",
  "payload":    { "to": "customers@list", "subject": "Q4 promo" },
  "created_at": "2025-11-02T01:00:10Z"
}
```

`action` and `payload` are not interpreted by the platform — they are the same arguments the gated step would have passed. An operator reading the record sees exactly what would have been dispatched.

## The mental model: a typed pause

Think of an approval gate as a **typed pause**. The agent or workflow says "I am about to do this; I cannot proceed without permission." The runtime records the request, stops execution, and exposes the pending decision through the [Approvals API](/api-reference/resources/approvals) and the console. A decision unblocks the original session at the exact step that paused — not by restarting the run or by re-dispatching earlier steps.

```mermaid theme={null}
sequenceDiagram
    participant R as Runtime
    participant Q as Approvals queue
    participant O as Operator
    R->>R: step is gated
    R->>Q: create approval record (status: pending)
    R-->>R: pause session
    O->>Q: review record
    O->>Q: approve OR deny
    Q-->>R: decision delivered
    R->>R: resume — dispatch (approve) or stop (deny)
```

Two properties to hold:

* **The gate is bound to the exact step.** A decision unblocks one action. If the run has three gated steps, each one creates its own record.
* **A denial is terminal.** The run stops at the denied step. The platform does not retry the gate or try a different path; re-running the work means firing the agent or workflow again, after the underlying issue is resolved.

## What triggers a gate

A step is gated for one of three reasons.

<DefList>
  <Def term="Declared on the step">
    The recipe explicitly marks the action `requires_approval: true`. Use this when the gate is intrinsic to the action — a write you always want a human on.
  </Def>

  <Def term="Policy decision">
    A workspace-level policy rule pattern-matched the step and inserted a gate. Use this when the gate is *conditional* on the payload (amount over a threshold, recipient outside a domain allow-list, time of day, etc.) without rewriting every recipe to know about it.
  </Def>

  <Def term="Risk score">
    The platform's risk classifier flagged the step as anomalous compared to the agent's recent history. Use this as a safety net — gates kick in even when neither the recipe nor a policy rule asked for one, because the action looked unusual.
  </Def>
</DefList>

The three reasons compose: a single step can be gated for all of them at once. The approval record carries the reason set so an operator knows what asked for the review.

## Who can decide

Decisions are scope-gated. A token needs `approvals:write` to call approve or deny. The platform does not separately enforce "the decider must not be the requester" by default — that is a policy you can add at the workspace level if your compliance posture requires it. Two patterns are common:

<ArrowList>
  <ArrowItem>**Workspace operator pool.** All workspace admins can decide any pending approval. Fine for small teams.</ArrowItem>
  <ArrowItem>**Two-person rule.** A policy requires a different principal to decide than the one that fired the trigger. Useful for high-stakes operations; configured on the workspace, not per-step.</ArrowItem>
  <ArrowItem>**Out-of-band channel.** Approvals are pushed to a Slack or email channel via a webhook subscription on the `approvals.pending` event family. An approver clicks a link that redirects to the console with the approval pre-selected.</ArrowItem>
</ArrowList>

## Lifecycle and timeouts

Approval records walk a small state machine.

<FeatureTable
  columns={[
{ key: "status", label: "Status" },
{ key: "meaning", label: "Meaning" },
{ key: "next", label: "Possible next transitions" }
]}
  rows={[
{ status: "pending", meaning: "Awaiting decision. Run is paused.", next: "approved, denied, expired" },
{ status: "approved", meaning: "An operator approved. Run resumed and dispatched the step.", next: "(terminal)" },
{ status: "denied", meaning: "An operator denied. Run stopped permanently at this step.", next: "(terminal)" },
{ status: "expired", meaning: "No decision was made before the workspace's approval TTL. Run stopped.", next: "(terminal)" }
]}
/>

Each workspace configures an approval TTL (default: 7 days). A pending record older than the TTL transitions to `expired` automatically and the parent run stops. Long-running approvals are a smell — if your reviewers routinely take longer than a working week, either raise the TTL or rethink whether the action belongs behind a gate at all.

## What gets logged

Every approval and every decision lands in the workspace audit chain. The recorded fields:

<DefList>
  <Def term="reason_set" type="enum[]">
    Why the gate fired — `declared`, `policy:<rule_id>`, `risk_score`. May contain several when multiple reasons matched.
  </Def>

  <Def term="reviewer" type="principal">
    The id of the operator (user or token) that made the decision. Set when status moves to `approved` or `denied`.
  </Def>

  <Def term="decision_at" type="timestamp">
    When the decision landed. Together with `created_at` this gives the queue dwell time per approval.
  </Def>

  <Def term="payload_hash" type="hash">
    SHA-256 of the canonicalized payload at the moment the gate opened. If the agent retries the step later, the platform compares hashes to detect that the underlying request drifted.
  </Def>
</DefList>

The audit record outlives the run; you can read the decision history for a closed approval indefinitely.

## When to use a gate (and when not to)

Use a gate when:

<ArrowList>
  <ArrowItem>**A reasonable person would want a second pair of eyes** on the action. If you would not be comfortable explaining the audit trail after the fact, that is the test.</ArrowItem>
  <ArrowItem>**The compliance posture requires it.** Some operations — financial above thresholds, outbound communication to broad audiences, identity changes — need recorded human authorization, full stop.</ArrowItem>
  <ArrowItem>**The action is reversible at higher cost than reviewing it.** Stopping a wrong send is cheap; clawing back a wrong send is not.</ArrowItem>
</ArrowList>

Don't use a gate when:

<ArrowList>
  <ArrowItem>**Every run will be approved.** A gate that is always rubber-stamped is friction without a function. Remove it.</ArrowItem>
  <ArrowItem>**The action is reversible.** If the agent can undo its own action in a follow-up step, a gate is overkill. Use the undo path.</ArrowItem>
  <ArrowItem>**A policy can express the constraint statically.** "Amount under \$100" doesn't need a human — encode it as a policy that denies the step entirely.</ArrowItem>
</ArrowList>

## Related

<Columns cols={2}>
  <BrandCard eyebrow="REFERENCE" title="Approvals API" href="/api-reference/resources/approvals" icon="circle-check">
    List, fetch, approve, and deny — the endpoints behind the queue.
  </BrandCard>

  <BrandCard eyebrow="CONCEPT" title="Agents" href="/concepts/agents" icon="bot">
    The automation surface whose steps are gated.
  </BrandCard>

  <BrandCard eyebrow="CONCEPT" title="Workflows" href="/concepts/workflows" icon="workflow">
    Multi-step recipes that an agent can fire and whose steps may also be gated.
  </BrandCard>

  <BrandCard eyebrow="EVENTS" title="approvals.pending" href="/webhooks/events" icon="bell">
    The event family to subscribe to if you want approval requests delivered to Slack or email.
  </BrandCard>
</Columns>
