Skip to content
boboddy.dev

SDK Reference

Install the SDK:

Terminal window
npm install @boboddy/sdk

Define a reusable, versioned step with typed input/output schemas.

import { defineStep } from "@boboddy/sdk";
import { z } from "zod";
const myStep = defineStep({
key: "my-step",
name: "My Step",
version: 1,
description: "Does something useful.",
additionalInput: z.object({ text: z.string() }),
result: z.object({ summary: z.string(), score: z.number() }),
signals: [
{
sourcePath: "score",
key: "quality_score",
type: "number",
required: true,
},
],
agentPrompt: ({ input, env, boboddy }) => `
Analyze the provided text from ${input.text}.
Base URL: ${env.BASE_URL}
Write any generated files to ${boboddy.artifactsDir}
Return a summary and quality score.
`,
status: "active",
});
Field Type Required Description
key string Yes Unique step key within the project
name string Yes Display name
version number No Version (default: 1)
description string No Short description
agentPrompt string | ((context) => string) Yes AI instruction given to the executing agent
additionalInput ZodType No Additional input payload schema; fields are bound via the pipeline mapper
result ZodType No Output payload schema
signals Signal[] No Values to extract from the result
mcpServers OpenCodeMcpServers No MCP server configs for tool-using agents
plugins OpenCodePluginEntry[] No Opencode plugins merged into the step’s execution config
features StepFeature[] No Built-in feature plugins (e.g. Features.notifications())
status "draft" | "active" No Draft steps are skipped by workers
executionMode "workspace" | "no_workspace" No "no_workspace" runs the agent without cloning your repo or a dev container; defaults to "workspace". See Execution mode

agentPrompt accepts either a raw string or a function that receives a typed prompt context. The function form is recommended because it gives autocomplete for supported prompt variables and keeps prompt tokens consistent with your step schema.

const browserReproStep = defineStep({
key: "browser-repro",
name: "Browser Repro",
additionalInput: z.object({
title: z.string(),
description: z.string(),
}),
agentPrompt: ({ input, env, boboddy }) => `
Open ${env.BASE_URL}.
Reproduce the issue described in ${input.title}.
Save traces to ${boboddy.artifactsDir}trace.zip.
`,
});
Scope Example Source
input ${input.title} Step execution input bound through the pipeline
env ${env.BASE_URL} Any defined environment variable available to the worker
boboddy ${boboddy.artifactsDir} Boboddy-provided runtime values

At runtime these become {{input.title}}, {{env.BASE_URL}}, and {{boboddy.artifactsDir}} inside the stored prompt template.

Boboddy currently provides:

  • boboddy.artifactsDir for files that should be uploaded as step artifacts.

Legacy raw prompt tokens such as {{title}} and {{stepArtifactsDir}} still resolve, but new steps should prefer the scoped form.

type Signal = {
sourcePath: string; // dot-notation path into result, e.g. "metrics.score"
key?: string; // signal name used in advancement rules (defaults to sourcePath)
type?: "number" | "string" | "boolean" | "object" | "array";
required?: boolean; // fail execution if signal is missing
};

mcpServers is a Record<string, McpServerConfig> where each value is one of three shapes:

// Local subprocess server. `command` is [executable, ...args] — no separate `args` field.
type LocalMcpServer = {
type: "local";
command: string[];
environment?: Record<string, string>; // {env:VAR} interpolates a worker env var
enabled?: boolean;
timeout?: number;
};
// Remote HTTP server.
type RemoteMcpServer = {
type: "remote";
url: string;
headers?: Record<string, string>;
oauth?:
| { clientId?: string; clientSecret?: string; scope?: string; redirectUri?: string }
| false;
enabled?: boolean;
timeout?: number;
};
// Enabled override — toggle an inherited server without redefining it.
type McpEnabledOverride = { enabled: boolean };

See MCP servers for examples, and Secrets for how {env:VAR} resolves and where the real values live.

plugins is an array of Opencode plugin entries merged into the step’s execution config:

type OpenCodePluginEntry = string | [packageName: string, options: Record<string, unknown>];

Entries are deduplicated by package name when combined with the baseline Opencode config.

features accepts built-in feature plugins from the Features namespace (imported from @boboddy/sdk/definitions/steps). A feature extends the step’s result schema, appends signals, and injects prompt text.

import { defineStep, Features } from "@boboddy/sdk/definitions/steps";
const step = defineStep({
key: "repro",
name: "Repro",
agentPrompt: "Reproduce the bug.",
features: [Features.notifications()],
});
Feature Effect
Features.notifications() Adds a $boboddy_notifications_v1 result array + signal (type array, not required) and prompt text for emitting user notifications.
Features.feedbackRequests() Convenience wrapper over notifications(), backed by the same $boboddy_notifications_v1 signal, aimed at asking the project team questions.

Each notification item is:

