Skip to main content

Playbooks

Playbooks are reusable, declarative workflows that orchestrate investigation and response. They run inside the services/agents service via the PlaybookEngine, emit realtime events, and can be authored as JSON or via the visual React Flow editor in the web app.

Anatomy of a playbook​

A playbook is a Pydantic model with metadata, a trigger, and an ordered list of steps. The wire format is JSON; the runtime model is in services/agents/app/playbook/models.py.

{
"id": "ransomware-response-v1",
"name": "Ransomware Response",
"description": "Immediate containment and investigation for ransomware detections.",
"version": "1.0.0",
"tags": ["ransomware", "malware", "critical"],
"trigger": {
"on": "alert",
"severity": ["critical"],
"tags": ["ransomware"]
},
"author": "AiSOC",
"enabled": true,
"steps": [
{
"id": "isolate",
"name": "Isolate affected host",
"type": "isolate_host",
"params": { "host_field": "alert.host" },
"on_failure": "abort",
"timeout_seconds": 30
}
]
}
FieldTypeDescription
idstringStable identifier. Generated if omitted.
namestringHuman-readable name shown in the UI.
versionsemverBumped on every breaking change to the playbook.
tagsstring[]Free-form labels for filtering and marketplace search.
triggerobjectWhen the playbook fires. See Trigger types.
stepsobject[]Ordered list of steps. See Steps.
enabledboolDisabled playbooks are skipped by the dispatcher.

Trigger types​

A trigger declares when the playbook should run. The dispatcher evaluates triggers against incoming alerts, cases, and schedules.

{
"trigger": {
"on": "alert",
"severity": ["high", "critical"],
"tags": ["ransomware"],
"rule_ids": ["AIS-EDR-0001"]
}
}
onFires whenCommon filters
alertA new alert arrives in the fusion serviceseverity, tags, rule_ids, source
caseA case is opened or transitions stateseverity, case_status
manualAn operator clicks Run playbook in the UIn/a
scheduleA cron expression matchescron (e.g. "0 */6 * * *")
webhookAn external system POSTs to /v1/playbooks/{id}/triggeroptional secret

Severity values follow the four-tier ladder used everywhere in AiSOC: info | low | medium | high. Vendor-native ladders (Azure, SCC, GitHub) are collapsed into this set in each connector's normalize().

Steps​

Each step has a type. The authoring contract is schemas/playbook.schema.json, and its x-aisoc-execution map records what engine.py actually does with each one. scripts/check_playbook_schema_parity.py holds the two together in both directions, so the table below cannot quietly stop being true.

Executed — a handler runs and has a real effect:

typeWhat it does
enrichCalls the enrichment service for IOC reputation, geo, ASN, GreyNoise, VT, OTX.
investigateTriggers the AI investigator agent with focus areas (forensics, lateral_movement, etc.).
httpCalls one path on an integration this tenant has configured — see Integration references. SSRF-guarded, and off by default.
close_caseMarks the AiSOC case as closed via the API service.
conditionPure branching node. Evaluates condition and routes to next_true / next_false.
waitHold for a timer or a callback — see Control flow.
parallelRun the child steps concurrently, then join.
loopRun the child steps once per item in a list from the run context, bounded.
osquery_live_queryDistributed osquery via osctrl / FleetDM / aisoc-direct, against an allow-listed template.

Governed — the step is dispatched to the action registry in the actions service, which grades it against the verb's own capability contract and the tenant's autonomy policy before anything reaches a vendor:

block_ip, block_ioc, isolate_host, kill_process, quarantine_file, run_av_scan, run_script, disable_user, reset_password, revoke_session, force_mfa, search_siem, create_notable_event, create_ticket, notify, osquery_live_query.

notify delivers to the tenant's configured Slack, Teams, email or PagerDuty destination; the channel comes from the destination, not from the step, so a credential and a transport cannot disagree. osquery_live_query runs an allow-listed template across an osctrl, FleetDM or aisoc-direct fleet — the caller passes a template id and parameters, never SQL.

Each step is dispatched individually. Approving or running a playbook does not authorise whatever its steps happen to contain: the contract is applied per verb, per step, at the moment that step runs. See Live actions for what each verb declares about its impact, reversibility, approval requirement and verification probe.

Control flow​

A playbook could branch and it could not wait, fan out or repeat, so "contain, then re-check in five minutes" had to be hand-unrolled into a fixed chain of steps. wait, parallel and loop close that, and each carries a ceiling rather than trusting the author:

