Skip to content

SDK reference: core

Create a workflow containing jobs.

function workflow(name: string, options: WorkflowOptions): Workflow;

Parameters:

ParameterTypeRequiredDescription
namestringyesUnique workflow name
options.jobsJobOrFactory[]yesStatic jobs and/or dynamic job generators
options.onTrigger | Trigger[]noWhen the workflow should trigger
options.rulesRule[]noConditions that must pass for execution
options.descriptionstringnoHuman-readable description
options.hashFilesstring[]noExtra repo-relative paths or globs mixed into the workflow content hash. Changes invalidate the source cache.
options.registriesRegistry[]noPrivate npm registries the agent authenticates against before npm install. Each tokenSecret uses qualified <context>:<secret> syntax.
options.installEnvstring[]noQualified <context>:<secret> refs projected as env vars onto the install subprocess (used with a customer-committed .kici/.npmrc).
options.onCancelHookInputnoRuns when the workflow is cancelled
options.cleanupHookInputnoAlways runs after the workflow (success, failure, or cancel)
options.onSuccessHookInputnoRuns on workflow success
options.onFailureHookInputnoRuns on workflow failure
options.concurrency{ group: (ctx) => string; cancelInProgress?: boolean; max?: number }noWorkflow-scoped concurrency. See Concurrency.
options.timeoutnumbernoWhole-run wall-clock timeout in milliseconds across all jobs. On breach the orchestrator cancels the run and marks it timed out. See Timeouts.

Returns: Workflow — an immutable workflow definition.

export default workflow('ci', {
on: [pr({ target: 'main' }), push({ branches: 'main' })],
rules: [rule('has source changes')],
jobs: [lint, test, deploy],
description: 'Main CI pipeline',
});

Secret scoping happens at the job level via context (see job options and Secrets) — the workflow itself does not declare which secret contexts it can read.

Create a job with an explicit name or auto-generated ID.

function job(name: string, options: JobOptions): Job;
function job(options: JobOptions): Job;

Parameters:

ParameterTypeRequiredDescription
namestringnoJob name (auto-generated UUID if omitted)
options.runsOnRunsOnyesRunner label(s) and optional exclusions (see below)
options.stepsStepInput[]yes (or use run)Steps to execute in order. Mutually exclusive with run.
options.run(ctx) => Promise<unknown>yes (or use steps)Single-step shorthand — see Single-step job shorthand. Mutually exclusive with steps.
options.needsNeedsEntry[]noJob dependencies (must complete first) — see Job dependencies (needs)
options.rulesRule[]noConditions for conditional execution
options.descriptionstringnoHuman-readable description
options.matrixMatrixnoMatrix configuration for job expansion
options.includeMatrixInclude[]noAdditional matrix combinations
options.excludeMatrixExclude[]noMatrix combinations to remove
options.checkoutbooleanno (default: true)When false, agent skips git clone. Useful for deploy/notify jobs.
options.containerstring | ContainerConfignoDocker image for job execution. String form is the image name; object form adds env. All steps run inside the container.
options.sandbox{ capabilities?: string[]; network?: 'default' | 'none' | 'host' }noPer-job container sandbox escape hatch (container jobs only). Request extra Linux capabilities / host networking; granted only within your operator’s allow-list, else the run fails at dispatch. See below.
options.environmentstring | ((event) => string | Promise<string>)noDeployment environment for this job. Static string or async/dynamic function — see Dynamic values.
options.envRecord<string, string> | ((event) => Record<string, string>)noEnvironment variables. Static object or async/dynamic function — see Dynamic values.
options.concurrencyGroupstring | ((event) => string | Promise<string>)noConcurrency group name (defaults to environment name) — see Concurrency.
options.onCancelHookInputnoHook that runs when the job is cancelled
options.cleanupHookInputnoHook that always runs after completion
options.onSuccessHookInputnoHook that runs when the job succeeds
options.onFailureHookInputnoHook that runs when the job fails
options.beforeStepHookInputnoHook that runs before each step
options.afterStepHookInputnoHook that runs after each step
options.gracePeriodnumbernoSeconds before SIGKILL after SIGTERM during cancellation — see Hooks.
options.timeoutnumbernoTotal job wall-clock timeout in milliseconds (init + all steps + hooks). On breach the job is aborted and reported timed out. See Timeouts.
options.resourcesResourceRequestnoPer-job CPU / memory request and limit. See Per-job resources below.
options.initInitConfignoPer-job initialization run after clone, before steps — provisions a toolchain. A generic config, a typed preset ('mise' / { mise }), 'auto', or false. See Per-job init below.

