Skip to content
boboddy.dev

Defining Steps

A step is the atomic unit of work in Boboddy. Each step has a typed input schema, a result schema, an agent prompt, and optionally a set of signals extracted from its output.

import { defineStep } from "@boboddy/sdk";
import { z } from "zod";
export const summarizeStep = defineStep({
key: "summarize-text",
name: "Summarize Text",
agentPrompt: "Summarize the provided text concisely.",
additionalInput: z.object({
text: z.string(),
}),
result: z.object({
summary: z.string(),
}),
status: "active",
});

The agent is automatically given the step’s input as default context — the full input JSON is injected into the prompt when the step runs. You do not need to interpolate fields like input.text into agentPrompt for the agent to see them. Referencing input fields (see Prompt context) is optional and only inlines a specific value into your instructions.

Field Type Required Description
key string Yes Unique identifier for this step within the project
name string Yes Human-readable display name
version number No Version number (defaults to 1)
description string No Brief description shown in the UI
agentPrompt string | ((context) => string) Yes AI prompt given to the worker agent when executing this step
additionalInput ZodType No Zod schema for the step’s additional input fields; bound in the pipeline mapper
result ZodType No Zod schema for the step’s output
signals Signal[] No Values to extract from the result for pipeline advancement logic
mcpServers OpenCodeMcpServers No MCP server configurations for tool-using agents
healthChecks HealthCheck[] No Real tool calls forced against the environment before the agent is prompted
plugins OpenCodePluginEntry[] No Opencode plugins merged into the generated config when this step runs
features StepFeature[] No Built-in feature plugins that extend the result schema, signals, and prompt
status "draft" | "active" No Draft steps are not executed; defaults to "active"
environment { runtime?, vars?, repo? } No Where the step runs, which environment variables it gets, and what it does to your repository. Declare it before agentPrompt. See Environment

agentPrompt can be a raw string, but the recommended form is a function that receives a typed prompt context. This gives autocomplete for step input fields and supported runtime variables.

export const browserReproStep = defineStep({
key: "browser-repro",
name: "Browser Repro",
additionalInput: z.object({
title: z.string(),
}),
agentPrompt: ({ input, env, boboddy }) => `
Open ${env.BASE_URL}.
Investigate the issue titled ${input.title}.
Save traces and screenshots to ${boboddy.artifactsDir}.
`,
});

Available scopes:

Scope Use for
input Fields from additionalInput and any pipeline-bound step input
env The worker’s environment, or the step’s environment.vars (below)
boboddy Boboddy-provided runtime values

Boboddy currently provides boboddy.artifactsDir, which points to the directory whose contents will be uploaded as step artifacts after the run.

The function form compiles to the same template syntax Boboddy stores internally, such as {{input.title}} and {{env.BASE_URL}}. Existing raw tokens still work, but new steps should prefer scoped variables.

In a step that does not declare environment.vars, env is the worker’s environment: .boboddy/.env merged over the worker’s process.env. Once a step declares vars, the prompt’s env is strict — only the declared, non-secret keys, and nothing else from the worker.

environment describes how a step runs: where it runs (runtime), which environment variables it gets (vars), and what it does to your repository (repo). It is accepted by defineStep() and codeStep(), and every field is optional.

import { defineStep, Runtime, Env } from "@boboddy/sdk";
import { z } from "zod";
export const buildFrontend = defineStep({
key: "build-frontend",
name: "Build Frontend",
additionalInput: z.object({ targetUrl: z.string() }),
environment: {
runtime: Runtime.devcontainer({
config: ".devcontainer/frontend/devcontainer.json",
}),
vars: ({ input }) => ({
TARGET_URL: input.targetUrl,
PROFILER_TOKEN: Env.inherit({ secret: true }),
}),
},
agentPrompt: ({ env }) => `Build the frontend and profile ${env.TARGET_URL}.`,
});
Field Type Default
environment.runtime Runtime.devcontainer({ config? }) or Runtime.host() Runtime.devcontainer()
environment.vars ({ input }) => Record<string, string | EnvEntry> none
environment.repo Repo.readWrite(), Repo.readOnly() or Repo.none(), or ({ input, result }) => RepoSpec The runtime’s default (see Repo access)

runtime controls whether a step gets a checkout of your repository, and which container it runs in.