StepShapeBound
wait{"until": "timer", "seconds": 300} or {"until": "callback"}Up to 60s runs in place; longer suspends the run to Postgres and a sweeper resumes it. A callback resumes through POST /api/v1/playbook-waits/{token}/resume. Maximum 7 days.
parallelsteps: [...], {"join": "all"} or {"join": "any"}At most 20 branches. Each branch gets its own copy of the run context, so two branches cannot overwrite each other and leave whichever finished last to win.
loop{"over": "sessions"} with steps: [...]At most 100 iterations, 25 by default. Each iteration binds item and index.

Three refusals worth knowing, because a control-flow step that quietly does less than it says is worse than one that is absent:

  • an empty parallel or loop fails, rather than reporting a success for work it did not do;
  • a loop that hits its ceiling says so, rather than truncating — stopping at 25 of 300 sessions leaves 275 live and a clean-looking record;
  • a wait whose pause cannot be written fails, because continuing past it would run the steps the wait exists to delay.

Every step execution carries an idempotency key derived from the run, the step and the position it was reached at, so the third loop iteration is distinguishable from the fourth and a resumed run reproduces the keys its first attempt used. It is a name, not a distributed deduplication store.

Integration references​

An http step addresses its target by name, never by host:

{ "url": "${IDP_BASE_URL}/users/{{alert.user}}/sessions", "headers_env": "IDP_BEARER_HEADERS" }

${NAME} resolves against the tenant's playbook references — a base URL, a set of bearer headers, a Slack or Teams incoming webhook, a PagerDuty routing key or an SMTP relay. {{dot.path}} resolves against the run context.

This is the security property rather than a formatting convention. A pack is shared content: a literal host in one is either somebody else's tenant or an invitation to point a playbook wherever its author likes. With a name, an author chooses which of this tenant's integrations to call and which path under it, and cannot choose the origin. The resolved URL still goes through the SSRF guard, because the stored value is tenant data and therefore as untrusted as the step that asked for it.

Secrets live in the credential vault and the run record holds header names, never values. A reference is disabled when created, so importing a pack cannot start paging an on-call rota.

What comes back is a report whose executed field is the single thing that means a vendor was actually touched:

statusexecutedMeaning
executedtrueThe vendor ran it. verification says whether a probe confirmed the effect, or unverified if no probe exists.
awaiting_completiontrueThe vendor accepted it and the outcome is not known yet.
dry_runfalsePreviewed. This is the default posture — see below.
pending_approvalfalseHeld for an analyst by the contract or the tenant's tier. Nothing ran.
blockedfalseRefused by tenant policy.
simulatedfalseThe executor found no usable credentials and took its safe path.
no_integrationfalseThe verb is supported and this tenant has no enabled connector that performs it.
unsupportedfalseNo executor is registered for this verb in this deployment.
failedfalseThe vendor or the dispatch itself failed.

A step that did not execute is recorded FAILED, so the run halts under the default on_failure: abort. This is deliberate: the steps after a containment assume the containment happened.

Execution is off by default. AISOC_PLAYBOOK_ACTIONS_EXECUTE is unset, so response steps preview and report dry_run. Set it to 1 to let a playbook touch a vendor — and note that governance can still refuse an execution this allows, and can never allow one it refuses. AISOC_AGENTS_SERVICE_TOKEN must also be set, or the agents service cannot reach the API's service path and the step fails closed saying so.

Every declared step type has a handler. approval was once the exception — the engine had no pause and no resume, so the step failed closed and twelve shipped playbooks aborted on it. It is a durable pause now; see Approvals and dry-runs below.

Common step fields:

FieldDefaultDescription
idautoUsed as the target for next_true / next_false.
namerequiredHuman-readable label.
params{}Step-specific parameters. Supports {{field}} substitution from run context.
conditionnullOptional gate. If false, step is SKIPPED.
on_failureabortOne of abort, continue, retry.
retry_max0Retry attempts before applying on_failure. Backoff is min(2^attempt, 30) seconds.
timeout_seconds30Per-step timeout.
next_true / next_falsenullStep IDs to jump to for branching.

Conditions​

