--- title: 'Background jobs & workflow triggers' description: 'Run background and recurring work as durable workflow runs, started by name over the runtime contract.' position: 16 --- # Background jobs & workflow triggers > In the runtime, **a background job is another workflow** that runs > with its own independent run: its own run id, its own steps, and its > own row in your runs list. There is no separate job queue to reason > about. You start it by name, you observe and retry it like any run, > and its durability (replay, retries, crash recovery) comes from the > [Workflow SDK](https://workflow-sdk.dev/docs) runtime that already runs > every workflow and every agent session. **Decompose the work into a durable workflow**, then start that workflow. The familiar use cases (send an email after a turn, start a long data import, chain a piece of work without holding the caller open) are all workflows you start. See [Workflows](/docs/sdk/workflows/overview) for the authoring model (`'use workflow'`, `'use step'`, `defineWorkspace`, input/output schemas) and [Agents & sessions](/docs/sdk/agents/overview) for how an agent turn hands work off. ## Why a workflow instead of a queue job A workflow run is durable by construction, so it already gives you what the old queue knobs did: - It survives crashes. Each step (`'use step'`) runs once, the runtime persists its result, and the run replays from the last completed step after a restart: you never re-charge a card or re-send an email on a retry. - The runtime handles **retries and backoff** per step. - It is observable. A triggered workflow is an ordinary run, so it shows up in [`stackbone runs list`](/docs/cli/reference/runs#stackbone-runs-list) and streams logs through [`stackbone logs tail --run `](/docs/cli/reference/logs), with no separate queue inspector. - It is typed at the edge. The runtime validates the input against the workflow's declared input schema before anything starts (see below). The engine is the upstream [Vercel Workflow SDK](https://github.com/vercel/workflow) (the `workflow` package). You author against it through `@stackbone/sdk` and `@stackbone/sdk/workflow`; the runtime hosts it. ## Starting a workflow by name Every workflow your workspace declares is reachable on the runtime by name. To start one as a background run, POST its input to its start route: ```sh POST /api/workflows//start ``` The body is the workflow's input. The response is the new run: ```json { "workflowName": "send-welcome-email", "status": "started", "runId": "run_…", "worldRunId": "…", "trigger": "POST /api/workflows/send-welcome-email/start" } ``` `status` is `"started"` when the runtime enqueued the run. A [serial](/docs/sdk/workflows/serial-execution) workflow whose previous run is still active answers `"queued"` instead, with no run ids: the queued start runs automatically when the active run finishes. From there the run is independent: it survives the caller closing the connection, restarts, and redeploys, and you track it with the [runs commands](/docs/cli/reference/runs). > **The runtime validates input first.** It checks the body against the > target workflow's declared input schema **before** the run starts. A bad > payload comes back as a `400` with the offending fields, and the runtime > enqueues **nothing**: you never start a run on garbage input. An unknown workflow > name fails immediately as "workflow not found." Author the schema as a > sibling `inputSchema` export next to the workflow (see > [Workflows](/docs/sdk/workflows/overview)); inspect it any time with > `stackbone workflows schema `. ## Fan out from a turn or a step The most common "background job" is fanning work out from an agent turn so the user gets an instant reply while the heavy lifting runs durably. Inside a deep-agent tool, do the small, immediate part and start the durable workflow for the rest. The ambient `stackbone` client is available in every tool and step, so there is no `createClient()` and no credential wiring: ```ts // deep-agents/support/index.ts import { tool } from '@langchain/core/tools'; import { stackbone, z } from '@stackbone/sdk'; const startOnboarding = tool( async ({ userId }: { userId: string }) => { // Persist the immediate bit, then hand the heavy lifting to a durable workflow. await stackbone.workflows.start('onboard-user', { userId }); return `Onboarding started for ${userId}.`; }, { name: 'start_onboarding', description: 'Start onboarding for a new user.', schema: z.object({ userId: z.string() }), }, ); ``` Inside a workflow, you compose the heavy work directly: split it into durable steps, each of which runs once and replays on restart, so a long import is a sequence of checkpointed steps in one run: ```ts // workflows/import-orders.workflow.ts import { z } from '@stackbone/sdk'; export const inputSchema = z.object({ source: z.string() }); export const outputSchema = z.object({ imported: z.number() }); export async function importOrdersWorkflow(input: z.infer) { 'use workflow'; const rows = await fetchRows(input.source); let imported = 0; for (const row of rows) { imported += await upsertRow(row); } return { imported }; } async function fetchRows(source: string) { 'use step'; // runs once, persisted, retried on failure. Keep it idempotent // pull from `source`… return [] as Array<{ id: string }>; } async function upsertRow(row: { id: string }) { 'use step'; // write one row to the agent's own database via `stackbone.database` return 1; } ``` To hand a piece of work to a _sibling_ agent from inside a step, call it by name with `callDeepAgent` and wait for its reply. This replaces "chaining agent calls without holding the caller open": the agent runs in-process, and the wait is durable and survives restarts: ```ts import { callDeepAgent } from '@stackbone/sdk/workflow'; async function askSupport(plan: string) { 'use step'; const { text } = await callDeepAgent( 'support', `A customer joined the "${plan}" plan. Give up to 3 onboarding tips.`, ); return text; } ``` To hand a slice off to **another workflow** instead of an agent, trigger it by name with `stackbone.workflows.start` (fire-and-forget) or `stackbone.workflows.startAndWait` (wait for its result). Each starts its target as an independent run, and the input is validated against the target's `inputSchema` before anything starts: ```ts import { stackbone } from '@stackbone/sdk'; // fire-and-forget: `send-receipt` runs as its own independent run await stackbone.workflows.start('send-receipt', { orderId }); // …or wait durably for another workflow's result before continuing const summary = await stackbone.workflows.startAndWait<{ total: number }>('reconcile', { orderId }); ``` See [Workflows](/docs/sdk/workflows/building-workflows#3-triggering-a-run) for the full trigger surface (including the typed `name` narrowing). ## Pausing for a human If a background job needs sign-off (a refund, a publish, a destructive action), pause the workflow durably on an approval gate instead of starting it blind. `requestApproval()` from `@stackbone/sdk/workflow` suspends the run, records it in the inbox, and resumes (or applies your `fallback`) when a human decides: ```ts import { z } from '@stackbone/sdk'; import { requestApproval } from '@stackbone/sdk/workflow'; export async function refundWorkflow(input: { orderId: string; amount: number; approvalToken: string; }) { 'use workflow'; const decision = await requestApproval({ token: input.approvalToken, topic: 'refund', payload: { orderId: input.orderId, amount: input.amount }, title: 'Approve refund', timeout: '24h', fallback: 'reject', }); if (decision.status !== 'approved') { return { orderId: input.orderId, refunded: false, decision: decision.status }; } await performRefund(input.orderId, input.amount); return { orderId: input.orderId, refunded: true, decision: decision.status }; } async function performRefund(orderId: string, amount: number) { 'use step'; // keep idempotent: the run may replay return { refundId: `rf_${orderId}_${amount}` }; } ``` The pause survives restarts; the human can take a day. See [Human-in-the-loop](/docs/sdk/humans/approval) for the full `requestApproval` contract and the raw `defineHook` / `sleep` escape hatch. ## Recurring work Recurring jobs (a nightly digest, an hourly reconcile) are recurring _workflow runs_. Each tick starts a fresh run of the named workflow, with its own run id and steps, so a schedule is an observable list of runs over time rather than an opaque cron entry. Declare a schedule in one of two ways. For one that ships with your workspace, export a `schedules` array next to the workflow: the build harvests it and the runtime reconciles it on every deploy and boot, with no imperative call: ```ts // workflows/nightly-digest.workflow.ts export const schedules = [{ cron: '0 3 * * *', input: { scope: 'daily' } }]; export async function nightlyDigestWorkflow(input: { scope: string }) { 'use workflow'; // … } ``` For schedules you add or remove at run time, manage them from a workflow with `stackbone.workflows.schedule` / `.unschedule` / `.listSchedules`: ```ts import { stackbone } from '@stackbone/sdk'; await stackbone.workflows.schedule('reconcile', { scope: 'daily' }, '0 3 * * *'); const active = await stackbone.workflows.listSchedules(); // declarative + imperative triggers await stackbone.workflows.unschedule('reconcile'); ``` To see what is armed on the box, open **Recurring jobs** in Studio. It lists every timer the runtime holds (declarative and imperative alike) with its cadence, when it fires next, and how the last execution went. The same data is on the runtime at `GET /api/recurring-jobs`, so you can check a self-hosted box without a UI. There is no CLI command for schedules today. The `cron` value is a five-field pattern (minute hour day month weekday) or a named macro (`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`), read in UTC. There is no timezone option. A pattern the box cannot parse never arms: the boot log names the workflow, and its row on the Recurring jobs screen reads `not armed`. The [Recurring jobs](/docs/home/features/recurring-jobs) page covers the screen, the five kinds of timer it lists, and what each failure looks like. ## Observing triggered runs Triggered workflows are runs, so you need no special inspector: ```sh stackbone runs list # recent runs, including triggered ones stackbone runs get # one run's status, steps, and result stackbone logs tail --run # stream that run's logs stackbone runs retry --yes # re-run a failed run as a fresh run stackbone runs cancel --yes # stop an in-flight run ``` See [Observability](/docs/sdk/platform/observability) for the full picture of runs, steps, and logs. ## When things go wrong The failure modes follow the durable-run model: - **Bad input**: the runtime rejects the start with a `400` carrying the field-level issues and creates **no run**. Fix the payload (or the workflow's input schema) and try again. - **Unknown workflow**: starting a name your workspace doesn't declare fails immediately with "workflow not found." Check `stackbone workflows list`. - **A step throws**: the runtime retries the step with backoff; if it keeps failing the run lands in a failed state you can inspect with `stackbone runs get ` and re-drive with `stackbone runs retry --yes` (both `retry` and `cancel` refuse to run without `--yes`). `retry` does not resume the failed run in place: it starts a brand-new run from the original input, which is one more reason to keep every step idempotent. ## Where to go next - **[Workflows](/docs/sdk/workflows/overview)**: author durable workflows: `'use workflow'`, `'use step'`, input/output schemas, `defineWorkspace`. - **[Agents & sessions](/docs/sdk/agents/overview)**: how a turn hands work off, and the ambient `stackbone` client. - **[Human-in-the-loop](/docs/sdk/humans/approval)**: `requestApproval()` and durable pauses. - **[Observability](/docs/sdk/platform/observability)**: runs, steps, and logs. - **[`@stackbone/sdk` overview](/docs/sdk/reference/overview)**: the rest of the surfaces.