Skip to content
scarn.io

SDK Reference

You don’t install the SDK yourself to write pipelines. @scarn/sdk is a dependency of .scarn/pipeline-builder/package.json, which scarn pipelines design scaffolds and installs for you (scarn pipelines init scaffolds it; you run the install). Add it to another package directly only to script against the API client.

See the Pipeline Graph catalog entry for an interactive view of a compiled pipeline’s node/edge graph.


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

import { defineStep } from "@scarn/sdk";
import { z } from "zod";
const myStep = defineStep({
key: "my-step",
name: "My Step",
version: 1,
description: "Does something useful.",
input: z.object({ text: z.string() }),
result: z.object({ summary: z.string(), score: z.number() }),
errors: ["NoAccess"],
agentPrompt: ({ input, env, scarn }) => `
Analyze the provided text from ${input.text}.
Base URL: ${env.BASE_URL}
Write any generated files to ${scarn.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
input ZodType No Input payload schema; the pipeline passes these fields as the step’s args
result ZodType No Output payload schema
errors string[] No Error names the step can fail with; a pipeline can .retry/.catch them by name
retry { on: string[]; maxAttempts: number }[] No How a standalone Task of this step retries: a failure named in on (built-in or in errors) gets up to maxAttempts more runs. In a pipeline, the node’s .retry applies instead. Compiled onto the handle as retryJson
concurrency { max: number; waiting: number } No The step’s concurrency group on a work item: at most max (≥ 1) live Tasks, and up to waiting (≥ 0) more waiting for a place; a Task past both is dropped. Pipeline nodes can’t override it. See Concurrency groups
mcps Record<string, McpHandle> No MCP servers defined with defineMcp, keyed by the name the step runs them under. The only way to give a step an MCP server
healthChecks StepHealthCheck[] No cli checks and non-MCP tool checks forced against the environment before the agent is prompted. See healthChecks
features StepFeature[] No Built-in feature plugins (e.g. Features.notifications())
status "draft" | "active" No Draft steps are skipped by workers
environment { runtime?: RuntimeSpec; vars?: ({ input }) => Record<string, string | EnvEntry>; repo?: RepoSpec | (({ input, result }) => RepoSpec) } No Where the step runs, which environment variables it gets, and what it does to your repository; declare before agentPrompt. See Runtime, Env and Repo

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",
input: z.object({
title: z.string(),
description: z.string(),
}),
agentPrompt: ({ input, env, scarn }) => `
Open ${env.BASE_URL}.
Reproduce the issue described in ${input.title}.
Save traces to ${scarn.artifactsDir}trace.zip.
`,
});
Scope Example Source
input ${input.title} Task input bound through the pipeline
env ${env.BASE_URL} The worker’s environment (.scarn/.env over process.env); with declared environment.vars, only its non-secret keys
scarn ${scarn.artifactsDir} Scarn-provided runtime values

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

Scarn currently provides:

  • scarn.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.

Runtime builds the environment.runtime value: where the step runs. It is exported from @scarn/sdk. Omit runtime for the default: Runtime.devcontainer() on defineStep(), Runtime.managed.bun1() on codeStep().

import { defineStep, Runtime } from "@scarn/sdk";
defineStep({
key: "build-frontend",
name: "Build Frontend",
environment: {
runtime: Runtime.devcontainer({
config: ".devcontainer/frontend/devcontainer.json",
}),
},
agentPrompt: "Build the frontend.",
});
defineStep({
key: "summarize",
name: "Summarize",
environment: { runtime: Runtime.host() },
agentPrompt: "Summarize the provided text.",
});
type RuntimeSpec = DevcontainerRuntimeSpec | HostRuntimeSpec | ManagedRuntimeSpec;
Runtime.devcontainer(opts?: {
config?: string; // full repo-relative path to devcontainer.json or .devcontainer.json
}): DevcontainerRuntimeSpec;
Runtime.host(): HostRuntimeSpec;
Constructor Behaviour
Runtime.devcontainer() Clones the repository and runs the step in its devcontainer. Without config, the worker uses .devcontainer/devcontainer.json, then devcontainer.json.
Runtime.host() No clone, no container: the step runs on the worker host in an empty temporary directory. Takes no options.

config rules, enforced by Runtime.devcontainer (which throws), by definition validation at push, by the server (422), and by the worker:

  • The base name must be devcontainer.json or .devcontainer.json.
  • Repo-relative: no leading /, no drive letter, no backslashes, no .. segment. A leading ./ is stripped. At most 255 characters.
  • There is no fallback search. If the file is missing on the branch the step is based on, the step fails with Devcontainer config "<path>" not found in the cloned repository (branch "<branch>"). The work branch of the nearest earlier step that produced one can change the config a later step finds (see Base branch).

codeStep() accepts the same environment, but Runtime.host() is a type error there and is rejected by validation: a code step runs repo code and needs a workspace. The container a code step’s config selects must provide a JavaScript runtime for the runner script.

Runtime.managed.<id>() is a container the worker supplies (image, config and, for code steps, dependency install), so the step does not boot your project’s devcontainer. It is the default for codeStep(): omit runtime and you get Runtime.managed.bun1(). Use Runtime.devcontainer() to run a code step in your project’s devcontainer instead. defineStep() accepts it explicitly: the agent runs inside the managed container against the clone, and no dependencies are installed (see Managed runtimes for agent steps).

import { codeStep, Runtime } from "@scarn/sdk/definitions/steps";
codeStep({
key: "lookup-criteria",
name: "Lookup Criteria",
environment: { runtime: Runtime.managed.node24() },
fn: lookupCriteriaFn,
});
type ManagedRuntimeId = "bun1" | "node24";
Runtime.managed: { [K in ManagedRuntimeId]: () => ManagedRuntimeSpec<K> };
Constructor Image Behaviour
Runtime.managed.bun1() oven/bun:1.4.0-debian The default for codeStep(). Runs bun install --production in .scarn/pipeline-builder, then runs the step with Bun. Skips dependency postinstall scripts unless listed in trustedDependencies.
Runtime.managed.node24() node:24-bookworm-slim Runs npm install --omit=dev (or corepack pnpm install --prod for pnpm-lock.yaml), then runs the step with Node 24. Runs postinstall scripts. Does not resolve extensionless relative imports.

The install and the runner apply to code steps only. Runtime.managed.nope is a type error: the identifiers come from MANAGED_RUNTIME_IDS in @scarn/sdk/managed-runtimes. A managed step has no config. See Runtime for code steps for the lockfile handling and runtime differences, and Running Workers for the worker side.

The wire form is derived from runtime: executionMode ("workspace" for the devcontainer and managed runtimes, "no_workspace" for host), devcontainerConfigPath (config, or null), and managedRuntime (the identifier for a managed runtime, otherwise null). The path schema is devcontainerConfigPathSchema from @scarn/sdk/devcontainer-config-path.

See Environment and Multiple configs for the guides.

environment.vars is a function of { input }, run once at definition time, returning a record of variable name to an EnvEntry. Declare environment before agentPrompt so the keys are typed into the prompt’s env, which is then strict (declared, non-secret keys only). Env is exported from @scarn/sdk.

import { defineStep, Env } from "@scarn/sdk";
import { z } from "zod";
defineStep({
key: "account-investigation",
name: "Account Investigation",
input: z.object({ tenant: z.string() }),
environment: {
vars: ({ input }) => ({
WAREHOUSE_URL: "https://warehouse.internal",
TENANT_API_KEY: Env.value({ value: `${input.tenant}-key`, secret: true }),
WAREHOUSE_TOKEN: Env.inherit({ secret: true }),
LOG_LEVEL: Env.inherit({ optional: true, default: "info" }),
}),
},
agentPrompt: ({ env }) => `Query ${env.WAREHOUSE_URL}.`,
});
type EnvEntry = string | EnvValue | EnvInherit;
Env.value(opts: {
value: string; // literal or {{input.…}} template; stored in plaintext
secret?: boolean; // mask in logs; requires an {{input.…}} token in `value`
unsafeAllowStatic?: boolean; // allow a static value on a credential-looking name; never serialized
applies?: "agent" | "setup" | "both"; // default "agent"
}): EnvValue;
Env.inherit(opts?: {
from?: string; // worker variable to read; defaults to the key
secret?: boolean; // mask in logs; cannot be combined with `default`
optional?: boolean; // omit instead of failing when the worker has no value
default?: string; // used when the worker has no value; stored in plaintext
applies?: "agent" | "setup" | "both"; // default "agent"
}): EnvInherit;

applies picks the process a variable reaches: agent (the agent or code step), setup (only devcontainer up, where initializeCommand runs), or both. Setup variables are not added to containerEnv; forward one with ${localEnv:NAME:default} in devcontainer.json. setup and both are rejected on Runtime.host() and on MCP variables. See Setup variables.

Env.inherit reads from the worker (.scarn/.env, then process.env), not from the devcontainer’s containerEnv. A bare string and Env.value are stored in the definition in plaintext; inherit stores only the name (and default). See the forms table for what is stored and masked.

secret: true together with default is a type error. Optional entries without a default are typed string | undefined in the prompt. The key must match /^[A-Z_][A-Z0-9_]*$/; SCARN_*, HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, OPENCODE_CONFIG_CONTENT, and npm_config_cache are reserved, and inherit cannot read SCARN_* worker variables. A step may declare at most 64 variables of at most 4096 characters each.

Defining a step throws on a static secret, and on a bare static string whose key matches /SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE/i. The wire shape is envJson, validated by envVarsSchema from @scarn/sdk/env-vars.

See Environment variables for precedence, per-runtime behaviour, and caveats.

Repo builds the environment.repo value: what the worker does with the repository clone. It is exported from @scarn/sdk. Omit repo for the runtime’s default.

import { defineStep, Repo } from "@scarn/sdk";
import { z } from "zod";
defineStep({
key: "triage",
name: "Triage",
environment: { repo: Repo.readOnly() },
agentPrompt: "Find the code behind this report.",
});
defineStep({
key: "implement",
name: "Implement",
result: z.object({ summary: z.string() }),
environment: {
repo: ({ result }) =>
Repo.readWrite({
message: `fix: ${result.summary}`,
onPushFailure: "warn",
}),
},
agentPrompt: "Fix the reported bug.",
});
type RepoSpec = NoneRepoSpec | ReadOnlyRepoSpec | ReadWriteRepoSpec;
Repo.readWrite(opts?: {
message?: string; // {{input.…}} / {{result.…}} tokens; one line, at most 500 characters
onPushFailure?: "fail" | "warn"; // defaults to "fail"
gitCredentials?: "auto" | "none"; // defaults to "auto"
}): ReadWriteRepoSpec;
Repo.readOnly(opts?: {
gitCredentials?: "auto" | "none"; // defaults to "auto"
}): ReadOnlyRepoSpec;
Repo.none(): NoneRepoSpec;
Constructor Behaviour
Repo.readWrite() Creates a work branch, then commits and pushes it when the step succeeds. The default for Runtime.devcontainer().
Repo.readOnly() Clones and checks out the base branch. No work branch, no commit, no push, and no workBranch reported. A policy, not a sandbox: edits stay in the workspace and are discarded.
Repo.none() No repository: no clone, branch, commit or push. The default for Runtime.host().

gitCredentials (on readOnly or readWrite) defaults to "auto": when the worker has git credentials enabled, git inside the container can fetch private repositories, such as submodules or git dependencies, with the worker’s git credentials. That credential may be able to push and the agent can read it; git push from the container is blocked as a guardrail only. Set "none" to opt a step out. See Git credentials.

Which repo each runtime accepts:

Runtime readWrite readOnly none Default
Runtime.devcontainer() Yes Yes No readWrite
Runtime.managed.<id>() Yes Yes No readWrite
Runtime.host() No No Yes none

A pair the runtime rejects is a type error where TypeScript can express it, and otherwise a validation error at definition time, at push, and from the server (422). codeStep() accepts repo with the same rules.

repo can be a function of { input, result }, run once at definition time against the same proxies agentPrompt uses, so a template literal built from them becomes a {{result.…}} or {{input.…}} token that the worker renders. Start the string with a prefix (`fix: ${result.summary}`): a bare `${result.summary}` trips a lint rule. A static message string with the tokens written out works too.

message rules:

  • Only input and result tokens are allowed. {{env.…}} is rejected so environment values cannot reach git history.
  • The rendered subject has whitespace collapsed and control characters stripped, is truncated to 200 characters, and falls back to scarn: task <taskId> when empty.

onPushFailure defaults to "fail": a failed push of the work branch or of a submodule fails the step. "warn" logs the failure and lets the step finish, but the step reports no work branch, so later steps do not build on it. See Work branches for the behavior change and its consequences.

The wire form is a nested repo object, { mode: "none" | "readOnly" | "readWrite", message?, onPushFailure? }. The SDK emits it only when the author wrote a repo; the server resolves an omitted one to the runtime’s default and stores it fully resolved (message is null for the default). The schemas are repoConfigInputSchema and repoConfigSchema, with resolveRepoConfig, from @scarn/sdk/repo-config.

See Repo access for the guide.

z from @scarn/sdk (and @scarn/sdk/definitions/steps) is the whole of Zod plus z.artifact. Import it instead of zod in pipeline code.

import { z } from "@scarn/sdk/definitions/steps";

Declares a result field that holds a file. Also exported as artifact.

Option Type Description
accept string[] Accepted file extensions, lower-case with a leading dot (".mp4"). A wrong entry throws. Empty or omitted accepts any file

An artifact field can’t be inside an array. Use .nullable() for a file that may not exist.

Extension lists for accept:

Key Extensions
video .mp4, .webm, .mov
image .png, .jpg, .jpeg, .gif, .svg
Artifact.path(relativePath); // code steps: the value of an artifact field
Artifact.embed(target, { caption }); // ![caption](artifact:...)
Artifact.link(target, { label }); // [label](artifact:...)
  • path takes a path relative to the code step’s artifactsDir and returns an ArtifactPath.
  • embed and link return a marker string for a notification body or a step prompt. target is { step, path }, an artifact field in that step’s result, which gives a required marker, or a file name or glob, which gives an optional one (artifact:<glob>).

See Embedding and linking in a comment for the marker rules and what each platform shows.

defineMcp, from @scarn/sdk/definitions/steps or @scarn/sdk, defines an MCP server once with its environment variables and health checks. It is the only way to give a step an MCP server or an MCP health check. A step mounts the returned McpHandle under mcps; the key is the name the step runs it under.

function defineMcp(config: DefineMcpConfig): McpHandle;
type DefineMcpConfig = (RefServer | LocalServer | RemoteServer) & {
env?: string[]; // worker variables the server needs
optionalEnv?: string[]; // worker variables passed through when set
healthChecks?: Record<string, McpCheckInput>;
};
type SharedFields = {
timeout?: { startup?: number; catalog?: number; execution?: number }; // milliseconds
codemode?: boolean; // Scarn defaults step servers to false
protocol?: "legacy" | "auto" | "2026-07-28";
};
// A server defined in the repository's MCP config files, resolved by the
// worker before launch. Needs a checkout, so not usable on Runtime.host().
type RefServer = SharedFields & {
type: "ref";
name: string; // the server's name in the repository's MCP config
};
// A subprocess server. `command` is [executable, ...args]; `name` labels errors.
type LocalServer = SharedFields & {
type: "local";
name: string;
command: string[];
cwd?: string;
environment?: Record<string, string>; // every value written as {env:VAR}
};
// An HTTP server; `name` labels errors.
type RemoteServer = SharedFields & {
type: "remote";
name: string;
url: string;
headers?: Record<string, string>; // every value {env:VAR}, optionally "Bearer {env:VAR}"
oauth?:
| {
client_id?: string;
client_secret?: string;
scope?: string;
callback_port?: number;
redirect_uri?: string;
auth_server_metadata_url?: string;
}
| false;
};
// A tool check without `kind`, `mcp` or `name`, plus `enabled` (default: true).
type McpCheckInput = {
tool: string; // bare tool name; the mcps key supplies the qualifier
args?: Record<string, unknown>;
severity?: "required" | "warn";
timeoutSeconds?: number;
serialGroup?: string;
enabled?: boolean;
};
// The fields `overrideCheck` may change.
type McpCheckPatch = Partial<
Pick<McpCheckInput, "tool" | "args" | "timeoutSeconds" | "severity" | "serialGroup">
>;

defineMcp throws on an empty name, an unknown type or field, a literal (non-{env:VAR}) environment or headers value, a check name that is empty or contains ., and a variable name that is invalid, declared twice, or reserved (SCARN_*), including one read by a {env:VAR} placeholder.

Every {env:VAR} in a local server’s command, cwd or environment, or a remote server’s url or headers, compiles to a required worker variable tagged with the server’s mcps key, unless it is listed in optionalEnv. See Variables in inline servers.

Every McpHandle method returns a new handle and leaves its receiver unchanged:

Method Returns Effect
overrideCheck(name, patch) McpHandle Patches a check with an McpCheckPatch; args is replaced whole
disableCheck(name) McpHandle Turns a check off; wins over overrideCheck in either order
enableCheck(name) McpHandle Turns a check on; the last of enableCheck/disableCheck wins
addCheck(name, check) McpHandle Adds an McpCheckInput under a new name
disableAllChecks() McpHandle Turns off every check defined so far

name is typed from the handle’s check names. A server has no severity of its own: each check has its own severity (default "required"), changed for one step with overrideCheck(name, { severity }). A handle also exposes its state as readonly fields: type, name, server (the OpenCode fields), env, optionalEnv, checks and overrides (the builder calls, in order). isMcpHandle(value) tells a handle from any other value, and compileMcps(mcps, stepKey) compiles a record of handles to the wire fields (opencodeMcpJson, health checks and env entries) the way defineStep does.

See MCP servers for examples of each type, where refs are looked up, how checks are named and serialized, check severity, and what happens when a variable is missing, and Secrets for where the real values live.

A step’s own checks: cli checks and tool checks on plugin, standalone and built-in tools. MCP checks are declared on the server, in defineMcp’s healthChecks; a step check has no mcp field.

type StepHealthCheck = StepToolHealthCheck | CliHealthCheck;
type Shared = {
name?: string; // defaults to the resolved tool id
severity?: "required" | "warn"; // default: "required"
timeoutSeconds?: number; // default: 15, max: 3600
serialGroup?: string; // checks sharing a value run serially, in declaration order
};
type StepToolHealthCheck = Shared & {
kind?: "tool"; // the default
tool: string; // a flat plugin, standalone or built-in tool id
args?: Record<string, unknown>;
};
type CliHealthCheck = Shared & {
kind: "cli";
command: string[]; // [executable, ...args], run through OpenCode's bash tool
expectExitCode?: number; // default: 0
};

The schema is stepHealthChecksInputSchema from @scarn/sdk/health-checks. The stored wire shape (healthChecksSchema) also allows mcp on a tool check: that is what defineMcp checks compile to.

Every check runs in parallel by default, each in its own lane. Give two or more checks the same serialGroup to force them to run one at a time, in declaration order, against each other — while still running concurrently with every other check/group. required checks all run before any warn check; the first required failure stops every check that hasn’t started yet, but one already in flight still finishes and reports its real outcome.

See Health checks for a full example and the no-secrets-in-args guidance.

defineStep has no plugins option. Add OpenCode plugins to .opencode/opencode.json in your repo; the in-container OpenCode loads it for every step. See Plugins.

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

import { defineStep, Features } from "@scarn/sdk/definitions/steps";
const step = defineStep({
key: "repro",
name: "Repro",
agentPrompt: "Reproduce the bug.",
features: [Features.notifications()],
});
Feature Effect
Features.notifications() Adds an optional $scarn_notifications_v1 result array and prompt text for emitting user notifications.
Features.feedbackRequests() A real specialization of notifications() (not an alias): narrows every emitted item to kind: "feedback_request", in the same $scarn_notifications_v1 array.
Features.workItems.read | update | comment | block | close({ scope }) Grants the step’s Tasks that action on their work item (scope: "self") or its children (scope: "children"), and adds a prompt section naming the tool. read also takes scope: "project": any work item in the project.
Features.workItems.label({ scope, labels }) Grants adding or removing only the listed labels.
Features.workItems.create({ scope: "children", labels }) Grants creating child work items with labels from labels only.

Work item features add nothing to the result; they collect into the step’s grants. Attach each action and scope at most once. See Features.workItems.* for the guide.

Features is exclusively for features: [...] attachment — value construction (Notify) is a separate export, kept apart on purpose so Features. autocomplete only ever shows things you attach to a step.

Each notification item is:

// Inferred from the same zod schema `resultSchemaJson` is built from —
// there is no independently hand-written type to drift out of sync.
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 }
};

A flat namespace of builder functions for constructing a notification result value at runtime without hand-writing the object shape. Most useful inside a codeStep()’s fn, where there’s no agent to follow the prompt instructions.

Notify.create(item: NotificationItem);
Notify.inApp(title, body, priority, options?);
Notify.feedbackRequest(question, category, urgency);
Notify.merge(...fragments);
  • create is the one generic constructor — field names match NotificationItem exactly.
  • inApp is the only channel with dedicated sugar, because in_app is the only channel delivered without a human in the loop. work_item_platform_comment is delivered as a draft that a human approves before it is posted on Jira, GitHub or Bitbucket. email and slack are accepted by the schema but have no delivery adapter yet. Use Notify.create({ ..., suggestedChannels: [...] }) directly to suggest any channel other than in_app.
  • feedbackRequest is the value-builder counterpart to Features.feedbackRequests().
  • Each of create/inApp/feedbackRequest returns { $scarn_notifications_v1: [item] } — return it directly, or spread it into a larger result. inApp’s options is { kind?, payload? }; kind defaults to "status_update".
  • merge(...fragments) combines multiple builder results into one.

See Features for the guide.


Defines a kind: "code" step: a plain function instead of an LLM prompt. Produces the same callable step handle defineStep() does, so a pipeline uses it the same way.

import { codeStep } from "@scarn/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) }),
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
fn (input, context: CodeStepContext) => result | Promise<result> Yes The step’s implementation; may be inline. The worker imports the module and finds the step by key, so export the step by name. See Code steps
inputSchema ZodType No Zod schema for fn’s input
resultSchema ZodType No Zod schema for fn’s return value
errors string[] No Error names the step can fail with; a pipeline can .retry/.catch them by name
retry { on: string[]; maxAttempts: number }[] No As defineStep’s retry: how a standalone Task of this step retries
concurrency { max: number; waiting: number } No As defineStep’s concurrency
features AnyStepFeature[] No Feature plugins from Features; only each feature’s result extension applies (no prompt on a code step)
environment { runtime?: DevcontainerRuntimeSpec | ManagedRuntimeSpec; vars?: ({ input }) => Record<string, string | EnvEntry>; repo?: DevcontainerRepoSpec | (({ input, result }) => DevcontainerRepoSpec) } No runtime is Runtime.managed.<id>() or Runtime.devcontainer({ config? }); the default is Runtime.managed.bun1(). Runtime.host() is not supported. vars are environment variables for the runner process, read by fn through process.env. repo is Repo.readWrite() (default) or Repo.readOnly(). See Runtime, Env and Repo
status "draft" | "active" No Draft steps are not executed; defaults to "active"

CodeStepContext is { artifactsDir: string }, the absolute path of the step’s artifacts folder, which already exists. Write a file there and return Artifact.path(<relative path>) in a z.artifact field. The worker checks that the path stays inside the folder, exists, is non-empty and has an accepted extension.


Exported from @scarn/sdk/pipelines. Write a pipeline in TypeScript; scarn pipelines build compiles its default export to <key>.json, the document the server runs. See Building Pipelines for the JSON format each call compiles to.

import { decision, definePipeline, equal, fail, succeed } from "@scarn/sdk/pipelines";
import { reproduce, writeFailingTest } from "./steps";
const review = decision("review", {
actions: { approve: { label: "Approve" }, reject: { label: "Reject" } },
});
const done = succeed("done");
const rejected = fail("rejected", { error: "Rejected" });
export default definePipeline({
key: "bug-repro",
title: "Bug repro",
start: reproduce({ appUrl: "http://localhost:3000" }),
flow: [
reproduce.next({
routes: [{ when: (r) => equal(r.result.reproduced, true), to: (r) => writeFailingTest({ observed: r.result.observed }) }],
otherwise: review,
}),
review.next({
routes: [{ when: (r) => equal(r.result.action, "approve"), to: (r) => writeFailingTest({ observed: r.node(reproduce).result.observed }) }],
otherwise: rejected,
}),
writeFailingTest.next(done),
],
});
Field Type Required Description
key string Yes The pipeline key; build writes <key>.json
title string No Display name
start target Yes The first node. Only workItem.* refs and literals are available as args
flow FlowChain[] Yes At most one finished chain (ending in .next(...)) per node. A step can be a target without a chain; its path ends after it runs
API Compiles to Notes
step(args) / bare step A transition’s to and args A step handle from defineStep/codeStep is callable. Bare only when its input has no required fields. The node id is the step’s key
step.as("id") A second node for the same step The only way to use a step twice
decision("id", { input?, actions }) { kind: "wait", for: "decision", actions: [{ key, label, body }] } Waits for a person in the dashboard to pick one of actions, each { label, body?: "optional" | "required" } (buttons in key order). Result is { action, body }: action is one of the keys, body the trimmed text or null. input is a Zod schema typing the args shown to the person deciding
succeed("id", { title? }) / fail("id", { error?, cause?, title? }) Terminal nodes No chain
reply("id") { kind: "wait", for: "reply" } Posts questions: string[] on the work item and waits for an answer. Result is { replied, body, commentId }
workItem.title, workItem.description, workItemField(alias, label) workItem.title, workItem.description, workItem.fields.<alias> Work-item values for args. workItem.description is string | null
stepRef<TInput, TResult>("key") step: "key" A step that exists only on the server; checked against the server’s catalog at build time
step ─▶ .settings? ─▶ .retry* ─▶ .catch* ─▶ .next(t | { match?, routes, otherwise? }) finished
decision ─▶ .settings? ─▶ .catch* ─▶ .next(…) succeed / fail ─▶ no methods
route = { when?: (r) => Condition, to: t | (r) => t }
t = step | step(args) | step.partial(args) | each(over, options?, (item) => step(args))

.next ends the chain, and flow accepts only finished chains. .next twice, or .retry / .catch after .next, is a type error.

Method Description
.settings({ title?, timeoutSeconds?, heartbeatSeconds?, maxRuns? }) Node settings. maxRuns caps a loop back to this node
.retry({ on, maxAttempts }) Re-run the node when it fails with one of on
.catch(on, target) Go to target instead; its r has error and input, no result
.next(target) Always go to target. Compiles to { routes: [{ to, args }] }
.next({ match?, routes, otherwise? }) match: "first" (the default) takes the first route whose when matches; a route may omit when only when it is the only route. match: "all" takes every matching route, each as a lane; a route with no when always runs. routes needs at least one entry. otherwise runs when no route with a when matched; with nothing taken, the path ends. match is left out of the JSON when it’s "first"
.partial(args) On step and decision handles, only as a target in .next(...). Arrives at that node as a merge, with this lane’s share of its args. args is a Partial of the node’s input. Compiles to { to, args, partial: true }. A .catch or start target can’t be one (a type error), and nor can a match: "all" route’s own target (a build error)

on lists the step’s declared errors and the built-in errors Scarn.All, Scarn.StepFailed, Scarn.HealthCheckFailed, Scarn.Timeout, Scarn.HeartbeatTimeout, Scarn.MaxRunsExceeded, Scarn.ConditionError and Scarn.ArgsError. Anything else is a type error.

A target is a node, a node called with args, or a callback (r) => target. In the callback, r.result and r.input are the node that just finished, and r.node(step).result / .input an earlier node that ran on every path to this one. Args are each a reference, a plain JSON value, or an array containing references ([r.result] compiles to { list: ["result"] }; an array with no references stays { value: [...] }). An object literal containing references is a type error. { ...r.input } passes every field on.

equal, notEqual, lessThan, lessThanInclusive, greaterThan, greaterThanInclusive, exists, isEmpty, contains, startsWith, endsWith, matches, before, after; all, any, not; some, every, none over a list ((list, (x) => cond)); and sum, avg, min, max, count, which take a list of references or a list and a field selector and go on the left of a comparison. The right side is typed against the left.

Callbacks run once, at build time, with references. JavaScript logic on a reference (r.result.ok ? a : b, r.result.score > 0.8) doesn’t branch at run time: use .next({ routes }) and the helpers above. A comparison or template string on a reference throws at build time, and a field read but never used fails the build.

classify.next({
match: "all",
routes: [
{ to: (r) => reproduce({ appUrl: r.result.appUrl }) },
{ when: (r) => equal(r.result.category, "security"), to: notifySecurity },
{
when: (r) => not(isEmpty(r.result.pages)),
to: (r) => each(r.result.pages, { maxConcurrency: 3 }, (page) => screenshot({ url: page })),
},
],
}),
reproduce.next((r) => summarise.partial({ observed: r.result.observed })),
notifySecurity.next((r) => summarise.partial({ securityNotes: r.result.notes })),
screenshot.next((r) => summarise.partial({ screenshots: [r.result.url] })),
API Compiles to Notes
.next({ match: "all", routes, otherwise? }) next: { match: "all", ... } One lane per taken route. With nothing taken, no lanes start and a closing merge runs straight away
each(over, options?, (item) => target) each: { over, maxConcurrency?, toleratedFailures? } on the route One lane per item. Only a next target. item exists only as the callback’s parameter, and the target can’t be a .partial(...). toleratedFailures is { count } or { percent }, default { count: 0 }
step.partial(args) A route with partial: true Ends the lane at the merge step, which runs once every lane that could reach it has arrived or can’t any more

At a merge, a field from item lanes is an array, every lane’s array concatenated in item order ([] when no lane supplied it). Other fields are copied, and a field from a route that wasn’t taken is left unset. A lane that ends without reaching a merge (no route taken, no chain, or succeed) is left out. A failed all lane fails the split’s parent; failed item lanes count against toleratedFailures. See Routes, splits and merges.

partial on .catch, toleratedFailures for all lanes, and a loop back into a merge from outside its split aren’t supported.

definePipeline and the server’s validator report these with the same message:

Build error Fix
In first mode, a route with no when when there’s more than one route Add a when, or use otherwise
A .partial(...) route outside any split Remove .partial, or start lanes earlier
A merge node with a plain (non-partial) incoming route Make every route into it .partial(...)
Lanes from two different routes meeting at a node without partial Make it a merge, or use .as(...) for a second node
A field supplied by two different routes Supply it from one route
A required merge field that no route supplies, or that might not arrive Make it optional, or supply it on every path that doesn’t end at fail
A field from item lanes whose type isn’t an array Make it an array
A node reached both inside and outside a split Use .as(...). succeed and fail nodes are exempt
r.node(x) after a merge, where x ran in its lanes Pass the value through the partial args
A match: "all" route that is itself .partial(...) Route to a step first, and make its route .partial

@scarn/sdk/eslint has the rule no-logic-in-pipeline-callbacks, which flags JavaScript logic in your editor before you build. It reports ?:, &&, ||, ??, !, comparisons and if statements written directly in a callback passed to .next (a target, or a route’s when/to and otherwise), .catch(on, target) or each. Functions nested inside a callback, such as some(list, (x) => ...), aren’t checked. scarn pipelines init doesn’t set up ESLint, so add it yourself (ESLint 9, flat config):

.scarn/pipeline-builder/eslint.config.js
import tseslint from "typescript-eslint";
import scarn from "@scarn/sdk/eslint";
export default [
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
scarn.configs.recommended,
];

Install eslint and typescript-eslint as dev dependencies first. To pick the files yourself, register the plugin and turn the rule on:

{
files: ["**/*.ts"],
plugins: { scarn },
rules: { "scarn/no-logic-in-pipeline-callbacks": "error" },
}

Define an automation: rules that react to events and start pipelines. Import it with onEvent, attach and skip from @scarn/sdk/automations; conditions come from @scarn/sdk/pipelines. The project’s default automation lives in .scarn/pipeline-builder/default.ts.

import { contains } from "@scarn/sdk/pipelines";
import { defineAutomation, onEvent } from "@scarn/sdk/automations";
import bugTriage from "./bug-triage";
import triage from "./triage";
export default defineAutomation({
key: "default",
scope: "project",
rules: [
onEvent({
event: "workItem.ready",
routes: [{ when: (r) => contains(r.workItem.labels, "bug"), to: bugTriage }],
otherwise: triage,
}),
],
});
Field Type Description
key string Stable identity; default for the project’s default automation
title string (optional) Display name
scope "project" | "workItem" Project automations react to project events; work-item ones are attached to an item
rules onEvent(...)[] onEvent({ event, to }), or onEvent({ event, match?, routes, otherwise? })

A rule’s target is a pipeline, attach(automation) (project scope only) or skip. See Project Automations for the events and what r holds.


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

import { createScarnClient } from "@scarn/sdk";
const client = createScarnClient("https://app.scarn.io");

Use createStepDefinitionsClient for CRUD operations on step definitions:

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

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

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

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

import { readProjectConfig } from "@scarn/worker";
const { project } = await readProjectConfig(); // "owner/slug"