type NotificationItem = {
kind: "feedback_request" | "status_update" | "blocked" | "result_ready" | "warning";
title: string;
body: string;
priority: "low" | "normal" | "high" | "urgent";
suggestedChannels?: ("in_app" | "work_item_platform_comment" | "email" | "slack")[];
payload?: Record<string, unknown>; // feedback_request: { category, urgency, suggestedKey? }
};

Features.notifications.signal.find(signals) and Features.notifications.signal.key are helpers for reading emitted notifications from a step’s signals.

See Features for the guide.


Define an ordered sequence of steps using the fluent builder.

import { pipeline } from "@boboddy/sdk/definitions/pipelines";
import { z } from "zod";
const inputSchema = z.object({ text: z.string() });
const myPipeline = pipeline({
key: "my-pipeline",
name: "My Pipeline",
status: "active",
additionalPipelineInput: {
schema: z.object({ text: z.string() }),
bindings: ({ workItem }) => ({ text: workItem.field("Text") }),
},
})
.step(myStep, {
input: ({ input }) => ({ text: input.text }),
advance: () => ({ default: "continue" }),
})
.build();
Field Type Required Description
key string Yes Unique pipeline key
name string Yes Display name
version number No Version (default: 1)
description string No Short description
status "draft" | "active" No Draft pipelines are not executed
additionalPipelineInput object No Custom input fields; requires both schema and bindings
additionalStepInput object No Default bindings applied to every step in the pipeline

additionalPipelineInput.schema is a Zod object schema for extra pipeline input fields. additionalPipelineInput.bindings receives { workItem, literal } and returns their bindings.

additionalStepInput applies default bindings to every step in the pipeline. Its bindings function receives { workItemField, literal } and compiles into regular step input bindings. Explicit options.input bindings on a .step() call override pipeline-level defaults.

Method Description
.step(step, options) Append a step. options.input receives { input, signal, output, literal, signalsList } and returns a record of input bindings keyed by the step’s input fields (optional if the step declares no additional input). options.advance is required — receives { signal, stepSignals, all, any, route, avg, sum, min, max, count, weightedAvg, booleanAny, booleanAll } and returns { default, rules? }, deciding how the pipeline continues past this step. options.timeout (optional, seconds) caps execution time.
.fanOutStep(step, config) Runs step as N parallel branches instead of one, in place of .step(...). config resolves both the per-branch and whole-cohort advancement policies inline (advance, advanceAll) alongside over, input, and timeout — see Fan-out for FanOutStepConfig’s full field list.
.build() Finalize and return a PipelineDefinitionSpec.

Inside .step()’s input option:

  • input.workItemTitle / input.workItemDescription — always available; bind to the work item title or description.
  • input.<path> — custom fields from additionalPipelineInput.schema. input.code binds to path "code"; input.ticket.title binds to "ticket.title". The accessor is a proxy — do not spread or coerce it to a primitive.
  • signal(step, signalKey) — bind to a prior step’s signal. signalKey is typed against step.__signalKeys.
  • output(step) — bind to a prior step’s whole output object.
  • literal(value) — a hardcoded constant.
  • signalsList(fanOutStep) — bind to a fan-out’s whole cohort (every terminal branch’s signals, aggregated server-side). fanOutStep is constrained to a step already passed as the first argument to an earlier .fanOutStep(...) call. See Fan-out.

Inside .step()’s advance option:

  • signal(key) — returns a typed SignalRef for the current step’s signal. Chain a comparator (.eq, .gt, .gte, .lt, .lte, .ne, .in, .notIn, .contains, .doesNotContain) followed by .then(outcome).
  • stepSignals.<key> — property-map shorthand equivalent to signal(key). Both produce identical output.
  • Computed factoriesavg, weightedAvg, sum, min, max, count, booleanAny, booleanAll. Each takes 2+ signal(key) or stepSignals.key references and returns a SignalRef. Identical calls across rules are deduplicated at build time.
  • all(...refs) / any(...refs) — group SignalRefs and other groups; terminate with .then(outcome).
  • route(pipelineKey, inputJson?) — produces a route outcome value for .then(...).

.fanOutStep(step, config) runs step as a variable number of parallel branches, resolved at runtime from a signal’s value. It always compiles to a paired fanOut + cohortGate node; config resolves both the per-branch (advance) and whole-cohort (advanceAll) advancement policies inline, the same way .step()’s advance option resolves inline rather than via a chained call. See the Fan-out guide for a full example.

.fanOutStep(reviewStep, {
over: "reviewer_count",
input: ({ literal }) => ({ mode: literal("thorough") }),
timeout: 900,
advance: ({ signal }) => ({
default: "continue",
rules: [signal("passed").eq(false).then("block")],
}),
advanceAll: ({ branchOutcomes }) => ({
default: "block",
rules: [branchOutcomes.every("continue").then("continue")],
}),
})

over’s resolved signal shape decides the branch mode: a number-typed signal resolves a fixed branch count (no item on input’s ctx); an array-typed signal resolves branch count from the array’s length and adds a typed item — the array’s element type — to every branch’s input mapper.

