--- title: 'Recurring jobs' description: 'Work that runs on a schedule, as a fresh workflow run per tick instead of an opaque cron entry, and one Studio screen for everything the box runs on a timer.' position: 16 --- # 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. ![The Recurring jobs screen in Studio: four rows, the approval timeout sweep every minute, the connection refresh every 15 minutes, and two workflow crons, daily-digest at 08:00 and weekly-digest on Mondays, each with a countdown ring to its next run and a badge for its last result.](/images/recurring-jobs/recurring-jobs-list.png) _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: ```ts // 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) { '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//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:#` 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](/docs/sdk/workflows/serial-execution): 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](/docs/examples/workflows/scheduled-digest). ## 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: ```ts 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:` 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:`) 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 ` 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](/docs/home/features/integrations#receive-events) 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](/docs/sdk/humans/approval#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. ![The daily-digest entry in the Studio Catalog: its recent runs, then the "Runs on a timer" block with one row, the cron 0 8 * * *, its last result and the job id decl:daily-digest#0, and the "All recurring jobs" button.](/images/recurring-jobs/catalog-entry-schedules.png) _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: ```json { "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 trace of the run a daily-digest tick started: the collectRecipients step, a short wait, the first deliver step, a two-second wait, the second deliver step and Success, with the inspector showing the recipients the first step returned.](/images/recurring-jobs/tick-run.png) _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: ```sh stackbone runs list # recent runs, ticks included stackbone runs get # one run: status, steps, result stackbone logs tail --run # that run's log lines stackbone runs retry --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 - [Background jobs & workflow triggers](/docs/sdk/platform/queues): a background job is a workflow run, started by name. - [A scheduled digest with a long wait](/docs/examples/workflows/scheduled-digest): the full workflow behind the screenshots. - [Serial execution](/docs/sdk/workflows/serial-execution): when two ticks must never overlap. - [Integrations](/docs/home/features/integrations#receive-events): the trigger links whose polling shows up as **Intake link** rows. - [Governance](/docs/home/features/governance): the rest of Studio, from runs to approvals. - [Observability](/docs/sdk/platform/observability): logging from steps, and the run timeline.