Event-Driven Patterns

Clockwork exposes two event interfaces: a real-time SSE stream for browser and server applications, and webhooks for push delivery to your endpoint. Both carry the same event envelope with the same names (task.ready, task.completed, task.delayed, conflict.detected, schedule.changed, ...); see the Webhooks guide for exactly which events exist. This guide covers common patterns for both.

Planning v2 availability. Merged to main and deployed to production (platform.clockwork-co.com; F1 to F7 and F9 to F11 on 2026-10-09, F12 pool assignment and the security fixes on 2026-10-12). The platform flag CLOCKWORK_PLANNING_V2 is currently on globally, in production and in preview, as of 2026-10-12: every organization gets planning-v2 behavior, and with the flag off the planning-v2 endpoints answer 404. Per-organization gating (CLOCKWORK_PLANNING_V2=orgs) is merged but not enabled in production, so the global flag is the only access control in effect. Exact dates and commits: Changelog.

The stream and schedule.changed need the platform setting CLOCKWORK_PLANNING_V2 (on in production and preview). Every sample on this page is from docs-examples/events-patterns.mts in the Clockwork repository, which runs against a platform with it on. The scenario is a print shop: "Proof sign-off" must be approved before "Print run" can start, the run is due within four hours, and the press is a resource with two slots.

Pattern 1: Task inbox (SSE stream)

Build a real-time inbox that shows tasks becoming ready without polling. The stream is polling-backed (about one second of lag) and the server ends each connection after exactly 270 seconds (4.5 minutes; measured 270.1 s), so the consumer reconnects with the last sequence it saw (sinceSequence, or Last-Event-ID for an EventSource); resuming loses nothing.

typescript
let lastSeen: number | undefined = head
let stopped = false
 
// Listen for newly-ready tasks and notify assignees. Reconnect with the last sequence: the
// server ends each stream after a few minutes, and resuming loses nothing.
async function watchForReadyTasks() {
  while (!stopped) {
    for await (const event of client.events.stream({ types: ['task.ready'], sinceSequence: lastSeen })) {
      lastSeen = event.sequence
      const taskId = event.task_id!
      const { data: task } = await client.tasks.get(taskId)
      if (task.assigneePlatformUserId) {
        await sendPushNotification(task.assigneePlatformUserId, {
          title: 'New task ready',
          body: task.name,
          url: `/tasks/${task.id}`,
        })
      }
      if (taskId === job.refs.print) stopped = true // (so this demo ends)
      if (stopped) break
    }
  }
}

To keep a screen in sync with a plan, listen for schedule.changed on one job. One event is sent per recompute and lists every task that moved:

typescript
// Keep a screen in sync with a plan: one job, `schedule.changed` only.
const taskCards: Record<string, { start: string; end: string }> = {}
async function syncScreen() {
  for await (const event of client.events.stream({ workflowInstanceId: job.instance.id, types: ['schedule.changed'], sinceSequence: head })) {
    for (const change of event.data.changes as Array<{ taskId: string; current: { start: string; end: string } }>) taskCards[change.taskId] = change.current
    if (Object.keys(taskCards).length > 0) break
  }
}

Pattern 2: Cascade trigger (webhook)

Trigger a downstream system when a workflow milestone is reached. In a webhook receiver you would call this after verifying the signature (see the Webhooks guide); here it is called with an envelope read from the stream, which is identical to a webhook body:

typescript
async function processCascade(payload: StreamEvent) {
  if (payload.type === 'task.completed') {
    const { data: task } = await client.tasks.get(payload.task_id!)
 
    // When the "Proof sign-off" task completes, send the contract
    if (task.name === 'Proof sign-off') {
      const { data: instance } = await client.workflowInstances.get(task.workflowInstanceId!)
      await sendContractToCounterparty({ dealId: instance.subjectId })
    }
  }
}

Pattern 3: Conflict alerting

Subscribe to conflict.detected and page on-call when a critical-path task is at risk. The event names the task but not the conflict, so read the conflict from the API. It is sent again on every recompute that still finds the conflict, so make the alert idempotent (for example, keyed by task and conflict type):

typescript
async function processConflict(payload: StreamEvent) {
  if (payload.type === 'conflict.detected') {
    const conflicts = await client.conflicts.list({ resolved: false })
 
    for (const conflict of conflicts.data) {
      if (conflict.taskId !== payload.task_id) continue // this event's task (list is already open conflicts only)
      if (conflict.type === 'deadline_violated' && conflict.slackViolationSec > 3600) {
        // More than 1 hour over deadline — escalate
        await pageOnCall({
          message: `Deadline violated: ${conflict.description}`,
          severity: 'high',
          taskId: conflict.taskId,
        })
      }
    }
  }
}

Pattern 4: Event-sourced state reconstruction

Use events.replay() to rebuild your local state from the event log after an outage. fromSequence is exclusive: the call returns events strictly after it, so pass the last sequence you applied (not that number plus one, which would skip an event):

typescript
const applied: number[] = []
const applyEventToLocalState = async (e: { sequence: number }) => { applied.push(e.sequence) }
let savedSequence = head
const getLastProcessedSequence = async () => savedSequence
const saveLastProcessedSequence = async (s: number) => { savedSequence = s }
 
async function rebuildState() {
  let sequence = await getLastProcessedSequence()
 
  while (true) {
    // `fromSequence` is exclusive: it returns events AFTER this sequence, so pass the last one you applied.
    const { data: events } = await client.events.replay({
      fromSequence: sequence,
      limit: 500,
    })
 
    if (events.length === 0) break
 
    for (const event of events) {
      await applyEventToLocalState(event)
      sequence = event.sequence
    }
 
    await saveLastProcessedSequence(sequence)
  }
}

Pattern 5: Workflow-per-subject fan-out

Create one workflow instance per incoming item and monitor them all:

typescript
async function onOrderReceived(orderId: string) {
  const { data: instance } = await client.workflowInstances.create({
    subjectType: 'order',
    subjectId: orderId,
  })
 
  console.log(`Started workflow ${instance.id} for order ${orderId}`)
}
 
// Monitor all in-progress orders
async function getDashboard() {
  const { data: instances } = await client.workflowInstances.list({
    status: 'running',
  })
 
  return instances.map(i => ({
    orderId: i.subjectId,
    status: i.status,
    startedAt: i.startedAt,
  }))
}

Choosing SSE vs webhooks

SSE streamWebhooks
Best forBrowser UIs, long-lived server processesServerless functions, external system triggers
DeliveryPull (you consume the stream)Push (Clockwork calls your endpoint)
LatencyAbout one second (the server polls the log once a second; local median 0.5 to 0.9 s)Sent right after the event is written (local median 7 to 12 ms, plus your network)
PersistenceThe connection ends every ~4.5 minutes; reconnect with the last sequence and nothing is lostAt-least-once (planning v2): a failed delivery is retried for up to ~44 hours, on the sweep cadence described in Webhooks; dedupe on the event id. With planning v2 off: one attempt, no retries
AuthYour API key (events:read)HMAC-SHA256 signature
ReplayReconnect with sinceSequence / Last-Event-ID, or events.replay() (up to 1,000 per call)events.replay() or events.list() (up to 500 per call) for what you missed
LimitsOne request of your rate limit to connect; the code sets no cap on concurrent streamsNot counted against your limit; the subscription's owner must be connected to the task
CoverageThe whole project log, including events with no webhook (workflow.started, resource.created, ...)Events about tasks you are connected to