Returns: Job — an immutable job definition.

// Named job
const build = job('build', {
runsOn: 'linux',
steps: [checkout, install, compile],
needs: [lint],
});
// Anonymous job (auto-generated UUID name)
const build = job({
runsOn: 'linux',
steps: [checkout, install],
});

A job’s runsOn selects which agents may run it. Every label listed must be present on the agent (a subset match). It accepts three forms, and each label can be an exact string, a glob, or a regular expression (see Targeting by pattern below):

// 1. Simple string -- agent must have this label
runsOn: 'kici:os:linux'
// 2. Array of required labels -- agent must have ALL labels
runsOn: ['kici:os:linux', 'gpu']
// 3. Object form with exclusions -- agent must have ALL required labels
// and NONE of the excluded labels
runsOn: { labels: ['kici:os:linux'], exclude: ['kici:host:box-01'] }

The label model:

  • Every agent automatically reports kici:os:<platform>, kici:arch:<cpu>, and kici:host:<hostname>, so runsOn: 'kici:os:linux' targets any connected Linux agent without configuring labels — a fresh kici init matches out of the box.
  • Use custom labels (e.g. 'gpu', 'prod-pool') — defined in your scaler’s labelSet — to target a specific agent pool.
  • You can also target scaler-assigned labels (kici:agent:<backend>, kici:scaler:<name>), but those names are deployment-specific, so custom labels are more portable.
  • runsOn is a requirement on candidate agents, never a grant: targeting a label only narrows the candidate set. Users cannot set kici: labels on agents — that namespace is reserved for the scaler and the agent’s self-reported platform facts — but they may freely target any label in runsOn.

Semantics:

  • Required labels: The agent must have every label in the labels array (or the string/array form).
  • Excluded labels: The agent must NOT have any label in the exclude array. This includes auto-derived labels like kici:arch:arm64, kici:os:linux, etc.
  • Compile-time validation: The compiler will error if any label appears in both labels and exclude (overlap detection).
  • Operator-declared mandatory labels: Operators may mark a scaler with mandatoryLabels (Kubernetes-taint-style opt-in). When a scaler declares a mandatory label, a job is only allowed to land on it if runsOn.labels includes that label. A workflow targeting such a scaler must explicitly list the mandatory label in runsOn. See the auto-scaler mandatory labels for details.
// Route to any Linux agent that does NOT have the 'gpu' label
const build = job('build', {
runsOn: { labels: ['linux'], exclude: ['gpu'] },
steps: [checkout, compile],
});
// Route to arm64 Linux agents, excluding those with 'staging' label
const deploy = job('deploy', {
runsOn: { labels: ['linux', 'arch:arm64'], exclude: ['staging'] },
steps: [deployStep],
});

When more than one agent matches a runsOn selector, the object form’s pick field controls which one runs the job:

// Always the same host across re-runs (default — can be omitted)
runsOn: { labels: ['role:db'], pick: 'deterministic' }
// Any available host (load spread)
runsOn: { labels: ['role:db'], pick: 'any' }
  • 'deterministic' (the default) — the orchestrator sorts the matching agents by their agent id and picks the lowest. A job that must run exactly once on one stable host — a database migration, a backup dump — lands on the same host every run. The string and array shorthand forms (runsOn: 'role:db', runsOn: ['role:db', 'linux']) inherit this default.
  • 'any' — pick any available matching agent. Use this for jobs that don’t need a stable host and benefit from spreading load across an equivalent pool.

Trade-off: 'deterministic' can hot-spot — if many jobs target the same label set, they all pin to the same lowest-id agent. Use 'any' to spread those across the pool; keep 'deterministic' when reproducibility matters more than balance. (pick selects among single-agent candidates; to fan a job out to every matching host, use runsOnAll instead.)

Every selector element — in runsOn, in runsOnAll, on both the include and the exclude side — can be a plain string, a glob pattern, or a regular expression. KiCI picks the matching mode from the value itself:

  • Plain string → exact match. 'kici:os:linux' matches the label kici:os:linux and nothing else.
  • String with glob metacharacters (*, ?, [], {}) → glob. 'kici:host:web-*' matches every host label starting with kici:host:web-. 'kici:host:box-0[1-3]' matches box-01, box-02, box-03.
  • RegExp literal → regular expression. /kici:host:box-0[1-3]/ matches any label the expression matches.