Runtime What the worker sets up Use for
Runtime.devcontainer() Clones your repository and launches your devcontainer; the agent runs inside that container. This is the default. Steps that read, run, or modify your code.
Runtime.host() No clone and no dev container. The agent runs on the worker host against a temporary empty directory with only the prompt and its bound input. Prompt-only steps: research, summarization, drafting, classification, or routing decisions.
import { defineStep, Runtime } from "@boboddy/sdk";
import { z } from "zod";
export const classifyStep = defineStep({
key: "classify-ticket",
name: "Classify Ticket",
environment: { runtime: Runtime.host() },
additionalInput: z.object({ title: z.string(), body: z.string() }),
result: z.object({ category: z.string() }),
agentPrompt: ({ input }) =>
`Classify this ticket into a single category:\n\n${input.title}\n${input.body}`,
});

Runtime.host() steps are faster and cheaper because they skip the clone and container startup, and they run even for projects without a dev container. Everything else works the same: bound input, agentPrompt, result, signals, and mcpServers all behave identically. Because there is no checkout, the agent has no access to your repository files; if a step needs to read or change your code, keep the default Runtime.devcontainer(). Runtime.host() takes no options, so a host step cannot name a devcontainer config.

See Running Workers for how each runtime is executed.

By default the worker launches .devcontainer/devcontainer.json, falling back to devcontainer.json at the repository root. A repository can keep more than one config (a frontend image, a perf image, a CI image); pass config to pick one for a step:

environment: {
runtime: Runtime.devcontainer({
config: ".devcontainer/frontend/devcontainer.json",
}),
},

config is the full repo-relative path to the file. There is no name shorthand.

  • The file name must be devcontainer.json or .devcontainer.json. This is the devcontainer CLI’s own rule for --config.
  • The path must be relative to the repository root: no leading /, no drive letter, no backslashes, and no .. segment. A leading ./ is stripped. It can be at most 255 characters.
  • These rules are checked when you define the step (Runtime.devcontainer throws), by boboddy pipelines push validation, by the server (a 422), and again by the worker before launch.

An explicit config has no fallback. The worker does not go looking for another config if yours is missing, because that would run the step in a different container than the one you chose. If the file does not exist, the step fails before the container launches:

Devcontainer config ".devcontainer/frontend/devcontainer.json" not found in the cloned repository (branch "main")

The error names the path and the branch it was looked up on.

The worker patches the config it launches (to add mounts, ports, and containerEnv). That patched file is not committed to the step’s work branch, wherever it lives. See Set up a dev container for writing an alternate config.

codeStep() takes the same environment, with one restriction: Runtime.host() is not supported. A code step runs code from your repository, and the host runtime has no clone, so the entrypoint could never be found. This is a type error on codeStep, and boboddy pipelines push validation rejects a hand-built spec that does it.

A code step runs in a managed runtime by default: a small container the worker supplies, so the step does not boot your project’s devcontainer to run a plain function. The worker owns the image, the generated config and the dependency install; your repository holds nothing for it. Pick one with Runtime.managed.<id>(), or opt out with Runtime.devcontainer() to run in your project’s devcontainer (or the config it selects) instead.

import { codeStep, Runtime } from "@boboddy/sdk/definitions/steps";
export const lookupCriteria = codeStep({
key: "lookup-criteria",
name: "Lookup Criteria",
environment: { runtime: Runtime.managed.node24() },
fn: lookupCriteriaFn,
});
export const needsProjectToolchain = codeStep({
key: "needs-project-toolchain",
name: "Needs Project Toolchain",
environment: { runtime: Runtime.devcontainer() },
fn: needsProjectToolchainFn,
});
Identifier Image Runtime Install, run in .boboddy/pipeline-builder
bun1 oven/bun:1.4.0-debian Bun 1.x bun install --production, whichever lockfile is present or none
node24 node:24-bookworm-slim Node 24 npm install --omit=dev --no-audit --no-fund for package-lock.json, yarn.lock or no lockfile; corepack pnpm install --prod for pnpm-lock.yaml; bun.lock or bun.lockb is not read by npm, so it installs without it and logs a notice

bun1 is the default: omit runtime and you get Runtime.managed.bun1(). An identifier is the runtime plus its major line. Within a line the worker floats minor and patch versions, so a worker upgrade can move the image under your step. A new major is a new identifier.

