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 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 for the authoring model ('use workflow', 'use step', defineWorkspace, input/output schemas) and Agents & sessions 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 and streams logs through stackbone logs tail --run <id>, 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 (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:

POST /api/workflows/<name>/start

The body is the workflow's input. The response is the new run:

{
  "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 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.

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); inspect it any time with stackbone workflows schema <name>.

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:

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:

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<typeof inputSchema>) {
  '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:

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:

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 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:

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 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:

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:

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 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:

stackbone runs list                   # recent runs, including triggered ones
stackbone runs get <run-id>           # one run's status, steps, and result
stackbone logs tail --run <run-id>    # stream that run's logs
stackbone runs retry <run-id> --yes   # re-run a failed run as a fresh run
stackbone runs cancel <run-id> --yes  # stop an in-flight run

See 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 <id> and re-drive with stackbone runs retry <id> --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

BUILT WITH ❤️ FROM CANADA AND SPAIN