BountyHub Docs
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:
Reading it:
input.string(name = "domain", ...)creates anInputRef— the module's public input nameddomain. It is independent of the Starlark variable it is assigned to (domainhere 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):
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:
#Invoking a Module: load + step.module
Modules are loaded from a registry with load(). Loading is purely an import —
it never executes anything:
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:
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.<name>.
#Isolation and File Handling
A module can come from another publisher, so it runs in its own isolated workspace:
- Every
file/directoryinput 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
<module-path>.<output-name>(for exampleprojectdiscover/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.execargv element, transform it, or use it as an artifact source. - A declared output must exist when the module finishes.
allow_failureon 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 examplestep.exec(command = ["touch", "report.json"])).
#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 <invocation>.outputs.<output-name>, using a name
the module declared in outputs = [...]:
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:
An output is a file reference, so it can be used anywhere a file resource is accepted:
Rules:
- Only declared output names exist on
outputs; an undeclared name is a compile-time error. - A module output is not an activity artifact.
<activity>.artifacts.<name>exists only for astep.upload_artifact(name = "<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.<name>.
#Platform modules
The platform publishes modules under the reserved bountyhub publisher, so
load("bountyhub/<name>", "<name>") 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).
#A Realistic Reusable Library
Split your methodology into composable modules mirroring how you actually hunt:
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:
#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
Workflowmode (workflow/activityglobals plusconfig,mcp,dataset); a module file is compiled inModulemode (module/input.*/output.*globals, dataset access disabled). A module file must contain exactly one top-levelmodule(...)definition; nothing is executed at compile time. - Identity is
<username>/<module-name>.ModuleRef::parserequires 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-memoryModuleRegistryin 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 anInputRefcarrying the public name, type,required,description,secret, anddefault. 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=" + flagis rejected — use a conditional). File and directory inputs cannot be used as rawstep.execargv elements; format them into strings.secret = Trueis string-only and forbidsdefault. - Outputs are symbolic refs. Today only
output.file(name = ...)exists, producing anOutputRef. Each declared output must be assigned exactly once viastep.output(ref, path = path.file(...)); duplicate declaration names are rejected.Artifact/Tableoutput 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 exposesinvocation.outputs.<name>— 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/<you>/workflow-modules/new, then publish. Your modules are listed on your profile (/u/<you>?tab=workflow-modules) and addressable asload("<you>/<module-name>", "<symbol>"). - Organizations publish under the org:
/o/<org>/workflow-modules/<name>, loaded asload("<org>/<module-name>", "<symbol>"). - Workflow templates (
/u/<you>/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.