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
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
{ "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:
pnpm kb workflow:run --workflow-id=my-workflowWith inputs:
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:
if: "${{ trigger.type == 'manual' }}"push
{ "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:
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
{ "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
{
"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
{
"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:
if: "${{ trigger.payload.action == 'opened' }}"{
"with": {
"command": "echo Processing PR #${{ trigger.payload.pull_request.number }}"
}
}schedule
{
"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:
"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 midnightThe 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:
{
"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:
if: "${{ trigger.type == 'manual' || trigger.type == 'push' }}"Trigger object in runs
When a run is created, the engine builds a RunTrigger:
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.cronis an empty string.webhook: { secret: '' }— an empty secret is rejected by themin(1)constraint.schedule.timezone: ''— same.
What to read next
- Jobs — what runs when a trigger fires.
- Patterns — CI/CD, scheduled maintenance, and approval-gated release recipes.
- Spec Reference — the full schema.