Both the required (include) side and the excluded side accept all three forms:

// Glob include + regex exclude, single-agent targeting.
const build = job('build', {
runsOn: { labels: ['kici:os:linux', 'kici:host:web-*'], exclude: [/.*-canary$/] },
steps: [compile],
});
// A bare regex picks any agent whose label the expression matches.
const probe = job('probe', {
runsOn: /kici:host:box-0[1-3]/,
steps: [smoke],
});

In the runsOnAll array form, a leading ! still routes an entry to the exclude side. The ! is stripped before the matching mode is decided, so '!kici:host:box-*' is an exclude glob and '!box-01' an exclude exact match. Regular-expression exclusions use the structured exclude: [/…/] form (a RegExp cannot carry a ! prefix). The structured runsOnAll form below targets every Linux host in the db or replica role except those whose hostname ends in -canary:

const fanout = job('deploy', {
runsOnAll: {
include: [{ all: ['kici:os:linux', 'kici:host:web-*'] }],
exclude: [/.*-canary$/],
},
run: async (ctx) => {
/* runs once per matched host */
},
});

Edge case — custom labels that contain glob metacharacters. Because the matching mode is inferred from the value, a custom label that literally contains *, ?, [], or {} is always treated as a glob and can no longer be matched exactly. Avoid glob metacharacters in label names you intend to target by exact string.

ReDoS protection. Glob patterns are linear by construction. A regular expression you supply is validated for catastrophic-backtracking (ReDoS) when you run kici compile — a pattern that could hang on a crafted input is rejected with an error, so it never reaches the orchestrator. The orchestrator re-validates every pattern when it loads the lock file.

Create a step with a run function or with typed outputs.

// Simple form (no outputs)
function step(name: string, run: StepRunFn): Step;
// Full form (with outputs)
function step<TOutputs extends OutputSchema>(
name: string,
options: StepOptions<TOutputs>,
): Step<TOutputs>;

Simple form:

const checkout = step('checkout', async ({ $ }) => {
await $`git checkout`;
});

With typed outputs:

import { z } from 'zod';
const build = step('build', {
outputs: {
version: z.string(),
artifacts: z.array(z.string()),
},
run: async ({ $ }) => {
await $`pnpm build`;
return { version: '1.0.0', artifacts: ['dist/main.js'] };
},
});

StepRunFn type: (ctx: StepContext) => Promise<void>

With a check facet (idempotent step):

Add a check function to describe desired state instead of a fixed action. When check is present, run becomes the apply function and receives the drift value check returned; summarize (required) renders that drift for logs and the dashboard; whenInSync optionally produces the step’s outputs when already in sync.

const configureNginx = step('configure-nginx', {
check: async (ctx) => ((await inSync(ctx)) ? null : { want: DESIRED }),
summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
run: async (ctx, drift) => {
await writeConfig(drift.want);
return { reloaded: true };
},
whenInSync: async () => ({ reloaded: false }),
});

A checked step can run in apply mode (converge) or --check preview mode (report drift, change nothing). See Idempotent steps and check mode.

options.resources declares the CPU and memory the job needs. The orchestrator’s auto-scaler uses these numbers to:

  1. Bill against capacity caps (request). Decides whether the job can be admitted under the per-scaler maxAgents, per-scaler resourceCap, orchestrator-wide globalResourceCap, and machine-pool caps.
  2. Enforce kernel limits (limit). Sets the cgroup memory.max and CPU quota on the running container / VM / scope.

The shape mirrors Kubernetes:

const heavy = job('build', {
runsOn: 'linux',
resources: {
requests: { memory: '2g', cpus: 1 },
limits: { memory: '4g', cpus: 2 },
},
steps: [...],
});

Three input shapes are accepted; all normalise to the same { requests, limits } pair:

ShapeExampleEffective behavior
Both{ requests: { memory: '2g' }, limits: { memory: '4g' } }Used as-is
Request only{ requests: { memory: '2g' } }limits mirrors the request
Limit only{ limits: { memory: '4g' } }requests mirrors the limit
Flat shorthand{ memory: '2g', cpus: 1 }Both requests and limits set to these values

Memory accepts container-style suffixes: 512m, 4g, 2048k. CPUs are fractional cores (0.5, 2).

If resources is omitted, the job inherits the matched scaler’s label-set or default resources (configured by the operator in scalers.yaml). This keeps existing workflows behaving as they did before per-job resources existed.