How the install works:

  • Each image contains one runtime: bun1 has Bun and no Node, node24 has Node and no Bun. The worker runs the step with the runtime the identifier names.
  • The install runs on every execution, before your function. A failing install fails the step.
  • When several lockfiles are present, the first of bun.lock, bun.lockb, pnpm-lock.yaml, yarn.lock, package-lock.json decides.
  • With no lockfile the install is a plain install, never a frozen one. The pipeline-builder scaffold gitignores lockfiles, so a fresh clone usually has none.
  • A lockfile the install generates, where your repository had none, is deleted afterwards.
  • A Repo.readWrite() step never commits the install’s output (node_modules, bun.lock and package-lock.json under .boboddy/pipeline-builder), even in a repository that does not gitignore them.

Runtime-specific behavior to know about:

  • bun1 skips dependency postinstall scripts unless the package is listed in trustedDependencies in .boboddy/pipeline-builder/package.json. node24 runs them. A dependency that needs a native build or a binary download can install cleanly on bun1 and then fail when it is imported or run.
  • node24 does not resolve extensionless relative imports. import { x } from "./helpers" fails with ERR_MODULE_NOT_FOUND; write ./helpers.ts, or use bun1.
  • A code step’s working directory is the workspace (the repository root in the container), not the image’s default directory. This applies to every code step, including ones on Runtime.devcontainer().

Managed runtimes are for code steps only. defineStep() rejects them (a type error, and push validation) because an agent step needs your project’s toolchain. A managed step has no config, and repo follows the usual rules: Repo.readWrite() (the default) or Repo.readOnly().

With Runtime.devcontainer() the runner script executes in your container. A container selected with a non-default config must provide a JavaScript runtime for it: the runner uses bun if it is on PATH, otherwise node. That is up to the author of the config.

repo controls what the worker does with the repository clone: whether the step gets a work branch, and whether its changes are committed and pushed. Pick one of three modes:

Mode What the worker does Use for
Repo.readWrite() Creates a work branch, then commits the agent’s changes and pushes the branch when the step succeeds. This is the devcontainer default. Steps that change code.
Repo.readOnly() Clones and checks out the base branch, but creates no work branch, commits nothing, pushes nothing, and reports no workBranch. Steps that only read code: triage, analysis, review.
Repo.none() No repository. The worker does not clone, branch, commit, or push. This is the Runtime.host() default. Prompt-only steps on Runtime.host().

Which modes a runtime accepts, and its default:

Runtime readWrite readOnly none Default
Runtime.devcontainer() Yes Yes No: the config is read from the clone readWrite
Runtime.managed.<id>() Yes Yes No: the worker writes its config into the clone readWrite
Runtime.host() No: there is no clone No: there is no clone Yes none

A pair the runtime does not accept is a type error where TypeScript can express it. boboddy pipelines push validation and the server (a 422) reject it otherwise. A step that omits repo gets its runtime’s default, so Repo.readWrite() on a devcontainer step and Repo.none() on a host step are redundant. codeStep() accepts repo with the same rules.

import { defineStep, Repo } from "@boboddy/sdk";
import { z } from "zod";
export const triage = defineStep({
key: "triage",
name: "Triage",
environment: { repo: Repo.readOnly() },
agentPrompt: "Find the code behind this report and summarize it.",
});
export const implement = defineStep({
key: "implement",
name: "Implement",
additionalInput: z.object({ title: z.string() }),
result: z.object({ summary: z.string() }),
environment: {
repo: ({ result }) =>
Repo.readWrite({ message: `fix: ${result.summary}` }),
},
agentPrompt: ({ input }) => `Fix: ${input.title}`,
});
Option Type Default Description
message string boboddy: step <stepExecutionId> The commit message, rendered from the step’s input and result
onPushFailure "fail" | "warn" "fail" Whether a failed push fails the step ("fail") or is logged and ignored ("warn")

