Private Beta
.md

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:

BHW

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:

DeclarationProducesNotes
input.string(name, default, required, description, secret)String inputRequired iff no default; secret = True marks a credential
input.bool(name, default, required, description)Bool input
input.file(name, required, description)File inputRequired by default
input.directory(name, required, description)Directory inputRequired 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
BHW

Output declarations:

DeclarationProduces
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

#Invoking a Module: load + step.module

Modules are loaded from a registry with load(). Loading is purely an import — it never executes anything:

BHW

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

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/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 <module-path>.<output-name> (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

#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 = [...]:

BHW

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

An output is a file reference, so it can be used anywhere a file resource is accepted:

BHW

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 a step.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:

BHW
BHW

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

#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 <username>/<module-name>. 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.<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 as load("<you>/<module-name>", "<symbol>").
  • Organizations publish under the org: /o/<org>/workflow-modules/<name>, loaded as load("<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.