Per-backend kernel enforcement of limits:

  • Container backend (Docker / Podman): always enforced via cgroup.
  • Firecracker backend: always enforced. Fractional CPU rounds up to the nearest integer vCPU.
  • Bare-metal backend: advisory by default — the scaler caps still apply, but no cgroup is created. Operators can opt in to kernel enforcement via enforceCgroups: true on the scaler entry.

Container-sandbox jobs (a job with a container: image) run with all Linux capabilities dropped by default. When a job genuinely needs one extra capability or the host network namespace, request it with sandbox::

job('probe', {
runsOn: 'kici:os:linux',
container: 'node:20',
sandbox: {
capabilities: ['NET_ADMIN'], // added back on top of the dropped-all default
network: 'host', // share the host network namespace
},
steps: [/* ... */],
});

Two levers are exposed, and only these two are ever grantable:

  • capabilities — extra Linux capabilities (bare or CAP_-prefixed; both accepted). An unknown capability name is rejected when you compile.
  • network'default' (bridge, the default), 'none' (loopback only), or 'host'.

Every request is checked at dispatch against an allow-list your orchestrator operator controls. A capability or network: 'host' that the operator has not allow-listed fails the whole run with a reason naming what was blocked — the request is never silently ignored. 'none' and the default bridge never need approval. Ask your operator to allow-list a capability (kici-admin org-settings sandbox-allowlist) before relying on it. A job with no sandbox: field keeps the fully hardened default.

options.init declares a hand-written command that runs after the repo is cloned and before the job’s steps execute. Its purpose is to provision a repo-declared toolchain (a mise toolchain, a custom setup script, a language runtime) and put it on the environment every subsequent step sees.

import { workflow, job, step, push } from '@kici-dev/sdk';
export const build = workflow('build', {
on: [push()],
jobs: [
job('build', {
runsOn: 'linux',
init: {
run: `
set -euo pipefail
command -v mise >/dev/null || curl -fsSL https://mise.run | sh
export PATH="$HOME/.local/bin:$PATH"
mise install
mise env -s bash | sed -n 's/^export //p' >> "$KICI_ENV"
echo "$HOME/.local/share/mise/shims" >> "$KICI_PATH"
`,
cache: { key: 'mise-jq-1.7.1', paths: ['~/.local/share/mise'] },
timeout: 600_000,
},
steps: [
step('show-jq-version', async (ctx) => {
// jq is on PATH because the init phase appended the mise shims dir to $KICI_PATH.
const { stdout } = await ctx.$`jq --version`;
ctx.log.info(`jq version: ${stdout.trim()}`);
}),
],
}),
],
});

GenericInitConfig shape:

FieldTypeRequiredDescription
runstringyesCommand run after clone, before steps. Runs in the job’s sandbox at the clone root. Must be a non-empty command.
shellstringnoShell used to run run. Defaults to bash.
cacheCacheSpecnoCache spec for binaries the command installs — restored before the command, saved after on a key miss. See Caching.
timeoutnumbernoMax wall-clock for this init command in milliseconds. Defaults to 10 minutes. On breach the init is aborted and the job is reported timed out.
envRecord<string,string>noStatic environment variables available to the command.

The $KICI_ENV / $KICI_PATH handoff. The init command does not mutate the step environment directly. Instead it writes what it wants visible to later steps to two files the agent allocates and exposes as environment variables:

  • $KICI_ENV — append one KEY=value line per environment variable. The agent reads the file after the command and makes each variable available to every subsequent step.
  • $KICI_PATH — append one directory per line. The agent prepends each directory to PATH for every subsequent step.

The agent reads both files after the command succeeds, applies the delta, and the resulting environment is visible to all steps that follow (and to any later init command).

Failure before steps. If the init command exits non-zero or exceeds its timeout, the job fails before any step runs — the init surfaces as a failed init:<n> pseudo-step in the run timeline (alongside the step list), its logs are attached, and the step loop never executes. This makes a broken toolchain a clear, early failure rather than a confusing mid-run error.

Arrays run in order. Passing GenericInitConfig[] runs the inits sequentially; each one’s $KICI_ENV / $KICI_PATH delta is applied before the next runs, so a later init sees an earlier init’s tools on PATH. The first init to fail stops the sequence and fails the job.

init: false is an explicit opt-out; it behaves the same as omitting init.

