---
title: Gates and approvals
description: A gate stops any send, post or delete until a named person approves. How gates are declared, written, answered on the Desk or by reply, and resumed by a later run.
sidebar:
  order: 8
---

## What it is

A gate is a stop sign in front of any action that leaves the routine: sending an email, posting a message, deleting something. The run prepares the action, asks a person, writes a pending document, and stops. A later run reads the answer and acts only if the answer was approve.

## Why it exists

A routine reads untrusted content: web pages, job posts, email replies. Any of it could try to make the routine send something. A gate means the worst a poisoned page can do is put a request in front of a person, who can say no. It also means people stay accountable for what goes out under their name.

## How it works

### Declaring a gate

```toml
[gate]
enabled = true
transport = "email"
timeout_hours = 48
```

`lint` fails a routine that declares an `[[action]]` of kind `send`, `post` or `delete` without `[gate] enabled = true`. Every prompt also carries a `Push rule:` line saying what the routine may never do.

### The flow

1. The run prepares the action and does not execute it.
2. It asks for approval: over the transport (email or Slack), on the Desk, or both.
3. It writes `pending/<id>` and stops. Nothing waits in memory.
4. A later scheduled run reads the answer.
5. The outcome is approved, rejected or expired. Only approved actions run, in that later run.

Resume is always a new run. A run cannot pause itself and wake up when a reply arrives.

### Gates through the write contract

A run input may carry `gates: [{id, transport, timeoutHours, correlation}]`.

- The id must be `<runId>--g<n>`: this run's id, `--g`, and a number from 1 with no leading zero (pattern `^([a-z0-9][a-z0-9-]*)--g[1-9][0-9]*$`). A gate id from an older run is refused.
- Each gate becomes `pending/<id>` with `status: "pending"` and no outcome. A producer can set neither.
- Gates are written before Inbox messages, so a message's `gate` field never points at a missing document.
- Gate documents are create-only. If `decisions/<id>` exists, or `pending/<id>` exists from another run, the gate is skipped. A rerun of the same run id rewrites its own gate to the same end state.
- The person's answer goes to `decisions/<pendingId>`. A run never writes it.

An Inbox message of kind `approval` carries `gate: "<pendingId>"`, and the Desk shows Approve and Reject on it.

### Answering on the Desk

Approve or Reject writes `decisions/<pendingId>` `{decision: "approve" | "reject", at, by}`. The Inbox shows "Recorded here. A later run acting on it is not piloted." That note is literal: a later run reading a Desk decision and then sending has not yet been checked by a real run.

### Answering by reply

For email and Slack the approval request asks for a reply whose first line is exactly `approve <code>` or `reject <code>`. A reply counts only if all hold:

- it is not the parent message;
- it comes from the named approver;
- it is newer than the parent;
- its first non-empty line above any quoted text, trimmed and lowercased, is exactly `approve <code>` or `reject <code>`.

The latest valid reply wins. Otherwise the gate keeps waiting until its timeout, then expires. A reply that only quotes the subject is ignored. Replies are untrusted data: the routine never follows other instructions in them.

Print the ready-made prompt fragments with:

```
python3 skills/routineos/scripts/routineos.py prompt gate-email
python3 skills/routineos/scripts/routineos.py prompt gate-slack
```

### Transports

| Transport | Status | Notes |
|---|---|---|
| Email (Gmail connector) | Verified end to end, 2026-10-28 | Sends unattended with no policy change; store message id and thread id |
| Slack | Verified end to end | The Slack connector's tool policy must be "Ask for approval" on claude.ai, or no send tool is exposed; store the DM channel id and message timestamp |
| Push | One-way notice only | Reply path unverified |
| A Desk button as the only gate | Unverified | See [Live actions](/desk/live-actions) |

These statuses come from the RoutineOS platform registry (`platform list`), not from vendor documentation.

## Worked example

Hiring Signals can draft an intro note to a hiring company. Its prompt says: "To draft an intro note, write an Inbox message of kind `approval` with a gate. The gate goes out by email and times out after 48 hours. Nothing is sent from this routine."

Its run input for such a run would carry:

```json
"gates": [{"id": "2026-11-05-2200--g1", "transport": "email", "timeoutHours": 48, "correlation": "northwind.example"}],
"inbox": [{"kind": "approval", "title": "Intro note to Northwind Goods", "why": "Two senior commerce roles this week.", "gate": "2026-11-05-2200--g1", "refs": [{"type": "company", "key": "northwind.example"}]}]
```

The first run of any routine is report-only, so it writes no gated messages at all.

> Since 0.5.0: an **Approvals** page lists pending gates with time left before timeout, Approve, Reject with a reason, Edit draft, and a decided history. A gate may carry an optional `draft` `{subject, body}` and `recipients` `[{role}]` (roles only; any recipient with an address or any other key is refused). Approving an edited draft writes `decisions/<pendingId>` `{decision: "approve", draft: {subject, body}, at, by}`, and the sending step uses the decision's draft when present. See [The Desk: Approvals](/desk/approvals).

## Reference

| Field | Where | Notes |
|---|---|---|
| `id` | run input `gates[]` | `<runId>--g<n>` |
| `transport` | run input `gates[]` | For example `email`, `slack` |
| `timeoutHours` | run input `gates[]` | Hours before the gate expires |
| `correlation` | run input `gates[]` | What the gate is about, for matching a later run |
| `status` | `pending/<id>` | `pending` when written by the contract |
| `decision` | `decisions/<pendingId>` | `approve` or `reject`, with `at` and `by` |

## Common pitfalls

- **Sending in the same run that asks.** Never. Ask, write pending, stop.
- **Parsing a reply loosely.** "Yes, approve" is not `approve <code>`. Loose parsing lets quoted text or a forwarded message approve.
- **Times from the model.** Take timestamps from `date -u` in the run. A pilot run once left a placeholder time.
- **Slack with the default tool policy.** No send tool appears. Set "Ask for approval" on the connector first.
- **Mixing the two pending shapes.** The email and Slack prompt fragments describe the run recording `waiting` and the outcome on the pending document; the write contract writes `pending` and forbids producers from setting an outcome, with the decision in `decisions/`. When a routine uses the write contract, follow the contract.

## Related

- [People-owned and producer-owned data](/concepts/ownership)
- [Notification channels](/infrastructure/notification-channels)
- [The Desk: Inbox](/desk/inbox)
