# KiCI SDK reference: core This bundle covers: Core authoring API: workflow/job/step factories, triggers, rules, matrix, dynamic jobs, cross-job outputs. ## SDK reference: core Source: https://docs.kici.dev/user/sdk/core/ ## Factory functions ### workflow(name, options) Create a workflow containing jobs. ```typescript function workflow(name: string, options: WorkflowOptions): Workflow; ``` **Parameters:** | Parameter | Type | Required | Description | | --------------------- | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | yes | Unique workflow name | | `options.jobs` | `JobOrFactory[]` | yes | Static jobs and/or dynamic job generators | | `options.on` | `Trigger \| Trigger[]` | no | When the workflow should trigger | | `options.rules` | `Rule[]` | no | Conditions that must pass for execution | | `options.filter` | `FilterFn` | no | Pre-dispatch predicate deciding whether the workflow applies to the event's source repo. A `false` result suppresses the workflow's jobs -- none is dispatched and none is reported as skipped. See [Global workflows](https://docs.kici.dev/user/global-workflows/). | | `options.description` | `string` | no | Human-readable description | | `options.hashFiles` | `string[]` | no | Extra repo-relative paths or globs mixed into the workflow content hash. Changes invalidate the source cache. | | `options.registries` | `Registry[]` | no | Private npm registries the agent authenticates against before `npm install`. Each `tokenSecret` uses qualified `:` syntax. | | `options.installEnv` | `string[]` | no | Qualified `:` refs projected as env vars onto the install subprocess (used with a customer-committed `.kici/.npmrc`). | | `options.onCancel` | `HookInput` | no | Runs when the workflow is cancelled | | `options.cleanup` | `HookInput` | no | Always runs after the workflow (success, failure, or cancel) | | `options.onSuccess` | `HookInput` | no | Runs on workflow success | | `options.onFailure` | `HookInput` | no | Runs on workflow failure | | `options.concurrency` | `{ group: (ctx) => string; cancelInProgress?: boolean; max?: number }` | no | Workflow-scoped concurrency. See [Concurrency](https://docs.kici.dev/user/concurrency/). | | `options.timeout` | `number` | no | Whole-run wall-clock timeout in milliseconds across all jobs. On breach the orchestrator cancels the run and marks it timed out. See [Timeouts](https://docs.kici.dev/user/sdk/core/#timeouts). | | `options.approval` | `ApprovalConfig` | no | Pause for a manual human approval before the whole workflow dispatches. See [Approval gates](https://docs.kici.dev/user/approvals/). | **Returns:** `Workflow` -- an immutable workflow definition. ```typescript 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](https://docs.kici.dev/user/sdk/core/#jobname-options--joboptions) and [Secrets](https://docs.kici.dev/user/secrets/)) — the workflow itself does not declare which secret contexts it can read. ### job(name, options) / job(options) Create a job with an explicit name or auto-generated ID. ```typescript function job(name: string, options: JobOptions): Job; function job(options: JobOptions): Job; ``` **Parameters:** | Parameter | Type | Required | Description | | ------------------------------ | -------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | `string` | no | Job name (auto-generated UUID if omitted) | | `options.runsOn` | `RunsOn` | yes (or `runsOnAll`) | Single-agent targeting -- runner label(s) and optional exclusions (see below). Mutually exclusive with `runsOnAll`. | | `options.runsOnAll` | `RunsOnAllInput` | yes (or `runsOn`) | Host fan-out -- one pinned execution per roster host matching the predicate. Mutually exclusive with `runsOn`. See [runsOnAll host fan-out](https://docs.kici.dev/user/sdk/runs-on-all/). | | `options.onUnreachable` | `'skip' \| 'fail' \| 'hold'` | no (default: `hold`) | Failure policy for unreachable durable hosts. `skip` omits them, `fail` fails the run, `hold` queues a pinned child and waits. Only meaningful alongside `runsOnAll`. | | `options.includeUninitialized` | `boolean` | no (default: `false`) | Widen a `runsOnAll` fan-out to declared-but-un-agented hosts -- a matching host with no live agent gets a temporary init-runner brought up over SSH. Only meaningful alongside `runsOnAll`. | | `options.steps` | `StepInput[]` | yes (or use `run`) | Steps to execute in order. Mutually exclusive with `run`. | | `options.run` | `(ctx) => Promise` | yes (or use `steps`) | Single-step shorthand -- see [Single-step job shorthand](https://docs.kici.dev/user/sdk/core/#single-step-job-shorthand). Mutually exclusive with `steps`. | | `options.needs` | `Array` | no | Job dependencies (must complete first) -- see [Job dependencies (`needs`)](https://docs.kici.dev/user/sdk/core/#job-dependencies-needs) | | `options.rules` | `Rule[]` | no | Conditions for conditional execution | | `options.description` | `string` | no | Human-readable description | | `options.matrix` | `Matrix` | no | Matrix configuration for job expansion | | `options.include` | `MatrixInclude[]` | no | Additional matrix combinations | | `options.exclude` | `MatrixExclude[]` | no | Matrix combinations to remove | | `options.maxParallel` | `number` | no | Fan-out concurrency width -- the maximum number of fan-out children (matrix combinations or `runsOnAll` hosts) running at once. A sliding window; `1` is strictly serial. Must be `>= 1`. | | `options.failFast` | `boolean` | no (default: `false`) | Halt the fan-out on the first child failure: stop releasing new children and skip the ones still held. Applies to both matrix and `runsOnAll` fan-out. | | `options.checkout` | `boolean` | no (default: `true`) | When `false`, agent skips git clone. Useful for deploy/notify jobs. | | `options.container` | `string \| ContainerConfig` | no | Docker 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' }` | no | Per-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.context` | `string \| ((event) => string \| Promise)` | no | Bound context for this job -- the secret / variable scope it resolves against. Static string or async/dynamic function -- see [Contexts](https://docs.kici.dev/user/contexts/) and [Dynamic values](https://docs.kici.dev/user/dynamic-values/). | | `options.contexts` | `(string \| ((event) => string \| Promise))[]` | no | Bound contexts in merge order (later entries override earlier on name collisions). Mutually exclusive with `context`. | | `options.env` | `Record \| ((event) => Record)` | no | Environment variables. Static object or async/dynamic function -- see [Dynamic values](https://docs.kici.dev/user/dynamic-values/). | | `options.concurrencyGroup` | `string \| ((event) => string \| Promise)` | no | Concurrency group name. Defaults to the first bound context's name -- see [Concurrency](https://docs.kici.dev/user/concurrency/). | | `options.onCancel` | `HookInput` | no | Hook that runs when the job is cancelled | | `options.cleanup` | `HookInput` | no | Hook that always runs after completion | | `options.onSuccess` | `HookInput` | no | Hook that runs when the job succeeds | | `options.onFailure` | `HookInput` | no | Hook that runs when the job fails | | `options.beforeStep` | `HookInput` | no | Hook that runs before each step | | `options.afterStep` | `HookInput` | no | Hook that runs after each step | | `options.gracePeriod` | `number` | no | Seconds before SIGKILL after SIGTERM during cancellation -- see [Hooks](https://docs.kici.dev/user/hooks/#hook-timeout). | | `options.timeout` | `number` | no | Total job wall-clock timeout in milliseconds (init + all steps + hooks). On breach the job is aborted and reported timed out. See [Timeouts](https://docs.kici.dev/user/sdk/core/#timeouts). | | `options.resources` | `ResourceRequest` | no | Per-job CPU / memory request and limit. See [Per-job resources](https://docs.kici.dev/user/sdk/core/#per-job-resources) below. | | `options.init` | `InitConfig` | no | Per-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](https://docs.kici.dev/user/sdk/core/#per-job-init) below. | | `options.cache` | `CacheInput` | no | Declarative cache: restored before steps, saved after the job on a key miss. See [Caching](https://docs.kici.dev/user/sdk/caching/). | | `options.approval` | `ApprovalConfig` | no | Pause for a manual human approval before this job dispatches. See [Approval gates](https://docs.kici.dev/user/approvals/). | **Returns:** `Job` -- an immutable job definition. ```typescript // 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], }); ``` #### runsOn forms 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](https://docs.kici.dev/user/sdk/core/#targeting-by-pattern) below): ```typescript // 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:`, `kici:arch:`, and `kici:host:`, 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:`, `kici:scaler:`), 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. - **Case:** Label matching is **case-insensitive** at every step. `runsOn: 'gpu'` matches an agent that reports `GPU`, and a pool declaring `["Docker"]` serves a `runsOn: ["docker"]` job (see [auto-scaler matching rules](https://docs.kici.dev/operator/orchestrator/auto-scaler/operations/#matching-rules)). KiCI stores and displays every label in lowercase, so the dashboard, `kici-admin agent list`, and `ctx.kici.inventory[…].labels` report the folded form. Compare against a lowercase value when you read a label back in workflow code: `h.labels.includes('gpu')`, not `h.labels.includes('GPU')`. - **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](https://docs.kici.dev/operator/orchestrator/auto-scaler/common-config/#mandatory--exclude-labels) for details. ```typescript // 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], }); ``` #### Single-host selection: `pick` When more than one agent matches a `runsOn` selector, the object form's `pick` field controls **which** one runs the job: ```typescript // 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`](https://docs.kici.dev/user/sdk/runs-on-all/) instead.) #### Targeting by pattern 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. All three forms match case-insensitively. `'GPU'`, `'kici:host:Web-*'` and `/kici:host:BOX-0[1-3]/` each match a label of any case, and a `RegExp` you write with the `i` flag behaves the same. The `g` and `y` flags are dropped — a selector asks one question per label, so a sticky match would resume part-way through the next one. `kici:host:` carries the machine's hostname folded to lowercase. A host that calls itself `Build-Box-01` advertises `kici:host:build-box-01`, and both `runsOn: 'kici:host:build-box-01'` and `runsOn: 'kici:host:Build-Box-01'` match it. Case folding covers labels and hostnames only. An **agent ID** stays an opaque identifier and compares exactly, which is what keeps a per-host secret binding on `prod-01` away from an agent named `PROD-01` — see [per-host secret scoping](https://docs.kici.dev/operator/security/secrets/#per-host-secret-scoping). Both the required (include) side and the excluded side accept all three forms: ```typescript // 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`: ```typescript 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. ### step(name, run) / step(name, options) Create a step with a run function or with typed outputs. ```typescript // Simple form (no outputs) function step(name: string, run: StepRunFn): Step; // Full form (with outputs) function step( name: string, options: StepOptions, ): Step; ``` **Simple form:** ```typescript const checkout = step('checkout', async ({ $ }) => { await $`git checkout`; }); ``` **With typed outputs:** ```typescript 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` **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. ```typescript 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](https://docs.kici.dev/user/idempotent-steps/). ### Per-job resources `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: ```typescript 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: | Shape | Example | Effective 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. ### Per-job sandbox escape hatch 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:`: ```ts 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. ### Per-job init `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. ```typescript 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:** | Field | Type | Required | Description | | --------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `run` | `string` | yes | Command run after clone, before steps. Runs in the job's sandbox at the clone root. Must be a non-empty command. | | `shell` | `string` | no | Shell used to run `run`. Defaults to `bash`. | | `cache` | `CacheSpec` | no | Cache spec for binaries the command installs -- restored before the command, saved after on a key miss. See [Caching](https://docs.kici.dev/user/sdk/caching/). | | `timeout` | `number` | no | Max 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. | | `env` | `Record` | no | Static 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:` 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`. #### Toolchain presets 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: {} }`. ```typescript 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()}`); }), ], }); ``` #### Auto-detect (`init: 'auto'`) **`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. #### Cross-platform 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. ## Step & job authoring patterns KiCI supports several authoring patterns for steps and jobs to reduce boilerplate and improve developer experience. ### Bare function steps 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. ```typescript 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 }; }, ], }); ``` ### Id-less steps and jobs Steps and jobs can be created without a name. The compiler assigns counter-based IDs at compile time. **Id-less steps:** ```typescript // 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:** ```typescript 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 ``` ### Step output types Steps have three output tiers: | Tier | Syntax | Naming | TypeScript Type | Zod Validation | | ---- | ------------------------------ | ---------------- | --------------------- | -------------- | | 1 | Bare function | Auto (`step-N`) | Inferred return type | No | | 2 | `step(name, fn)` or `step(fn)` | Explicit or auto | Inferred return type | No | | 3 | `step(name, { outputs, run })` | Explicit or auto | Inferred + Zod schema | Yes (runtime) | ```typescript 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' }; }, }); ``` ### Single-step job shorthand Use the `run` property as an alternative to `steps` for jobs with a single step: ```typescript 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. ### Timeouts `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. | Level | Field | Caps | Enforced by | On breach | | ------------ | ---------------------------- | ------------------------------------------------------ | ---------------- | ----------------------------------------------------------------- | | **step** | `step(..., { timeout })` | A single step's wall-clock. | the agent | The step fails; falls back to the 30-minute default when unset. | | **job** | `job(..., { timeout })` | The job's total wall-clock (init + all steps + hooks). | the agent | The job is aborted and reported failed with a "timed out" reason. | | **workflow** | `workflow(..., { timeout })` | The whole run's wall-clock across all jobs. | the orchestrator | The run is cancelled with a "timed out" reason. | ```typescript 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. ### Retries 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. ```typescript 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 --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`](https://docs.kici.dev/user/sdk/wait-for/) instead — it is purpose-built for declarative wait-for-condition with intervals, a timeout, and on-timeout handling. ### Output chaining Steps and jobs can access outputs from preceding steps/jobs using two patterns. **Within-job output chaining:** ```typescript 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: ```typescript 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:** ```typescript 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 form** — `step('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`). 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:** | Pattern | Scope | Notes | | ------------------------------ | ------------------------- | ----------------------------- | | `stepRef.result.field` | Within-job | Proxy on Step object | | `ctx.outputsOf(stepRef)` | Within-job | Works with bare function refs | | `jobRef.result.stepName.field` | Cross-job (multi-step) | Proxy on Job object | | `jobRef.result.field` | Cross-job (run shorthand) | Flat for single-step jobs | | `ctx.jobOutputs(jobRef)` | Cross-job | Explicit 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 --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](https://docs.kici.dev/architecture/execution/needs-scheduler/) for the full dispatch semantics. ### Job dependencies (`needs`) The `needs` array accepts four entry forms. Mix freely within the same array. ```typescript // 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. | Keyword | Satisfied when the upstream is… | Use for | | ------------------------ | ------------------------------------------------------------- | ------------------------------------------- | | `'on-success'` (default) | `success` | normal dependencies | | `'always'` | any terminal status | cleanup / notification / teardown jobs | | `'on-skip'` | `success` or `skipped` | continue when an upstream was narrowed out | | `'on-failure'` | `failed`, `timed_out_stale`, `drift_dropped`, or `unroutable` | error-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..status` exposes each upstream's terminal status (`success`, `failed`, `skipped`, …) and `ctx.needs..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: ```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..status` — see [Dynamic jobs](https://docs.kici.dev/architecture/execution/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. ### dynamicGroup(name, options?) Create a reference to a dynamic job group, for use inside a static job's `needs` array. ```typescript 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). ```typescript 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 }, }); ``` ### dynamicJob(groupName, fnOrConfig) Tag a dynamic job generator with a group name so other jobs can reference it via `dynamicGroup()`. ```typescript function dynamicJob( groupName: string, fnOrConfig: | DynamicJobFn | { needs?: DynamicJobNeed[]; generate: DynamicJobFn; gitCredentials?: GitCredentialMap; }, ): TaggedDynamicJobFn; ``` The second argument is either a plain generator (event-only, evaluated at webhook time) or an options config. An options config that declares `needs` is result-aware: it defers the generator until those upstreams complete and exposes their frozen outputs as `ctx.needs`. See [Rules, matrix, dynamic jobs](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#dynamicjob--result-aware-generation) for the result-aware form. `needs` is optional. An options config without it is evaluated at webhook time, like the plain generator form. Use that form to declare `gitCredentials` on an event-only generator: every job the generator produces inherits the map, which is the only way a generated job gets named credentials. See [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/#generated-jobs). 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](https://docs.kici.dev/architecture/execution/dynamic-jobs/). ### Auto-generated IDs 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. --- ## SDK reference: parallel Source: https://docs.kici.dev/user/sdk/parallel/ `parallel([...steps], opts?)` runs a group of independent steps **concurrently** within one job, behind a join barrier: execution continues past the group only once every child has settled. Each child is its own observable step — it gets its own logs, status, timing, and retry — instead of being hidden inside one step's `Promise.all`. ```ts import { workflow, job, step, parallel, push } from '@kici-dev/sdk'; export default workflow('ci', { on: push(), jobs: [ job('checks', { runsOn: 'kici:os:linux', steps: [ checkout, // lint, typecheck, and the unit tests have no ordering between them, // so they run together — the job's wall-clock is the slowest child, // not the sum of all three. parallel([lint, typecheck, unitTests], { failFast: true }), deploy, ], }), ], }); ``` `parallel(...)` returns a `ParallelGroup` that sits in the ordinary flat `steps: [...]` array — there is no new `job` field. A group's children are **sequential steps only**; groups cannot be nested. ## Options `parallel(steps, opts?)` accepts: - **`failFast?: boolean`** — default `true`. When a child fails, the in-flight siblings are cancelled immediately and the job fails. With `failFast: false` every child runs to completion first, then the job fails if any child failed. - **`maxParallel?: number`** — default unlimited. Caps how many children run at once; children waiting for a slot report a `pending` status until they launch. - **`name?: string`** — a label for the group's dashboard band. A child marked `continueOnError: true` never trips fail-fast and never fails the job — it still shows a `failed` status badge, but the group treats it as non-fatal. ## Statuses Parallel steps introduce two step statuses: - **`pending`** — a child queued behind `maxParallel`, not yet launched. - **`cancelled`** — a sibling aborted by fail-fast. A cancelled step is **not** a failure: only the child that actually failed fails the job; the cancelled siblings render in gray (distinct from the red failing step) on the dashboard. Children may also complete **out of order** — the fastest child finishes first regardless of array position. A later sequential step can read a parallel child's `.result` after the barrier; children within a group cannot read each other's results (there is no ordering inside the group). ## Scope: nests inside job-level fan-out `parallel()`'s `failFast` / `maxParallel` are **step-group** scopes — they govern only the steps inside the group. They are a different layer from the **job-level** `failFast` / `maxParallel` on a matrix / `runsOnAll` fan-out, which govern how a job's child _jobs_ spread across the matrix or host roster. A `parallel()` group inside a fan-out job nests its concurrency inside each fan-out child. ## Local vs remote execution Run remotely (the orchestrator + agent), parallel children execute concurrently and each surfaces as its own dashboard step. `kici run --local` executes the same children in array order in its single-process model — the results are identical, only the wall-clock and the live fail-fast cancellation differ. Use a remote run to observe the concurrent timeline. --- ## SDK reference: rules, matrix, dynamic jobs Source: https://docs.kici.dev/user/sdk/rules-matrix-dynamic/ ## Rules Rules control conditional execution of workflows and jobs. A rule that returns `false` (or whose check function returns `false`) prevents execution. A rule's check function that **returns `false`** cleanly skips the job or step. A check function that **throws** counts as an evaluation failure, not a skip. The job or step **fails** with the error surfaced (both on a remote run and when running locally with `kici run --local`), so a broken rule can never silently pass as a green run. For example, `rule('main only', (ctx) => ctx.event.ref.endsWith('main'))` throws on an event whose `ref` is undefined — that run fails with the error instead of quietly skipping every step. Fix the thrown error (guard the access) rather than relying on the skip. ### rule(label) / rule(label, check) Create a rule. ```typescript function rule(label: string): Rule; function rule(label: string, check: RuleCheckFn): Rule; ``` **Without check function:** Always passes. Useful as a marker in the decision trace. ```typescript rule('ci: required check'); ``` **With check function:** Passes when the function returns `true`. ```typescript rule('has source changes', async (ctx) => { return ctx.changedFiles.some((f) => f.startsWith('src/')); }); ``` ### skip(label, check) Create a rule that skips when the condition is met. Inverts the check function. ```typescript function skip(label: string, check: RuleCheckFn): Rule; ``` When the check returns `true` (condition met), the rule returns `false` (skip execution). When the check returns `false` (condition not met), the rule returns `true` (allow execution). ```typescript // Skip when only docs changed skip('docs only PR', async (ctx) => { return ctx.changedFiles.every((f) => f.endsWith('.md')); }); ``` ### RuleCheckFn ```typescript type RuleCheckFn = (ctx: RuleContext) => Promise | boolean; ``` Can be sync or async. Receives a `RuleContext`: | Property | Type | Description | | -------------------- | ----------------------------------------- | ----------------------------------------------------------------------- | | `event` | `EventPayload` | The triggering event payload (discriminated union — narrow on `type`) | | `changedFiles` | `string[]` | Files changed in this event (see availability note below) | | `changedFilesStatus` | `'fetched' \| 'unavailable' \| 'skipped'` | Whether `changedFiles` is available | | `sourceRepo` | `RepoInfo \| undefined` | Repo whose event triggered the run, when the evaluation has a checkout | | `workflowRepo` | `RepoInfo \| undefined` | Repo that registered the workflow (same repo outside a global workflow) | | `env` | `Record` | Environment variables | | `$` | zx shell | Shell executor for running commands | `changedFiles` is available on `push` and `pull_request` events — the agent computes the diff from its checkout, so no `paths:` trigger is required. It is `unavailable` for events with no diff (`schedule`, `tag`, `manual_schedule`), and in the rare case where the diff cannot be computed (e.g. a history deeper than the agent's bounded fetch). Reading `changedFiles` when it is unavailable throws and fails the job, so guard with `changedFilesStatus` first when a rule can run on such events: ```typescript rule('has source changes', (ctx) => { if (ctx.changedFilesStatus !== 'fetched') return true; // no diff — don't gate on files return ctx.changedFiles.some((f) => f.startsWith('src/')); }); ``` The throw is a `ChangedFilesUnavailableError` (exported from `@kici-dev/sdk`, carrying the `changedFilesStatus` and `eventType` that produced it). `evaluateRules()` deliberately re-throws it rather than folding it into a `passed=false` skip, so a path-based gate fails loudly instead of silently mis-evaluating. ### evaluateRules(rules, context, label, onRuleResult?) The agent calls this on your behalf. A workflow does not call it. It lives on `@kici-dev/sdk/internal`, outside semver, and stays exported from the root barrel as `@deprecated` until v1.0.0 — see [deprecations](https://docs.kici.dev/user/deprecations/). It is described here because its return shape is what a rule's outcome looks like in the run log. Evaluate an array of rules sequentially with fail-fast behavior. Stops on the first failure. ```typescript function evaluateRules( rules: Rule[], context: RuleContext, label: string, onRuleResult?: (result: RuleResult) => void, ): Promise; ``` Returns a `RuleEvaluationResult`: ```typescript interface RuleEvaluationResult { allPassed: boolean; results: RuleResult[]; } ``` ### isEventType(event, type) Type guard that narrows an `EventPayload` to a specific event type variant. Use this in rule check functions to get autocomplete on provider-specific fields. ```typescript function isEventType( event: EventPayload, type: T, ): event is Extract; ``` **Example — skip draft PRs:** ```typescript rule('skip-draft-prs', (ctx) => { if (!isEventType(ctx.event, 'pull_request')) return true; // ctx.event is now PullRequestEventPayload — full autocomplete return !ctx.event.payload.pull_request.draft; }); ``` **Example — branch-based rule with push narrowing:** ```typescript rule('only-main-pushes', (ctx) => { if (!isEventType(ctx.event, 'push')) return false; // ctx.event.payload.ref is typed as string return ctx.event.payload.ref === 'refs/heads/main'; }); ``` You can also narrow directly with `if (ctx.event.type === 'pull_request')` — TypeScript's discriminated union narrowing works on the `type` field. ### EventPayload `EventPayload` is a discriminated union over the `type` field. Each variant provides typed access to the normalized event fields and the raw webhook payload. Every variant carries the shared `EventBase` fields — `type`, `action`, `targetBranch`, `sourceBranch`, `provider`, `isForkPR`, `baseBranch`, `senderUsername`, `sourceRepo`, `changedFiles`, and the raw `payload` — plus a per-type `payload` shape for the typed variants. The complete field-by-field schema, including every typed `payload` shape and the shared GitHub object types, is in the [event payload reference](https://docs.kici.dev/user/sdk/event-payloads/). **Typed variants** (with GitHub-specific payload fields): `pull_request`, `push`, `tag`, `comment`, `review`, `review_comment`, `release`, `dispatch`, `create`, `delete`, `status`, `workflow_run`, `fork`, `star`, `watch`. **Generic variants** (payload is `Record`): `webhook`, `kici_event`, `workflow_complete`, `job_complete`, `generic_webhook`, `schedule`, `lifecycle`. ## Matrix Matrix configurations expand a single job into multiple instances, one per parameter combination. Maximum 256 combinations. Combinations must be **unique**. Two combinations that would produce the same instance name — in the simplest case, the same value listed twice — fail the job instead of quietly running it twice. Expansion happens at **dispatch time**: the orchestrator materializes the matrix into N execution jobs — one per combination, each dispatched to its own agent — before any job runs. Each instance receives its combination as `ctx.matrix`. This is identical whether the workflow runs via `kici run --local` or remotely through a webhook trigger, and the dashboard groups the N instances under one parent node. ### Static array (single dimension) ```typescript matrix: ['18', '20', '22']; ``` Creates 3 job instances. In steps, the current value is `matrix.value`: ```typescript step('test', async ({ $, matrix }) => { console.log(matrix!.value); // '18', '20', or '22' }); ``` ### Static object (multi-dimensional) ```typescript matrix: { os: ['linux', 'arm64'], node: ['18', '20'], } ``` Creates 4 job instances (2 x 2). The `os` values (`linux`, `arm64`) are **customer-defined scaler labels** matched by subset semantics against the labels your orchestrator advertises in its scaler `labelSets` — not hosted-runner names. In steps, values are named properties: ```typescript step('test', async ({ $, matrix }) => { console.log(matrix!.os); // 'linux' or 'arm64' console.log(matrix!.node); // '18' or '20' }); ``` ### Dynamic function Compute matrix values at runtime: ```typescript matrix: async ({ $ }) => { const result = await $`ls packages/`; return result.stdout.trim().split('\n'); }; ``` The function receives a `DynamicMatrixContext`: | Property | Type | Description | | -------- | ----------------------------------- | ------------------------- | | `$` | zx shell | Shell executor | | `ctx` | `{ workflow, job }` | Workflow and job metadata | | `log` | `Logger` | Structured logger | | `env` | `Record` | Environment variables | Must return `string[]` (single dimension) or `Record` (multi-dimensional). The contract is enforced at runtime. A function that returns anything else — `undefined` (a missing `return`), a bare string, or an object whose values are not arrays — fails the job with an error naming the job and the expected shape. Numbers and booleans are accepted and converted to strings, since matrix values appear in job names as text. Values must also be unique. A matrix containing the same value twice would produce two children with the same name, so it fails the job instead — de-duplicate the values your function returns (for example, `[...new Set(values)]`). The same rule applies to a static matrix. A dynamic matrix that would expand to an unreasonable number of raw combinations is refused before it is built, so a runaway discovery command fails the job with an error rather than exhausting the agent. A dynamic matrix is resolved at runtime, then materialized into N instances exactly like a static matrix. Because the combinations are not known until the function runs, the 256-combination cap (and the "zero combinations" guard) is enforced at that point: a dynamic matrix that resolves to more than 256 combinations, or to none, fails the job with a matrix-expansion error rather than dispatching. ### Include and exclude Fine-tune matrix combinations on multi-dimensional matrices: ```typescript matrix: { os: ['linux', 'arm64', 'windows'], node: ['18', '20', '22'], }, exclude: [ { os: 'windows', node: '18' }, ], include: [ { os: 'linux', node: '23' }, ], ``` **Exclude** removes combinations matching all specified keys. Applied first. **Include** adds exact combinations. Applied after exclude. Values appear in the expanded job name ordered by their dimension name, alphabetically, whichever order you write the keys in. The include entry above therefore becomes `test (23, linux)` — the `node` value then the `os` value, the same order as its expanded siblings `test (18, linux)` and `test (20, linux)`. That order is also the `byMatrix` key a downstream job reads its outputs under (see [Consuming matrix outputs downstream](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#consuming-matrix-outputs-downstream)), so an include entry whose keys you wrote out of alphabetical order is keyed alphabetically too. Types: ```typescript type MatrixInclude = Record; type MatrixExclude = Record; ``` ### MatrixValues The shape of `matrix` in `StepContext`: ```typescript interface MatrixValues { value?: string; // Single-dimension value [dimension: string]: string | undefined; // Named dimensions } ``` ### Bounding matrix concurrency (maxParallel / failFast) A matrix fan-out runs every combination at once by default. The fan-out-generic `maxParallel` and `failFast` job options bound it the same way they bound a [`runsOnAll`](https://docs.kici.dev/user/sdk/runs-on-all/#rolling-rollout-maxparallel--failfast) host fan-out: ```typescript const test = job('test', { runsOn: 'linux', matrix: { os: ['ubuntu', 'macos', 'windows'] }, maxParallel: 1, // run one combination at a time (sliding window) failFast: true, // stop launching combinations after the first failure run: async (ctx) => { /* ctx.matrix.os */ }, }); ``` `maxParallel` is a sliding window (each combination that finishes releases the next; `1` = serial; must be `>= 1`); `failFast` halts the fan-out on the first failure and skips the held remainder (default `false`). They are ignored on a job with no `matrix` or `runsOnAll`. ### Consuming matrix outputs downstream A downstream job that lists a matrix job in its `needs` receives a **keyed envelope** instead of a flat outputs object, because the upstream produced N sets of outputs (one per combination). `ctx.jobOutputs(matrixJob)` returns a `MatrixJobOutputs`: ```typescript interface MatrixJobOutputs> { /** Keyed by the combination suffix — the text inside `(...)` of the child name. */ byMatrix: Record; /** Last-write-wins flat merge across children, in child (name) order. */ merged: T; } ``` The suffix key matches the child job's display name: `byMatrix['a']` for a single-dimension `['a', 'b']` matrix, and for a multi-dimension combination the values ordered by dimension name — `byMatrix['arm64, linux']` for `{ arch: 'arm64', os: 'linux' }`. Use `isMatrixJobOutputs` (or `'byMatrix' in result`) to discriminate: ```typescript import { isMatrixJobOutputs } from '@kici-dev/sdk'; step('collect', async ({ jobOutputs }) => { const out = jobOutputs(buildMatrixJob); if (isMatrixJobOutputs(out)) { console.log(out.byMatrix['a']); // outputs of the `a` combination console.log(out.merged); // last-write-wins across all combinations } }); ``` The downstream job waits for **all** matrix combinations to terminate before it dispatches. A non-matrix upstream keeps the flat outputs shape. The envelope is identical under `kici run --local` and the remote path. ### Matrix type guards ```typescript import { isStaticArray, isStaticObject, isDynamicFunction } from '@kici-dev/sdk'; isStaticArray(matrix); // true if string[] isStaticObject(matrix); // true if Record isDynamicFunction(matrix); // true if async function ``` ### Matrix expansion utilities The agent expands a matrix for you. A workflow does not call these. They live on `@kici-dev/sdk/internal`, outside semver, and stay exported from the root barrel as `@deprecated` until v1.0.0 — see [deprecations](https://docs.kici.dev/user/deprecations/). They are described here because they define the combinations a matrix job actually produces. ```typescript import { expandMatrix, applyIncludeExclude } from '@kici-dev/sdk/internal'; ``` `expandMatrix(matrix)` takes a string array or an object of string arrays and returns all combinations as `MatrixValues[]`. For a single-dimension array, each value becomes `{ value: '...' }`. For multi-dimensional objects, it produces the Cartesian product. Anything else throws a `MatrixShapeError` naming the expected shape; numbers and booleans inside the values are accepted and converted to strings. `applyIncludeExclude(values, include?, exclude?)` filters an expanded matrix: removes combinations matching any exclude entry, then appends a key-sorted copy of each include entry that is not already present. Returns the filtered `MatrixValues[]`. ## Dynamic jobs Generate jobs at runtime using async factory functions. ### DynamicJobFn ```typescript type DynamicJobFn = (context: DynamicJobContext) => Promise; ``` Receives a `DynamicJobContext`: | Property | Type | Description | | -------------- | ----------------------------------- | ----------------------------------------------------------------------- | | `$` | zx shell | Shell executor | | `ctx` | `{ workflow, event? }` | Workflow metadata and event | | `log` | `Logger` | Structured logger | | `env` | `Record` | Environment variables | | `sourceRepo` | `RepoInfo \| undefined` | Repo whose event triggered the run, when the evaluation has a checkout | | `workflowRepo` | `RepoInfo \| undefined` | Repo that registered the workflow (same repo outside a global workflow) | `RepoInfo` carries `path` — an absolute path to that repo's checkout — plus optional `ref` and `sha`; guard before reading either, since an event that carries no single ref leaves them undefined. In a [global workflow](https://docs.kici.dev/user/global-workflows/) `sourceRepo` and `workflowRepo` are different repos, so one generator can produce a different job set per source repo. **`sourceRepo.path` is not stable across calls.** A generator is invoked once to discover the job set and again to extract the step closures of the job being run; both see the same tree at the same commit, but not necessarily the same path or even the same machine. Read _through_ it, and derive job names from what the tree contains — never from the path itself, or the second call produces different names and the run fails the determinism check. ```typescript const discoverJobs: DynamicJobFn = async ({ $ }) => { const result = await $`ls packages/`; const packages = result.stdout.trim().split('\n'); return packages.map((pkg) => job(`test-${pkg}`, { runsOn: 'linux', steps: [ step('test', async ({ $ }) => { await $`cd packages/${pkg} && pnpm test`; }), ], }), ); }; export default workflow('ci', { jobs: [discoverJobs], }); ``` ### dynamicJob — result-aware generation `dynamicJob(group, fnOrConfig)` tags a generator with a group name (so static jobs can depend on it via `needs: [dynamicGroup('group')]`). It is polymorphic: - **Function form** — event-only, dispatched at webhook time: `dynamicJob('shards', async ({ ctx }) => [...])`. - **Options-object form** — `dynamicJob('reports', { needs, generate })`. With `needs`, it is result-aware: deferred until those upstreams complete, then run with their frozen outputs as `ctx.needs`. - `needs` is optional. Without it the generator is dispatched at webhook time, like the function form. That form is how a generator declares `gitCredentials`, which every job it produces inherits — see [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/#generated-jobs). ```typescript import { workflow, job, step, dynamicJob, dynamicGroup, z } from '@kici-dev/sdk'; // Upstream job A discovers a list of targets at runtime. const discover = job('discover', { runsOn: 'linux', steps: [ step('emit', { outputs: { targets: z.array(z.string()) }, run: async () => ({ targets: ['api', 'web'] }), }), ], }); // Result-aware generator fans out one report job per discovered target. const reports = dynamicJob('reports', { needs: ['discover'], generate: async ({ ctx }) => { const targets = ctx.needs.discover.result.targets; // OutputProxy over discover's outputs return targets.map((target) => job(`report-${target}`, { runsOn: 'linux', run: async ({ log }) => log.info(`reporting on ${target}`), }), ); }, }); export default workflow('discovery-fan-out', { jobs: [discover, reports] }); ``` `ctx.needs` shape: | Need form | `ctx.needs[...]` value | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'jobName'` / `{ name, when }` | `{ result, status }` — `result` is an `OutputProxy` (`ctx.needs..result..`; single-step `run` jobs flatten to `ctx.needs..result.`); `status` is the upstream's terminal status | | `dynamicGroup('g')` / `dynamicGroup('g', { when })` | ordered array of `{ name, result, status }`, one per group member | `ctx.needs` is deterministic — a snapshot of upstream outputs frozen at first eval and replayed unchanged on re-eval, like `ctx.event`. Use result-aware generation for same-run fan-out from a prior job's result; use [`jobComplete()`](https://docs.kici.dev/user/sdk/triggers/) for cross-workflow reactions to a job finishing. See the architecture deep-dive in [dynamic jobs](https://docs.kici.dev/architecture/execution/dynamic-jobs/#result-aware-generation). ### JobOrFactory The `jobs` array in `WorkflowOptions` accepts both static jobs and dynamic generators: ```typescript type JobOrFactory = Job | DynamicJobFn; ``` ### isDynamicJobFn(item) Type guard to distinguish static jobs from dynamic generators: ```typescript function isDynamicJobFn(item: JobOrFactory): item is DynamicJobFn; ``` ```typescript for (const item of workflow.jobs) { if (isDynamicJobFn(item)) { const generatedJobs = await item(context); } else { // item is Job } } ``` --- ## SDK reference: triggers Source: https://docs.kici.dev/user/sdk/triggers/ ## Triggers Triggers define when a workflow runs. KiCI provides 23 trigger types: 16 GitHub webhook triggers and 7 internal/generic triggers for event routing, scheduling, and non-GitHub sources. Each trigger returns a frozen config object with a unique `_tag` discriminator. All triggers use a config object form -- pass an options object to configure the trigger. ### pr() Create a pull request trigger. Returns a frozen `PrTriggerConfig` directly. ```typescript function pr(config?: PrConfigInput): PrTriggerConfig; ``` **Config options:** ```typescript interface PrConfigInput { events?: PrEvent[]; target?: string | RegExp | (string | RegExp)[]; source?: string | RegExp | (string | RegExp)[]; paths?: string[]; // Use '!' prefix for exclusions (e.g., '!docs/**') repos?: string | RegExp | (string | RegExp)[]; // Cross-repo source patterns -- see global-workflows.md description?: string; } ``` **PrEvent values:** `'opened'`, `'synchronize'`, `'reopened'`, `'closed'`, `'assigned'`, `'unassigned'`, `'labeled'`, `'unlabeled'`, `'edited'`, `'converted_to_draft'`, `'ready_for_review'`, `'locked'`, `'unlocked'`, `'review_requested'`, `'review_request_removed'`, `'auto_merge_enabled'`, `'auto_merge_disabled'` **Default events** (when `events` is not specified): `opened`, `synchronize`, `reopened`, `closed` **Examples:** ```typescript // All PRs with default events pr(); // PRs targeting main with path filter pr({ target: 'main', events: ['opened', 'synchronize'], paths: ['src/**'] }); // Regex branch pattern pr({ target: /^release\/v\d+$/ }); ``` ### push() Create a push trigger. Returns a frozen `PushTriggerConfig` directly. ```typescript function push(config?: PushConfigInput): PushTriggerConfig; ``` **Config options:** ```typescript interface PushConfigInput { branches?: string | RegExp | (string | RegExp)[]; tags?: string | RegExp | (string | RegExp)[]; paths?: string[]; // Use '!' prefix for exclusions (e.g., '!docs/**') repos?: string | RegExp | (string | RegExp)[]; // Cross-repo source patterns -- see global-workflows.md description?: string; } ``` **Examples:** ```typescript // Any push push(); // Push to main only push({ branches: 'main' }); // Push with branch and path filters push({ branches: ['main', 'develop'], paths: ['src/**'] }); // Tag pushes push({ tags: ['v*'] }); ``` ### Path filter behavior A `pr()` or `push()` trigger with `paths` matches the event's changed files against your patterns. An available list is matched exactly (an event with no matching change — including a diff-less branch create or delete — does not run). When the diff is **unavailable** (chiefly a universal-git pull-request event, whose webhook carries no diff), path filters match **conservatively** so the workflow runs rather than being silently dropped, and the delivery is recorded as degraded. GitHub always provides an exact list; a transient API error fails loudly, not as empty. ### Content requirements (`requires`) Where `paths` filters on **which files changed**, `requires` filters on **what those files contain**. It is a declarative filter on the `pr()`, `push()`, and `tag()` triggers: a list of queries over the bytes of named source files, read at the event's commit. The orchestrator evaluates it as pure data before dispatching — it reads only the referenced files, never clones the whole repository, and never runs any of your workflow code. A workflow whose `requires` does not pass is not dispatched. Each entry is a `ContentRequirement`: ```typescript interface ContentRequirement { file: string; // repo-relative path to query format?: 'auto' | 'json' | 'yaml' | 'text'; // how to parse the file (default: 'auto') exists?: string[]; // JSONPath expressions that must each resolve to ≥1 node (json/yaml) match?: Record; // JSONPath → expected value; every one must match (json/yaml) not?: Record; // JSONPath → value; passes only when NONE match (json/yaml) contains?: string | string[]; // literal substrings, all of which must appear (text only) notContains?: string | string[]; // literal substrings, none of which may appear (text only) matches?: string | RegExp | (string | RegExp)[]; // regexes, all of which must match (text only) notMatches?: string | RegExp | (string | RegExp)[]; // regexes, none of which may match (text only) ignoreCase?: boolean; // applies to contains/notContains only (default: false) absent?: boolean; // passes only when the file does NOT exist } ``` **Format.** `format: 'auto'` (the default) picks the parser by extension: `.json` → JSON, `.yaml` / `.yml` → YAML, everything else → text. Set `format` explicitly to override — e.g. treat an extensionless file as JSON, or read a `.json` file as raw text. JSON and YAML both parse to an object, so the JSONPath keys (`exists` / `match` / `not`) work identically over either; `text` files are queried by `contains`, `notContains`, `matches`, and `notMatches` over the raw bytes. **Query keys.** - **`exists`** — an array of JSONPath expressions; each must resolve to at least one node in the parsed document. - **`match`** — a JSONPath → expected-value map; every expression must match. An expected value is an exact value, a regex string in `/pattern/flags` form (against a string node), or an array of acceptable values (any one matches). - **`not`** — the same map shape, inverted: the entry passes only when **none** of the expressions match. - **`contains` / `notContains`** — literal substrings tested against the raw file text. Every entry must be present (`contains`) or absent (`notContains`). No escaping needed (text format only). - **`matches`** — one or several regexes (a `RegExp` or `/pattern/flags` string), each of which must match the raw file text (text format only). - **`notMatches`** — the inverse of `matches`: the entry passes only when none of the regexes match (text format only). - **`ignoreCase`** — case-insensitive `contains` / `notContains` only; a regex carries its own flags. Default false. - **`absent: true`** — passes only when the file does **not** exist at the event's commit. It is mutually exclusive with the query keys above. - A bare `{ file }` with no query key requires the file to **exist**. The keys inside one entry are AND-ed, and the entries in a `requires` list are AND-ed with each other. An empty or absent `requires` matches everything, exactly like `paths`. **Examples:** ```typescript // Only run CI when package.json declares a `ci` script. push({ branches: 'main', requires: [{ file: 'package.json', exists: ['$.scripts.ci'] }] }); // Deploy only when the service config enables it (YAML, matched by value). push({ branches: 'main', requires: [{ file: 'service.yaml', match: { '$.deploy.enabled': true } }], }); // Only run when the Dockerfile builds from a Node base image (raw-text regex). pr({ requires: [{ file: 'Dockerfile', format: 'text', matches: '/^FROM node:/m' }] }); // Skip the workflow whenever a repo carries an opt-out marker file. push({ requires: [{ file: '.skip-ci', absent: true }] }); // Combine filters: a tag build that requires a version file AND forbids a draft flag. tag({ patterns: ['v*'], requires: [ { file: 'VERSION', matches: '/^\\d+\\.\\d+\\.\\d+$/' }, { file: 'release.json', not: { '$.draft': true } }, ], }); ``` **Fail-visible evaluation.** Files are read at the event's commit. If a referenced file is larger than **1 MiB**, or fails to parse for its format, the requirement is **indeterminate** — the candidate workflow is dropped and does **not** run. A `requires` that cannot be evaluated never silently passes. **Compile-time validation.** `kici compile` rejects a malformed requirement before it ever reaches the orchestrator: a raw-text key that is invalid or catastrophic (ReDoS-prone) is rejected by a safe-regex check; a text file cannot carry a JSON/YAML query key (`exists` / `match` / `not`) and a JSON/YAML file cannot carry a raw-text key; `absent` cannot be combined with a query key; and an explicit `format` with no query key is rejected as having nothing to check. ### Commit-message filters (`commitMessage`) Where `requires` filters on what the repository's **files** contain, `commitMessage` filters on what the **event** says. It is a declarative filter on the `pr()`, `push()`, and `tag()` triggers. The orchestrator evaluates it directly from the webhook payload: no file is fetched, and no repository is cloned. For an organization-wide workflow it dispatches no evaluation job. It is the cheapest gate available. The text it tests is the **full head-commit message** — subject and body — for `push` and `tag`, and the **title plus body** for pull-request events. ```typescript interface TextMatch { contains?: string | string[]; // every needle must be present notContains?: string | string[]; // no needle may be present matches?: string | RegExp | (string | RegExp)[]; // every regex must match notMatches?: string | RegExp | (string | RegExp)[]; // no regex may match ignoreCase?: boolean; // applies to contains/notContains only (default: false) } ``` **Every entry in a list is a conjunct.** `contains: ['a', 'b']` passes only when the text contains both, and the keys AND together. To express OR, declare two triggers — a workflow's trigger list already matches on the first one that fits: ```typescript // AND — one trigger. push({ commitMessage: { contains: ['release:', 'approved'] } }); // OR — two triggers. on: [ push({ branches: 'main', commitMessage: { contains: 'deploy:' } }), push({ branches: 'main', commitMessage: { contains: 'release:' } }), ]; ``` Needles are **literal substrings** — no glob, no regex, no escaping, so a needle containing `.*` matches only the literal `.*`. Use `matches` / `notMatches` for a pattern; both accept a `RegExp` literal, and the `m` flag reaches the body: ```typescript // The single most common use: skip marker commits. push({ branches: 'main', commitMessage: { notContains: ['[skip ci]', '[ci skip]'] } }); // Ignore dependency-bump noise across an organization. push({ commitMessage: { notMatches: /^chore\(deps\):/ } }); // Require a conventional-commit prefix and forbid a WIP marker. pr({ target: 'main', commitMessage: { matches: /^(feat|fix)\(/, notContains: 'WIP' } }); // Match a trailer in the commit body. push({ commitMessage: { matches: /^Fixes: #\d+$/m } }); ``` `ignoreCase` affects `contains` and `notContains` only — a regex already carries its own flags, so write `/^feat:/i` rather than expecting `ignoreCase` to reach it. **Fail-visible evaluation.** Some events carry no message at all. A branch-deletion push has no head commit, and a self-hosted forge may publish none. The trigger then does **not** match, and the decision trace records it as `indeterminate` rather than as an exclusion. A `commitMessage` filter that cannot be evaluated never silently passes. **Compile-time validation.** `kici compile` rejects a malformed matcher. It refuses: - a matcher with no query key; - an `ignoreCase` that would affect nothing; - an empty needle list; - an empty-string needle (it would match every text); - a regex that is invalid or catastrophic (ReDoS-prone). ### tag() Create a tag trigger. Returns a frozen `TagTriggerConfig`. ```typescript function tag(config?: TagConfigInput): TagTriggerConfig; ``` **Config options:** `patterns` (string/RegExp/array), `description` ```typescript tag(); // Any tag tag({ patterns: ['v*'] }); // Semver tags tag({ patterns: /^v\d+\.\d+$/ }); // Regex match ``` ### comment() Create an issue/PR comment trigger. Returns a frozen `CommentTriggerConfig`. ```typescript function comment(config?: CommentConfigInput): CommentTriggerConfig; ``` **Config options:** `actions` (created/edited/deleted), `source` (issue/pr), `bodyMatch` (string or RegExp), `description` ```typescript comment(); // Any comment comment({ bodyMatch: '/deploy' }); // Glob match on body comment({ bodyMatch: /^\/deploy/i }); // Regex match on body comment({ source: 'pr', actions: ['created'] }); // PR comments only ``` ### review() Create a pull request review trigger. Returns a frozen `ReviewTriggerConfig`. ```typescript function review(config?: ReviewConfigInput): ReviewTriggerConfig; ``` **Config options:** `actions` (submitted/edited/dismissed), `states` (approved/changes_requested/commented/dismissed), `description` ```typescript review(); // Any review review({ states: ['approved'] }); // Approvals only review({ actions: ['submitted'], states: ['approved'] }); // Submitted approvals ``` ### reviewComment() Create a PR review comment trigger. Returns a frozen `ReviewCommentTriggerConfig`. ```typescript function reviewComment(config?: ReviewCommentConfigInput): ReviewCommentTriggerConfig; ``` **Config options:** `actions` (created/edited/deleted), `description` ```typescript reviewComment(); // Any review comment reviewComment({ actions: ['created'] }); // New review comments only ``` ### release() Create a release trigger. Returns a frozen `ReleaseTriggerConfig`. ```typescript function release(config?: ReleaseConfigInput): ReleaseTriggerConfig; ``` **Config options:** `actions` (published/unpublished/created/edited/deleted/prereleased/released), `description` ```typescript release(); // Any release event release({ actions: ['published'] }); // Published releases only ``` ### dispatch() Create a repository_dispatch trigger. Returns a frozen `DispatchTriggerConfig`. ```typescript function dispatch(config?: DispatchConfigInput): DispatchTriggerConfig; ``` **Config options:** `types` (string[]), `description`, `inputs` (typed dispatch inputs map) ```typescript dispatch(); // Any dispatch dispatch({ types: ['deploy', 'rollback'] }); // Specific event types ``` #### Typed dispatch inputs A `dispatch()` trigger can declare a typed `inputs` schema. Operators supply values with `kici run remote --input key=value`; KiCI validates, coerces, defaults, and exposes them to steps and rules as `ctx.dispatchInputs`. The values are validated on the orchestrator from the compiled lock file — a missing required input or a bad value is rejected before any agent runs, without cloning the repository. ```typescript import { workflow, job, step, dispatch, defineDispatchInputs, z } from '@kici-dev/sdk'; const inputs = defineDispatchInputs({ target: z.string().optional(), skipCveScan: z.boolean().default(false), skipCveScanReason: z.string().min(1).optional(), mode: z.enum(['full', 'edge-only']).default('full'), retries: z.number().int().min(0).max(10).default(3), }); export default workflow('deploy-prod', { on: dispatch({ types: ['deploy-prod'], inputs }), jobs: [ job('gates', { runsOn: 'kici:group:ops', steps: [ step('cve-gate', async (ctx) => { const i = inputs.from(ctx); // fully typed per declared key if (i.skipCveScan) { ctx.log.warn(`CVE gate skipped: ${i.skipCveScanReason ?? '(no reason)'}`); return; } await ctx.$`pnpm scan:cve:gate`; }), ], }), ], }); ``` - **`defineDispatchInputs(map)`** is the single declaration site. It returns a handle that `dispatch({ inputs })` accepts and exposes `inputs.from(ctx)` — a typed reader over `ctx.dispatchInputs`, typed per declared key. `dispatch({ inputs })` also accepts a bare `{ name: schema }` map directly when you don't need the reader. - **`ctx.dispatchInputs`** is always present (a validated map of `string | number | boolean | null`), distinct from `ctx.inputs` (typed outputs from `needs` dependencies). Rules see the same values via `ctx.dispatchInputs`, so `skipUnless(ctx => !ctx.dispatchInputs.skipCveScan)` works. - **Defaults are applied once**, on the orchestrator (the authoritative side); the CLI pre-validates `--input` for fast feedback and forwards the raw operator pairs. **Allowed input types (closed subset):** `z.string()`, `z.number()`, `z.boolean()`, `z.enum([...])`, `z.literal(v)`, with the modifiers `.optional()`, `.nullable()`, `.default(v)`, `.min(n)`, `.max(n)`, `.regex(re)`, `.int()`. Anything outside this set (`.refine()`, `.transform()`, `.pipe()`, `z.object()`, `z.array()`, `z.union()`, `z.record()`, `z.coerce.*`) is a **compile error** — the closed set is what guarantees the schema survives the trip to the orchestrator's lock file without silently dropping any validation. CLI strings are coerced for you (`--input retries=3` becomes the number `3`; booleans accept `true`/`false`/`1`/`0`/`yes`/`no`), so author your schema with clean types. ### create() Create a ref creation trigger (branches/tags). Returns a frozen `CreateTriggerConfig`. ```typescript function create(config?: CreateConfigInput): CreateTriggerConfig; ``` **Config options:** `refTypes` (branch/tag), `patterns` (string/RegExp/array), `description` ```typescript create(); // Any ref creation create({ refTypes: ['tag'], patterns: ['v*'] }); // Tag creation only ``` ### delete() Create a ref deletion trigger (branches/tags). Returns a frozen `DeleteTriggerConfig`. Note: Since `delete` is a JavaScript reserved word, import as `del`: `import { delete as del } from '@kici-dev/sdk'` ```typescript function del(config?: DeleteConfigInput): DeleteTriggerConfig; ``` **Config options:** `refTypes` (branch/tag), `patterns` (string/RegExp/array), `description` ```typescript del(); // Any ref deletion del({ refTypes: ['branch'], patterns: ['temp/*'] }); // Temp branch cleanup ``` ### status() Create a commit status trigger. Returns a frozen `StatusTriggerConfig`. ```typescript function status(config?: StatusConfigInput): StatusTriggerConfig; ``` **Config options:** `contexts` (picomatch strings like 'ci/\*'), `states` (error/failure/pending/success), `description` ```typescript status(); // Any status status({ contexts: ['ci/*'], states: ['success'] }); // CI success ``` ### workflowRun() Create a workflow_run trigger. Returns a frozen `WorkflowRunTriggerConfig`. ```typescript function workflowRun(config?: WorkflowRunConfigInput): WorkflowRunTriggerConfig; ``` **Config options:** `actions` (requested/completed/in_progress), `workflows` (name filters), `conclusions` (success/failure/cancelled), `description` ```typescript workflowRun(); // Any workflow run workflowRun({ workflows: ['CI'], actions: ['completed'], conclusions: ['success'] }); ``` ### fork() Create a fork trigger. No filter fields. Returns a frozen `ForkTriggerConfig`. ```typescript function fork(config?: ForkConfigInput): ForkTriggerConfig; ``` ```typescript fork(); // Any fork event fork({ description: 'Track forks' }); // With description ``` ### star() Create a star trigger. Returns a frozen `StarTriggerConfig`. ```typescript function star(config?: StarConfigInput): StarTriggerConfig; ``` **Config options:** `actions` (created/deleted), `description` ```typescript star(); // Any star event star({ actions: ['created'] }); // New stars only ``` ### watch() Create a watch trigger. Returns a frozen `WatchTriggerConfig`. ```typescript function watch(config?: WatchConfigInput): WatchTriggerConfig; ``` **Config options:** `actions` (started), `description` ```typescript watch(); // Any watch event watch({ actions: ['started'] }); // Watch started only ``` ### webhook() Create a catch-all webhook trigger for any GitHub event. Returns a frozen `WebhookTriggerConfig`. Unlike other triggers, `events` is **required** -- catch-all must specify what to catch. ```typescript function webhook(config: WebhookConfigInput): WebhookTriggerConfig; ``` **Config options:** `events` (required string[]), `actions` (optional string[]), `repos` (optional cross-repo source patterns -- see [global workflows](https://docs.kici.dev/user/global-workflows/)), `description` ```typescript webhook({ events: ['deployment'] }); // Deployment events webhook({ events: ['deployment', 'deployment_status'] }); // Multiple events webhook({ events: ['deployment'], actions: ['created'] }); // With action filter ``` #### Cross-source delivery A `webhook()` trigger fires whenever a matching event arrives via **any inbound webhook source within the same org**, not just the source the workflow's repository is bound to. If your repo is registered through a github source and a separate generic source in the same org POSTs an event with a matching name, the workflow still runs. Two important rules govern the cross-source path: 1. **The registration's source owns dispatch credentials.** The runtime clone, auth, and check-status posting come from the source the workflow was registered with (via its default-branch push), never from the inbound source. A generic webhook fanning out to a github-registered workflow uses the github bundle's clone token provider — the generic source contributes only the event payload. 2. **Org isolation is structural.** A webhook delivered to org A can never trigger a workflow registered against org B. The lookup index is keyed on `(customerId, eventName)` so cross-org leakage is impossible. The orchestrator emits `kici_cross_source_fanout_size` (histogram) per inbound webhook so operators can observe how many workflows each event reaches. ### Event triggers The following 7 trigger types support internal event routing, scheduling, lifecycle orchestration, and non-GitHub webhook sources. ### kiciEvent() Create a custom event trigger. Fires when a named internal event is emitted from a workflow step via `ctx.emit()`. Returns a frozen `KiciEventTriggerConfig`. ```typescript function kiciEvent(config: KiciEventConfigInput): KiciEventTriggerConfig; ``` **Config options:** ```typescript interface KiciEventConfigInput { name: string; // Required: event name to listen for match?: Record; // JSONPath payload matching (e.g., { '$.env': 'prod' }) not?: Record; // Negative JSONPath filter source?: string; // Cross-repo source filter (e.g., 'org/infra-repo') description?: string; } ``` ```typescript kiciEvent({ name: 'deploy-complete' }); // Match by name kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }); // With payload filter kiciEvent({ name: 'deploy-complete', not: { '$.env': 'staging' } }); // Negative filter kiciEvent({ name: 'deploy-complete', source: 'org/infra-repo' }); // Cross-repo ``` ### workflowComplete() Create a workflow completion trigger. Fires automatically when another workflow finishes execution. Returns a frozen `WorkflowCompleteTriggerConfig`. ```typescript function workflowComplete(config?: WorkflowCompleteConfigInput): WorkflowCompleteTriggerConfig; ``` **Config options:** ```typescript interface WorkflowCompleteConfigInput { name?: string; // Filter by workflow name status?: WorkflowCompleteStatus[]; // Filter by completion status source?: string; // Cross-repo source filter description?: string; } type WorkflowCompleteStatus = 'success' | 'failed' | 'cancelled'; ``` ```typescript workflowComplete(); // Any workflow completion workflowComplete({ name: 'CI' }); // Specific workflow workflowComplete({ name: 'CI', status: ['success'] }); // Success only workflowComplete({ name: 'CI', status: ['success'], source: 'org/repo' }); // Cross-repo ``` ### workflowsFailedBatch() Create a batched failure trigger. Instead of firing once per failed workflow, it accumulates every failed workflow completion over a time window and fires the subscribing workflow **once** with the whole list — so a mass incident (a bad deploy failing hundreds of runs at once) notifies a single time, not once per failure. Returns a frozen `WorkflowsFailedBatchTriggerConfig`. ```typescript function workflowsFailedBatch( config: WorkflowsFailedBatchConfigInput, ): WorkflowsFailedBatchTriggerConfig; ``` **Config options:** ```typescript interface WorkflowsFailedBatchConfigInput { accumulateFor: number; // Accumulation window in milliseconds (opens on the first failure) name?: string; // Filter by failed workflow name source?: string; // Cross-repo source filter description?: string; } ``` The first failure inside the window opens it; when the window closes, the subscribing workflow is dispatched once. The batch is delivered on `ctx.event.payload`: ```typescript // ctx.event.payload for a workflowsFailedBatch dispatch: // { // total: number, // total failures in the window // runs: Array<{ // the failed runs (bounded — the first 200) // runId: string; // repo: string; // workflowName: string; // failureClass?: string; // why the run failed // senderUsername?: string; // triggering actor, when known // }>, // } ``` ```typescript workflowsFailedBatch({ accumulateFor: 10000 }); // One notification per 10s burst of failures workflowsFailedBatch({ accumulateFor: 30000, name: 'CI' }); // Only CI failures workflowsFailedBatch({ accumulateFor: 30000, source: 'org/repo' }); // Cross-repo source filter ``` A workflow dispatched by a failure trigger (`workflowsFailedBatch`, or `workflowComplete({ status: ['failed'] })`) never re-triggers the same batch on its own failure — a notifier that itself fails cannot loop. ### jobComplete() Create a job completion trigger. Fires automatically when a specific job within a workflow finishes. Returns a frozen `JobCompleteTriggerConfig`. ```typescript function jobComplete(config?: JobCompleteConfigInput): JobCompleteTriggerConfig; ``` **Config options:** ```typescript interface JobCompleteConfigInput { workflow?: string; // Filter by workflow name job?: string; // Filter by job name status?: JobCompleteStatus[]; // Filter by completion status source?: string; // Cross-repo source filter description?: string; } type JobCompleteStatus = 'success' | 'failed' | 'cancelled' | 'skipped'; ``` ```typescript jobComplete(); // Any job completion jobComplete({ workflow: 'CI', job: 'build' }); // Specific workflow + job jobComplete({ workflow: 'CI', job: 'build', status: ['success'] }); // Success only jobComplete({ workflow: 'CI', job: 'build', source: 'org/repo' }); // Cross-repo ``` `jobComplete()` starts a **new** workflow run that reacts to another job finishing (gated on the prior job's status). For same-run fan-out — generating follow-up jobs from a prior job's _outputs_ within the same run — use a result-aware [`dynamicJob(group, { needs, generate })`](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#dynamicjob--result-aware-generation) instead. ### genericWebhook() Create a generic webhook trigger. Fires when a non-GitHub webhook is received from an external source configured via the admin API. Returns a frozen `GenericWebhookTriggerConfig`. ```typescript function genericWebhook(config: GenericWebhookConfigInput): GenericWebhookTriggerConfig; ``` **Config options:** ```typescript interface GenericWebhookConfigInput { source: string; // Required: must match `--name` from `kici-admin source add generic` events?: string[]; // Filter by event types match?: Record; // JSONPath payload matching not?: Record; // Negative JSONPath filter auth?: GenericWebhookAuth; // HMAC or API key authentication path?: string; // URL path pattern (replaces source for URL matching) description?: string; } ``` ```typescript genericWebhook({ source: 'argocd' }); // Any event from ArgoCD genericWebhook({ source: 'argocd', events: ['deploy.success'] }); // Specific events genericWebhook({ source: 'argocd', match: { '$.env': 'prod' } }); // With payload filter genericWebhook({ source: 'argocd', not: { '$.dry_run': true } }); // Negative filter genericWebhook({ source: 'stripe', auth: { method: 'hmac-sha256', secret: 'stripe-key', signatureHeader: 'stripe-signature' }, }); // HMAC auth genericWebhook({ source: 'slack', auth: { method: 'api-key', secret: 'slack-token' } }); // API key auth genericWebhook({ source: 'stripe', path: 'stripe/payments' }); // URL path pattern ``` ### schedule() Create a cron-based schedule trigger. Returns a frozen `ScheduleTriggerConfig`. ```typescript function schedule(config: ScheduleConfigInput): ScheduleTriggerConfig; ``` **Config options:** ```typescript interface ScheduleConfigInput { cron: string; // Required: cron expression (5-field) timezone?: string; // Timezone for cron evaluation (default: 'UTC') description?: string; // Human-readable description of the schedule inputs?: DispatchInputsMap; // Optional: defaults-only typed inputs (see below) } ``` ```typescript schedule({ cron: '0 * * * *' }); // Every hour schedule({ cron: '0 0 * * *' }); // Daily at midnight UTC schedule({ cron: '0 9 * * 1', timezone: 'America/New_York' }); // Monday 9am ET schedule({ cron: '*/15 * * * *', description: 'health check every 15 min' }); ``` A workflow may declare **multiple** `schedule()` triggers. Each schedule is evaluated and fired independently — a `Monday 9am` schedule and a `Friday 6pm` schedule on the same workflow both run at their own times: ```typescript on: [schedule({ cron: '0 9 * * 1' }), schedule({ cron: '0 18 * * 5' })]; ``` #### Schedule inputs (defaults-only) A `schedule()` trigger may declare typed `inputs`. A cron or dashboard "run now" fire carries **no operator-supplied values**, so each input resolves from its declared **default** and is exposed to steps and rules as `ctx.dispatchInputs` — the same surface as [typed dispatch inputs](https://docs.kici.dev/user/sdk/triggers/#typed-dispatch-inputs). Because there is no operator to supply a value, every schedule input must declare a `.default()` **or** be `.optional()`. An input that is neither is rejected at `kici compile` time. ```typescript import { workflow, job, schedule, z } from '@kici-dev/sdk'; export default workflow('nightly', { on: schedule({ cron: '0 3 * * *', inputs: { mode: z.enum(['full', 'quick']).default('full') }, }), jobs: [ job('build', { runsOn: 'default', run: async (ctx) => { ctx.log(`mode = ${ctx.dispatchInputs.mode}`); // "full" on every fire }, }), ], }); ``` You can also share a typed handle via `defineDispatchInputs(...)` and read it back with `.from(ctx)`, exactly as with `dispatch()`. The allowed input types are the same closed subset documented under [typed dispatch inputs](https://docs.kici.dev/user/sdk/triggers/#typed-dispatch-inputs). ### lifecycle() Create a lifecycle trigger for cross-workflow orchestration events. Returns a frozen `LifecycleTriggerConfig`. ```typescript function lifecycle(config: LifecycleConfigInput): LifecycleTriggerConfig; ``` **Config options:** ```typescript interface LifecycleConfigInput { events: LifecycleEvent[]; // Required: lifecycle events to listen for sources?: string[]; // Optional: filter by source repo (e.g., 'org/repo') description?: string; // Human-readable description } type LifecycleEvent = 'workflow_complete' | 'job_complete' | 'job_failed' | 'registration_updated'; ``` ```typescript lifecycle({ events: ['workflow_complete'] }); // Any workflow completion lifecycle({ events: ['job_failed'], sources: ['org/deploy-repo'] }); // Job failures from specific repo lifecycle({ events: ['registration_updated'] }); // Workflow registration changes ``` ### Branch patterns Both `pr()` and `push()` (as well as `tag()`, `create()`, and `delete()`) accept glob strings and RegExp literals for pattern matching: ```typescript // Glob patterns (micromatch syntax) pr({ target: ['main', 'release/*', 'feature/**'] }); // Regex patterns pr({ target: /^release\/v\d+\.\d+$/ }); // Mixed push({ branches: ['main', /^hotfix\//] }); ``` Glob patterns use micromatch syntax. Regex patterns use standard JavaScript `RegExp`. --- ## SDK reference: validation & events Source: https://docs.kici.dev/user/sdk/validation-events/ ## Validation ### validateDag(nodes) Validate a directed acyclic graph for correctness. ```typescript function validateDag(nodes: DagNode[]): DagValidationResult; ``` **DagNode:** ```typescript interface DagNode { id: string; needs: string[]; } ``` **DagValidationResult** (discriminated union): ```typescript // Valid graph with topological sort order { valid: true; sortedOrder: string[] } // Cycle detected { valid: false; error: 'cycle'; nodesInCycle: string[] } // Job depends on itself { valid: false; error: 'self-reference'; nodeId: string } // Job depends on non-existent job { valid: false; error: 'missing-dependency'; nodeId: string; missingDep: string } ``` Checks (in order): self-references, missing dependencies, cycles (Kahn's algorithm). ```typescript const result = validateDag([ { id: 'lint', needs: [] }, { id: 'test', needs: ['lint'] }, { id: 'deploy', needs: ['test'] }, ]); if (result.valid) { console.log(result.sortedOrder); // ['lint', 'test', 'deploy'] } ``` ## Event definitions The `defineEvent()` helper creates typed event definitions with Zod validation schemas. Event definitions serve as contracts for custom event payloads used with `ctx.emit()` and `kiciEvent()`. ### defineEvent(name, schema) ```typescript function defineEvent(name: string, schema: T): EventDefinition; ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | ----------- | -------- | --------------------------------- | | `name` | `string` | yes | Unique event name | | `schema` | `z.ZodType` | yes | Zod schema for payload validation | **Returns:** `EventDefinition` -- a frozen event definition with `name` and `schema`. ```typescript import { defineEvent, z } from '@kici-dev/sdk'; const deployComplete = defineEvent( 'deploy-complete', z.object({ env: z.string(), version: z.string(), services: z.array(z.string()), }), ); ``` The `z` (Zod) module is re-exported from `@kici-dev/sdk` so you can define event schemas without adding Zod as a direct dependency. ## Emitting events Workflow steps can emit custom events via `ctx.emit()`. Emitted events are delivered immediately (mid-workflow, not queued until completion) and can trigger other workflows that listen with `kiciEvent()`, `workflowComplete()`, or `jobComplete()` triggers. ### ctx.emit(eventName, payload?, options?) ```typescript // Typed — a defineEvent() definition drives payload type-checking emit( definition: EventDefinition, payload: z.infer, options?: EventEmitOptions, ): Promise<{ deliveryId: string }>; // Ad-hoc — event name string, payload typed as Record emit( eventName: string, payload?: Record, options?: EventEmitOptions, ): Promise<{ deliveryId: string }>; ``` **Parameters:** | Parameter | Type | Required | Description | | ---------------- | ------------------------- | -------- | ---------------------------------------------------------------------------- | | `definition` | `EventDefinition` | — | A `defineEvent()` definition; its Zod schema types `payload` as `z.infer` | | `eventName` | `string` | yes | Name of the event to emit | | `payload` | `Record` | no | Event payload data | | `options.target` | `{ repos?: string[] }` | no | Target specific repos for cross-repo delivery | **Returns:** `Promise<{ deliveryId: string }>` -- a delivery receipt after the event is persisted and routed. **Examples:** ```typescript // Typed emit — payload is checked against the deploy-complete schema import { defineEvent, z } from '@kici-dev/sdk'; const deployComplete = defineEvent( 'deploy-complete', z.object({ env: z.string(), version: z.string() }), ); step('notify-typed', async (ctx) => { await ctx.emit(deployComplete, { env: 'prod', version: '1.2.3' }); }); // Emit a simple event step('notify', async (ctx) => { await ctx.emit('deploy-complete', { env: 'prod', version: '1.2.3' }); }); // Cross-repo targeting step('notify-other-repos', async (ctx) => { await ctx.emit( 'deploy-complete', { env: 'prod' }, { target: { repos: ['org/other-repo', 'org/monitoring'] }, }, ); }); ``` ### Cross-repo event delivery Events emitted from one repo can trigger workflows in another repo, provided: 1. A trust relationship exists between the source and target repos (configured via the admin API) 2. The target workflow uses a trigger with `source` filter matching the emitting repo ```typescript // In repo A: emit event step('deploy', async (ctx) => { await ctx.emit( 'deploy-complete', { env: 'prod' }, { target: { repos: ['org/repo-B'] }, }, ); }); // In repo B: listen for event from repo A workflow('post-deploy', { on: kiciEvent({ name: 'deploy-complete', source: 'org/repo-A' }), jobs: [postDeployJob], }); ``` ### System events The orchestrator automatically emits system events for workflow and job completions. You do not need to call `ctx.emit()` for these -- they are generated by the orchestrator after execution. Listen for them with `workflowComplete()` and `jobComplete()` triggers. ### Event scaler events The [event scaler backend](https://docs.kici.dev/operator/orchestrator/event-scaler/) emits two reserved events that your provisioning and teardown workflows subscribe to. The SDK exports their names and payload schemas, so a workflow imports the contract instead of re-declaring it. | Export | What it is | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `SCALER_EVENT_NAMES` | The two reserved event names: `SCALER_EVENT_NAMES.scaleUp` and `SCALER_EVENT_NAMES.scaleDown`. | | `ScalerScaleUpPayload` | Schema of the scale-up payload. `.parse(ctx.rawPayload)` returns the typed payload and rejects a malformed one. | | `ScalerScaleDownPayload` | Schema of the scale-down payload, including the narrowed `reason`. | | `ScaleDownReason` | Why the scaler asked for a teardown: `idle`, `job-complete`, `heartbeat-timeout`, `spawn-timeout`, `drain`, `shutdown`. | ```ts import { workflow, job, kiciEvent, SCALER_EVENT_NAMES, ScalerScaleUpPayload } from '@kici-dev/sdk'; export default workflow('provision', { on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleUp, match: { '$.scalerName': 'hetzner' } })], jobs: [ job('provision', { runsOn: ['kici:os:linux'], run: async (ctx) => { const payload = ScalerScaleUpPayload.parse(ctx.rawPayload); ctx.log.info(`provision agent ${payload.agentId}`); }, }), ], }); ``` These names are reserved. `ctx.emit()` rejects any event name that starts with `kici.`, so a workflow step cannot forge a scaler event. For every payload field, see the [event contract reference](https://docs.kici.dev/operator/orchestrator/event-scaler-events/). For complete provisioning and teardown workflows, see [autoscaling workflows](https://docs.kici.dev/user/workflows/autoscaling-workflows/). --- ## SDK reference Source: https://docs.kici.dev/user/sdk-reference/ Reference documentation for `@kici-dev/sdk`. The reference is split across the per-topic pages below. | Page | Covers | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Core](https://docs.kici.dev/user/sdk/core/) | `workflow()`, `job()`, `step()` factory functions and step / job authoring patterns (bare functions, output chaining, `needs`, dynamic groups). | | [Triggers](https://docs.kici.dev/user/sdk/triggers/) | All 23 trigger factories -- GitHub events (`pr`, `push`, `tag`, `comment`, ...), event triggers (`kiciEvent`, `workflowComplete`, `workflowsFailedBatch`, `jobComplete`), `genericWebhook`, `schedule`, `lifecycle`, plus branch-pattern semantics. | | [Rules, matrix, dynamic jobs](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/) | `rule()`, `skip()`, matrix builds (static + dynamic), and `dynamicJob()` / `dynamicGroup()`. | | [runsOnAll host fan-out](https://docs.kici.dev/user/sdk/runs-on-all/) | `runsOnAll` — fan one job out to every matching connected host, one pinned execution per host, with exact / glob / regex host selectors. | | [Caching](https://docs.kici.dev/user/sdk/caching/) | `CacheSpec`, declarative `cache` on jobs/steps, imperative `ctx.cache.restore()` / `ctx.cache.save()`, immutable keys, `restoreKeys` prefix fallback, per-org + per-ref isolation. | | [Artifacts](https://docs.kici.dev/user/sdk/artifacts/) | `ctx.artifacts` — share named, durable build outputs between jobs of a run and download them from the run page. | | [Validation & events](https://docs.kici.dev/user/sdk/validation-events/) | `validateDag()`, `defineEvent()`, event emission patterns. | | [Runtime](https://docs.kici.dev/user/sdk/runtime/) | Types index, `StepContext`, secrets, and fixtures. | | [Temp directories](https://docs.kici.dev/user/sdk/temp-directories/) | `ctx.mktemp()` / `ctx.mktempFile()` — allocate job-scoped scratch dirs and files that are cleaned up automatically when the job ends. | | [Event payload reference](https://docs.kici.dev/user/sdk/event-payloads/) | Generated schema of the normalized event envelope passed to rules and dynamic functions. | | [Idempotent helpers](https://docs.kici.dev/user/sdk/idempotent/) | `idempotent()`, `idempotentStep()`, and the check-mode-aware `checkStep()` — check / apply pattern with typed results on both the skipped and applied branches. | | [Wait-for helpers](https://docs.kici.dev/user/sdk/wait-for/) | `waitFor()` and `waitForStep()` — poll a condition on an interval, run an optional success action, recover gracefully on timeout. | | [Parallel steps](https://docs.kici.dev/user/sdk/parallel/) | `parallel()` — run independent steps concurrently within one job behind a join barrier, each as its own observable step, with `failFast` and `maxParallel` controls. | The `@kici-dev/sdk` package re-exports the entire surface from a single entry point. Pick what you need: ```typescript import { workflow, job, step, pr, push, rule, defineEvent } from '@kici-dev/sdk'; ``` For the complete list of every named export (factory functions, triggers, rules, validation, hook factories, types), see the per-topic pages above. ## `@kici-dev/sdk/internal` is not a supported surface The package also publishes an `@kici-dev/sdk/internal` subpath. It carries the runtime contract between the SDK and the KiCI agent. Those are the functions that install the maps a `.result` proxy reads, build the step context your workflow body receives, evaluate its rules, and expand its matrix. The agent drives all of it on your behalf. It is **not covered by semver** and may change shape in any release. Do not import it from a workflow. Everything a workflow author needs is on the root entry point above. Those same symbols are also still exported from the root barrel, marked `@deprecated`, so an older SDK in a repository keeps working. They are removed from the root at v1.0.0 — see [deprecations](https://docs.kici.dev/user/deprecations/). ## See also - [Getting started](https://docs.kici.dev/user/getting-started/) -- install the SDK, write your first workflow, test locally - [CLI reference](https://docs.kici.dev/user/cli-reference/) -- compile, test, and manage workflows from the command line - [Workflow patterns](https://docs.kici.dev/user/workflow-patterns/) -- common patterns using the SDK features documented above - [Secrets management (operator)](https://docs.kici.dev/operator/security/secrets/) -- configure encrypted secret storage and admin API - [Secrets architecture](https://docs.kici.dev/architecture/security/secrets/) -- encryption model, multi-backend, and data flow - [Execution status vocabulary](https://docs.kici.dev/architecture/execution/state-machine/) -- run/job/step statuses and terminal states ---