Playbook YAML Syntax Reference¶
This is the authoring reference for the YAML playbook DSL that the
fsr_playbooks compiler (the pyfsr[playbooks] extra) accepts. It is the
companion to the narrative Playbook Authoring & Deployment guide: that page shows the
workflow (write → compile → deploy); this page is the syntax — every
top-level key, every step type, and the friendly fields each step accepts.
The DSL is a thin, friendly layer over FortiSOAR’s wire format: you write short
type: names and friendly keys like module: / vars: / when:, and the
compiler expands them into the canonical workflow/step/route JSON the import API
expects. Anything you don’t recognise on the wire, you can usually still set by
its canonical key — the compiler only rejects unknown keys, never canonical
ones.
Note
The step catalogue is owned by the compiler and validated against a packaged
reference DB of real FortiSOAR step types. When in doubt, pyfsr playbook validate <file> is the source of truth — it reports unknown keys, missing
fields, and wrong shapes with a path: into your YAML.
What the compiler turns a step into¶
The friendly type: / vars: / next: you write expand into the canonical
workflow/step/route JSON the import API expects. Compiling is offline (no
network), so you can inspect exactly what gets sent before deploying:
>>> from pyfsr.authoring import compile_playbook_yaml
>>> result = compile_playbook_yaml('''
... name: wire-shape-demo
... description: show one step's canonical JSON
... playbooks:
... - name: Demo
... steps:
... - name: Start
... type: start
... next: Set Greeting
... - name: Set Greeting
... type: set_variable
... vars:
... greeting: hello from pyfsr
... count: 3
... ''')
>>> result.ok
True
>>> wf = result.fsr_json["data"][0]["workflows"][0]
>>> step = wf["steps"][1]
>>> step["@type"], step["name"], step["arguments"]
('WorkflowStep', 'Set Greeting', {'greeting': 'hello from pyfsr', 'count': 3})
>>> [r["name"] for r in wf["routes"]]
['Start -> Set Greeting']
The set_variable step type maps to a fixed stepType IRI (a UUID the
compiler resolves from its catalog); the friendly vars: mapping lands verbatim
in arguments, and next: becomes a WorkflowRoute whose name is
"<source> -> <target>". The volatile fields ��� uuid, top/left (canvas
position), and the /api/3/workflow_steps/<uuid> IRIs in each route — are
compiler-generated and stable across runs, so you only need to author the
friendly shape on the left.
File structure¶
A playbook file describes one collection and the workflows (playbooks) inside it:
collection: My Collection # required — the collection name
description: What this does # optional
visible: true # optional — show in the UI (default true)
playbooks: # required — one or more workflows
- name: My Playbook # required — workflow name
is_active: false # optional — live trigger? (default false)
trigger: start # optional — trigger step type (default "start")
parameters: [] # optional — referenced-playbook input params
steps: # required — the step list
- name: Start
type: start
next: Do Something
- name: Do Something
type: set_variable
vars: {greeting: hello}
Top-level key |
Meaning |
|---|---|
|
Collection display name. |
|
Free-text description. |
|
Whether the collection shows in the UI (default |
|
List of workflows; each is one playbook. |
Playbook key |
Meaning |
|---|---|
|
Workflow name (required). |
|
If |
|
Short-name of the trigger step type; defaults to |
|
Input parameters for a referenced playbook ( |
|
The step list (see below). |
Steps: common shape¶
Every step has a name, a type, and (except terminals/decisions) a next:
pointing at the next step’s name:
- name: Enrich IP # unique within the playbook; also the jinja slug
type: connector
next: Decide # name of the next step
arguments: {...} # type-specific (many types have friendlier keys)
nameis also how you reference a step’s output downstream:{{ vars.steps.Enrich_IP.data }}(spaces become underscores).nextwires the linear flow.decision/manual_inputsteps putnext:on each branch instead (see those types).Terminal steps (
stop/end) omitnext.
Step types¶
Friendly type: → canonical FortiSOAR step type (from the compiler’s alias
table). Use the friendly name on the left:
|
FortiSOAR step |
Purpose |
|---|---|---|
|
|
Manual / referenced trigger (the default Start). |
|
|
Auto-fire when a record is created in a module. |
|
|
Auto-fire when a record is updated. |
|
|
Define |
|
|
Branch on conditions. |
|
|
Run a connector operation. |
|
|
Query records of a module. |
|
|
Create a record. |
|
|
Update a record. |
|
|
Bulk feed insert (bypasses on-create triggers). |
|
|
Wait. |
|
|
Pause for human input. |
|
|
Approval gate. |
|
|
Run a Python snippet. |
|
|
Call another playbook. |
|
|
First-class no-op terminal. |
start — manual trigger¶
- name: Start
type: start
next: First Step
Bind a module: to make it a manual Execute-menu trigger on that module’s
records:
- name: Start
type: start
module: alerts
next: First Step
start_on_create / start_on_update — record triggers¶
Auto-fire when a record is created (or updated) in module:. Set the
playbook’s is_active: true for it to actually fire.
- name: Start
type: start_on_create
module: heists # required — the module to watch
next: Stamp Status
Add a when: field-based filter to fire only on records matching a query
(logic + filters, each {field, op, value}):
- name: Start
type: start_on_create
module: heists
when:
logic: AND
filters:
- {field: takeUsd, op: gt, value: 1000000}
next: Stamp Status
For start_on_update, op: changed (no value) fires when the listed field
changes. The compiler expands when: into the canonical fieldbasedtrigger
envelope (resource/resources, step_variables, triggerOnSource, …) for
you.
Important
A start_on_create / start_on_update playbook only fires when the workflow is
is_active: true. The triggering record arrives as
{{ vars.input.records[0] }}.
set_variable¶
Write a top-level vars: mapping (not arguments:):
- name: Set Inputs
type: set_variable
vars:
greeting: hello from pyfsr
source_ip: "{{ vars.input.records[0].sourceIp }}"
next: Next Step
decision¶
Branches carry their own next: per condition entry — there is no step-level
next: or branches::
- name: Big Score?
type: decision
conditions:
- condition: "{{ vars.input.records[0].takeUsd > 1000000 }}"
label: big
next: Alert The Boss
- label: default
next: Log It
connector¶
Connector op, operation name, and params go under arguments:. Resolve the
exact connector / operation / param names with the discovery tools
(pyfsr playbook MCP / find_operation) — don’t guess them:
- name: Enrich IP
type: connector
arguments:
connector: virustotal
operation: get_ip_reputation
ip: "{{ vars.input.records[0].sourceIp }}"
next: Decide
find_record / create_record / update_record¶
- name: Find Open Heists
type: find_record
arguments:
module: heists
query: {logic: AND, filters: [{field: status, operator: eq, value: Open}]}
- name: Log It
type: create_record
arguments:
module: heist_logs
resource: {note: "triggered by {{ vars.input.records[0].codename }}"}
- name: Stamp Status
type: update_record
arguments:
module: heists # → collectionType
collection: "{{ vars.input.records[0]['@id'] }}" # the record IRI to update
resource: {status: Briefed}
module: is friendly-expanded: on create_record it becomes the target
collection IRI; on update_record it becomes collectionType (and
collection: stays the record IRI you’re updating). Bare picklist labels
(e.g. status: Briefed) are auto-resolved to picklist IRIs.
delay, code_snippet, manual_input, approval, workflow_reference¶
These accept their canonical arguments: (see pyfsr playbook validate /
get_step_type). manual_input keys (title, description, options,
inputs) go at the step level, not under arguments:, and its options,
like decision conditions, carry a per-entry next: for branching.
Note
description: on manual_input is optional: when omitted the compiler now falls
back to the step’s title: (the FortiSOAR runtime rejects a genuinely empty
description body, so the fallback keeps a description-less prompt runnable). Set
an explicit description: when you want prompt text distinct from the title.
stop / end¶
First-class no-op terminals — use them on a branch that should do nothing rather than leaving it dangling:
- name: Done
type: end
Compile, validate, deploy¶
pyfsr playbook validate heist_intake.yaml # diagnostics only, no network
pyfsr playbook compile heist_intake.yaml -o envelope.json
pyfsr playbook deploy heist_intake.yaml --replace
validate compiles offline and prints one line per diagnostic to stderr
(nonzero exit on any error). Each diagnostic carries a stable code, a path
into your YAML, a human message, and a severity (error or warning):
>>> from pyfsr.authoring import compile_playbook_yaml, format_diagnostic
>>> bad = compile_playbook_yaml('''
... name: bad-demo
... playbooks:
... - name: P
... steps:
... - name: S
... type: not_a_real_type
... ''')
>>> bad.ok, bad.fsr_json
(False, None)
>>> [d["code"] for d in bad.errors]
['unknown_step_type']
>>> diag = bad.errors[0]
>>> (diag["severity"], diag["path"])
('error', 'playbooks[0].steps[0].type')
>>> format_diagnostic(diag) # the line `validate` prints
"[ERROR] unknown_step_type at playbooks[0].steps[0].type: unknown step type: 'not_a_real_type'"
The code is the stable machine identifier to branch on (e.g.
unknown_step_type, missing_field, no_trigger); path is the
YAML-location you fix. format_diagnostic renders the same [SEVERITY] code at path: message line the CLI emits, so in-process checks and the CLI stay in
sync.
…or from Python with
import_from_yaml().
See Playbook Authoring & Deployment for the full deploy flow and the compile-result
object.
See also
Sample file:
examples/playbooks/yaml_demo.yaml
and the end-to-end
examples/heist_tracker.py
(modules → permissions → on-create playbook → triggering record).