# Modules
> Package reusable hunting phases into modules with explicit input/output contracts, share them, and invoke them with step.module.
Once you have hunted for a while, you will notice you rewrite the same phase in
every workflow: probe a list of hosts, screenshot them, enumerate a domain.
Modules let you package such a phase once — with an explicit input/output
contract — and reuse it everywhere, or publish it for other hunters.
## Defining a Module
A module is a Starlark file whose value is a `module(...)` definition. It
declares its public contract with **symbolic** input and output references:
```bhw
# module: projectdiscover/http-probe
domain = input.string(name = "domain", description = "Domain to enumerate")
domains = output.file(name = "domains", description = "Discovered subdomains")
module(
inputs = [domain],
outputs = [domains],
steps = [
step.exec(command = ["subfinder", "-d", domain, "-o", "subs-raw.txt"]),
step.transform(
source = path.file("subs-raw.txt").lines(),
pipeline = transform.trim().lower().unique().sort(),
destination = path.file("subs.txt"),
),
step.output(domains, path = path.file("subs.txt")),
],
)
```
Reading it:
- `input.string(name = "domain", ...)` creates an `InputRef` — the module's
*public* input named `domain`. It is independent of the Starlark variable it
is assigned to (`domain` here is just a handle for referencing it below).
- `output.file(name = "domains", ...)` declares a file output.
- `step.output(domains, path = path.file("subs.txt"))` assigns the declared
output to an actual file produced inside the module. Each declared output
must be assigned exactly once.
Input declarations:
|
Declaration
|
Produces
|
Notes
|
input.string(name, default, required, description, secret)
|
String input
|
Required iff no
default
;
secret = True
marks a credential
|
input.bool(name, default, required, description)
|
Bool input
|
|
input.file(name, required, description)
|
File input
|
Required by default
|
input.directory(name, required, description)
|
Directory input
|
Required by default
|
Callers may bind a string input from a literal or from configuration:
`config.var(...)` / `config.secret(...)` are accepted wherever a string input
is expected. Mark credential inputs with `secret = True` (the default is
`False`):
```bhw
api_token = input.string(name = "api_token", required = False, secret = True)
module(
inputs = [api_token],
outputs = [],
steps = [step.exec(command = ["tool"], env = {"API_TOKEN": api_token})],
)
```
```bhw
step.module(tool, inputs = {"api_token": config.secret("api_token")})
```
Output declarations:
|
Declaration
|
Produces
|
output.file(name, description)
|
File (path) output
|
Inputs are not limited to argv: they integrate with the string expression
system, so `.format(...)` and concatenation work too:
```bhw
target_url = input.string(name = "target_url", description = "Base URL to crawl")
module(
inputs = [target_url],
outputs = [],
steps = [
step.exec(command = ["katana", "-u", target_url, "-d", "3"]),
],
)
```
## Invoking a Module: `load` + `step.module`
Modules are loaded from a registry with `load()`. Loading is purely an import —
it never executes anything:
```bhw
load("projectdiscover/http-probe", "http_probe")
```
The first argument is `"publisher/module-name"`; the second binds it to a local
name. Instantiate it with `step.module`, binding inputs against its contract:
```bhw
activity(
name = "discover",
triggers = [trigger.dispatch()],
steps = [
step.module(http_probe, inputs = {
"domain": config.var("target_domain"),
}),
],
)
```
`step.module` validates at save time:
- Unknown inputs are rejected.
- Required inputs must be bound.
- Values are type-checked against the declared input type.
Only modules imported with `load()` can be invoked. A `module(...)` value
defined locally in the same workflow file cannot be passed to `step.module`.
Module outputs become available to subsequent steps through the invocation,
e.g. `probe.outputs.domains` for a module declaring a `domains` file output.
A module with exactly one declared output also has `.output`
(`probe.output`); with several, use `.outputs.`.
## Isolation and File Handling
A module can come from another publisher, so it runs in its **own isolated
workspace**:
- Every `file`/`directory` input is copied into that workspace before the
module runs. Nothing else is placed there, so files are never implicitly
visible; the module still runs as the runner user and can read what that user
can read (toolchains, config) at absolute paths.
- The module can only write inside its own workspace.
- Only outputs declared by the module are copied back to the caller. They land
at `.` (for example
`projectdiscover/http-probe.domains`) and are reachable through the
invocation: `probe.outputs.domains`.
- Undeclared files the module wrote are discarded with its workspace; there is
no implicit shared directory. Pass everything a module needs as declared
inputs, and never guess the producer's internal file name.
- An output is an ordinary file reference: pass it to another module input, use
it as a `step.exec` argv element, transform it, or use it as an artifact
source.
- A declared output must exist when the module finishes. `allow_failure` on the
producing step only tolerates its exit code; a missing output file still
fails the module. When a tool writes its own report file, create it first
(for example `step.exec(command = ["touch", "report.json"])`).
```bhw
probe = step.module(http_probe, inputs = {"domain": config.var("target_domain")})
# The caller never guesses "domains.txt": it uses the declared output.
step.exec(command = ["cat", probe.outputs.domains])
```
## Accessing Module Outputs
`step.module(...)` returns the **invocation** value, and that value is the only
way to reach the module's outputs. A `load(...)` symbol is the module
**definition**, not an invocation: it has no outputs. Bind the invocation to a
distinct variable and read `.outputs.`, using a name
the module declared in `outputs = [...]`:
```bhw
load("bountyhub/naabu", "naabu")
naabu_run = step.module(
naabu,
id = "naabu",
inputs = {"hosts": path.file("hosts.txt")},
)
step.update_dataset(
table = "ports",
source = naabu_run.outputs.ports,
pipeline = transform.lines(),
mapping = {"port": row},
mode = "sync",
)
```
Do not write `naabu.outputs.ports`: `naabu` is the loaded definition. Prefer a
distinct invocation name (`naabu_run`) rather than shadowing the imported
symbol.
The same pattern chains modules:
```bhw
enum_run = step.module(
subdomain_enum,
inputs = {"domain": config.var("target_domain")},
)
scan_run = step.module(
vuln_scan,
inputs = {"targets": enum_run.outputs.subs},
)
```
An output is a file reference, so it can be used anywhere a file resource is
accepted:
```bhw
# As a module input (above), or as a step.exec argv element.
step.exec(command = ["nuclei", "-l", enum_run.outputs.subs])
# As an artifact entry, to share the result with another activity.
step.upload_artifact(
name = "findings",
entries = [
artifact_entry(
source = scan_run.outputs.findings,
destination = path.file("findings.txt"),
),
],
)
```
Rules:
- Only declared output names exist on `outputs`; an undeclared name is a
compile-time error.
- A module output is **not** an activity artifact.
`.artifacts.` exists only for a `step.upload_artifact(name = "", ...)` call. To carry a module output to another activity, upload it
as an artifact (as above) and download that artifact in the consumer.
- Do not guess the file the module wrote; pass `module.outputs.`.
### Platform modules
The platform publishes modules under the reserved `bountyhub` publisher, so
`load("bountyhub/", "")` resolves without you publishing anything.
That includes wrappers for agent CLIs — `bountyhub/claude`, `bountyhub/codex`,
`bountyhub/hermes`, and `bountyhub/opencode` — which you invoke like
`step.agent` (see [Agents](/workflows/agents)).
## A Realistic Reusable Library
Split your methodology into composable modules mirroring how you actually
hunt:
```bhw
# module: yourname/subdomain-enum — subfinder + permutation + validation
domain = input.string(name = "domain", description = "Root domain")
subs = output.file(name = "subs", description = "Validated subdomains")
module(
inputs = [domain],
outputs = [subs],
steps = [
step.exec(command = ["subfinder", "-d", domain, "-silent", "-o", "passive.txt"]),
step.exec(command = ["alterx", "-l", "passive.txt", "-o", "perms.txt"]),
step.exec(command = ["shuffledns", "-d", domain, "-l", "perms.txt", "-o", "resolved.txt"]),
step.output(subs, path = path.file("resolved.txt")),
],
)
```
```bhw
# module: yourname/vuln-scan — nuclei with curated templates
targets = input.file(name = "targets", description = "Newline-delimited target URLs")
findings = output.file(name = "findings", description = "nuclei findings")
module(
inputs = [targets],
outputs = [findings],
steps = [
step.exec(command = ["nuclei", "-l", targets, "-t", "templates/", "-o", "findings.txt"]),
step.output(findings, path = path.file("findings.txt")),
],
)
```
And wire them into a workflow where each phase is one line. Module invocations
can be bound like any other value, and their outputs feed later steps directly
— the reference to `enum_run.outputs.subs` is both the data flow and the
ordering:
```bhw
load("yourname/subdomain-enum", "subdomain_enum")
load("yourname/vuln-scan", "vuln_scan")
enum_run = step.module(subdomain_enum, inputs = {"domain": config.var("target_domain")})
scan_run = step.module(vuln_scan, inputs = {"targets": enum_run.outputs.subs})
workflow(
name = "full-hunt",
activities = [
activity(
name = "hunt",
triggers = [trigger.cron("0 0 * * *")],
steps = [
enum_run,
scan_run,
step.upload_artifact(name = "findings", entries = [
artifact_entry(
source = scan_run.outputs.findings,
destination = path.file("findings.txt"),
),
]),
],
),
],
)
```
## Contract Rules Recap
- Inputs and outputs are lists of declaration refs; duplicate names are
rejected.
- Every declared output must be assigned exactly once via `step.output`.
- The workflow stores references only — the module's steps run as part of the
invoking activity's execution.
## Compilation Modes
Workflow files and module files compile in different modes. A workflow file
produces a `workflow(...)` value and has `workflow` / `activity` globals; a
module file produces a `module(...)` value and instead has `input.*` /
`output.*` declaration namespaces. Inside a module, `workflow` and `activity`
are unavailable, as are the project-context namespaces `config`, `dataset`,
and `mcp`: a module must stay pure and receive configuration and data through
its declared inputs, so callers bind values like `config.var(...)` /
`config.secret(...)` at the `step.module` call site. Agent steps remain
available.
Inside a workflow, bare `input.string(name = ...)` /
`output.file(name = ...)` declarations are unavailable (workflows use
`trigger.dispatch(inputs = {...})` for inputs).
Omitted optional module inputs fall back to their declared `default` at
invocation time; a required input with no default must always be bound by
`step.module(..., inputs = {...})`.
## How Modules Work
A module is a `Module { inputs, outputs, steps }` value. It is not an
activity and not a scheduling boundary — its steps run as part of the
invoking activity's execution.
- **Compilation modes are explicit and never inferred.** A workflow file is
compiled in `Workflow` mode (`workflow` / `activity` globals plus
`config`, `mcp`, `dataset`); a module file is compiled in `Module` mode
(`module` / `input.*` / `output.*` globals, dataset access disabled).
A module file must contain exactly one top-level `module(...)`
definition; nothing is executed at compile time.
- **Identity is /.** `ModuleRef::parse` requires
exactly one `/` separating two non-empty names of alphanumerics, `-`, or
`_`. The published export symbol is the module name with dashes replaced
by underscores (`word-count` → `word_count`).
- **load() is a pure import.** It resolves against the module registry
(in-memory `ModuleRegistry` in tooling/tests, server-side in production),
checks that the requested symbol is exported, and freezes the definition
for the evaluator. It never executes the module or schedules steps.
- **Inputs are symbolic refs.** `input.string / bool / file / directory(name = ...)` produce an `InputRef` carrying the public name, type, `required`,
`description`, `secret`, and `default`. The Starlark variable name is just
a handle. String inputs compose with `+` / `.format(...)` as lazy
expressions resolved at execution time; bool inputs bind as booleans
(`"flag=" + flag` is rejected — use a conditional). File and directory
inputs cannot be used as raw `step.exec` argv elements; format them into
strings. `secret = True` is string-only and forbids `default`.
- **Outputs are symbolic refs.** Today only `output.file(name = ...)`
exists, producing an `OutputRef`. Each declared output must be assigned
exactly once via `step.output(ref, path = path.file(...))`; duplicate
declaration names are rejected. `Artifact` / `Table` output types map to
path values at invocation.
- **Invocation binds by public name.**`step.module(loaded_symbol, inputs = {name: value})` validates unknown
inputs, required bindings, and value types at save time, and exposes
`invocation.outputs.` — a symbolic resource reference that is both
data flow and scheduling order for later steps.
## Publishing and Reusing
Modules are published under a publisher name and versioned by the platform:
- Author at `/u//workflow-modules/new`, then publish. Your modules are
listed on your profile (`/u/?tab=workflow-modules`) and addressable
as `load("/", "")`.
- Organizations publish under the org: `/o//workflow-modules/`,
loaded as `load("/", "")`.
- Workflow **templates** (`/u//workflow-templates/`) are the sibling
concept: whole-workflow starting points rather than composable phases.
Prefer a module when the phase is reused inside other workflows; prefer a
template when the whole hunt is the reusable unit.