# Agents > AI agent steps with explicitly granted capabilities: prompt, provider, model, typed context list, tools, and output contracts. `step.agent` is a first-class execution primitive for AI agents inside workflows. Use it for work that needs judgment rather than a fixed command: triaging nuclei findings, deduplicating noisy scan results, summarizing what changed on a target since last week. ## The Principle: Explicit Capability Granting An agent receives **only** what you explicitly give it: - `prompt` — its instructions. - `context` — named resources it may read. - `tools` — the capabilities it may act with (shell, MCP servers). - `output` — the file it must write its result to, and in what format. - `mode` — whether it runs unattended, asks before side effects, or plans first. - `iterations` — an optional cap on the agent's loop. Nothing from the workflow leaks in implicitly. This makes agent steps safe to reason about: you always know exactly what an agent can see and do. ```bhw step.agent( prompt = """ You are triaging vulnerability scanner results for a bug bounty target. For each finding, decide whether it is a real, exploitable issue or scanner noise. Discard findings on out-of-scope hosts. """, provider = provider.key("anthropic/personal"), model = model("default"), context = [ agent.context.path( description = "Raw nuclei findings to triage.", path = path.file("findings.txt"), ), agent.context.path( description = "In-scope hosts; discard anything else.", path = path.file("scope.txt"), ), ], tools = [tool.shell(), mcp.server("github")], output = agent.output(path = path.file("agent-output.txt")), ) ``` `provider` is **required**: every agent names the project-enabled provider key it runs with via `provider.key("provider/key-name")`. The key must be enabled for the project; otherwise the editor reports an `unknown_provider_key` diagnostic and the run fails. `model` is optional and only selects which configured model to use. ## Parameters ### `prompt` The instructions for the agent. A plain string, or a function evaluated at runtime with the run context (same as `step.script` content): ```bhw step.agent( prompt = lambda run: "Summarize new findings for {}".format(run.inputs["domain"]), ... output = agent.output(path = path.file("agent-output.txt")), ) ``` ### `provider` and `model` `provider` is mandatory and selects a stored API key; key values never appear in the workflow: ```bhw provider = provider.key("anthropic/personal") ``` The referenced key must be enabled for the project (the editor flags other references with an `unknown_provider_key` diagnostic). `model` is optional: ```bhw model("default") ``` The name selects which configured model to use; the workflow stores only the reference. ### `context` A **list** of typed items built with `agent.context.*` (a dict is rejected). Each item pairs an LLM-facing `description` with a value passed by reference — contents are never injected wholesale into the model context: ```bhw context = [ # Inline text: plain strings, module inputs, config refs. agent.context.text( description = "Target domain under review.", value = config.var("target_domain"), ), # A file or directory (or glob) exposed through file tooling. agent.context.path( description = "Raw nuclei output to triage.", path = path.file("findings.txt"), ), # An artifact produced by step.upload_artifact or .artifacts.. agent.context.artifact( description = "Signed scan results from the previous phase.", artifact = scan.artifacts.findings, ), # A project dataset table exposed through dataset tooling. agent.context.dataset( description = "Previously seen subdomains for dedup.", dataset = dataset.table("subdomains"), ), ] ```
Constructor Value Meaning
agent.context.text(description, value) string / input / config.* Inline text, resolved at execution time
agent.context.path(description, path) path.file / path.directory / path.glob File access by reference
agent.context.artifact(description, artifact) step.upload_artifact(...) result or .artifacts. Artifact by reference
agent.context.dataset(description, dataset) dataset.table("name") Dataset table by reference
Before the agent starts, every `path`, `artifact` and `dataset` item is **materialized into its workspace**, and the preamble tells the agent where to find each one: - `agent.context.path` — used in place (it is already a workspace path). - `agent.context.artifact` — downloaded and extracted under `context///`. - `agent.context.dataset` — exported to `context/.jsonl`, one record per line. The export is bounded (1000 rows / 4 MiB); when it is cut short the preamble says so, so the agent treats it as a subset rather than the whole table. Declaring any of them also grants the agent the `read_file` and `list_dir` tools, so it can actually fetch what you gave it instead of only being told it exists. `agent.context.text` is resolved and inlined into the preamble instead. Declaration order is preserved. Duplicate handling aside, keep descriptions specific: they are the agent's only map to what each reference contains. ### `tools` Grant capabilities explicitly. Without them, the agent can only read its context and produce text.
Grant Meaning
tool.shell() May run shell commands
mcp.server(name) May use an already-configured MCP server (e.g. "github" )
tool.task() May delegate work to the specialized subagents declared in harness (see below)
MCP references point at connections configured outside the workflow; credentials never appear in your workflow file. Grant the minimum needed: a triage agent usually only needs read access to context plus one MCP server, while a report-drafting agent may need nothing but its context. ### Specialized agents (harness) A single agent runs every delegated task on its own model. When some work is easy (reconnaissance, parsing, triage) and some is hard (exploitation, reporting), that wastes money. Declare a **harness** so the agent can delegate through `tool.task()` to specialized subagents, each with its own provider key and model: ```bhw step.agent( prompt = "Map the target, then validate the most promising finding.", provider = provider.key("anthropic/main"), model = model("claude-sonnet-4-5"), tools = [tool.shell(), tool.task()], harness = agent.harness([ # A builtin role: only the model is overridden; recon's preamble and # tools are inherited. agent.specialist.recon( provider = provider.key("openai/cheap"), model = model("gpt-4o-mini"), ), # A builtin role on the strong model. agent.specialist.exploit(model = model("claude-sonnet-4-5")), ]), output = agent.output(path = path.file("report.md")), ) ``` - `agent.harness([...])` declares the delegation targets; the agent reaches them only when `tool.task()` is granted. - `agent.specialist.recon(...)`, `.exploit(...)`, `.triage(...)` and `.report(...)` — one method per builtin role. Each inherits that role's preamble and tools; declaring the same role twice is rejected. - Each specialist's `provider` and `model` default to the step's, so a specialist only names what it overrides. `provider` may point at a different key than the step's; every referenced key must be enabled for the project. - Subagents cannot delegate further; declaring `tool.task()` inside a specialist's `tools` is rejected. Every specialist provider key must be enabled for the project, exactly like the step's own `provider`. Delegation is bounded — subagents run at depth one and return distilled results, not transcripts. ### `output` **Required.** Declares where the agent writes its final response and in what format. The file becomes an ordinary workspace resource, so later steps read it directly, push it into a dataset, or publish it as an artifact: ```bhw output = agent.output(path = path.file("findings.json"), format = "json") ```
format The runner requires Use for
"text" (default) nothing Reports, summaries, prose
"json" the whole response is one valid JSON value A single structured result
"jsonl" each non-empty line is a JSON object Record streams that feed transform.jsonl() and step.update_dataset
The format is enforced at run time: a response that does not satisfy it fails the step (unless `allow_failure = True`). The runner also tells the agent the destination file and the required format before it starts. Chaining off an agent's output is just ordinary file handling: ```bhw step.agent( prompt = "Return one JSON object with an `endpoints` array.", provider = provider.key("anthropic/personal"), context = [agent.context.path(description = "Live hosts.", path = path.file("live.txt"))], output = agent.output(path = path.file("endpoints.jsonl"), format = "jsonl"), ) step.update_dataset( table = "endpoints", source = path.file("endpoints.jsonl"), pipeline = transform.jsonl(), mapping = {"url": row.url, "status": row.status}, mode = "upsert", ) ``` ### `mode` How the agent handles approvals and side effects. Defaults to `auto`. ```bhw mode = agent.mode.interactive ```
mode Behaviour
agent.mode.auto Run to completion without asking. Safe for unattended/scheduled runs.
agent.mode.interactive Pause and ask before side-effectful actions (shell commands, etc.). Needs a human to answer.
agent.mode.plan Investigate read-only, present a plan, and wait for approval before running anything that changes state.
`auto` is the default because a cron-triggered workflow has nobody to answer an approval prompt. Choose `interactive` for attended runs and `plan` when you want to review the agent's intended steps first. In `plan` mode the agent's mutating tools are blocked until it calls `present_plan` and the user approves the plan; read-only tools stay available throughout. On approval the block lifts and the same session continues with the step's full capabilities — the agent keeps its context across the decision. On rejection the step fails. ### `iterations` The maximum number of agent loop iterations before the step fails. Defaults to unlimited. ```bhw iterations = 25 # a cap iterations = agent.iterations.unlimited # explicit: no cap (the default) ``` Use a cap to bound cost and catch a looping agent; each iteration is one model turn (which may include several tool calls). ## Realistic Patterns ### Triage scanner noise Nuclei on a wide surface produces noise. Let an agent filter before you ever see it: ```bhw activity( name = "triage", triggers = [trigger.on(scan.succeeded())], steps = [ step.download_artifact(scan.artifacts.findings), step.agent( prompt = """ Classify each finding as EXPLOITABLE, LIKELY_NOISE, or OUT_OF_SCOPE based on the scope file. For exploitable ones, include reproduction steps. """, provider = provider.key("anthropic/personal"), model = model("default"), context = [ agent.context.path(description = "Findings to classify.", path = path.file("findings.txt")), agent.context.path(description = "Scope definition.", path = path.file("scope.txt")), ], output = agent.output(path = path.file("agent-output.txt")), ), ], ) ``` ### Investigate a suspicious change Combine scheduled diffing with an agent that investigates: ```bhw activity( name = "investigate", triggers = [trigger.on(diff.succeeded())], steps = [ step.agent( prompt = """ A new endpoint appeared on this target since yesterday. Investigate whether it exposes sensitive data or an admin interface, and draft a finding summary if so. """, provider = provider.key("anthropic/personal"), model = model("default"), context = [ agent.context.path(description = "Observed changes.", path = path.file("changes.txt")), ], tools = [tool.shell()], allow_failure = True, # investigation is best-effort output = agent.output(path = path.file("agent-output.txt")), ), ], ) ``` ## Agents the Platform Does Not Own Not every agent is a `step.agent`. You can run any agent CLI in a shell step (`step.script`/`step.exec`). When such an agent needs a human decision, the runner has already provisioned the way to ask: it installs the `bh` CLI on the step's `PATH` and exports the per-step endpoint as `BOUNTYHUB_ASK_URL`. ```sh bh ask "Which environment should I deploy to?" bh ask --kind choice --option staging --option production "Pick one" bh ask --kind confirm "Delete the staging database?" # prints yes or no ``` `bh ask` posts the question to the platform, blocks until the operator answers (the run parks as `WAITING_FOR_INPUT` meanwhile), and prints the answer. It needs no token, so it works in a step that has none. A declined request exits with status `1`; use `bh --json ask ...` when the step needs the full answer (`text`, `confirmed`, `declined`) — for example to branch on the `confirmed` flag of a `--kind confirm` question. ```bhw step.exec( command = ["my-agent", "--instructions", "Ask before any destructive action."], stdout = path.file("agent-report.txt"), ) ``` For the agent to discover this, the runner writes an `AGENTS.md` in the step workspace (only when one is not already there) describing `bh ask`, and hosts a loopback MCP server that exposes the same decision as the `request_user_input` tool. An agent that speaks MCP can call it directly. ### Platform modules for agent CLIs You do not have to hand-roll the `step.exec` wrapper. The platform ships ready-made modules for the common agent CLIs, so you invoke them with `step.module` using the same prompt → output shape as `step.agent`:
Module Wraps Extra inputs
bountyhub/claude Claude Code ( claude -p ) model , permission_mode , tools , allowed_tools , disallowed_tools , append_system_prompt , max_turns , output_format
bountyhub/codex OpenAI Codex ( codex exec ) model , sandbox , output_schema
bountyhub/hermes Hermes Agent ( hermes chat -q ) model , provider , skills
bountyhub/opencode opencode ( opencode run ) model ( provider/model ), agent , config , permission
Each one takes a required `prompt`, runs in its **own isolated workspace** (only its declared file/directory inputs are copied in; files from earlier steps are not visible unless passed explicitly), and declares two file outputs: `output` (the agent's final response) and `log` (the CLI's stderr transcript). Credentials are optional secret inputs bound from `config.secret(...)` — e.g. `anthropic_api_key`, `openai_api_key`, `openrouter_api_key` — mapped to the CLI's conventional environment variables. When you do not bind them, the CLI falls back to its own auth store, so the runner host must have the CLI on `PATH`. ```bhw load("bountyhub/claude", "claude") review = step.module(claude, inputs = { "prompt": "Review src/ for injection sinks and write findings.md.", "model": "sonnet", "permission_mode": "acceptEdits", "anthropic_api_key": config.secret("anthropic_api_key"), }) # review.outputs.output — the agent's final response # review.outputs.log — stderr transcript ``` Because the modules are ordinary `step.module` invocations, their outputs chain like any other step. A read-only Codex triage pass, for example: ```bhw load("bountyhub/codex", "codex") triage = step.module(codex, inputs = { "prompt": "Triage the findings in findings.jsonl and rank by exploitability.", "sandbox": "read-only", "codex_api_key": config.secret("codex_api_key"), }) ``` The safety defaults keep unattended runs non-destructive: Codex starts in a read-only sandbox (pass `sandbox = "workspace-write"` to allow edits), Claude Code gets no `bypassPermissions`, Hermes one-shot sessions deny dangerous commands, and the opencode module never passes `--auto`. Configure permissions per module through the CLI-specific inputs (for opencode, `config` / `permission`; for Codex, `sandbox`). ## Notes - Agent steps participate in the normal lifecycle, including waiting for input (`WAITING_FOR_INPUT`) when the platform asks them something mid-run. - `step.agent` accepts `name`, `allow_failure`, and `timeout` like other steps, but it accepts neither `id` nor `cond`. - Secrets are safest kept out of prompts entirely; reference configuration via [`config.*`](/workflows/config) when a tool or MCP server needs credentials — resolution happens at execution time and values are never serialized into your workflow.