Commit message. repo is a function of { input, result }, the same way vars is a function of { input }: both are typed from the step’s additionalInput and result, and a template literal built from them (`fix: ${result.summary}`) becomes a {{result.summary}} token that the worker renders when the step finishes. Write the string with a prefix before the interpolation, since a bare `${result.summary}` trips a lint rule. A static Repo.readWrite({ message: "fix: {{result.summary}}" }) is the same thing, and the common case when the message needs no tokens.

  • Only {{input.…}} and {{result.…}} tokens are allowed. {{env.…}} is rejected, so an environment value can never reach git history.
  • The template is one line and at most 500 characters.
  • The rendered text carries agent output, so the worker reduces it to a safe subject: whitespace runs collapse to one space, control characters are stripped, and the subject is cut to 200 characters. If nothing is left (an empty template, or tokens that render empty), the message is boboddy: step <stepExecutionId>.
  • The same message is used for the superproject commit and for each submodule commit.

Push failure. By default a failed push of the work branch, or of a submodule’s copy of it, fails the step with a message naming the branch and the underlying git error. See Work branches for the behavior change this made and the consequences.

Repo.readOnly() tells the worker not to publish anything. It does not mount the repository read-only and does not revert edits, so an agent can still write files in its workspace. Those edits live and die with the workspace, which is deleted after the step. The guarantee you can rely on is the observable one: no branch appears on your remote and no workBranch is reported for the step.

A read-only step checks out the same base branch a readWrite step would be created off. Because it produces no work branch, later steps skip it: they are created off the nearest earlier step that did produce one. See Base branch.

vars declares the environment variables a step needs. It is a function of { input } that runs once at definition time against the same input proxy agentPrompt uses.

import { defineStep, Env } from "@boboddy/sdk";
import { z } from "zod";
export const accountInvestigation = defineStep({
key: "account-investigation",
name: "Account Investigation",
additionalInput: z.object({ accountId: z.string(), tenant: z.string() }),
environment: {
vars: ({ input }) => ({
WAREHOUSE_URL: "https://warehouse.internal",
ACCOUNT_ID: input.accountId,
TENANT_API_KEY: Env.value({ value: `${input.tenant}-key`, secret: true }),
WAREHOUSE_TOKEN: Env.inherit({ secret: true }),
DB_PASSWORD: Env.inherit({ from: "STAGING_DB_PASSWORD", secret: true }),
LOG_LEVEL: Env.inherit({ optional: true, default: "info" }),
}),
},
agentPrompt: ({ env }) =>
`Query ${env.WAREHOUSE_URL} for account ${env.ACCOUNT_ID}.`,
});
Form Source Stored in the definition Masked in logs
A bare string The literal, which may interpolate input The template, plaintext No
Env.value({ value, secret? }) Same, with an explicit secret flag The template, plaintext When secret
Env.inherit({ from?, secret?, optional?, default? }) The worker. The name defaults to the key; from reads a differently named variable The name only When secret

Env.inherit fails the step before it starts if the variable is missing from the worker, unless it is optional (the variable is omitted) or has a default. A variable set to an empty string counts as set.

When the same name could come from more than one place, the first match wins:

  1. The step’s own value (a bare string or Env.value).
  2. The worker’s .boboddy/.env (inherit).
  3. The worker’s process.env (inherit).
  4. The inherit default.

The step wins over the devcontainer’s containerEnv as well. A single entry is either a value or an inherit, so in practice 2–4 apply to inherit entries and 1 to everything else.

Resolution happens once, in the worker, before anything launches. Only injection differs by runtime:

Runtime.devcontainer() (container) Runtime.host() (host process)
value Passed to the OpenCode process with docker exec -e Merged into the OpenCode process’s environment
inherit The only way a worker value reaches the container The host process already inherits the worker’s process.env; inherit adds .boboddy/.env, preflight, renaming, and masking
secret Masked in logs Masked in logs
Code steps docker exec -e on the runner; the function reads process.env Passed to the runner process’s environment; the function reads process.env