For the common case, a typed preset removes the hand-written run block entirely. The agent expands the preset to the same generic init it would otherwise run.

  • init: 'mise' — zero-config. Installs mise, trusts and runs mise install against the committed mise config (mise.toml / .mise.toml / .tool-versions), hands mise’s env + shims dir to subsequent steps, and caches mise’s data dir under a key derived from the committed config (so a config change rotates the cache). The committed config is trusted automatically — committing it to your repo is the trust signal.
  • init: { mise: { cache, timeout, env, shell } } — the same preset with overrides. These tune the generic fields a hand-written init exposes (minus run): cache: false disables caching, a CacheSpec replaces the default key/paths, and timeout / env / shell map straight through. init: 'mise' is exactly init: { mise: {} }.
const build = job('build', {
runsOn: 'linux',
init: 'mise', // committed mise.toml pins the toolchain; jq, node, etc. land on PATH
steps: [
step('show-jq-version', async (ctx) => {
const { stdout } = await ctx.$`jq --version`;
ctx.log.info(`jq version: ${stdout.trim()}`);
}),
],
});

init: 'auto' detects the toolchain from committed files instead of naming a preset. The agent scans the clone root and selects a preset when a marker is present: mise.toml / .mise.toml / .tool-versions -> the mise preset. With no markers found, 'auto' is a logged no-op.

'auto' is opt-in: an unset init does nothing even when the repo carries a mise.toml for local development. Use 'auto' to enable detection and false to keep the explicit opt-out.

The mise preset works on Linux, macOS, and Windows. On Linux and macOS mise is installed via its standalone install script; on Windows it is installed from its standalone GitHub release. The resulting toolchain reaches every step the same way on all three. On Windows the standalone mise binary requires the Microsoft Visual C++ runtime (vc_redist.x64) to be present on the agent host — install it once when provisioning a Windows agent that uses the mise preset.

KiCI supports several authoring patterns for steps and jobs to reduce boilerplate and improve developer experience.

Async functions are accepted directly in a job’s steps array without wrapping in step(). They receive auto-generated counter names (step-1, step-2) at compile time. Return values are captured at runtime.

const myJob = job('example', {
runsOn: 'default',
steps: [
async (ctx) => {
ctx.log.info('hello from bare function');
},
step('named', async (ctx) => {
// Named steps keep their explicit name
}),
async (ctx) => {
// This becomes step-2 (counter skips named steps)
return { value: 42 };
},
],
});

Steps and jobs can be created without a name. The compiler assigns counter-based IDs at compile time.

Id-less steps:

// Id-less step with just a function
const s = step(async (ctx) => {
await ctx.$`echo hello`;
});
// Id-less step with full options
const s = step({
run: async (ctx) => {
return { version: '1.0.0' };
},
timeout: 60000,
});

Id-less jobs:

const deploy = job({
runsOn: 'default',
steps: [step('deploy', async (ctx) => { ... })],
});
// deploy.name is a UUID at definition time, replaced with job-1 at compile time

Steps have three output tiers:

TierSyntaxNamingTypeScript TypeZod Validation
1Bare functionAuto (step-N)Inferred return typeNo
2step(name, fn) or step(fn)Explicit or autoInferred return typeNo
3step(name, { outputs, run })Explicit or autoInferred + Zod schemaYes (runtime)
import { z } from '@kici-dev/sdk';
// Tier 3: step with Zod outputs (validated at runtime)
const build = step('build', {
outputs: {
version: z.string(),
artifact: z.string(),
},
run: async (ctx) => {
return { version: '2.0.0', artifact: 'dist/main.js' };
},
});

Use the run property as an alternative to steps for jobs with a single step:

const deploy = job('deploy', {
runsOn: 'default',
run: async (ctx) => {
ctx.log.info('Deploying...');
return { url: 'https://app.example.com' };
},
});

The run function is stored as the job’s only step with an auto-generated name (step-1). run and steps are mutually exclusive — providing both throws an error.

timeout (milliseconds) can be set at three levels. Each level caps its own scope independently — a workflow or job timeout is a separate wall-clock cap, not a default that flows down to steps.

LevelFieldCapsEnforced byOn breach
stepstep(..., { timeout })A single step’s wall-clock.the agentThe step fails; falls back to the 30-minute default when unset.
jobjob(..., { timeout })The job’s total wall-clock (init + all steps + hooks).the agentThe job is aborted and reported failed with a “timed out” reason.
workflowworkflow(..., { timeout })The whole run’s wall-clock across all jobs.the orchestratorThe run is cancelled with a “timed out” reason.
export default workflow('ci', {
timeout: 1_800_000, // whole run must finish within 30 minutes
jobs: [
job('build', {
runsOn: 'linux',
timeout: 600_000, // this job (init + steps + hooks) within 10 minutes
steps: [
step('compile', {
timeout: 120_000, // this single step within 2 minutes
run: async (ctx) => {
await ctx.$`make build`;
},
}),
],
}),
],
});

