--- title: 'An onboarding pipeline' description: 'A three-step workflow that validates a signup, drafts a welcome email with one model call, and stores the result.' position: 1 --- # 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](/docs/sdk/workflows/building-workflows#3-triggering-a-run) shows. ```ts // 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) { '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) { '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 - **[A refund gated by a human approval](/docs/examples/workflows/refund-approval)**: park the run until a person decides. - **[A scheduled digest with a long wait](/docs/examples/workflows/scheduled-digest)**: start a run from a cron schedule and pace it with `sleep`. - **[What is a workflow](/docs/sdk/workflows/overview)**: the model behind this example.