Recurring jobs

A recurring job is a workflow the box starts on a timer. You declare the timer next to the workflow (a cron pattern and the input to send), the box arms it every time it boots, and every tick starts an ordinary run with its own run id, its own steps and its own row under Runs. Recurring jobs in Studio lists every timer the box holds, when each fires next and how its last tick went, so a schedule that stopped firing shows as a red row.

Everything this workspace runs on a timer: two workflow crons, and the two sweeps the box runs for itself.

Run a workflow on a timer

Export a schedules array next to the workflow. The build picks it up, and the box arms it every time it boots:

workflows/daily-digest.workflow.ts
import { z } from 'zod';

export const schedules = [{ cron: '0 8 * * *', input: { scope: 'daily' } }];

export const inputSchema = z.object({ scope: z.enum(['daily', 'weekly']) });
export const outputSchema = z.object({ sent: z.number() });

export async function dailyDigestWorkflow(input: z.infer<typeof inputSchema>) {
  'use workflow';
  // collect recipients, deliver, return { sent }
}
Field What it does
cron Five fields (minute hour day month weekday), or a macro: @hourly, @daily, @weekly, @monthly, @yearly. Read in UTC: 0 8 * * * fires at 08:00 UTC, which is 10:00 in Madrid in summer. There is no timezone option.
input The object every tick sends as the run's input. Optional. The box validates it against the workflow's inputSchema before the run starts, like a POST /api/workflows/<name>/start.

A few rules that decide what you see on the screen:

  • One workflow can export several schedules. Each one gets its own row, named decl:<workflow>#<position> after its position in the array: decl:daily-digest#0, decl:daily-digest#1.
  • Ticks fire on schedule whether or not the previous run has finished. If two ticks of the same workflow must not overlap, mark the workflow serial: a tick that arrives while a run is active waits in line.

The complete example, with the durable sleep that paces the sends, is A scheduled digest with a long wait.

Add a schedule at run time

For a schedule that depends on data, or that a workflow decides to add or remove, call stackbone.workflows.schedule from inside a running workflow. The stackbone client is ambient there, so there is nothing to construct:

import { stackbone } from '@stackbone/sdk';

// one dynamic schedule per workflow name; calling it again replaces cadence and input
await stackbone.workflows.schedule('reconcile', { scope: 'daily' }, '0 3 * * *');

// declarative and dynamic alike, each with its `source`
const active = await stackbone.workflows.listSchedules();
// → [{ name: 'daily-digest', cron: '0 8 * * *', input: {…}, source: 'declarative' },
//    { name: 'reconcile',    cron: '0 3 * * *', input: {…}, source: 'dynamic' }]

// removes the dynamic schedule only; a `schedules` export is untouched
await stackbone.workflows.unschedule('reconcile');

A dynamic schedule shows up as dyn:<workflow> under the Dynamic cron family. It survives boots and redeploys like a declared one, and a redeploy never removes it: only unschedule does. Calling any of the three outside a running workflow throws, because the box binds the scheduler when it dispatches the first workflow.

Watch what is armed

Open Recurring jobs under Data in the Studio sidebar. One row per timer the box holds, workflow crons and the box's own sweeps alike. The screen sorts the rows by family, then by name, so the table does not reshuffle under your cursor.

Column What it shows
Job The job's id (decl:daily-digest#0, dyn:reconcile, sub:<link-id>) over the queue it runs on.
Family Which of the five kinds of timer this is. See the table below.
Cadence The cron pattern verbatim (0 8 * * *), or every <duration> for a fixed interval. The screen shows the pattern as the box holds it, never as prose.
Next run A ring that fills from the last tick towards the next one, with the wait in words: in 19h, in 22s, and 45s late once the fire time has passed.
Last result done · 1m ago or failed · 2h ago, and the error text under a failed one. never ran until the first tick.

The five families:

Family Where it comes from
Workflow cron A schedules export on a workflow. One row per entry.
Dynamic cron A stackbone.workflows.schedule call. One row per workflow name.
Intake link A trigger link that is switched on. The box polls the provider on the trigger's interval (30 seconds by default) and starts one run per new event.
Connection refresh The box's own sweep, every 15 minutes: it refreshes the OAuth tokens of your connections so a long-lived trigger keeps polling. Always there.
Approval timeout The box's own sweep, every minute: it applies the timeout and fallback of approvals nobody decided. Always there.

