KB LabsDocs

Patterns

Last updated April 7, 2026


Copy-paste recipes for common workflow shapes.

End-to-end examples you can start from. Every pattern on this page is a complete workflow spec — change the names and handlers to fit your case.

If you're looking for reference material (every field, every validation rule), go to Spec Reference. This page is for building things.

CI pipeline: build, test, lint in parallel

Classic fan-out. build runs first, then test and lint run in parallel, then the run is done.

JSON
{
  "name": "ci",
  "version": "1",
  "on": { "push": true, "manual": true },
  "env": { "CI": "true" },
  "jobs": {
    "build": {
      "runsOn": "sandbox",
      "isolation": "balanced",
      "timeoutMs": 600000,
      "steps": [
        { "name": "Install", "uses": "builtin:shell", "with": { "command": "pnpm install --frozen-lockfile" } },
        { "name": "Build",   "uses": "builtin:shell", "with": { "command": "pnpm build" } }
      ],
      "artifacts": { "produce": ["dist"] }
    },
    "test": {
      "runsOn": "sandbox",
      "needs": ["build"],
      "artifacts": { "consume": ["dist"] },
      "retries": { "max": 2, "backoff": "exp", "initialIntervalMs": 5000 },
      "steps": [
        { "name": "Test", "uses": "builtin:shell", "with": { "command": "pnpm test" } }
      ]
    },
    "lint": {
      "runsOn": "sandbox",
      "needs": ["build"],
      "artifacts": { "consume": ["dist"] },
      "steps": [
        { "name": "Lint", "uses": "builtin:shell", "with": { "command": "pnpm lint" } }
      ]
    }
  }
}

What makes this work:

  • test and lint both needs: ['build'], so they're ready at the same time and the scheduler runs them in parallel.
  • Retries are scoped to test only, because lint failures are not transient.
  • produce/consume guarantees both downstream jobs see the build output, independent of needs.

CI/CD: build → test → manual approval → deploy

Adds a human gate before production deploy. Only runs on manual trigger by authorized actors.

JSON
{
  "name": "ci-cd",
  "version": "1",
  "on": { "push": true, "manual": true },
  "jobs": {
    "build":  { "runsOn": "sandbox", "steps": [...], "artifacts": { "produce": ["dist"] } },
    "test":   { "runsOn": "sandbox", "needs": ["build"], "artifacts": { "consume": ["dist"] }, "steps": [...] },
 
    "deploy-staging": {
      "runsOn": "sandbox",
      "needs": ["test"],
      "secrets": ["STAGING_DEPLOY_KEY"],
      "steps": [
        { "name": "Deploy to staging", "uses": "plugin:release:deploy", "with": { "env": "staging" } }
      ]
    },
 
    "approve-prod": {
      "runsOn": "local",
      "needs": ["deploy-staging"],
      "if": "${{ trigger.type == 'manual' && contains(trigger.actor, '@company.com') }}",
      "steps": [
        {
          "name": "Approve prod deploy",
          "id": "approve",
          "uses": "builtin:approval",
          "with": {
            "title": "Deploy to production?",
            "context": {
              "version":  "${{ trigger.payload.version }}",
              "staging":  "https://staging.example.com"
            },
            "instructions": "Verify the staging deploy before approving.",
            "timeoutMs": 3600000
          }
        }
      ]
    },
 
    "deploy-prod": {
      "runsOn": "sandbox",
      "needs": ["approve-prod"],
      "concurrency": { "group": "prod-deploy" },
      "secrets": ["PROD_DEPLOY_KEY"],
      "steps": [
        { "name": "Deploy to production", "uses": "plugin:release:deploy", "with": { "env": "production" } }
      ],
      "hooks": {
        "onSuccess": [
          { "name": "Tag release",    "uses": "plugin:release:tag" },
          { "name": "Notify success", "uses": "plugin:notify:slack", "with": { "message": "Prod deploy complete" } }
        ],
        "onFailure": [
          { "name": "Rollback",       "uses": "plugin:release:rollback" },
          { "name": "Notify failure", "uses": "plugin:notify:slack", "with": { "message": "Prod deploy FAILED" } }
        ]
      }
    }
  }
}

