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.

What this workspace runs on a timer: two workflow crons, and the 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 unless tz names a zone: 0 8 * * * fires at 08:00 UTC, which is 10:00 in Madrid in summer.
tz The IANA zone the cron is read in, such as Europe/Madrid. Optional. 0 9 * * * with tz: 'Europe/Madrid' fires at 09:00 Madrid time in summer and in winter. The build warns about an unknown zone and skips that schedule; it never falls back to UTC. Recurring jobs shows the zone next to the pattern.
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.

Two rules 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 in a fixed order, so the table does not reshuffle under your cursor: each workflow's crons together, one block per workflow by name, then the box's own sweeps by family.

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 kind 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 seven 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.
Log retention The box's own sweep, every hour: it deletes the log lines past your retention window. Always there.
Trace export The box's own sweep, every five minutes: it re-sends the run traces your collector has not received yet. Always there, and it sends nothing until you name a collector.

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 row with its Enabled toggle off and no next run is not a problem: someone paused that cron, or its whole workflow, on purpose. See Pause a workflow's schedule.

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,
      "pause_key": "workflow:daily-digest",
      "paused": false,
      "workflow_paused": false,
      "cron_pause_key": "cron:daily-digest:5e0c3a91b2d4",
      "cron_paused": false,
      "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. pause_key names the workflow a row pauses with and cron_pause_key the one cron; both are null on the box's own sweeps. paused is true when either is paused, and workflow_paused / cron_paused say which. There is no CLI command for schedules today.

Pause a workflow's schedule

Crons sit in Recurring jobs in one block per workflow. The block's header carries the workflow's switch, and each row carries its own. Neither touches the code.

  • The workflow's switch stops every cron of it at once: every entry in its schedules export and its dynamic schedule. While it is off, its rows read paused (workflow) and their own switches are greyed out.
  • A row's switch stops that one cron and leaves the others firing. A workflow with a weekday 08:00 report and a Monday 09:00 summary can drop the summary and keep the report.

Switching either back on re-arms what it stopped. Each cron fires at its next scheduled time, not straight away: a daily 08:00 report switched on at 15:00 runs tomorrow at 08:00. A row's own switch keeps its value while the workflow is off, so a cron you switched off stays off when the workflow comes back.

A pause is stored on the box, so it holds across restarts and redeploys until someone resumes it. A workflow's pause belongs to the workflow, so editing its schedules list does not undo it. A cron's pause belongs to its pattern and its input, not its place in the list: reordering schedules keeps the same cron off, while editing that cron's pattern or input makes it a new cron, which starts switched on.

Without Studio, send the row's key to the box yourself: POST /api/recurring-jobs/pause or POST /api/recurring-jobs/resume with { "pause_key": "workflow:daily-digest" } for the workflow, or its cron_pause_key in the same field for one cron.

Any seat that can change the workspace's configuration can flip the switches: owner, admin and member. A viewer sees them, but cannot flip them. Every pause and resume lands in the activity log, with who pressed it: "Paused the schedule of the <workflow> workflow" for the workflow, "Paused the 0 9 * * 1 schedule of the <workflow> workflow" for one cron.

The box's own sweeps (connection refresh, approval timeout, log retention, trace export) have no toggle: the box needs them to keep working. Intake links have their own switch on the trigger, under Integrations.

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

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. A failed tick is not re-driven by hand: the next tick fires on schedule, and the durable engine retries a throwing step with backoff — 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.
A row has its Enabled toggle off and no next run Someone paused that cron from this screen. The activity log says who. Switch the toggle back on to re-arm it; it fires at its next scheduled time.
A row reads paused (workflow) and its toggle is greyed out The workflow's switch, on the block's header, is off. Switch that one back on; the row returns to whatever its own toggle says.
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