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.
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
Errorwhen a retry can fix it. The model call indraftWelcomeis the case: a rate limit or a timeout clears on its own. - Throw
FatalErrorwhen 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.FatalErrorskips the budget and fails the run at once.
What's next
- A refund gated by a human approval: park the run until a person decides.
- A scheduled digest with a long wait:
start a run from a cron schedule and pace it with
sleep. - What is a workflow: the model behind this example.