Background jobs & workflow triggers

There is no separate job queue to reason about. In the runtime, a background job is just another workflow that runs with its own independent run — its own run id, its own steps, and its own row in your runs list. 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.

The model is simple: decompose the work into a durable workflow, then start that workflow. The familiar use cases — send an email after a turn, kick off a long data import, chain a piece of work without holding the caller open — are all just 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 everything the old queue knobs gave you, and more:

  • Survives crashes. Each step ('use step') runs once, its result is persisted, 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.
  • Retries and backoff are built into the runtime, per step.
  • Observable. A triggered workflow is an ordinary run: it shows up in stackbone runs list and streams logs through stackbone logs tail --run <id>. No separate queue inspector.
  • Typed at the edge. Its input is validated 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",
  "runId": "run_…",
  "worldRunId": "…",
  "trigger": "api"
}

From there the run is independent — it survives the caller closing the connection, restarts, and redeploys, and you track it with the runs commands.

Input is validated first. The runtime validates 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 nothing is enqueued — 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: 'Kick off 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 is replayed on restart, so a long import is just 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', {
    message: `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 firing and hoping. 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 just an observable list of runs over time, not an opaque cron entry.

Declare a schedule 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');

You can also manage and inspect recurring triggers from the CLI; see the command reference and Workflows for the current surface. A recurring trigger's cron value is validated when you register it, so a bad pattern fails up front instead of silently never firing:

  • The pattern is a five-field cron (minute hour day month weekday) or a named macro (@hourly, @daily, @weekly, @monthly, @yearly). Six-field, seconds-granularity patterns are rejected — one minute is the finest resolution.
  • The timezone, when given, must be a real IANA name (e.g. Europe/Madrid); it defaults to UTC.

Observing triggered runs

Triggered workflows are runs — you don't need a 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>       # re-run a failed run
stackbone runs cancel <run-id>      # 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, not a queue:

  • Bad input — the runtime rejects the start with a 400 carrying the field-level issues, and no run is created. 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>. Because steps are persisted, the retry resumes from the last good step.

Where to go next

The platform for agent developers.Build, ship and observe agentsthat survive prod.

© 2026 · STACKBONEBUILT WITH ❤️ FROM CANADA AND SPAIN