# Steps
> Running scripts and processes with step.script and step.exec, and moving files between activities with artifacts.
Steps are the units of execution inside an activity. This page covers the two
execution primitives (`step.script` and `step.exec`) and artifact plumbing.
Other step kinds are covered in their own pages:
- [Concurrency](/workflows/concurrency) — `step.parallel`, `step.spawn`,
`step.join`
- [For Each](/workflows/for-each) — `step.for_each`
- [Transforms](/workflows/transforms) — `step.transform`
- [Modules](/workflows/modules) — `step.module`
- [Agents](/workflows/agents) — `step.agent`
- [Datasets](/datasets/workflows) — `step.update_dataset`
## Common Step Parameters
Most steps accept:
|
Parameter
|
Default
|
Meaning
|
id
|
—
|
Stable key for
run.steps.
lookups and
scan.succeeded()
predicates
|
name
|
—
|
Human-facing label shown in the UI (defaults are derived)
|
cond
|
run.ok
|
Run the step only when this runtime predicate holds
|
allow_failure
|
False
|
Do not fail the activity if the step fails
|
timeout
|
—
|
Bound the step's execution time
|
`step.spawn` is the exception: it returns a branch handle, so it accepts
`name` and `timeout` only (its `allow_failure` parameter is accepted but
ignored).
```bhw
step.exec(
command = ["ffuf", "-w", "wordlist.txt", "-u", target, "-o", "ffuf.json"],
id = "ffuf-dirs",
timeout = duration.minutes(30),
# Directory brute-forcing is noisy; keep going even if it exits non-zero.
allow_failure = True,
)
```
## Conditions: `cond`, `.when(...)` and step predicates
A condition is a **runtime predicate** `F(run) -> bool`. Write it as a lambda
or pass a step predicate. It is evaluated by the runner just before the step
executes, never while the workflow is being defined:
```bhw
scan = step.exec(
command = ["nuclei", "-l", "live.txt"],
id = "scan",
)
# Same condition, three spellings:
recheck = step.exec(command = ["httpx", "-l", "live.txt"], cond = scan.succeeded())
recheck = step.exec(command = ["httpx", "-l", "live.txt"]).when(scan.succeeded())
recheck = step.exec(
command = ["httpx", "-l", "live.txt"],
cond = lambda run: run.steps.scan.conclusion == steps.Conclusion.SUCCEEDED,
)
```
- `scan.succeeded()` / `scan.failed()` return a predicate `run -> bool`;
they require the referenced step to have an `id`.
- `.when(F)` and `cond = F` are the same mechanism. Static booleans are
rejected (`cond = True`, `.when(True)`) so a condition can never be
evaluated too early.
- A step predicate can only test an earlier step in the **same activity**.
Cross-activity wiring is `trigger.on(scan.succeeded())` — that is an
activity event, not a step condition.
Steps can be bound to variables so later conditions can inspect them via
`run.steps` (see the [Syntax Reference](/workflows/syntax-reference#conditions)).
## Shell Scripts: `step.script`
`step.script` is the **explicit** shell API. It runs a script under a shell
(`bash` by default):
```bhw
activity(
name = "notify",
triggers = [trigger.on(scan.succeeded())],
steps = [
step.script(content = """
echo "New findings on ${TARGET}:" >&2
cat findings.txt
"""),
step.script(
content = "curl -s https://api.example.com/ping",
shell = "bash",
allow_failure = True,
),
],
)
```
Script content can also be a **function**, which is evaluated at execution time
with the run context — useful for templating from dispatch inputs or earlier
results:
```bhw
step.script(
content = lambda run: "subfinder -d {} -o subs.txt".format(run.inputs["domain"]),
)
```
## Process Execution: `step.exec`
`step.exec` runs a program directly from a **complete argv vector**. There is no
implicit shell: element 0 is the program, and the process receives exactly what
you declare.
```bhw
step.exec(
command = [
"nuclei",
"-l", "live.txt",
"-severity", config.var("nuclei_severity"),
"-o", "findings.txt",
],
env = {"NUCLEI_TECHNOLOGIES": "wordpress"},
)
```
### Explicit everything
`step.exec` has **no implicit workflow data**. The process receives exactly the
explicitly provided command, environment, stdin, and working directory:
- `command` — the complete argv vector. Elements may be literal strings,
references to files produced by earlier steps, [`config.*`](/workflows/config)
values, module string inputs, or lazy runtime expressions. Module file and
directory inputs cannot be used as raw command elements; format them into
argv (for example `"{}".format(targets)`) instead. A nested list is an
**optional argv group**: it expands only when every optional module input it
references is bound, otherwise the whole group is dropped. A bare optional
module input element is dropped when unbound — so CLI flags vanish instead
of being passed empty. Concatenating a string literal with a bool module
input using `+` is rejected; use a conditional instead.
- `env` — only the entries you list. Values may be config references;
secret values are resolved at execution time and never serialized.
- `stdin` / `stdout` / `stderr` — optional; each must be a `path.file(...)`.
Without an explicit `stdout`, process output is captured for logs but never
becomes workflow data.
- `cwd` — optional; must be a `path.directory(...)`.
```bhw
step.exec(
command = ["httpx", "-silent", "-status-code", "-o", "/dev/null"],
stdin = path.file("subs-clean.txt"), # feed targets on stdin
stdout = path.file("live.txt"), # persist stdout as a resource
stderr = path.file("httpx-errors.log"),
env = {"GITHUB_TOKEN": config.secret("github_token")},
)
```
A non-zero exit code fails the step (unless `allow_failure = True`). Timeouts
are configured per step with `timeout`; there is no per-step retry policy.
### Data flow defines ordering
There is no implicit sequencing between independent processes beyond the order
you write them in. When a step's `command`, `stdin`, or environment references a
resource produced by an earlier step, that reference is both the data flow *and*
the scheduling order — the runner waits for the producer first:
```bhw
steps = [
step.exec(command = ["subfinder", "-d", domain, "-o", "subs.txt"], id = "enum"),
step.exec(command = ["httpx", "-l", "subs.txt", "-o", "live.txt"], id = "probe"),
step.exec(command = ["nuclei", "-l", "live.txt", "-severity", "critical", "-o", "crit.txt"], id = "scan"),
]
```
Each step consumes the previous step's output file, so they execute strictly in
order. Steps whose inputs are unrelated to each other should be declared as
parallel branches instead (see [Concurrency](/workflows/concurrency)).
Optional inputs make reusable commands clean. Inside a module, an unbound
optional input (and any nested list group containing only unbound optionals)
is omitted from argv rather than passed as an empty string:
```bhw
# Module with an optional extra-flags input; callers may omit it.
extra = input.string(name = "extra_flags", required = False, description = "Optional extra flags")
module(
inputs = [extra],
outputs = [],
steps = [
step.exec(command = ["nuclei", "-l", "live.txt", [extra]]),
],
)
```
## Artifacts
Activities are isolated: files produced by one activity are not automatically
visible to the next. Artifacts are the way to hand files between activities.
### Uploading: `step.upload_artifact`
```bhw
discover = activity(
name = "discover",
triggers = [trigger.dispatch()],
steps = [
step.exec(command = ["subfinder", "-d", config.var("target_domain"), "-o", "subs.txt"]),
step.upload_artifact(
name = "subdomains",
expires_in = duration.days(7),
entries = [
artifact_entry(source = "subs.txt", destination = "subs.txt"),
],
),
],
)
```
- `name` names the artifact so downstream activities can reference it via
`discover.artifacts.subdomains`.
- `entries` map files from the activity workspace (`source`) into the artifact
layout (`destination`).
- `expires_in` controls retention — useful for noisy raw scan output you do not
need to keep long.
### Downloading: `step.download_artifact`
```bhw
probe = activity(
name = "probe",
triggers = [trigger.on(discover.succeeded())],
steps = [
step.download_artifact(discover.artifacts.subdomains),
step.exec(command = ["httpx", "-l", "subs.txt", "-o", "live.txt"]),
step.upload_artifact(name = "live-hosts", entries = [
artifact_entry(source = "live.txt", destination = "live.txt"),
]),
],
)
```
- `step.download_artifact(artifact)` pulls a previously uploaded artifact into
the current activity workspace. There is no separate blob-store import
primitive: shared wordlists, nuclei templates, or resolver lists travel
through artifacts.
### Reading a dataset: `step.download_dataset`
```bhw
step.download_dataset(
table = "subdomains",
destination = path.file("known.csv"),
format = "csv",
)
```
- `step.download_dataset` exports a project dataset's current logical state into
the activity workspace (`"csv"` by default, or `"jsonl"`/`"json"`). Use it to
diff new findings against knowledge from earlier runs. See
[Datasets](/datasets/workflows).
## Choosing Between `script` and `exec`
Use
step.script
when...
|
Use
step.exec
when...
|
|
You want pipes, redirection, variable expansion
|
You want a single tool invocation
|
|
Gluing multiple commands together
|
Precision matters: exact argv, exact env
|
|
Prototyping a phase quickly
|
Building reusable, typed steps
|
Both fail the activity on non-zero exit unless `allow_failure = True`.