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