Precedence — each scope caps its own scope. The three timeouts are independent caps, not a fallback chain:

  • A step with no timeout falls back to the 30-minute agent default, regardless of the job or workflow timeout. A job timeout never becomes a step’s default.
  • A job timeout bounds the job’s total wall-clock (its init, every step including their own per-step timeouts, and its hooks). It does not change any step’s individual cap.
  • A workflow timeout is a run-level deadline. The orchestrator records it when the run starts and cancels the run if its wall-clock exceeds the timeout, even when individual jobs and steps are still within their own caps.

Workflow and job timeouts surface with a distinct “timed out” reason so the dashboard labels the run or job as timed out rather than a generic failure or cancel.

A step can declare a retry policy so a thrown attempt is re-run automatically instead of failing the job on the first error. Use it for genuinely transient failures — a flaky network call, an occasional 503, a dependency that is briefly not ready.

step('publish', {
retry: 3, // shorthand for { maxAttempts: 3 } with the defaults below
run: async (ctx) => {
await ctx.$`pnpm publish`;
},
});
step('fetch-token', {
retry: {
maxAttempts: 5, // total attempts including the first; must be >= 1
delayMs: 500, // base delay between attempts (default 1000)
backoff: 'exponential', // 'exponential' (default) or 'fixed'
maxDelayMs: 30_000, // cap for exponential growth (default 30000)
retryIf: (err) => err instanceof TransientError, // default: retry on any throw
},
run: async (ctx) => {
await fetchToken();
},
});
  • retry: N is shorthand for { maxAttempts: N } with all defaults applied.
  • Defaults: delayMs: 1000, backoff: 'exponential', maxDelayMs: 30000, and “retry on any throw” when no retryIf is given.
  • Backoff. With 'exponential', the wait after the n-th attempt (1-based) is min(delayMs * 2 ** (n - 1), maxDelayMs) — 1s, 2s, 4s, … capped at maxDelayMs. With 'fixed', the wait is always delayMs.
  • retryIf(err) runs against the thrown error before each retry; return false to stop retrying immediately and let the failure stand.
  • Timeout is per-attempt. Each attempt gets the step’s full timeout budget — a timed-out attempt counts as one failed attempt and is retried while attempts remain. The total wall-clock can therefore approach maxAttempts * (timeout + delay), so keep maxAttempts and maxDelayMs sane (the job-level timeout still bounds the whole job).
  • Retries exhaust before continueOnError. A step with both retries first; only the final failure is then softened to a warning by continueOnError.

retry works identically under kici run <event> --local and on a remote agent, and applies to dynamically-generated job steps too. The retryIf predicate is an in-memory function: it is honored at execution time but never serialized into the lock file.

Retry vs. wait-until-condition. retry re-runs a step that throws. To poll until a condition becomes true (a port listening, a /health endpoint returning 200, a unit becoming active), use waitForStep instead — it is purpose-built for declarative wait-for-condition with intervals, a timeout, and on-timeout handling.

Steps and jobs can access outputs from preceding steps/jobs using two patterns.

Within-job output chaining:

const buildStep = step('build', async (ctx) => {
return { version: '2.0.0' };
});
const lint = async (ctx) => {
return { warnings: 0 };
};
const pipeline = job('pipeline', {
runsOn: 'default',
steps: [
buildStep,
lint,
step(async (ctx) => {
// Pattern 1: .result proxy on Step objects
const version = buildStep.result.version;
// Pattern 2: ctx.outputsOf() for Step or bare function references
const lintOutputs = ctx.outputsOf(lint);
console.log(lintOutputs.warnings); // 0
}),
],
});

The .result proxy works for id-less steps too. An id-less step (step(fn) or step({ run, outputs }) without a name) receives a deterministic step-N name when the job’s steps are enumerated at execution start — before any step runs — and its .result resolves under that assigned name from any later step’s run function:

const build = step({ outputs: { version: z.string() }, run: async () => ({ version: '2.0.0' }) });
const pipeline = job('pipeline', {
runsOn: 'default',
steps: [
build,
step(async () => {
const version = build.result.version; // resolves under the assigned step-N name
console.log(version); // '2.0.0'
}),
],
});

