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, 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 is permanent, so retrying it only burns the step's retry budget before the run fails anyway. FatalError skips the budget and fails the run at once.

What's next

BUILT WITH ❤️ FROM CANADA AND SPAIN