.fanOutStep(assigneeReviewStep, {
over: "assigneeIds", // a string[] signal on the preceding step
input: ({ item, input }) => ({
assigneeId: item,
ticketTitle: input.workItemTitle,
}),
advance: () => ({ default: "continue" }),
advanceAll: () => ({ default: "continue" }),
})
Field Type Required Description
over string Yes Signal on the most recently declared step whose value determines branch count (and, if array-typed, each branch’s item) at runtime
advance (ctx) => result Yes Every branch’s own continue/block decision — see advance context below
advanceAll (ctx) => result Yes The whole cohort’s continue/block decision — see advanceAll context below
input (ctx) => bindings No Input bindings for the fan-out step; same context helpers as .step(...)’s mapper, plus item when over resolves to an array-typed signal
timeout number | null No Per-branch timeout in seconds

Evaluated per-branch against that branch’s own signals:

  • signal(key) / stepSignals.<key> — the branch’s own signal, typed against fanOutStep.__signalKeys. Same comparators as a regular step’s SignalRef (.eq, .gt, .gte, .lt, .lte, .ne, .in, .notIn, .contains, .doesNotContain).
  • all(...refs) / any(...refs) — group refs; terminate with .then(outcome).
  • .then(outcome)outcome is restricted to "continue" | "block" (no route/complete).
  • No computed-signal factories (avg, sum, etc.) — core has no mechanism yet to resolve a computed signal for a fan-out branch’s policy.

Evaluated once per cohort, after every branch has settled:

  • branchOutcomes.total() — the cohort’s total branch count (fact branchCount). Returns a CohortSignalRef<number>.
  • branchOutcomes.count(outcome) — a single outcome’s count across the cohort (fact ${outcome}Count). Returns a CohortSignalRef<number>.
  • branchOutcomes.every(outcome) / .some(outcome)true iff every/any branch resolved to outcome. Already a CohortRuleLeaf — go straight to .then(outcome).
  • stepSignalsList.pluck(signalKey) — seeds an aggregation across every branch’s signalKey value. Chain .filter(operator, value) / .sortBy(direction?) / .unique() to reshape, then exactly one reducer: .count(), .sum(), .avg(), .min(), .max(), .booleanAll(), .booleanAny(), .join(separator?), .first(), or .last(). Returns a CohortSignalRef.
  • all(...refs) / any(...refs) — group refs; terminate with .then(outcome).
  • outcome in .then(outcome) is restricted to "continue" | "block".

BranchOutcome (the outcome param to branchOutcomes.count/.every/.some) is "continue" | "block" | "error" | "abandoned" — the two additional values cover branches that failed or never ran.


Define which pipeline is automatically started when a work item arrives. This goes in the reserved file .boboddy/pipeline-builder/default-pipeline-assignment.ts.

import { defaultPipelineAssignment } from "@boboddy/sdk/definitions/pipelines";
import bugTriage from "./bug-triage";
import regressionReview from "./regression-review";
export default defaultPipelineAssignment(({ workItem, any, assign, skip }) => ({
default: assign(bugTriage),
rules: [
any(
workItem.field("status").eq("resolved"),
workItem.field("status").eq("manual support"),
).then(skip()),
workItem.field("issueType").eq("bug").then(assign(bugTriage)),
workItem
.field("labels")
.contains("regression")
.then(assign(regressionReview)),
],
}));

The callback receives a context object. Only defaultPipelineAssignment needs to be imported.

Property Description
workItem.field(name) Returns a comparator ref for the named work item field
context.isNew Comparator ref; true when the work item is new
assign(pipeline) Outcome: start the given pipeline (PipelineDefinitionSpec)
skip() Outcome: do not assign any pipeline
all(...refs) All nested conditions must match
any(...refs) Any nested condition must match
Field Type Description
default AssignOutcome | SkipOutcome Outcome when no rule matches; assign(pipeline) or skip()
rules AssignmentRule[] Ordered rules; first match wins

All comparators available on advancement SignalRefs are also available here: .eq, .ne, .gt, .gte, .lt, .lte, .in, .notIn, .contains, .doesNotContain.


The SDK ships an auto-generated API client built from the OpenAPI spec.

import { createBoboddyClient } from "@boboddy/sdk";
const client = createBoboddyClient("https://app.boboddy.dev");

Use createStepDefinitionsClient for CRUD operations on step definitions:

import { createStepDefinitionsClient } from "@boboddy/sdk";
const stepClient = createStepDefinitionsClient("https://app.boboddy.dev");

Parse .boboddy/boboddy.jsonc files (JSON with comments):

import { parseJsonc } from "@boboddy/sdk";
const config = parseJsonc(rawString);

Read the Boboddy project config from disk. readProjectConfig is exported from @boboddy/worker (not the SDK):

import { readProjectConfig } from "@boboddy/worker";
const { projectId } = await readProjectConfig();