Reference .result only from inside another step’s run function. Accessing it outside execution (for example at module top level, before names are assigned) raises “this step has no name yet”.

Cross-job output chaining:

const setup = job('setup', {
runsOn: 'default',
run: async (ctx) => {
return { env: 'production' };
},
});
const build = job('build', {
runsOn: 'default',
needs: [setup],
steps: [
// The options form `step(name, { run })` carries the return type through, so
// `build.result.compile.version` is typed `string`. A bare `step(name, fn)`
// is a void step and contributes no typed key.
step('compile', {
run: async (ctx) => {
return { version: '2.0.0' };
},
}),
],
});
const deploy = job('deploy', {
runsOn: 'default',
needs: [build],
steps: [
step(async (ctx) => {
// Multi-step job: jobRef.result.stepName.field — typed `string`, a typo on
// `.version` or `.compile` is a compile error.
const version = build.result.compile.version;
// Single-step job (run shorthand): jobRef.result.field — typed `string`.
const env = setup.result.env;
// Explicit context method — typed to the job's output shape for a Job ref.
const buildOutputs = ctx.jobOutputs(build);
}),
],
});

Cross-job outputs are typed. When you pass a job reference (not a string) in needs and read jobRef.result.… or ctx.jobOutputs(jobRef), the output types thread across the job boundary — a typo on an output field or a renamed step is a compile error, the same guarantee you get within a job. Two authoring rules unlock it:

  • Name your steps and use the options formstep('name', { run }) carries the return type into jobRef.result.name.field (nested by step name for multi-step jobs; flat jobRef.result.field for the run: shorthand). A bare step('name', fn) or an id-less step(fn) is a void step and contributes no typed key.
  • Pass references, not strings — reading buildJob.result.… on the job reference is typed from any job. In a run: shorthand job, a referenced needs: [buildJob] also types ctx.needs.buildJob.result.… (the run function’s ctx derives from the job’s needs tuple). A string needs: ['build'] still works but stays loosely typed (Record<string, unknown>).

For a dynamically-shaped job whose outputs the inference can’t reproduce, supply the shape explicitly: job<{ url: string }>('deploy', { … }). A matrix or runsOnAll upstream returns a typed envelope — discriminate it with isMatrixJobOutputs / isHostJobOutputs.

Access patterns summary:

PatternScopeNotes
stepRef.result.fieldWithin-jobProxy on Step object
ctx.outputsOf(stepRef)Within-jobWorks with bare function refs
jobRef.result.stepName.fieldCross-job (multi-step)Proxy on Job object
jobRef.result.fieldCross-job (run shorthand)Flat for single-step jobs
ctx.jobOutputs(jobRef)Cross-jobExplicit context method

Important: needs must be declared explicitly. Output chaining does not auto-infer dependencies — you must list job dependencies in needs even if you access their outputs via .result.

Cross-job output chaining works in both local execution (kici run <event> --local) and remote pipeline execution. The orchestrator’s needs-aware dispatch scheduler guarantees upstream jobs reach a terminal state before downstream jobs dispatch, and upstream outputs are transported to the downstream agent sandbox via the upstreamJobOutputs field on job.dispatch. See needs-scheduler for the full dispatch semantics.

The needs array accepts four entry forms. Mix freely within the same array.

// 1. Reference by Job object (type-safe, preferred)
const test = job('test', { needs: [lint], ... });
// 2. Reference by string name
const test = job('test', { needs: ['lint'], ... });
// 3. Object form with a per-edge run condition (`when`)
const cleanup = job('cleanup', {
needs: [{ name: 'build', when: 'always' }],
...
});
// 4. Dynamic group reference (for static jobs that depend on a dynamicJob group)
const deploy = job('deploy', {
needs: [dynamicGroup('test-shards')],
...
});

Run condition (when): controls when a downstream edge is satisfied, based on the upstream’s terminal status. when is keyword sugar (or a raw status-set) that resolves at compile time to the set of upstream terminal statuses that satisfy the edge. The downstream edge is satisfied when the upstream’s terminal status is a member of that set.

KeywordSatisfied when the upstream is…Use for
'on-success' (default)successnormal dependencies
'always'any terminal statuscleanup / notification / teardown jobs
'on-skip'success or skippedcontinue when an upstream was narrowed out
'on-failure'failed, timed_out_stale, drift_dropped, or unroutableerror-handler jobs that run only on failure

