Playbooks¶
A playbook is a declarative execution plan — an ordered list of
steps, each with a description, expected tools, hints, and validation
criteria. The PlaybookEnforcer checks that the agent runs the right
tools in the right order and reports any deviation.
If your agent isolates a host, files a case, or touches anything an auditor will review, you want a playbook. The model still picks the wording; the side effects follow the plan.
from tulip.playbooks import Playbook, PlaybookStep
from tulip.playbooks.hook import PlaybookEnforcerHook
# NIST SP 800-61 detection & analysis phase, encoded as a runbook.
incident_triage = Playbook(
id="incident-triage",
name="Incident detection & analysis",
steps=[
PlaybookStep(
id="gather_alerts",
description="Pull the alert and correlated events from the SIEM.",
expected_tools=["query_siem", "enrich_indicator"],
hints=["Start with the most recent", "Prioritise HIGH severity first"],
max_tool_calls=5,
),
PlaybookStep(
id="analyze_indicators",
description="Group indicators by type, note first/last seen.",
expected_tools=["lookup_hash", "enrich_indicator"],
),
PlaybookStep(
id="summarize_findings",
description="Write a one-paragraph root-cause summary.",
expected_tools=[],
),
],
strict_sequence=True,
)
agent = Agent(
model="anthropic:claude-sonnet-4-6",
tools=[query_siem, enrich_indicator, lookup_hash],
hooks=[PlaybookEnforcerHook(playbook=incident_triage)],
)
When to reach for a playbook¶
| Situation | Playbook? |
|---|---|
| Regulated workflow (NIST 800-61 IR phases, evidence handling, host isolation) | yes |
| Multi-step process where order matters | yes |
| Repeatable runbook the team executes manually today | yes — encode it |
| Audit-trail requirement: "every containment follows the same sequence" | yes — the enforcer's in-memory execution log captures the sequence (persist it, or pair it with AuditTrail, for a durable record) |
| One-shot exploration, freeform Q&A | no — overhead's not worth it |
| You want the model to choose tools freely | no — that's what Agent(tools=[...]) already gives you |
Getting started¶
1. Build a Playbook in Python¶
A runnable NIST SP 800-61 IR flow — detection → analysis → containment — wired to the real security toolset. Containment never fires before the IOCs are enriched:
from tulip.playbooks import Playbook, PlaybookStep
nist_ir = Playbook(
id="nist-800-61-ir",
name="NIST 800-61 incident response",
description="Detect from the SIEM, analyze the IOCs, then contain.",
steps=[
PlaybookStep(
id="detection",
description="Pull the alert and its correlated events from the SIEM.",
expected_tools=["query_siem"],
hints=["Filter on the alert's src_ip", "Last hour first"],
required=True,
),
PlaybookStep(
id="analysis",
description="Enrich every IOC from detection; confirm it's malicious.",
expected_tools=["enrich_indicator"],
hints=["Do not contain on an unenriched indicator"],
required=True,
),
PlaybookStep(
id="containment",
description="Isolate the affected host from the network.",
expected_tools=["isolate_host"],
required=True,
),
],
strict_sequence=True,
allow_extra_tools=False,
)
PlaybookStep fields:
| Field | Meaning |
|---|---|
id |
Unique step identifier. |
description |
Human-readable; the agent sees this as a hint. |
expected_tools |
Tools the agent is supposed to call during this step. |
hints |
Extra steering text. |
required |
If False, the step can be skipped. |
max_tool_calls |
Hard cap on tool calls for this step. |
validation |
Optional dict of post-step checks. |
2. Load from YAML or JSON¶
For checked-in playbooks, use the loader:
# playbooks/nist_800_61_ir.yaml
id: nist-800-61-ir
name: NIST 800-61 incident response
description: Detect from the SIEM, analyze the IOCs, then contain.
strict_sequence: true
allow_extra_tools: false
steps:
- id: detection
description: Pull the alert and its correlated events from the SIEM.
expected_tools: [query_siem]
- id: analysis
description: Enrich every IOC from detection; confirm it's malicious.
expected_tools: [enrich_indicator]
- id: containment
description: Isolate the affected host from the network.
expected_tools: [isolate_host]
3. Wire the enforcer¶
from tulip.playbooks.hook import PlaybookEnforcerHook
enforcer_hook = PlaybookEnforcerHook(playbook=nist_ir)
agent = Agent(
model="anthropic:claude-sonnet-4-6",
tools=[query_siem, enrich_indicator, isolate_host],
hooks=[enforcer_hook],
)
result = agent.run_sync("Triage alert ALT-7 (host WS-014) and contain if malicious.")
The hook injects step descriptions and hints into the agent's
context, validates each tool call against the current step's
expected_tools, and records the executions. The detection →
analysis → containment order is enforced: an isolate_host call
during the detection step is rejected, so the host can't be
contained before its IOCs are enriched.
Strict vs lenient enforcement¶
| Setting | Effect |
|---|---|
strict_sequence=True (default) |
Steps must run in order; skipping ahead rejects the call. |
strict_sequence=False |
Steps can run in any order, but each must complete. |
allow_extra_tools=False (default) |
Only expected_tools may fire during a step. |
allow_extra_tools=True |
Any registered tool may fire — playbook is a recommendation, not a contract. |
For compliance-grade workflows, keep both at their defaults. For "loose runbook" guidance, flip them.
Inspecting execution¶
The enforcer maintains a PlaybookPlan — an in-memory record of
every step's status, tool calls, and counts. It lives on the hook's
enforcer (not on the result), so read it off the hook after the run:
plan = enforcer_hook.enforcer.plan
for execution in plan.step_executions.values():
print(f"{execution.step_id}: {execution.status.value} "
f"({len(execution.tool_calls)} tool calls)")
plan.step_executions is a dict[str, StepExecution]; StepStatus is one of
pending, in_progress, completed, skipped, failed. The plan is an
ephemeral Pydantic object — serialize it (or wire an AuditTrail)
if you need a record that outlives the process.
Common gotchas¶
| Symptom | Likely cause |
|---|---|
| Agent skips a step it shouldn't | The current step's description isn't specific enough — the model is interpreting the user's request as already satisfying the step. Sharpen the description. |
| Enforcer rejects a tool that should be allowed | The tool isn't in expected_tools for the current step. Add it, or set allow_extra_tools=True if the policy allows. |
max_tool_calls exhausts mid-step |
Bump the limit or split the step in two — the model may need search-and-refine cycles. |
| YAML loads but the agent doesn't follow it | Pass it through PlaybookEnforcerHook(...) — Playbook alone is just data. |
Source and notebook¶
notebook_46_playbooks.py— runnable end-to-end with execution tracking.tulip.playbooks—Playbook,PlaybookStep,PlaybookEnforcerHook,load_playbook.