Step vars also feed MCP {env:VAR} substitution (see Secrets), because OpenCode reads it from its own process environment — this works for the generated OPENCODE_CONFIG_CONTENT as well as for config files (verified on OpenCode 1.18.11).

  • Keys must match /^[A-Z_][A-Z0-9_]*$/. At most 64 variables per step; each value at most 4096 characters.
  • Reserved names are rejected (at push validation and by the server): BOBODDY_*, HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, OPENCODE_CONFIG_CONTENT, and npm_config_cache.
  • Env.inherit cannot read worker variables whose name starts with BOBODDY_ (case-insensitive) — they carry the worker’s own credentials. This is rejected by definition validation when you push, by the server with a 422, and again by the worker.
  • A secret value must contain an {{input.…}} token; a static secret would be stored in plaintext. Use Env.inherit({ secret: true }) instead.
  • A secret inherit cannot carry a default, for the same reason.
  • A bare static string on a key that looks like a credential (/SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE/i) is rejected at definition time. If the value is not actually secret, opt out with Env.value({ value, unsafeAllowStatic: true }); the flag is never serialized.
  • value is stored in plaintext, in the step definition, and is visible in the API and the UI. Anything secret belongs in Env.inherit.
  • Inputs are stored in the database. A secret value that interpolates {{input.…}} is only as private as that input is.
  • Masking applies to logs only. Registered secrets are redacted from the shipped log feed; they are not hidden from the agent or from your code. Values shorter than 4 characters (MIN_MASKABLE_LENGTH) are never masked.
  • A secret is not isolated from the agent. The agent process has the variable in its environment and can read or print it.
  • Failure messages are not log-masked. The code-step runner redacts step env values from its exec error message, but for an AI step, if starting OpenCode in a container fails, the error can include the -e KEY=VALUE arguments. This is a known limitation, tracked as a follow-up. It does not apply to code steps, which never start OpenCode. A code step’s own stdout/stderr is likewise included, unmasked, in its failure message, so avoid printing secrets.
  • Prompts see declared, non-secret keys only. env.X for a secret is a type error, and a raw {{env.X}} token for it renders empty. Optional entries without a default are typed string | undefined.

boboddy pipelines pull writes a step’s environment back into steps.ts, before agentPrompt. A host step is written as runtime: Runtime.host(), a step with a selected config as runtime: Runtime.devcontainer({ config }), and a step on the default devcontainer omits runtime. vars is written inside environment, and so is repo, but only when it differs from the runtime’s default: Repo.readOnly() for a read-only step, and Repo.readWrite(...) for a read-write step with a custom message or onPushFailure: "warn" (the function form when the message uses input or result tokens). Runtime, Env and Repo are imported only when needed. A static, non-secret value on a credential-looking name round-trips as Env.value({ value, unsafeAllowStatic: true }).

See Running Workers for how the worker resolves and injects these, and Runtime, Env and Repo in the SDK reference.

Signals are scalar values (numbers, strings, booleans) extracted from the step result. They drive pipeline advancement policies — e.g., “only advance to the next step if clarity_score is above 7”.

export const reviewStep = defineStep({
key: "code-review",
name: "Code Review",
result: z.object({
feedback: z.string(),
quality: z.number(),
security: z.number(),
}),
signals: [
{
sourcePath: "quality",
key: "quality_score",
type: "number",
required: true,
},
{
sourcePath: "security",
key: "security_score",
type: "number",
required: true,
},
],
// ...
});
Field Type Description
sourcePath string Dot-notation path into the result object (e.g., "metrics.score")
key string Signal name used in pipeline advancement rules
type "number" | "string" | "boolean" Expected type
required boolean If true, a missing value causes the execution to fail

Computed signals aggregate multiple raw signals into a single derived value.

computedSignals: [
{
key: 'average_score',
type: 'average',
inputSignalKeys: ['quality_score', 'security_score'],
},
],

Steps can be given access to MCP (Model Context Protocol) servers, giving the agent tools like database access, browser automation, or custom APIs. mcpServers is a record keyed by server name. Each value is one of three shapes: a local server, a remote server, or an enabled override.

A local server is launched as a subprocess. command is an array — the executable followed by its arguments (there is no separate args field).

mcpServers: {
postgres: {
type: "local",
command: ["uvx", "postgres-mcp", "--access-mode=restricted"],
environment: { DATABASE_URI: "{env:DATABASE_URI}" },
enabled: true,
},
},
Field Type Required Description
type "local" Yes Marks a locally launched server
command string[] Yes Executable plus arguments; must have at least one entry
environment Record<string, string> No Environment variables. {env:VAR} interpolates a worker env var
enabled boolean No Set false to define but disable the server
timeout number No Startup/request timeout in milliseconds

A remote server is reached over HTTP.

