Running engines one at a time is fine for poking at a single host. It does not scale to "map this surface every week and pipe each stage into the next." For that you want the whole pipeline captured as one artifact you can version, parametrize, and rerun. That artifact is a .cfx playbook: a Python file that chains voyage, pulse, and mach (and any extensions you install) into a single repeatable workflow.
Playbooks are an early capability that we are actively building out. The shape shown here is real and intentional, but the surface is expanding: expect more engine bindings, richer conditionals, and more export targets over time. Treat the examples as the direction, not a frozen spec.
What a playbook is
A playbook is just Python, which means you already know the language. What Crossfyre adds is a small set of bindings: a way to declare the inputs a run takes, handles to the engines, and helpers to export results. A playbook reads top to bottom: take some targets, run an engine, look at what came back, feed it into the next engine, repeat, then export.
The same file runs two ways. Locally with crossfyre run, which needs no control plane and is perfect for iterating on a host in front of you. Or launched from the dashboard, where the platform distributes the steps across your fleet and runs them crash-safe. One file, two execution models, identical logic.
A first playbook: surface map
Here is a complete playbook that takes a domain, enumerates its subdomains with voyage, keeps only the live ones, port-scans them with pulse, and exports the result. It is parametrized on --domain so the same file works for any target you are authorized to test.
# surface-map.cfx
from crossfyre import playbook, engines, inputs
@playbook(
name="surface-map",
description="Enumerate, validate, and port-scan an authorized domain.",
)
def run(ctx):
# Declared inputs become CLI flags and dashboard fields.
domain = inputs.domain(required=True)
# Stage 1: subdomain enumeration (passive + active brute).
subs = engines.voyage.enumerate(
domain=domain,
wordlist="subdomains-default",
passive=True,
)
# Stage 2: keep only hosts that actually resolve.
live = subs.filter(lambda h: h.resolves)
ctx.log(f"{len(live)} live hosts of {len(subs)} enumerated")
# Stage 3: pipe live hosts into a port scan.
ports = engines.pulse.scan(
hosts=live.hostnames(),
ports="top-1000",
)
# Export the joined result set.
ctx.export(ports, formats=["json", "csv"], tag="surface")Run it locally against a domain you are authorized to test:
crossfyre run surface-map.cfx --domain example.comThe pattern to notice is the pipe: voyage produces hosts, you filter them, and the surviving list flows straight into pulse. No intermediate files to manage, no copy-paste between tools. The output of one stage is the typed input of the next.
A few things are doing quiet work in that example. The @playbook decorator registers the file so the CLI and dashboard both know its name, description, and the inputs it expects. The inputs helpers are how a single file stays reusable: declared inputs surface as --domain on the command line and as form fields in the dashboard, so the same playbook serves a quick local check and a scheduled fleet run without edits. And the engine results are not raw text, they are typed result sets, which is what lets you call .filter(...) and .hostnames() on them instead of parsing stdout by hand.
The ctx handle is the playbook's connection to the run itself: ctx.log writes to the run's live telemetry, ctx.export drops results into your Findings explorer in the formats you ask for, and (as you will see next) ctx.notify fires an alert. Everything a playbook produces flows through ctx, which is also how the platform knows what to bill, since it charges for the work that actually completed.
Branching on results
A flat pipeline is the easy case. Real recon wants to decide: only fuzz hosts that are actually serving HTTP, use a heavier wordlist when you find something interesting, skip a stage entirely when the surface is empty. Because a playbook is Python, conditionals are just if statements over the results you already have.
# web-recon.cfx
from crossfyre import playbook, engines, inputs
@playbook(name="web-recon")
def run(ctx):
domain = inputs.domain(required=True)
deep = inputs.flag("deep", default=False)
subs = engines.voyage.enumerate(domain=domain, passive=True)
live = subs.filter(lambda h: h.resolves)
ports = engines.pulse.scan(hosts=live.hostnames(), ports="web")
# Only content-discover hosts that answered on an HTTP service.
web_hosts = ports.with_service("http", "https")
if not web_hosts:
ctx.log("no web surface found, nothing to fuzz")
return
# Pick effort based on an input flag.
wordlist = "content-large" if deep else "content-default"
findings = engines.mach.discover(
targets=web_hosts.urls(),
wordlist=wordlist,
)
# Conditional export: only raise a report if something landed.
if findings.any(severity=">=medium"):
ctx.export(findings, formats=["markdown"], tag="web-findings")
ctx.notify("medium+ findings on " + domain)This is the full methodology from our recon walkthrough compressed into one file: enumerate, validate, port-scan, content-discover, and report only when there is something worth reporting. The --deep flag swaps the wordlist without you editing the playbook. Inputs are how one playbook serves many situations.
A playbook makes it trivial to run a lot of traffic with one command. That is exactly why the scope rule still applies: a playbook should only ever be seeded with targets you are authorized to test. Automation does not widen your authorization, it just makes it faster to overstep if you are careless. Seed in-scope targets only.
Running the same playbook across the fleet
crossfyre run executes a playbook on the box in front of you. That is great for development and for small jobs. When the surface is large, you launch the identical file from the dashboard and the platform orchestrates it across every node you have online.
The logic does not change. What changes is where the work happens:
- Distribution. A stage that operates over hundreds of hosts (a wide pulse scan, a big mach sweep) is split into operations and spread across nodes, so the run finishes in a fraction of the wall-clock time.
- Crash safety. Each operation rides the durable queue, so a node that drops mid-run does not lose its work. The operations it had in flight are redelivered and the playbook keeps going.
- Egress. Pin the workflow to a proxy chain or VPN-isolated node and every distributed step honors the same agreed source IP.
You write and debug a playbook locally, then run the exact same file at fleet scale without rewriting it for distribution. That portability is the point: the playbook describes what recon to do, and the execution model decides where.
Schedule a playbook to keep surface fresh
Once recon is a single file, keeping it running is trivial. Schedule the playbook to run on a recurring basis and have results pushed to you. New subdomains, newly exposed services, and fresh paths get caught as they appear, and you only triage the delta.
Because billing is reserve-then-reconcile and credits never expire, a scheduled playbook only ever costs you the work that actually completed. A scheduled run that hits a rate limit or a reclaimed node resumes and finishes rather than billing you for a failed attempt.
Where this is going
Playbooks are the layer where the engines stop being separate tools and become a programmable pipeline. As the capability matures, expect more engine and extension bindings, richer ways to branch and join result sets, and more export and notification targets. The design goal stays constant: your recon, expressed once as readable Python, runs anywhere from a laptop to a full fleet.
Write your first .cfx playbook and run it against an authorized target today.
Start free