Writing workspace signals
Checklist
-
descriptionpresent (required) -
providermatches exact enum:http,schedule,system,fs-watch,slack,telegram,whatsapp,discord,teams - Provider
configpresent and valid - HTTP signals with params:
schemadeclared withtype: object,properties,required - HTTP
config.pathunique across workspace - Schedule
config.schedulevalid cron - Signal referenced by at least one job trigger (or
dead_signalwarning) - No
idinside inline jobfsm:— runtime injects from job name
Two foot-guns
Schemaless signals with payload params
No schema → Run dialog renders no input fields → payload arrives empty. Job gets {} instead of the params it expects.
Wrong:
signals:
draft-reply:
description: "Draft a reply"
provider: http
config:
path: /draft-reply
Right:
signals:
draft-reply:
description: "Draft a reply to an email"
provider: http
config:
path: /draft-reply
schema:
type: object
properties:
message_id:
type: string
instructions:
type: string
required:
- message_id
id inside inline FSM blocks
Runtime auto-injects id from job.name. Adding your own risks mismatch.
Wrong:
jobs:
review-inbox:
fsm:
id: review-inbox-job # don't
Right:
jobs:
review-inbox:
fsm:
initial: idle
Wiring HTTP signals to external systems
A provider: http signal exposes a URL but does NOT tell upstream systems
(Bitbucket / GitHub / Jira) to call it. The user still needs to register the
webhook on the upstream side. The /hook/raw/{workspaceId}/{signalId} URL
pattern, the /status discovery flow, signal-schema requirements, and the
tunnel-level error catalog all live in [[wiring-external-webhooks]] — load
that skill any time the user asks how to "set up the webhook", "wire
Bitbucket/GitHub/Jira", or why /status is unreachable. The HTTP signal in
workspace.yml is necessary but not sufficient.
Templates
HTTP trigger, no payload
signals:
triage-now:
description: "Triage unread emails immediately"
provider: http
config:
path: /triage-now
HTTP trigger with payload
signals:
draft-reply:
description: "Draft a reply to a specific email"
provider: http
config:
path: /draft-reply
schema:
type: object
properties:
message_id:
type: string
tone:
type: string
required:
- message_id
Cron trigger
signals:
daily-scan:
description: "Triage inbox every morning at 9am"
provider: schedule
config:
schedule: "0 9 * * *"
timezone: "America/Los_Angeles"
# Optional: what to do with firings the daemon was down for.
# Defaults to manual — surfaces a pending row in /schedules,
# operator decides. All non-skip policies are bounded by
# missedWindow (default 24h) so a long outage can't produce
# an unbounded burst.
onMissed: manual # default; other options: skip | coalesce | catchup
missedWindow: 24h # optional; default 24h
onMissed policies:
manual(default) — surface the missed slot on the/schedulesUI as a pending row; do not auto-fire. The operator clicks "Fire now" or "Dismiss". Right for jobs with expensive or visible side effects (paid API calls, email blasts, Slack posts) where you want oversight without auto-replay. Default since 2026-05-03 — silent drops surprised users.skip— drop missed firings entirely. Pick this when missing slots is a non-event because the next scheduled fire will pick up where it left off (e.g., "fetch latest prices" — recency is implicit in the next fire).coalesce— fire once now to represent every missed slot insidemissedWindow. Payload carriespolicy: "coalesce",missedCount,firstMissedAt. Right for "did this happen recently?" jobs (digests, syncs) where one make-up call covers the gap.catchup— fire each missed slot in chronological order, one signal per slot, payload taggedpolicy: "catchup". Right for "every tick must run" jobs (rate-limit accruals, time-series ingest, slot-numbered exports). Use when missing a slot is incorrect, not just late.
missedWindow (Duration: s/m/h, default 24h) caps the catch-up window for every policy. A daemon down for a week with catchup on an hourly cron only fires the slots inside the window — never all 168.
Missed-schedule events surface on the playground /schedules page under "Missed schedules" and persist in the JetStream WORKSPACE_EVENTS stream for 30 days. manual events that haven't been fired/dismissed show a pending badge + action buttons.
Concurrency policy
concurrency controls what happens when a new envelope for the same (workspace, signal) arrives while a previous cascade is still running. Orthogonal to onMissed (which is the downtime catch-up policy). Lives on every signal provider, not just schedule.
signals:
send-alert:
provider: schedule
config:
schedule: "*/5 * * * *"
concurrency: skip # default; other options: queue | concurrent | replace
Policies:
skip(default) — drop the new envelope if a cascade is in flight for this(workspace, signal). Right for cron jobs where the next tick will run anyway and you don't want pile-up if cascades exceed the schedule.queue— serialize: chain the new envelope after the current one in arrival order. Right when every tick must eventually run; risks unbounded backlog on slow cascades.concurrent— no overlap guard, full parallelism. Right for stateless / idempotent jobs that benefit from parallel runs (independent fetches, fan-out scrapes).replace— abort the in-flight cascade and start the new one. Right for "freshest input wins" workloads (cancel-in-flight semantics for non-chat signals).
For HTTP-trigger correlated callers, skip and replace publish ok=false on the response subject immediately so callers don't wait out their HTTP timeout. queue blocks the caller until the in-flight cascade settles. concurrent returns each cascade's own response.
System trigger
signals:
workspace-ready:
description: "Fired when workspace runtime finishes init"
provider: system
Validation error decoder
| Code | Fix |
|---|---|
invalid_type on description |
Add description field |
invalid_type on config.path |
HTTP signals need config.path (string, unique) |
invalid_type on config.schedule |
Schedule signals need config.schedule (valid cron) |
http_path_collision |
Make each HTTP path unique |
cron_parse_failed |
Fix cron; use crontab.guru |
dead_signal |
Add a job trigger or delete the signal |
Runtime anti-patterns
- Schema not
type: object. Run dialog readsproperties. Bare string schema or missingproperties= no fields rendered, empty payload. Symptom: dialog says "trigger immediately" when it should ask for inputs. Fix: addtype: object,properties,required. - Payload type mismatch. Schema says
string, caller sendsnumber→ signal fails with 500, session never starts. Fix: preferstringfor IDs even if they look numeric. - HTTP path collision. Two signals same
config.path→ first one wins, second unreachable. Fix: unique paths per workspace. - Missing timezone on cron. Defaults UTC. 9am PST becomes 5pm. Fix: explicit
timezone.
Order of declaration
Signals last — nothing depends on them. But declare schema before you test the signal. A signal without schema may pass validate_workspace yet fail at runtime when the Run dialog sends {} to a job expecting fields.
Assets
assets/minimal-http-signal.yml— trigger, no payloadassets/http-signal-with-schema.yml— trigger with typed form fieldsassets/schedule-signal.yml— cron trigger with timezone