# 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`.