The screen re-reads the box every 20 seconds and the rings count down on their own between reads, so a tick shows up within one refresh. Two badges say that something needs you:

  • overdue, on a red row: the fire time passed more than 30 seconds ago and no read since has seen the tick. The box stopped firing it.
  • not armed, on a dimmed row: the box declared this job but could not arm it. The usual cause is a cron pattern the box cannot parse. Nothing fires until you fix it.

A failed tick keeps its row: failed · 12m ago and the first line of the error, in red, until the next tick overwrites it.

The workflow's own Catalog entry shows the same rows for that one workflow, under Runs on a timer, so you can check a schedule from where you read the workflow's steps and recent runs. All recurring jobs on that block opens the full screen.

One workflow, its recent runs and its one timer, on the same page.

The screen reads one route on the box, GET /api/recurring-jobs, so a self-hosted box answers the same question without a UI. Every timestamp is the box's, and server_time is there so a client can correct its own clock:

{
  "items": [
    {
      "id": "decl:daily-digest#0",
      "family": "decl:",
      "queue": "workflow-cron",
      "cadence": { "kind": "cron", "pattern": "0 8 * * *", "tz": null },
      "next_fire_at": "2026-08-18T08:00:00.000Z",
      "active": true,
      "last_execution": {
        "started_at": "2026-08-17T12:16:10.083Z",
        "finished_at": "2026-08-17T12:16:10.243Z",
        "outcome": "done",
        "error": null
      }
    }
  ],
  "server_time": "2026-08-17T12:17:03.512Z"
}

cadence is { "kind": "cron", "pattern", "tz" } or { "kind": "every", "ms" }. next_fire_at is what the box's scheduler holds, never recomputed from the pattern, and null when nothing is armed. There is no CLI command for schedules today.

Follow every tick

A tick starts a run like any other: it sits under Runs with the chat turns and the runs you started by hand, with a status, a duration and its steps. Open it and the trace shows what the tick did.

The run one tick started: two steps, the durable sleep between the sends, and the output of the first step in the inspector.

The trace lists the steps in order with their timing, and a sleep shows as a wait. Click a step and the inspector prints its input and output. View run logs shows the console.* lines the steps printed, filtered to this run. From a terminal the same data reads as:

stackbone runs list                   # recent runs, ticks included
stackbone runs get <run-id>           # one run: status, steps, result
stackbone logs tail --run <run-id>    # that run's log lines
stackbone runs retry <run-id> --yes   # re-run a failed tick as a fresh run

The run's trigger reads workflow, the same as a run started by hand or by another workflow, so Runs and stackbone runs list do not single ticks out. To see the runs of one scheduled workflow, open its Catalog entry: its Recent runs and its Runs on a timer sit on the same page. retry does not resume the failed run in place; it starts a new run from the tick's input, one more reason to keep every step idempotent.

When things go wrong

What you see What it means
A row reads not armed The box declared the job but could not arm it. A cron value the box cannot parse (99 * * * *, a stray word) is the usual cause; the box's boot log names the workflow. Fix the pattern and let the box reboot or redeploy.
Last result reads failed with an error under it The tick threw before or while starting the run: the input no longer matches the workflow's inputSchema, the workflow was removed, or the box's run engine could not start it. Read the error, fix the cause. The next tick tries again on schedule; stackbone runs retry re-drives a run that got as far as starting.
A row reads overdue The fire time passed and no tick was seen for over 30 seconds. The box still answers, but the timer inside it is not firing. The usual cause is a lost Redis. Check the box's logs, then Refresh.
A schedule you removed from the code is still on the screen The box has not rebooted since. Every boot and every deploy re-arms the declared set and prunes the rest; a dynamic schedule stays until unschedule.
A tick ran but no run appears A guardrail refused the tick's input. Your rule did what you told it to: the tick reads done, the box logs the guardrail's reason, and no run starts. The next tick meets the same rule.
This workspace runs nothing on a timer, with schedules declared The box has no Redis, so nothing arms. On a deployed box, give it one.
A yellow stale banner over the table The last refresh failed and the table shows the previous read. The banner says why: the box did not answer, the local session is gone, or your session no longer reaches the box.

Read more

BUILT WITH ❤️ FROM CANADA AND SPAIN