mcpServers: {
docs: {
type: "remote",
url: "https://mcp.example.com/sse",
headers: { Authorization: "Bearer {env:MCP_TOKEN}" },
enabled: true,
},
},
Field Type Required Description
type "remote" Yes Marks a remote HTTP server
url string Yes Server URL
headers Record<string, string> No Extra request headers
oauth object | false No OAuth config (clientId, clientSecret, scope, redirectUri), or false to disable
enabled boolean No Set false to define but disable the server
timeout number No Request timeout in milliseconds

To toggle an inherited server without redefining it, pass just enabled:

mcpServers: {
postgres: { enabled: false },
},

Never put a secret value directly in a step definition — it’s pushed to the server and rendered in the UI. Reference it as {env:VAR} in environment or headers instead, as shown above. {env:VAR} is resolved by OpenCode at execution time from the environment of the OpenCode process, which depends on the runtime:

  • Runtime.devcontainer(): your project’s .boboddy/.env is merged into the container’s environment, so {env:VAR} finds those values. Variables you set yourself in devcontainer.json containerEnv win over .boboddy/.env.
  • Runtime.host(): OpenCode runs on the worker host with the worker’s process.env. .boboddy/.env is not part of it.

With either runtime, a variable the step declares in environment.vars is also visible to {env:VAR} — for example environment: { vars: () => ({ DATABASE_URI: Env.inherit({ secret: true }) }) } makes .boboddy/.env’s DATABASE_URI available to the MCP server with both runtimes, masks it in logs, and fails the step up front if it is missing.

.boboddy/.env is a plain dotenv file at your repository root that you create and manage yourself; Boboddy never writes it and never uploads it. Commit .boboddy/.env.example (variable names only, no values) so teammates know what to set, and make sure .boboddy/.env itself stays out of version control.

If a pipeline-designer session (see the Quickstart) adds an MCP server that needs a secret, it writes the variable name to .boboddy/.env.example for you and tells you which ones to fill in — it never asks for or writes the real value.

If your project already has a .opencode/opencode.json (or .jsonc) or .opencode/tools/ at the repository root, whatever it declares loads for every devcontainer step automatically — you don’t need to repeat it in mcpServers. Reserve a step’s own mcpServers for servers that step needs and the project doesn’t already provide.

Steps can declare healthChecks — real tool calls Boboddy forces against the launched environment (MCP servers, plugin tools, standalone tools) before the agent is prompted. A broken tool fails the step immediately instead of quietly wasting an agent turn discovering it.

export const browserStep = defineStep({
key: "browser-repro",
name: "Browser Repro",
mcpServers: {
playwright: {
type: "local",
command: ["npx", "-y", "@playwright/mcp"],
enabled: true,
},
},
healthChecks: [
{ mcp: "playwright", tool: "browser_navigate", args: { url: "about:blank" } },
],
agentPrompt: "Reproduce the reported bug.",
});

tool is a bare tool name when mcp names one of the step’s own mcpServers keys (resolved to ${mcp}_${tool} at runtime, matching OpenCode’s MCP tool-naming convention); otherwise it’s a flat plugin/standalone/built-in tool id.

Field Type Required Description
tool string Yes Tool name to call. Bare name when mcp is set, otherwise a flat tool id
mcp string No One of the step’s declared mcpServers keys
name string No Human-readable label shown in logs/UI. Defaults to the resolved tool id
args Record<string, unknown> No Arguments passed to the tool call
severity "required" | "warn" No "required" (default) fails the step immediately if the check fails; "warn" is advisory — reported but never fails the step
timeoutMs number No How long to wait for the forced call before failing it as a timeout. Defaults to 15000
serialGroup string No Checks sharing the same value run one at a time, in declaration order. See Running checks in parallel

By default, every declared check runs in its own parallel lane — a step with several health checks isn’t slowed down by running them one after another. required checks all run before any warn check, and the first required failure stops every check that hasn’t started yet (a check already in flight still finishes and reports its real outcome).

Give two or more checks the same serialGroup value to force them to run one at a time, in declaration order, when they contend over the same underlying resource — e.g. two checks against the same database connection:

healthChecks: [
{ mcp: "postgres", tool: "list_schemas", serialGroup: "db" },
{ mcp: "postgres", tool: "list_tables", serialGroup: "db" },
{ mcp: "playwright", tool: "browser_navigate", args: { url: "about:blank" } },
],