What makes this work:

  • approve-prod runs on local (no sandbox needed for a pause).
  • deploy-prod.concurrency guarantees only one prod deploy at a time, across all runs.
  • Success/failure hooks decouple notifications and rollback from the main deploy step.
  • Approval has a 1-hour timeout so an abandoned request doesn't block other runs forever.

Scheduled maintenance

Runs nightly, does cleanup, alerts only on failure.

JSON
{
  "name": "nightly-maintenance",
  "version": "1",
  "on": {
    "schedule": { "cron": "0 3 * * *", "timezone": "Europe/London" },
    "manual":   true
  },
  "jobs": {
    "cleanup": {
      "runsOn": "sandbox",
      "timeoutMs": 1800000,
      "steps": [
        { "name": "Rotate logs",    "uses": "builtin:shell", "with": { "command": "./scripts/rotate-logs.sh" } },
        { "name": "GC workspaces",  "uses": "plugin:devkit:gc-workspaces" },
        { "name": "Vacuum db",      "uses": "plugin:admin:vacuum-db" }
      ],
      "hooks": {
        "onFailure": [
          { "name": "Alert on-call", "uses": "plugin:notify:pagerduty", "with": { "severity": "warning" } }
        ]
      }
    }
  }
}

Why manual: true too: when the scheduled run fails, you want a "re-run now" button — not "wait until 3am tomorrow".

Loop: apply → review → gate

Automated code change with bounded iteration. See Gates & Approvals for the full version; here's the compact pattern.

JSON
{
  "name": "auto-fix-loop",
  "version": "1",
  "on": { "manual": true },
  "jobs": {
    "fix-loop": {
      "runsOn": "sandbox",
      "steps": [
        {
          "name": "Apply fixes",
          "id": "apply",
          "uses": "plugin:auto-fix:run",
          "with": { "retry": "${{ trigger.payload.retry == true }}" }
        },
        {
          "name": "Review",
          "id": "review",
          "uses": "plugin:ai-review:run"
        },
        {
          "name": "Gate",
          "uses": "builtin:gate",
          "with": {
            "decision": "steps.review.outputs.verdict",
            "routes": {
              "approved":  "continue",
              "rejected":  "fail",
              "needs-fix": { "restartFrom": "apply", "context": { "retry": true } }
            },
            "default": "fail",
            "maxIterations": 5
          }
        },
        {
          "name": "Commit",
          "uses": "plugin:commit:commit"
        }
      ]
    }
  }
}

Multi-repo parallel work

Fan out the same job across several repos, then aggregate.

JSON
{
  "name": "multi-repo-audit",
  "version": "1",
  "on": { "manual": true, "schedule": { "cron": "0 4 * * 1" } },
  "jobs": {
    "audit-core": {
      "runsOn": "sandbox",
      "target": { "workspaceId": "kb-labs-core" },
      "steps": [ { "name": "Audit", "uses": "plugin:security:audit" } ],
      "artifacts": {
        "produce": ["audit-report"]
      }
    },
    "audit-cli": {
      "runsOn": "sandbox",
      "target": { "workspaceId": "kb-labs-cli" },
      "steps": [ { "name": "Audit", "uses": "plugin:security:audit" } ],
      "artifacts": { "produce": ["audit-report"] }
    },
    "audit-workflow": {
      "runsOn": "sandbox",
      "target": { "workspaceId": "kb-labs-workflow" },
      "steps": [ { "name": "Audit", "uses": "plugin:security:audit" } ],
      "artifacts": { "produce": ["audit-report"] }
    },
    "aggregate": {
      "runsOn": "local",
      "needs": ["audit-core", "audit-cli", "audit-workflow"],
      "artifacts": { "consume": ["audit-report"] },
      "steps": [
        { "name": "Combine and render", "uses": "plugin:security:aggregate-reports" }
      ]
    }
  }
}

Trade-off: you're copy-pasting job blocks. A future matrix mechanism would let you declare this once and expand at run time, but today you write the jobs explicitly. If you're doing this a lot, generate the spec programmatically instead of hand-writing it.

Webhook-driven release

GitHub webhook triggers a release flow. Uses the webhook payload to pick the version.

