MainSDK
Search⌘K
Interfaces

Reliability and operations

Playbooks

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.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec playbooksFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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) covers every way a run can start. It is free.

At a glance

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

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)

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

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (default)
playbooks:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-PLAYBOOK-0001Checkmainsdk check, before deployA 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-0002Checkmainsdk check, before deployA 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-0010Checkmainsdk check, before deployAn action used as a step isn't marked reaction: true, or its undo names an action that doesn't exist
MAIN-GUARD-0008Checkmainsdk check, before deployA guard value is outside its range, or min-prefix is set wider than /16 or /48
MAIN-PLAYBOOK-0004RuntimeError responses and logsSee the spec
MAIN-PLAYBOOK-0005RuntimeError responses and logsSee the spec
MAIN-PLAYBOOK-0006RuntimeError responses and logsSee the spec
MAIN-PLAYBOOK-0007RuntimeError responses and logsSee the spec
MAIN-PLAYBOOK-0008RuntimeError responses and logsSee the spec
MAIN-PLAYBOOK-0009RuntimeError responses and logsSee the spec
MAIN-GUARD-0003RuntimeError responses and logsSee the spec
MAIN-GUARD-0004RuntimeError responses and logsSee the spec
MAIN-GUARD-0006RuntimeError responses and logsSee the spec
MAIN-GUARD-0007RuntimeError responses and logsSee the spec
MAIN-GUARD-0001RuntimeError responses and logsA rule with a people step is run: auto; or a source asked for an auto run of one
MAIN-GUARD-0002RuntimeError responses and logsA step is on the never-a-reaction list; refused from every source
MAIN-GUARD-0005RuntimeError responses and logsAn 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-0003RuntimeError responses and logsA 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

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Rules and matchingPhase 10.4playbooks#match
The executor with dry-run, approve and autoPhase 10.4playbooks#executor
The reaction overlayPhase 10.4playbooks#overlay
Approval on the agents queuePhase 10.4playbooks#approval
Automatic rollbackPhase 10.4playbooks#rollback
Attack, pressure and key-rotation presetsPhase 10.4playbooks#presets
App actions marked reaction: true as steps, with undo as the rollbackPhase 10.4playbooks#action-step
Blast radiusPhase 10.4reaction-safety#never

Quick start

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] }
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:

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

StepClassWhat it does
limits.tighten { policy, to, tenant? }appSmaller amount, same period. Never below the limit-floor.
flags.disable { flag }appForces the flag off, before rules and rollout.
agents.disable, agents.approveappTurns an agent off or raises its approval level.
posture.raise { to }appApplies a posture level's steps as one group.
jobs.pause { names?, except: critical }instanceStops claiming matched work; never resources.critical.
cache.trim {}, resources.shed {}instanceCalls the pressure hooks; holds the instance in shed.
signal.emit { name, scope }noticeTells 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

CallWhat 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

CallWhat 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. Related: signals, norms, 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.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet