An onboarding pipeline

A linear pipeline: validate the signup, draft a welcome with a single model call, then persist the result. Each step is a durable checkpoint, so a crash after the draft never re-bills the model call before it.

The whole workflow is a single workflows/onboarding.workflow.ts file. Drop it into a workspace and trigger it the way Getting started with workflows shows.

workflows/onboarding.workflow.ts
import { z } from 'zod';
import { stackbone } from '@stackbone/sdk';
import { FatalError } from '@stackbone/sdk/workflow';

export const inputSchema = z.object({
  userId: z.string(),
  email: z.email(),
  plan: z.enum(['free', 'pro', 'scale']),
});
export const outputSchema = z.object({
  userId: z.string(),
  subject: z.string(),
  body: z.string(),
});

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

  const signup = await validateSignup(input);
  const copy = await draftWelcome(signup.plan);
  return await persistWelcome(signup.userId, copy);
}

async function validateSignup(input: z.infer<typeof inputSchema>) {
  'use step';
  // A bad address never becomes good on a retry, so fail the run at once.
  if (!input.email.includes('@')) throw new FatalError(`Invalid email: ${input.email}`);
  return { userId: input.userId, plan: input.plan };
}

async function draftWelcome(plan: string) {
  'use step';
  const result = await stackbone.ai.chat.completions.create({
    model: 'openai/gpt-4o-mini',
    messages: [
      { role: 'system', content: 'Write a one-line welcome email subject and body.' },
      { role: 'user', content: `The user joined the ${plan} plan.` },
    ],
  });
  if (result.error) throw new Error(result.error.code);
  const text = result.data.choices[0]?.message.content ?? '';
  return { subject: `Welcome to the ${plan} plan`, body: text };
}

async function persistWelcome(userId: string, copy: { subject: string; body: string }) {
  'use step'; // replace with a real write, keyed on userId so a replay overwrites
  return { userId, ...copy };
}

Which error to throw

The runtime retries a failed step, so pick the throw that matches the cause:

  • Throw a plain Error when a retry can fix it. The model call in draftWelcome is the case: a rate limit or a timeout clears on its own.
  • Throw FatalError when nothing can fix it. An invalid email address stays invalid, so a retry burns the step's retry budget and the run still fails. FatalError skips the budget and fails the run at once.

What's next

BUILT WITH ❤️ FROM CANADA AND SPAIN