KB LabsDocs

Triggers

Last updated April 7, 2026


Manual, push, schedule, webhook — how workflows get started.

A workflow starts when a trigger fires. The on field of a WorkflowSpec declares which triggers a workflow responds to. At least one trigger must be set — refine on WorkflowTriggerSchema enforces this.

A single workflow can combine multiple triggers. A release pipeline might have manual: true + webhook: { secret: '...' } so it can be triggered both from the CLI and from an external system.

Schema recap

TypeScript
interface WorkflowTrigger {
  manual?: boolean;
  push?: boolean;
  webhook?: boolean | {
    secret?: string;
    path?: string;
    headers?: Record<string, string>;
  };
  schedule?: {
    cron: string;         // min length 1
    timezone?: string;
  };
}

manual

JSON
{ "on": { "manual": true } }

The workflow can be started explicitly — from the CLI, Studio, or the REST API. This is the trigger every workflow should have during development.

Triggering from the CLI:

Bash
pnpm kb workflow:run --workflow-id=my-workflow

With inputs:

Bash
pnpm kb workflow:run --workflow-id=my-workflow \
  --inputs='{"version":"1.0.0","dryRun":true}'

The run record gets trigger.type: 'manual' and trigger.actor set to whoever invoked it. Inside if expressions and ${{ … }} interpolation, you can distinguish manual runs:

TypeScript
if: "${{ trigger.type == 'manual' }}"

push

JSON
{ "on": { "push": true } }

Fired when a git push happens on a repo the engine is configured to watch. The exact integration depends on how the workflow daemon is wired to git — typically through a webhook or a polling agent on the CI side. In both cases, the trigger arrives at the engine as trigger.type: 'push' with the push payload available as trigger.payload.

Inside the spec you can branch on the pushed ref:

TypeScript
if: "${{ trigger.payload.ref == 'refs/heads/main' }}"

Note that push is a boolean, not an object — there's no built-in filter for branches or paths at the schema level. Filtering happens inside if expressions or at the integration layer before the workflow is triggered.

webhook

Three forms, depending on how much control you need:

Boolean form

JSON
{ "on": { "webhook": true } }

Accept any webhook call at the default path, no signature verification. Useful for dev and for trusted internal integrations.

Object form with a secret

JSON
{
  "on": {
    "webhook": {
      "secret": "${{ secrets.WEBHOOK_SECRET }}"
    }
  }
}

The incoming request must carry an HMAC signature computed with this secret. Requests without a valid signature are rejected before the workflow is created. The secret itself isn't in the spec — this is a reference to a named secret resolved at runtime from the platform's secret store.

Object form with a custom path

JSON
{
  "on": {
    "webhook": {
      "secret": "...",
      "path": "/v1/webhooks/github/my-project",
      "headers": { "X-GitHub-Event": "pull_request" }
    }
  }
}
  • path — mount the webhook at a specific URL instead of the default.
  • headers — require specific headers on the incoming request. If any declared header doesn't match, the request is rejected.

Inside the workflow, the raw payload is available as trigger.payload:

TypeScript
if: "${{ trigger.payload.action == 'opened' }}"
JSON
{
  "with": {
    "command": "echo Processing PR #${{ trigger.payload.pull_request.number }}"
  }
}

schedule

JSON
{
  "on": {
    "schedule": {
      "cron": "0 3 * * *",
      "timezone": "Europe/London"
    }
  }
}

Runs on a cron schedule.

  • cron — standard 5-field cron expression (minute hour day month weekday). The schema requires a non-empty string; the cron parser lives in the engine's scheduler.
  • timezone — IANA timezone name (Europe/London, America/New_York, UTC). When omitted, the engine uses its own timezone. Make this explicit in production — you almost always want a fixed timezone, not "whatever server this runs on".

Examples:

TypeScript
"0 * * * *"        // every hour on the hour
"0 3 * * *"        // daily at 3am
"0 3 * * 1"        // every Monday at 3am
"*/15 * * * *"     // every 15 minutes
"0 0 1 * *"        // first of the month at midnight

The run's trigger.type is 'schedule'. There's no payload from the scheduler itself — you can use env and inputs (via defaults) to parameterize scheduled runs.

Combining triggers

All four can be set simultaneously:

JSON
{
  "on": {
    "manual": true,
    "push": true,
    "schedule": { "cron": "0 */6 * * *" },
    "webhook": { "secret": "..." }
  }
}

The engine subscribes to every set trigger. A run from any source takes the same workflow through the same jobs — only trigger.type and trigger.payload differ. Use if expressions when you need behavior that's conditional on the trigger source:

TypeScript
if: "${{ trigger.type == 'manual' || trigger.type == 'push' }}"

Trigger object in runs

When a run is created, the engine builds a RunTrigger:

TypeScript
interface RunTrigger {
  type: 'manual' | 'webhook' | 'push' | 'schedule' | 'workflow';
  actor?: string;
  payload?: Record<string, unknown>;
  parentRunId?: string;
  parentJobId?: string;
  parentStepId?: string;
  invokedByWorkflowId?: string;
}

There's a fifth type — 'workflow' — which isn't a trigger in the spec but appears in run records when one workflow invokes another as a sub-step via uses: 'workflow:<id>'. The parent run's id, jobId, and stepId are recorded so the engine can track the tree.

Validation errors

If you forget to set any trigger, the spec fails to parse with:

[{ path: ['manual'], message: 'At least one trigger must be defined' }]

(Zod reports the error under manual because that's the first field in the refine check, but the real issue is that the whole on object is empty.)

Other common failures:

  • schedule.cron is an empty string.
  • webhook: { secret: '' } — an empty secret is rejected by the min(1) constraint.
  • schedule.timezone: '' — same.
  • Jobs — what runs when a trigger fires.
  • Patterns — CI/CD, scheduled maintenance, and approval-gated release recipes.
  • Spec Reference — the full schema.
Triggers — KB Labs Docs