A StepCondition is evaluated against the run context (which starts as the trigger payload and accumulates each step's result):

{
"condition": {
"field": "alert.severity",
"operator": "eq",
"value": "critical"
}
}

Supported operators: eq, ne, gt, lt, contains, exists. The field uses dot-path resolution (alert.host.name) and null is returned for missing keys, so exists is the safe way to check optional data before branching on it.

Error handling​

The engine treats steps as discrete units of work with structured error handling.

  • on_failure: "abort" (default) — failed step stops the run, marks it FAILED, and emits run.done with the error.

  • on_failure: "continue" — log the failure but proceed to the next step. Useful for best-effort enrichment that shouldn't block containment. It decides whether the run keeps going, not what the run is called: a run that finishes with any failed step is FAILED, with an error naming how many and which. A green tick over a containment that never happened is the same fake success one level up.

  • on_failure: "retry" — combined with retry_max, retries with exponential backoff before falling back to whatever you set as the next-failure mode.

  • Cycle detection — if the engine revisits the same step.id, it aborts with error: "cycle at step <id>" rather than looping forever.

  • Step type with no handler — the step is recorded as FAILED with unimplemented: true and an error naming the verb, so a step the engine cannot run never reports success. It is not retried: a handler that is missing now will still be missing on the next attempt.

    This page previously said such a step was recorded as SKIPPED "so unknown actions never silently succeed". The engine did put skipped: true in the result, but left the step's status at SUCCESS and the run completed — so the documented safety property was the opposite of the behaviour. It now does what this section always claimed.

Each step result includes _elapsed_ms so you can see per-step latency in the UI and in run history.

Approvals and dry-runs​

dry_run flag — pass dry_run: true when calling PlaybookEngine.run(...). Handlers short-circuit with {"dry_run": true, "executed": false, "step": <name>} and emit the same realtime events, so you can preview an entire run without touching production. A governed step additionally reports would_dispatch and the target it resolved, so a preview of a containment playbook reads as one. A step whose type has no handler is reported as unimplemented: true, would_fail: true, so a preview tells you which steps a real run would stop on.

An approval step is a durable pause. The run suspends to Postgres (services/agents/app/playbook/pause.py), survives a restart of the agents service, resumes from the step after the approval once it is decided, and expires with a recorded outcome rather than hanging. A wait step uses the same mechanism: a short timer sleeps in place, anything longer becomes a durable pause.

You often do not need one. Every response step is graded against its own capability contract at dispatch and comes back pending_approval on its own when a human is required — isolate_host, for instance, declares approval: analyst, which no autonomy tier or confidence level lifts. An approval step in front of it gates a decision that is already gated. That is a reason to leave it out of a playbook, not a reason the engine refuses it.

Where you want an action held for a named approver with the deciding principal recorded, submit it to the actions service (POST /actions), which holds it at awaiting_approval.

Two things this section said before, both now wrong, kept because knowing which way a doc was wrong is worth more than a clean page. It said the step "is not implemented … the engine is a single pass with no pause or resume" — true when written, false since the pause landed. Before that it described modelling the gate as a condition step flipped by POST /v1/playbook-runs/{id}/approve; no such endpoint has ever existed, and an approval step used to report SUCCESS without doing anything, so a playbook that modelled a gate ran straight through it into whatever it was gating.

Realtime events​

Every run emits events to the realtime service so the UI can stream progress:

EventWhen
run.startedThe engine begins executing.
step.startedBefore each non-condition step.
step.doneAfter each step, with status and result_keys.
run.doneFinal state, including the full PlaybookRun object.

Channel format: playbook:<run_id>. Subscribe via WebSocket (/realtime/ws?channel=playbook:<run_id>) or the SDK (AiSOC.subscribe("playbook", run_id)).

Starter templates​

AiSOC ships with starter playbooks under services/agents/data/playbooks/:

TemplateTriggerDescription
ransomware-responseseverity: critical, tags: ransomwareIsolate, enrich IOCs, AI forensic investigation, P1 ticket, page on-call.
phishing-triageseverity: medium+, tags: phishingSandbox attachments, enrich URLs, quarantine, notify user.
credential-stuffingtags: auth-anomalyVelocity check, geo-impossible-travel, force-reauth, ticket.
data-exfiltrationtags: dlpBlock egress, snapshot session, enrich destination, page DPO.
malware-analysismanualSandbox detonation + AI summary of behaviour.
insider-threattags: uebaPull 30-day behaviour, peer comparison, escalate to HR-sec.
lateral-movementtags: lateralTrace process tree, identity-graph hunt, contain pivot host.
privilege-escalationtags: privescRevoke session tokens, audit recent role changes.
c2-beacontags: c2Block destination, isolate beaconing host, enrich infra.
supply-chain-alerttags: supply-chainPin dep, scan repo, open security advisory.
cloud-misconfigurationsource: cspmAuto-remediate via IaC PR, notify owner.
vulnerability-criticalcvss>=9.0Patch advisory, asset graph blast-radius, ticket.

Playbook editor​

The visual editor lives at Playbooks → Editor in the web app. It is built on React Flow and lets you:

  • Drag-and-drop nodes (trigger, enrichment, decision, action, notification).
  • Connect nodes to define next_true / next_false flow.
  • Configure params, condition, and on_failure inline per node.
  • Export to JSON, save to the API, or Run now with a sample payload.
  • Replay a past run with the same trigger context for debugging.

See also​