The two postgres checks run serially against each other, while the playwright check — no serialGroup — runs concurrently with them.

Attach Opencode plugins to a step with plugins. When the step runs, Boboddy merges these into the generated Opencode config for that execution. Use plain package names, or the [packageName, options] tuple form when a plugin needs configuration.

export const investigateStep = defineStep({
key: "bad-data-investigation",
name: "Bad Data Investigation",
agentPrompt: "Investigate the reported data issue.",
plugins: ["@datadog/opencode-plugin"],
});

Plugin entries are deduplicated by package name when Boboddy combines your baseline Opencode config with the step-specific plugins.

features adds built-in feature plugins to a step. Each feature extends the step’s result schema, appends signals, and injects supporting text into the prompt — so you get a consistent, typed convention without hand-writing the schema, signals, and instructions yourself.

import { defineStep, Features } from "@boboddy/sdk/definitions/steps";
export const reproStep = defineStep({
key: "browser-reproduction",
name: "Browser Reproduction",
agentPrompt: "Reproduce the reported bug.",
result: z.object({ reproduced: z.boolean() }),
features: [Features.notifications()],
});

Adds a $boboddy_notifications_v1 array to the result and a matching (optional) signal, plus prompt text instructing the agent how to surface messages for humans. Use it whenever a step may need to escalate a question, report a block, or flag a warning.

Each notification item has:

Field Type Required Description
kind "feedback_request" | "status_update" | "blocked" | "result_ready" | "warning" Yes The kind of notification
title string Yes Short, human-readable title
body string Yes Notification details
priority "low" | "normal" | "high" | "urgent" Yes How important the notification is
suggestedChannels ("in_app" | "work_item_platform_comment" | "email" | "slack")[] No Channels the agent suggests; the platform policy decides the final channels
payload Record<string, unknown> No Kind-specific data. For feedback_request: { category, urgency, suggestedKey? }

The feature contributes a single signal, $boboddy_notifications_v1 (type array, not required), so downstream advancement rules can react to emitted notifications.

A real specialization of Features.notifications(), not an alias — it narrows every emitted item to kind: "feedback_request" (in both the pushed JSON Schema and the prompt section), backed by the same $boboddy_notifications_v1 signal. Use it when a step’s primary escalation path is asking the project team clarifying questions.

Building notifications at runtime — Notify

Section titled “Building notifications at runtime — Notify”

Features.notifications() wires the schema/signal for you, but composing a well-formed NotificationItem by hand still requires knowing the exact field names and the $boboddy_notifications_v1 key. Notify is a separate, flat namespace of builder functions (the same shape as Rule/Computed in @boboddy/sdk/definitions/pipelines) for constructing a notification result value directly — most useful in a code step, where there’s no agent to follow the prompt instructions. It’s deliberately separate from Features: Features.* is only ever something you attach via features: [...]; Notify.* is only ever something you call to build a value.

Notify.inApp(title, body, priority, options?);

Returns { $boboddy_notifications_v1: [item] } — return it directly if the notification is the step’s whole result, or spread it into a larger result object. options accepts kind (defaults to "status_update") and payload.

inApp is the only channel with dedicated sugar, because it’s the only channel the platform actually delivers today — work_item_platform_comment, email, and slack suggestions are accepted by the schema but currently have no delivery adapter, so they’ll always end up as a failed delivery rather than a silent no-op. To suggest one of those anyway (e.g. once its adapter ships), use Notify.create({ ..., suggestedChannels: [...] }) directly — suggestedChannels is always just a suggestion the platform’s notification policy decides on, never a delivery guarantee, which is why it isn’t hidden behind a same-looking function per channel.

Notify.feedbackRequest(question, category, urgency, suggestedKey?) is the value-builder counterpart to Features.feedbackRequests(). Notify.merge(...) combines fragments from more than one Notify.* call into a single result.

import { codeStep, Features, Notify } from "@boboddy/sdk/definitions/steps";
export const notifyBlocked = codeStep({
key: "notify-blocked",
name: "Notify Blocked",
features: [Features.notifications()],
fn: () =>
Notify.inApp(
"Build failed",
"The build failed after 3 retries — needs a human look.",
"high",
),
});

Reading notifications back out — NotificationSignal

Section titled “Reading notifications back out — NotificationSignal”

