# 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]) ```