JSON
{
  "name": "gh-release",
  "version": "1",
  "on": {
    "webhook": {
      "secret": "${{ secrets.GITHUB_WEBHOOK_SECRET }}",
      "path": "/v1/webhooks/github/release",
      "headers": { "X-GitHub-Event": "release" }
    },
    "manual": true
  },
  "jobs": {
    "publish": {
      "if": "${{ trigger.payload.action == 'published' }}",
      "runsOn": "sandbox",
      "secrets": ["NPM_TOKEN"],
      "steps": [
        {
          "name": "Checkout tag",
          "uses": "builtin:shell",
          "with": { "command": "git checkout ${{ trigger.payload.release.tag_name }}" }
        },
        { "name": "Build",   "uses": "builtin:shell", "with": { "command": "pnpm install && pnpm build" } },
        { "name": "Publish", "uses": "builtin:shell", "with": { "command": "pnpm publish" } }
      ]
    }
  }
}

Notes:

  • The headers filter makes GitHub the only valid sender (combined with the signature check from secret).
  • The if on the job filters to action: 'published' — GitHub fires the release webhook for other actions (created, edited, deleted), and we only want publishes.

Aggregation across runs

Pull the last 7 days of lint reports and render a trend. The merge config resolves source runs at schedule time — you'd normally populate from via a helper plugin that queries the run history.

JSON
{
  "name": "weekly-quality",
  "version": "1",
  "on": { "schedule": { "cron": "0 9 * * 1" } },
  "jobs": {
    "aggregate-lint": {
      "runsOn": "local",
      "artifacts": {
        "consume": ["lint-report"],
        "merge": {
          "strategy": "append",
          "from": [
            { "runId": "${{ trigger.payload.runs[0] }}" },
            { "runId": "${{ trigger.payload.runs[1] }}" },
            { "runId": "${{ trigger.payload.runs[2] }}" }
          ]
        }
      },
      "steps": [
        { "name": "Render trend report", "uses": "plugin:quality:trend" }
      ]
    }
  }
}

Long-running job with progress updates

Shell commands stream stdout/stderr as events. Studio shows them live. The pattern is just builtin:shell with a long timeout and a command that prints progress.

JSON
{
  "jobs": {
    "reindex": {
      "runsOn": "sandbox",
      "timeoutMs": 7200000,
      "steps": [
        {
          "name": "Reindex",
          "id": "reindex",
          "uses": "builtin:shell",
          "with": {
            "command": "pnpm kb mind rag-index --scope default --progress",
            "timeout": 7200000
          },
          "progress": {
            "source": "outputs.chunksProcessed",
            "format": "{value} chunks processed"
          }
        }
      ]
    }
  }
}

The progress binding (optional) tells clients to read a live value from step outputs and render it. Combined with the ::kb-output:: marker in the command's output, you get a live progress counter in the UI.

Fire-and-forget sub-workflow

Invoke another workflow without waiting for it.

JSON
{
  "name": "parent",
  "version": "1",
  "on": { "manual": true },
  "jobs": {
    "main": {
      "runsOn": "sandbox",
      "steps": [
        { "name": "Do thing", "uses": "builtin:shell", "with": { "command": "./main.sh" } },
        {
          "name": "Kick off audit",
          "uses": "workflow:audit",
          "with": { "mode": "fire-and-forget", "inputs": { "sourceRunId": "${{ trigger.runId }}" } }
        },
        { "name": "Continue", "uses": "builtin:shell", "with": { "command": "./finish.sh" } }
      ]
    }
  }
}

The audit workflow runs independently; the parent continues immediately. The parent's step records the child run ID but doesn't wait.

Default invocation mode is 'wait'. Use 'fire-and-forget' when the child's outcome doesn't affect the parent's decisions.

Things that aren't really patterns

"Run everything in parallel with no dependencies" — just define multiple jobs with no needs. They'll run concurrently subject to execution backend capacity.

"Run one job at a time, serialized" — give them all the same concurrency.group.

"Skip if condition" — put the if on the job or the step, not a separate gate.

"Conditional sub-workflow" — use if on the step with workflow:<id>, same as any other step.

Patterns — KB Labs Docs