NotificationSignal.key is the raw $boboddy_notifications_v1 signal key, and NotificationSignal.find(signals) parses a step execution’s signals array back into NotificationItem[] (or undefined if none were emitted or the value doesn’t parse).

Increment version when you make a breaking change to a step’s schema or prompt. Old executions referencing version 1 continue using the v1 definition; new executions pick up v2.

export const reviewStep = defineStep({
key: "code-review",
version: 2,
// ...
});

codeStep() defines a step whose implementation is a plain function instead of an LLM prompt. It plugs into a pipeline state’s step field exactly like defineStep()’s output — use it for deterministic work (aggregation, formatting, calling an internal API) that doesn’t need an agent.

import { codeStep } from "@boboddy/sdk/definitions/steps";
import { z } from "zod";
export const sumScores = codeStep({
key: "sum-scores",
name: "Sum Scores",
inputSchema: z.object({ scores: z.array(z.number()) }),
resultSchema: z.object({ total: z.number() }),
fn: ({ scores }) => ({ total: scores.reduce((a, b) => a + b, 0) }),
signals: [{ sourcePath: "total", key: "total", type: "number" }],
status: "active",
});

fn can be an inline function, and the step can be exported by name, embedded in a pipeline’s states, or both. Boboddy records only the module’s sourceFile at push time, relative to the repo root (e.g. .boboddy/pipeline-builder/sum-scores.ts). At run time the worker imports sourceFile from the checked-out repo and finds the step in it by key, so the file must exist and be committed on whatever branch the pipeline execution runs against. Closures and module-level helpers work because the whole module is imported.

export default definePipeline({
key: "scoring",
startAt: "sum",
states: {
sum: {
kind: "step",
step: codeStep({
key: "inline-only",
name: "Inline only",
resultSchema: z.object({ ok: z.boolean() }),
fn: () => ({ ok: true }),
}),
next: "done",
},
done: { kind: "succeed" },
},
});

The key is the step’s identity, and version is not part of the lookup. Pushing fails if two code steps share a key but differ in version, or if two files define the same key with different fns. If a module holds more than one distinct fn under one key, the step fails at run time.

The worker resolves the step with @boboddy/sdk/code-step-lookup from the SDK installed in .boboddy/pipeline-builder/node_modules, not the version used to push. If that SDK is too old to ship it, the step fails with the installed @boboddy/sdk is too old for inline code steps; upgrade it, followed by the installed version (for example installed version: 0.6.0). Upgrade @boboddy/sdk in .boboddy/pipeline-builder (and commit the updated package.json and lockfile) to fix it.

During a run the worker writes a temporary .code-step-runner-<uuid>.mjs next to sourceFile and removes it afterwards.

Unlike defineStep’s signals, codeStep’s type is required on every signal rather than inferred from resultSchema. See codeStep(options) for the full option table.

codeStep() also accepts features — only each feature’s result-schema extension and signals apply (there’s no prompt to append to on a code step). See Building notifications at runtime — Notify for the Features.notifications() + Notify + code-step pairing.

codeStep() also accepts environment, with the same vars forms and rules as defineStep() and a runtime of Runtime.managed.<id>() (the default, bun1) or Runtime.devcontainer() (see Runtime for code steps). It takes repo with the same rules too (see Repo access): a code step that edits files is committed and pushed like an agent step, unless it uses Repo.readOnly(). There is no typed env argument to fn: the function reads the resolved variables from process.env.

A code step never starts OpenCode. The worker launches the step’s container (a managed runtime, or your devcontainer), runs the entrypoint, and reads the findings file the runner wrote, so a code step needs no AI provider credentials, and none (nor your host auth.json) are placed in its container. Boboddy does not inject the OpenCode runtime mount, published port or host-gateway run args into its devcontainer, and writes no .opencode/ files into the workspace. Those injections are what reject docker-compose-based devcontainer configs for AI steps, so a code step is not subject to that restriction. If the runner finishes without writing its findings file, the step fails immediately.

Steps are pushed together with pipeline definitions using a single command from .boboddy/pipeline-builder/:

Terminal window
boboddy pipelines push

This pushes all steps exported from steps.ts (and any steps embedded in pipeline files) before pushing the pipeline definitions. The key + version pair uniquely identifies each step definition on the server.