'on-failure' covers every terminal status that means the job did not do what the workflow declared, which includes a job dropped by determinism drift and a job whose runsOn matched no agent. It does not cover cancelled (deliberately stopped) or skipped (never ran) — use a raw status set if you need those.

For full control, pass a raw status-set instead of a keyword: when: ['skipped', 'failed', 'timed_out_stale']. The valid members are the terminal job statuses: success, failed, cancelled, skipped, timed_out_stale, drift_dropped, unroutable.

String and Job-reference entries default to when: 'on-success'. To override, use the object form ({ name, when } for static upstreams, { group, when } for dynamic groups — dynamicGroup(name, { when: 'always' }) produces the latter).

When an upstream’s terminal status is not in the edge’s set, the downstream transitions directly to skipped. Because a skipped job is itself terminal, this propagates transitively: each downstream’s when set governs whether the skip cascades further.

Dispatch gate: needs is a hard dispatch gate. A job dispatches only after every upstream in its needs array reaches a terminal status that satisfies that edge’s when set. Root jobs (empty needs, no dynamic group refs) dispatch immediately. The scheduler is DB-backed and fully recovers across orchestrator restarts.

Reading an upstream’s status in a step: inside a running job, ctx.needs.<job>.status exposes each upstream’s terminal status (success, failed, skipped, …) and ctx.needs.<job>.result its outputs. A group / matrix / runsOnAll fan-out upstream is an ordered array of { name, result, status }, one per child. Use this to branch in TypeScript:

job('report', {
needs: [{ name: 'probe', when: 'always' }],
run: async (ctx) => {
if (ctx.needs.probe.status === 'failed') await fileIncident(ctx.needs.probe.result);
else await publish(ctx.needs.probe.result);
},
});

For an arbitrary outcome-based gate that prevents a job from dispatching at all, use a result-aware dynamicJob that returns [] or [job] based on ctx.needs.<job>.status — see Dynamic jobs.

DAG validation: three-layer cycle detection.

  1. Compile time: validateDag (see below) catches static-to-static cycles.
  2. Eval time: after dynamic jobs are generated, a full topological sort runs on the resolved graph. Cycles reject the run with a clear error.
  3. Runtime: a defensive invariant check flags stuck jobs as an internal-bug backstop.

Create a reference to a dynamic job group, for use inside a static job’s needs array.

function dynamicGroup(
name: string,
options?: { when?: 'on-success' | 'always' | 'on-skip' | 'on-failure' | string[] },
): DynamicGroupRef;

Use when a static downstream must wait for every generated job tagged with a given group name to complete. If the dynamic group produces zero jobs, the downstream dispatches immediately (empty group satisfies all upstreams).

const shardedTests = dynamicJob('test-shards', async () => {
// Decide the shard set however you like — a constant, the event payload, or
// an upstream job's outputs via the result-aware `{ needs, generate }` form.
const shards = [0, 1, 2, 3];
return shards.map((i) => job(`test-shard-${i}`, { runsOn: 'linux', run: async () => {} }));
});
const deploy = job('deploy', {
runsOn: 'linux',
needs: [dynamicGroup('test-shards')],
run: async () => {
// Runs after ALL test-shards jobs have reached a terminal state
},
});

Tag a dynamic job generator with a group name so other jobs can reference it via dynamicGroup().

function dynamicJob(
groupName: string,
fnOrConfig: DynamicJobFn | { needs: DynamicJobNeed[]; generate: DynamicJobFn },
): TaggedDynamicJobFn;

The second argument is either a plain generator (event-only, evaluated at webhook time) or a result-aware { needs, generate } config, which defers the generator until its declared upstreams complete and exposes their frozen outputs as ctx.needs. See Rules, matrix, dynamic jobs for the result-aware form.

The generator runs twice: once in the init phase (to register expected job names) and once inside the executing agent (to produce the actual jobs). Mismatches between the two evaluations are detected as determinism drift — see dynamic-jobs.

Unnamed steps and jobs receive counter-based IDs at compile time:

  • Steps: step-1, step-2, etc. Counter is scoped per job and only increments for unnamed entries. Named steps do not consume counter values.
  • Jobs: job-1, job-2, etc. Counter is scoped per workflow and only increments for unnamed entries.

These IDs are stable as long as the order of unnamed entries does not change. Adding or removing unnamed entries shifts subsequent IDs.