# Step Outputs
Every step declares what it produces. Consumers reference the step's result,
not the producer's file name:
```bhw
hosts = step.exec(
command = ["subfinder", "-d", config.var("target")],
stdout = path.file("hosts.txt"),
)
live = step.exec(
command = ["httpx", "-l", hosts.output],
stdout = path.file("live.txt"),
)
```
`hosts.output` is a file reference. The second step never needs to know that
the file is called `hosts.txt`.
## `output` and `outputs`
A step result has:
|
Field
|
Meaning
|
run.output
|
the single declared output (file or directory)
|
run.outputs.
|
a named declared output
|
`run.output` exists only when the step declares **exactly one** output. With
zero or several, use `run.outputs.`; using `.output` there is an error.
## Where outputs come from
**The runner writes the file from stdout.** `stdout = path.file(...)` exposes
that file as `run.output`:
```bhw
hits = step.exec(
command = ["nuclei", "-l", live.output, "-jsonl"],
stdout = path.file("hits.jsonl"),
)
```
**The tool writes the file itself.** The runner only sees argv, so declare the
file:
```bhw
scan = step.exec(
command = ["nmap", "-sV", "-oA", "scan", target],
outputs = {
"xml": path.file("scan.xml"),
"gnmap": path.file("scan.gnmap"),
},
)
scan.outputs.xml
```
**The step type owns the destination.** `step.transform` publishes its
`destination`; `step.agent` publishes its declared `agent.output` file:
```bhw
clean = step.transform(
source = hits.output,
pipeline = transform.jsonl().filter(lambda row: row["severity"] == "high"),
destination = path.file("high.jsonl"),
)
clean.output
```
## Multiple files
Use a name → path map. Each name becomes a field:
```bhw
scan.outputs.xml
scan.outputs.gnmap
```
When a tool writes an unpredictable tree, publish a directory or a glob instead
of listing every file:
```bhw
ffuf = step.exec(
command = ["ffuf", "-u", url, "-o", "results", "-of", "json"],
outputs = {"results": path.directory("results")},
)
```
```bhw
nuclei = step.exec(
command = ["nuclei", "-l", live.output, "-jsonl"],
outputs = {"findings": path.glob("nuclei-*.jsonl")},
)
```
## Passing outputs on
An output is a path-like resource. Use it anywhere a file reference is
accepted:
```bhw
step.exec(command = ["cat", clean.output])
step.exec(command = ["nuclei", "-l", live.output])
step.exec(stdin = hits.output)
step.exec(env = {"TARGETS": live.output})
step.transform(source = hits.output, ...)
step.agent(context = [hits.output], ...)
step.module(nuclei, inputs = {"targets": live.output})
artifact_entry(source = hits.output, ...)
```
## Modules
A module declares its own inputs and outputs. The caller sees only the declared
outputs:
```bhw
scan = step.module(nuclei, inputs = {"targets": live.output})
step.exec(command = ["cat", scan.outputs.findings])
```
A module invocation is isolated: its declared file inputs are copied in, and
only its declared outputs are copied back. It never reads the caller's other
files, and it never writes into the caller's workspace.
## Loops
Inside `step.for_each`, each iteration runs the body as its own step sequence.
Give each iteration a distinct output path, then consume the published files
from a later step:
```bhw
step.for_each(
items = live.output,
concurrency = 8,
do = lambda host: step.exec(
command = ["nuclei", "-u", host, "-jsonl"],
outputs = {
"findings": path.file("findings/{}.jsonl".format(host)),
},
),
)
step.for_each(
items = path.glob(base = "findings", pattern = "*.jsonl"),
do = lambda path: step.exec(command = ["cat", path]),
)
```
Per-iteration result collections (`scans[i].outputs`) are not available yet;
the published files are the interface.
## Isolated steps
By default, `script`/`exec`/`agent` steps in an activity share one workspace.
To give every data step its own private filesystem, opt in at the workflow
level:
```bhw
workflow(
activities = [main],
isolated_steps = True,
)
```
With isolation:
- each `script`/`exec`/`transform`/`agent`/`module`/dataset/decision step gets a
fresh directory;
- declared outputs are the only data that leaves it;
- a later step receives a referenced output by copy, and cannot read anything
else the earlier step wrote;
- files a step writes without declaring them are discarded.
Module steps are always isolated, regardless of the flag. `for_each` control
steps run in the parent scope, but their body steps are isolated and their
items (including glob and batch files) are materialized into each iteration.
Artifact downloads are isolated too, and must declare `outputs` so the
downloaded files leave the step.
References are the interface either way: `run.output` / `run.outputs.`.
Passing a bare path string that another step happened to write is not data
flow; it only works while the shared workspace is enabled.
## Rules
- A step's file names appear once: where the step is defined.
- Consumers use `run.output` / `run.outputs.`, never a producer's path.
- `.output` only exists when exactly one output is declared.
- `outputs = {...}` is the complete contract; it replaces the implicit
`stdout` output.
- Directories and globs are valid outputs for tools that write a tree.
- Module steps are isolated; by default `script`/`exec`/`agent` share the
activity workspace, and `workflow(isolated_steps = True)` gives each data
step its own private scope.
## Migration
Old workflows shared a directory and passed file names between steps. Replace
the path with the producer's result:
```bhw
# before
step.exec(command = ["sh", "-c", "nuclei ... > hits.jsonl"])
step.exec(command = ["jq", ".", path.file("hits.jsonl")])
# after
hits = step.exec(
command = ["sh", "-c", "nuclei ... > hits.jsonl"],
outputs = {"hits": path.file("hits.jsonl")},
)
step.exec(command = ["jq", ".", hits.outputs.hits])
```