Getting started with workflows

This page takes you from an empty workspace to a workflow you can trigger and watch run. You develop it with stackbone dev, so everything here happens on your machine. The same files run unchanged inside a packaged agent. If the CLI is new to you, set up Node 24 and Docker with the CLI getting started guide first.

A workflow lives inside a workspace alongside your agents. The steps below assume you already have one (stackbone init my-workspace).

1. Scaffold a workflow

From inside your workspace:

stackbone add workflow qualify-lead

This writes workflows/qualify-lead.workflow.ts. It needs no login and touches no cloud: the workflow joins the workspace stackbone init already linked. It only writes new files, and a name collision fails with an error (re-run with --force to overwrite).

The file follows the naming convention the runtime scans for: the workflow name is the file basename without .workflow.ts, and the exported function is the camelCase name plus Workflow. So qualify-lead.workflow.ts exports qualifyLeadWorkflow.

2. Write the pipeline

A workflow is a plain async function marked 'use workflow'. Every side effect goes in a sub-function marked 'use step', which the runtime runs once, persists, and retries on failure. Declare the contract with sibling inputSchema and outputSchema exports:

workflows/qualify-lead.workflow.ts
import { z } from '@stackbone/sdk';

export const inputSchema = z.object({
  email: z.email(),
  company: z.string(),
});
export const outputSchema = z.object({
  email: z.string(),
  score: z.number(),
  qualified: z.boolean(),
});

export async function qualifyLeadWorkflow(input: z.infer<typeof inputSchema>) {
  'use workflow';

  const enriched = await enrich(input.company); // step 1: fetch firmographics
  const score = await scoreLead(enriched); // step 2: deterministic scoring

  return { email: input.email, score, qualified: score >= 60 };
}

async function enrich(company: string) {
  'use step';
  // Replace with a real lookup. A step's result is persisted and replayed on resume.
  return { company, employees: 250, industry: 'software' };
}

async function scoreLead(enriched: { employees: number }) {
  'use step';
  return Math.min(100, Math.round(enriched.employees / 5));
}

Keep the workflow body deterministic: no clocks, no randomness, no direct I/O. Put all of that inside a step. That includes every stackbone.* call: the body runs in an isolated sandbox, and a stackbone.* reference outside a step fails every workflow in the workspace at run time. See the overview for why.

3. Triggering a run

A workflow starts as its own run, with its own run id and step log. The two paths you will use most while developing:

From the CLI: start a run by name with a JSON input:

stackbone workflows start qualify-lead \
  --input '{"email":"sam@acme.com","company":"Acme"}'

List what the installation exposes, or inspect a workflow's contract, with:

stackbone workflows list
stackbone workflows schema qualify-lead

Over HTTP: the emulator serves a generic trigger route per workflow. The body is the workflow input; the emulator validates it against inputSchema before anything runs:

curl -X POST http://127.0.0.1:4242/api/workflows/qualify-lead/start \
  -H 'content-type: application/json' \
  -d '{"email":"sam@acme.com","company":"Acme"}'
# → { "workflowName": "qualify-lead", "status": "started",
#     "runId": "...", "worldRunId": "...",
#     "trigger": "POST /api/workflows/qualify-lead/start" }

The emulator rejects a bad payload with field-level issues and creates no run.

4. Watch the run

A started workflow is an ordinary run. Observe it, retry a failed step, or read its logs with:

stackbone runs list
stackbone runs get <runId>
stackbone logs tail --run <runId>

Kill stackbone dev mid-run and start it again: the run resumes from the last completed step instead of replaying the whole pipeline.

5. Run on a schedule

To run the workflow on a cadence, export a schedules array next to it. The runtime reconciles it on every boot, so there is no imperative call to make:

export const schedules = [{ cron: '0 3 * * *', input: { email: '', company: '' } }];

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. A pattern the box cannot parse never arms, and its row on the Recurring jobs screen reads not armed.

To add or drop a schedule at run time instead, call stackbone.workflows.schedule / .unschedule / .listSchedules from inside a workflow. Either way, Recurring jobs in Studio shows what is armed right now, when it fires next, and how the last run went. See Recurring jobs.

Where to go next

BUILT WITH ❤️ FROM CANADA AND SPAIN