---
title: "Playbooks"
description: "Declare what the app does when a signal arrives, with dry-run, approval, an audit trail and automatic rollback; every step only tightens and expires."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "playbooks"
canonical: "https://mainsdk.dev/docs/interfaces/playbooks"
updated: "2026-10-28"
---

# Playbooks

> Declare what the app does when a signal arrives, with dry-run, approval, an audit trail and automatic rollback; every step only tightens and expires.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/playbooks · Updated: 2026-10-28

`playbooks` lets you write down in `main.yaml` what your app does when something goes wrong, with dry-run, approval and automatic rollback. Nothing it does loosens anything, and nothing outlives 24 hours.

Examples: tighten the sign-in limit during an attack, switch off a heavy feature under CPU pressure, plan a key rotation. A rule matches signals, plans its steps, waits for a person unless you said `auto`, applies them as a short-lived overlay on your config, and undoes them when the condition clears, the time runs out or things get worse. The same guard (see [reaction safety](#the-guard)) covers every way a run can start. It is free.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/playbooks.md
- Category: Reliability and operations

Calls:

- `playbooks.list()`
- `playbooks.plan(rule, { signals? })`
- `playbooks.run(rule, { signals?, reason? })`
- `playbooks.get(runId)`
- `playbooks.revert(runId, { reason })`
- `posture.get()`
- `posture(object: the reaction posture)`
- `new Playbooks(opts)`
- `parseReaction(main, ctx)`
- `reactionIssues(main, ctx)`

In main.yaml:

```yaml
playbooks:
  use: [attack, pressure, key-rotation]
  approve-timeout: 15m                 # how long an approve step waits
  presets:
    attack: { limits: [sign-in], posture: high, notify: oncall }
    pressure: { flags: [recommendations] }
  rules:
    report-flood:                      # a rule of your own
      when: { signal: anomaly.detected, min: medium }
      run: approve                     # auto | approve | dry-run
      do:
        - limits.tighten: { policy: report-export, to: 10/hour }
posture:
  levels:
    high: [alerts.raise]               # steps added to the level below
guard:
  max-active: 5                        # runs active at once, 1 to 20
  max-per-hour: 20
  max-hops: 2
  quarantine: 1h                       # how long a blocked source stays held
  limit-floor: 10%
  tenant-quorum: 3
  block:
    max-ranges: 256                    # ranges a playbook may add in total
    never: [10.0.0.0/8]                # ranges never blocked
```

Guarantees, whatever the driver:

- Every step tightens or reduces work and expires within 24 hours
- Approval by default; steps that touch people always need a person
- Rolled back when the condition clears or things get worse
- One guard for every source, with a never-a-reaction list


## Drivers

Pick one in main.yaml with `playbooks.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-PLAYBOOK-0001` | check | A `playbooks` or `posture` value is invalid: an unknown step, preset, trust level or severity; a `for` over 24h; a `count` window under 1m; a `cooldown` under 1m; or a parameter that doesn't fit the step |
| `MAIN-PLAYBOOK-0002` | check | A step's interface isn't configured or built (for example `bots.block` with no `bots` section, `workers.scale` before parallelism); `fix` names the spec |
| `MAIN-PLAYBOOK-0010` | check | An action used as a step isn't marked `reaction: true`, or its `undo` names an action that doesn't exist |
| `MAIN-GUARD-0008` | check | A `guard` value is outside its range, or `min-prefix` is set wider than /16 or /48 |
| `MAIN-PLAYBOOK-0004` | runtime |  |
| `MAIN-PLAYBOOK-0005` | runtime |  |
| `MAIN-PLAYBOOK-0006` | runtime |  |
| `MAIN-PLAYBOOK-0007` | runtime |  |
| `MAIN-PLAYBOOK-0008` | runtime |  |
| `MAIN-PLAYBOOK-0009` | runtime |  |
| `MAIN-GUARD-0003` | runtime |  |
| `MAIN-GUARD-0004` | runtime |  |
| `MAIN-GUARD-0006` | runtime |  |
| `MAIN-GUARD-0007` | runtime |  |
| `MAIN-GUARD-0001` | runtime | A rule with a `people` step is `run: auto`; or a source asked for an auto run of one |
| `MAIN-GUARD-0002` | runtime | A step is on the never-a-reaction list; refused from every source |
| `MAIN-GUARD-0005` | runtime | An app-class step was triggered by one tenant's signals without `tenant-quorum` tenants and without approval; it was turned into an approval |
| `MAIN-PLAYBOOK-0003` | runtime | A step would not tighten: `limits.tighten` to a larger amount or another period, a flag that isn't defined, an agent that isn't declared; refused before anything runs |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Rules and matching | 1 | 0.4 | `playbooks#match` |
| The executor with dry-run, approve and auto | 1 | 0.4 | `playbooks#executor` |
| The reaction overlay | 1 | 0.4 | `playbooks#overlay` |
| Approval on the agents queue | 1 | 0.4 | `playbooks#approval` |
| Automatic rollback | 1 | 0.4 | `playbooks#rollback` |
| Attack, pressure and key-rotation presets | 1 | 0.4 | `playbooks#presets` |
| App actions marked reaction: true as steps, with undo as the rollback | 1 | 0.4 | `playbooks#action-step` |
| Blast radius | 1 | 0.4 | `reaction-safety#never` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
limits:
  policies:
    sign-in: { max: 10, per: 1m }
signals: {}
playbooks:
  use: [attack, pressure, key-rotation]
  presets:
    attack: { limits: [sign-in], posture: high }
    pressure: { flags: [recommendations] }
```

```ts title="scripts/what-if.ts"
import { playbooks } from "mainsdk";

// what would attack.contain change right now? nothing is applied
const plan = await playbooks.plan("attack.contain");
console.log(plan.why, plan.steps.map((s) => `${s.step}: ${s.result}`));
```

## Configuration

`playbooks.mode` is `on`, `dry-run` (every rule only plans) or `off`. `use` turns on presets, and `presets.<name>` gives them parameters. `rules.<name>` is your own rule:

```yaml title="main.yaml"
playbooks:
  rules:
    report-flood:
      when: { signal: anomaly.detected, subject: "metric:requests", min: medium, count: { at-least: 3, within: 10m } }
      run: approve                   # auto | approve (default) | dry-run
      do:
        - limits.tighten: { policy: report-export, to: 10/hour }
        - flags.disable: { flag: bulk-export }
      for: 30m                       # how long steps hold; at most 24h
      until: cleared                 # also roll back when the signal lapses
      cooldown: 10m
      rollback-if: { errors: 2x }    # compare the 5 minutes after with the 5 before
```

The presets are `attack.contain` (approve), `pressure.slim` (auto), `key-rotation.stale`, `key-rotation.compromised` (approve) and `key-rotation.rotated` (auto). A step whose interface is not built yet (`bots.block`, `alerts.raise`, `secrets.rotate`, `incident.sign-out`, `workers.scale`, and `action` steps) is planned as `skipped` and `mainsdk check` says so with `MAIN-PLAYBOOK-0002`.

## Steps available today

| Step | Class | What it does |
| --- | --- | --- |
| `limits.tighten { policy, to, tenant? }` | app | Smaller amount, same period. Never below the `limit-floor`. |
| `flags.disable { flag }` | app | Forces the flag off, before rules and rollout. |
| `agents.disable`, `agents.approve` | app | Turns an agent off or raises its approval level. |
| `posture.raise { to }` | app | Applies a posture level's steps as one group. |
| `jobs.pause { names?, except: critical }` | instance | Stops claiming matched work; never `resources.critical`. |
| `cache.trim {}`, `resources.shed {}` | instance | Calls the pressure hooks; holds the instance in `shed`. |
| `signal.emit { name, scope }` | notice | Tells the app or a neighbour. |
| `action { name, input }` | app (people when the action's `risk` is `destructive`) | Runs an app action declared with `reaction: true`, as subject `agent:playbooks` through access. Rollback runs its `undo` action once with the same input; with no `undo` the step is irreversible and rollback skips it. `check` reports `MAIN-PLAYBOOK-0010` for any other action. |

```ts
import { action } from "mainsdk";
import { s } from "mainsdk/schema";

export const freeze = action({
  name: "catalog.freeze",
  intent: "Freeze the catalogue",
  risk: "write",
  reaction: true,
  undo: "catalog.unfreeze",
  input: s.object({}),
  run: async () => ({ frozen: true }),
});
```

## Calls and command line

| Call | What it does |
| --- | --- |
| `playbooks.list()` | Every rule with its mode and step classes. |
| `playbooks.plan(rule, { signals? })` | Dry-run now; changes nothing. |
| `playbooks.run(rule, { reason? })` | A person's request; still guarded and approved. |
| `playbooks.get(runId)` | One run: state, steps, why, rollback. |
| `playbooks.revert(runId, { reason })` | Undo a run; needs a reason. |
| `posture.get()` | `normal`, `guarded`, `high` or `lockdown`. |

```bash
npx mainsdk playbooks list
npx mainsdk playbooks plan attack.contain --signal <id>
npx mainsdk playbooks runs --state waiting
npx mainsdk playbooks approve <run>      # asks the running dev app
npx mainsdk playbooks revert <run> --reason "false alarm"
```

`approve`, `decline` and `revert` talk to a running `mainsdk dev` app through `.mainsdk/control/`. In production an approval is a person with the right permission, on the agents queue.

## The guard

Every run, from a rule, a neighbour or a person, passes one guard, in order: never-a-reaction steps (`MAIN-GUARD-0002`: payments, data deletion, loosening, deploys, running programs, messaging users), protected targets (`MAIN-GUARD-0003`), loops (`MAIN-GUARD-0004`), quarantine after a rollback as worse (`MAIN-GUARD-0007`), `max-per-hour` (`MAIN-GUARD-0006`), `max-active` (`MAIN-GUARD-0003`), people steps in `auto` (`MAIN-GUARD-0001`, turned into an approval) and one tenant's signals driving an app-wide step (`MAIN-GUARD-0005`). Tune it with `guard:` (`max-active`, `max-per-hour`, `quarantine`, `max-hops`, `block`, `limit-floor`, `tenant-quorum`).

## What you get

- Records each run with a one-sentence `why`, the signals that caused it and every step's result, in the audit log.
- Applies steps as an overlay that wins over pushed config and `main.yaml`, and removes it on expiry, clearing, worse or revert. The overlay is shared across instances through the store when one is configured.
- Rolls back as `worse` when the error rate rises by at least one point over the 5 minutes before, with at least 100 requests on each side.
- Never touches the request path beyond one overlay lookup (under 0.1 microseconds added).

A single serverless app has no long-lived instance for instance-class steps and no sampling for `pressure.slim`.

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `posture(object)` | The reaction posture: `posture.get()` returns the level and what holds it. |
| `new Playbooks(opts)` | The playbook executor of one app instance, for tests and custom wiring. |
| `parseReaction(main, ctx)` | Read the reaction rules of `main.yaml` into settings. |
| `reactionIssues(main, ctx)` | List what is wrong in the reaction rules, without throwing. |

## Errors and checks

`MAIN-PLAYBOOK-0001` and `0002` and `0010`, `MAIN-GUARD-0001` and `0002` and `0008` come from `mainsdk check`. `MAIN-PLAYBOOK-0003` to `0009` and `MAIN-GUARD-0003` to `0007` come at run time. See [check codes](https://mainsdk.dev/docs/reference/check-codes). Related: [signals](https://mainsdk.dev/docs/interfaces/signals), [norms](https://mainsdk.dev/docs/interfaces/norms), [limits](https://mainsdk.dev/docs/interfaces/limits).

## Swapping drivers

`playbooks` has no driver of its own. The overlay is shared across instances through the store when one is configured, so changing the store driver changes where it lives and your rules stay the same.

## Testing

Run rules on `createTestApp()` from `@mainsdk/testing` with the fake clock. `playbooks.plan(rule)` changes nothing, so use it to assert what a rule would do, and `new Playbooks(opts)` builds the executor for custom wiring. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Next

- [Signals](https://mainsdk.dev/docs/interfaces/signals)
- [Norms](https://mainsdk.dev/docs/interfaces/norms)
- [Limits](https://mainsdk.dev/docs/interfaces/limits)
- [Ops server](https://mainsdk.dev/docs/interfaces/ops)
