around_step is so useful.
One hook. Logs, timing, tracing. Every step.
Workflows are the good part.
Devin Fitzsimons
Senior Engineer, AI · CompanyCam
Senior Engineer, AI · CompanyCam
TypeScript maximalist.
I love state machines.
I love pair programming.
Got an LLM-shaped problem?
Start here.
The worst solution.
The first solution.
Who has examples?
Got an LLM-shaped problem?
Start here.
The worst solution.
The first solution.
Who has examples?
Code and LLM calls, grouped into steps that turn broad inputs into reliable, accurate results.
Let the LLM handle the fuzzy parts. Push everything else toward code.
Code and LLM calls, grouped into steps that turn broad inputs into reliable, accurate results.
Let the LLM handle the fuzzy parts. Push everything else toward code.
Workflow
One task, predictable steps. Each call uses the last step’s output. Check the result before moving on.
const prompt = `
Use these Eras Tour notes.
Step 1: Create a newsletter outline.
Step 2: Use that outline to write a draft.
Step 3: Translate that draft into Spanish.
`;Workflow
One task, predictable steps. Each call uses the last step’s output. Check the result before moving on.
const prompt = `
Use these Eras Tour notes.
Step 1: Create a newsletter outline.
Step 2: Use that outline to write a draft.
Step 3: Translate that draft into Spanish.
`;Workflow
One task, predictable steps. Each call uses the last step’s output. Check the result before moving on.
const prompt = `
Use these Eras Tour notes.
Step 1: Create a newsletter outline.
Step 2: Use that outline to write a draft.
Step 3: Translate that draft into Spanish.
`;Workflow
One primitive. Different strategies.
const newsletter = new Workflow({
// Run one step at a time. The strategy is swappable.
strategy: new Sequential(),
steps: [],
});Workflow
Each output names a key in state. All three results stick around.
task("outline", {
input: () => tourNotes,
output: "outline", // Writes state.outline.
}),
task("draft", {
input: (state) => state.outline,
output: "draft",
}),
task("translate", {
input: (state) => state.draft,
output: "spanish",
}),
code("inspect", (state) => console.log(state)),
// Logged state (example values):
// {
// outline: "1. Surprise songs 2. Fan highlights",
// draft: "Tonight’s surprise songs delighted fans...",
// spanish: "Las canciones sorpresa de esta noche..."
// }Workflow
Some steps are just code. That’s the point.
task("translate", {
input: (state) => state.draft,
output: "spanish",
}),
// Same state. Same execution order. No LLM needed.
code("save", async (state) => {
await saveNewsletter(state.spanish);
}),Workflow
One result contract. Success or failure.
const result = await newsletter.run();
if (result.ok) {
showNewsletter(result.state.spanish);
} else {
// Keep partial state and identify exactly what failed.
reportFailure(result.failedStep, result.error);
}
// Required steps stop the workflow when they fail.Workflow
Lesson learned: a notification service outage shouldn’t fail the newsletter.
code("save", (state) => saveNewsletter(state.spanish)),
code("notify", (state) => notificationService.send(state.spanish), {
// Service down? This step is optional. Keep going.
continueOnFailure: true,
}),
code("recordCompletion", () => recordCompletion()),Workflow
Put it together.
const newsletter = new Workflow({
strategy: new Sequential(), // One step at a time. Swap strategies, keep the primitive.
steps: [
task("outline", { input: () => tourNotes, output: "outline" }),
task("draft", { input: (state) => state.outline, output: "draft" }),
task("translate", { input: (state) => state.draft, output: "spanish" }),
code("save", (state) => saveNewsletter(state.spanish)),
code("notify", (state) => notifyEditor(state.spanish), {
continueOnFailure: true, // A failed notification doesn't fail the newsletter.
}),
],
});
const result: WorkflowResult = await newsletter.run();
if (result.ok) {
showNewsletter(result.state.spanish);
} else {
reportFailure(result.failedStep, result.error);
}Workflow
When your prompt says “make sure,” give those quality requirements their own evaluation. Draft, review, and revise from feedback—with a limit.
const prompt = `
Write an invite to our Taylor Swift listening party
using this event brief.
Make sure new fans feel welcome, even if they
don't know the albums.
Make sure the date, location, and RSVP are clear.
Make it exciting, but don't imply Taylor will attend.
`;Workflow
When your prompt says “make sure,” give those quality requirements their own evaluation. Draft, review, and revise from feedback—with a limit.
const prompt = `
Write an invite to our Taylor Swift listening party
using this event brief.
Make sure new fans feel welcome, even if they
don't know the albums.
Make sure the date, location, and RSVP are clear.
Make it exciting, but don't imply Taylor will attend.
`;Workflow
When your prompt says “make sure,” give those quality requirements their own evaluation. Draft, review, and revise from feedback—with a limit.
const prompt = `
Write an invite to our Taylor Swift listening party
using this event brief.
Make sure new fans feel welcome, even if they
don't know the albums.
Make sure the date, location, and RSVP are clear.
Make it exciting, but don't imply Taylor will attend.
`;Workflow
Those “make sure” instructions are evaluation criteria.
const criteria = [
"Welcome new Taylor Swift fans without assuming album knowledge.",
"Make the date, location, and RSVP instructions easy to find.",
"Build excitement without implying Taylor will attend.",
];Workflow
Same workflow primitive. A different execution strategy.
const invite = new Workflow({
strategy: new EvaluatorOptimizer(),
maxAttempts: 3, // Up to three evaluations, including the first draft.
generate: () => draftInvite(eventBrief),
evaluate: (state) => reviewInvite(state.candidate, criteria),
optimize: (state) => reviseInvite({
draft: state.candidate,
feedback: state.evaluation.feedback,
eventBrief,
}),
});Workflow
Evaluate the actual draft. Return a verdict and useful feedback.
state.candidate = await draftInvite(eventBrief);
state.evaluation = await reviewInvite(state.candidate, criteria);
console.log(state.evaluation);
// Example verdict:
// {
// passed: false,
// feedback: "Explain what an era is. Make the RSVP instructions clearer."
// }Workflow
Rejected? Revise from the feedback, then evaluate again.
state.attempts = 1; // The first candidate already counts.
while (!state.evaluation.passed) {
if (state.attempts >= maxAttempts) return exhaustedFailure(state);
state.candidate = await reviseInvite({
draft: state.candidate,
feedback: state.evaluation.feedback,
eventBrief,
});
state.evaluation = await reviewInvite(state.candidate, criteria);
state.attempts += 1;
}
return success(state);Workflow
Bad draft? Improve it. Service down? Stop the loop.
try {
// Rejected candidates get feedback, up to the attempt budget.
return await runQualityLoop();
} catch (exception) {
// A phase threw. Infrastructure retries belong outside this loop.
return new WorkflowResult({
state,
failedStep: phaseInFlight.name,
error: exception.message,
exception,
});
}
// No candidate passed? Return failure, not an approved draft.Workflow
Different failures. Same shape. Same caller.
// Sequential failure, phase error, or exhausted attempts:
// every strategy returns the same WorkflowResult.
return new WorkflowResult({
state, // Preserve partial work.
failedStep: failed.name, // Identify the step or phase.
error: exception.message,
exception, // Preserve the original exception.
});
// The caller doesn't need to know which strategy ran.
const result = await workflow.run();
if (!result.ok) {
reportFailure(result.failedStep, result.error);
}
// Rails run! re-raises result.exception for job retries.Workflow
A workflow inside a step. The strategies compose.
const refineInvite = new Workflow({
strategy: new EvaluatorOptimizer(),
maxAttempts: 3, // Up to three evaluations, including the first draft.
generate: () => draftInvite(eventBrief),
evaluate: (state) => reviewInvite(state.candidate, criteria),
optimize: (s) => reviseInvite(s.candidate, s.evaluation.feedback, eventBrief),
});
const publishInvite = new Workflow({
strategy: new Sequential(),
steps: [
code("refine", async (state) => {
const result: WorkflowResult = await refineInvite.run();
// Bubble the original exception into the outer workflow's result.
if (!result.ok) throw result.exception;
state.invite = result.state.candidate;
}),
code("save", (state) => saveInvite(state.invite)),
],
});
const result: WorkflowResult = await publishInvite.run();
if (!result.ok) reportFailure(result.failedStep, result.error);Workflow
Parse the people. Parse the schedule. Neither needs the other’s output. Run both at once, then combine the results.
const prompt = `
An invitation for Taylor Swift just came in.
From the invite list, extract the people involved.
From the supplied schedule notes, extract:
- Tour commitments
- Football-team events
- Travel for football games
- Studio sessions
Combine both into an invite brief.
`;Workflow
Parse the people. Parse the schedule. Neither needs the other’s output. Run both at once, then combine the results.
const prompt = `
An invitation for Taylor Swift just came in.
From the invite list, extract the people involved.
From the supplied schedule notes, extract:
- Tour commitments
- Football-team events
- Travel for football games
- Studio sessions
Combine both into an invite brief.
`;Workflow
Parse the people. Parse the schedule. Neither needs the other’s output. Run both at once, then combine the results.
const prompt = `
An invitation for Taylor Swift just came in.
From the invite list, extract the people involved.
From the supplied schedule notes, extract:
- Tour commitments
- Football-team events
- Travel for football games
- Studio sessions
Combine both into an invite brief.
`;Workflow
Two inputs. Two jobs. Neither needs the other’s output.
task("people", {
input: () => request.inviteList,
output: "people",
}),
task("commitments", {
input: () => request.scheduleNotes,
output: "commitments",
}),
// Commitments: tour, football-team events, game travel, studio.
// Extract what the supplied notes say. Don't invent a schedule.Workflow
Start both. Wait once. Combine afterward.
// Inside the parallel strategy, conceptually:
const [people, commitments] = await Promise.all([
parsePeople(request.inviteList),
parseCommitments(request.scheduleNotes),
]);
const state = { people, commitments };
// Time is roughly the slower branch, not both added together.
// Each branch owns its output. No reading unfinished results.Workflow
Same workflow. Parallel strategy. Same result contract.
const parseInvite = new Workflow({
strategy: new Parallel(),
steps: [
task("people", {
input: () => request.inviteList,
output: "people",
}),
task("commitments", {
input: () => request.scheduleNotes,
output: "commitments",
}),
],
});
const result: WorkflowResult = await parseInvite.run();
if (result.ok) {
// Both branches have finished. Now combine their outputs.
showInviteBrief(result.state.people, result.state.commitments);
} else {
reportFailure(result.failedStep, result.error);
}Workflow
A tour asset comes in. The LLM classifies it, and code sends it to the right handler. No clear fit? Send it to a person.
const prompt = `
Review this Taylor Swift tour asset.
Pick the best use:
- Short-form video content
- A photo appropriate for a meme
- A high-quality photo for press use
- Unknown or unclear: needs manual review
Choose one path, then send it to that handler.
`;Workflow
A tour asset comes in. The LLM classifies it, and code sends it to the right handler. No clear fit? Send it to a person.
const prompt = `
Review this Taylor Swift tour asset.
Pick the best use:
- Short-form video content
- A photo appropriate for a meme
- A high-quality photo for press use
- Unknown or unclear: needs manual review
Choose one path, then send it to that handler.
`;Workflow
A tour asset comes in. The LLM classifies it, and code sends it to the right handler. No clear fit? Send it to a person.
const prompt = `
Review this Taylor Swift tour asset.
Pick the best use:
- Short-form video content
- A photo appropriate for a meme
- A high-quality photo for press use
- Unknown or unclear: needs manual review
Choose one path, then send it to that handler.
`;Workflow
It’s a classifier. Pick one path from a known set.
const route = await classifyAsset(asset, {
shortVideo: "A clip suitable for short-form video.",
memePhoto: "A photo with a clear meme-worthy moment.",
highQualityPhoto: "A sharp, well-composed photo for press use.",
manualReview: "Unknown, ambiguous, or unsuitable for these uses.",
});
// Pick the best fit. If there's no clear fit, request manual review.
// The LLM classifies the asset; it doesn't invent a new handler.Workflow
The LLM picks. Code dispatches. Unknown is a valid outcome.
const handlers = new Map([
["shortVideo", createShortVideo],
["memePhoto", createMeme],
["highQualityPhoto", preparePressPhoto],
["manualReview", queueManualReview],
]);
const handle = handlers.get(route) ?? queueManualReview;
const assetResult = await handle(asset);
// Queuing manual review is a successful handoff.
// A broken classifier or handler is still a workflow failure.Workflow
One chosen path. A fallback. Same result contract.
const tourAsset = new Workflow({
strategy: new Routing(),
classify: () => classifyAsset(asset),
routes: {
shortVideo: () => createShortVideo(asset),
memePhoto: () => createMeme(asset),
highQualityPhoto: () => preparePressPhoto(asset),
manualReview: () => queueManualReview(asset),
},
fallback: () => queueManualReview(asset),
output: "assetResult",
});
const result: WorkflowResult = await tourAsset.run();
if (result.ok) {
showAssetStatus(result.state.assetResult);
} else {
reportFailure(result.failedStep, result.error);
}Workflow
We know what our workers can do. We don’t know what work is coming. Each freelance journalist’s submission gets its own plan.
const prompt = `
Turn this freelance journalist's Taylor Swift
tour submission into a finished recap.
It might contain an article, voice notes,
photos, clips, or a half-finished draft.
Figure out what work is needed, delegate it
to our workers, and assemble their results.
`;Workflow
We know what our workers can do. We don’t know what work is coming. Each freelance journalist’s submission gets its own plan.
const prompt = `
Turn this freelance journalist's Taylor Swift
tour submission into a finished recap.
It might contain an article, voice notes,
photos, clips, or a half-finished draft.
Figure out what work is needed, delegate it
to our workers, and assemble their results.
`;Workflow
We know what our workers can do. We don’t know what work is coming. Each freelance journalist’s submission gets its own plan.
const prompt = `
Turn this freelance journalist's Taylor Swift
tour submission into a finished recap.
It might contain an article, voice notes,
photos, clips, or a half-finished draft.
Figure out what work is needed, delegate it
to our workers, and assemble their results.
`;Workflow
We know the workers. The submission determines the jobs.
const workers = {
transcribe,
edit,
caption,
};
const plan = await planCoverage(submission, Object.keys(workers));
// Finished article + photos? Maybe edit and caption.
// Voice notes + photos? Transcribe, edit, and caption.
// The LLM chooses jobs and dependencies, not new tools.Workflow
The LLM returns this plan. We didn’t write these jobs.
[
{
"id": "transcript", "worker": "transcribe",
"input": "submission.audio"
},
{
"id": "captions", "worker": "caption",
"input": "submission.photos"
},
{
"id": "article", "worker": "edit",
"dependsOn": ["transcript"], "inputFrom": ["transcript"]
}
]Workflow
The LLM plans. Code coordinates. Workers do the work.
validatePlan(plan, workers); // Known workers, valid inputs, no cycles.
const results = {};
while (hasPendingJobs(plan, results)) {
const ready = jobsWithCompletedDependencies(plan, results);
const batch = await runWorkers(ready, workers, results);
Object.assign(results, batch); // Results keyed by job ID.
}
const recap = await assembleRecap(results);
// Unlike fixed parallel tasks, the jobs came from this submission.Workflow
Sanitize first. Then let the orchestrator plan the work.
const createRecap = (cleanSubmission) => new Workflow({
strategy: new OrchestratorWorker(),
workers: { transcribe, edit, caption },
plan: () => planCoverage(cleanSubmission, {
availableWorkers: ["transcribe", "edit", "caption"],
}),
execute: (plan, workers) => executePlan(plan, workers),
combine: (results) => assembleRecap(results),
output: "recap",
});
const tourRecap = new Workflow({
strategy: new Sequential(),
steps: [
code("sanitize", async (state) => {
state.submission = await sanitizeSubmission(submission);
}),
code("recap", async (state) => {
const result: WorkflowResult = await createRecap(state.submission).run();
if (!result.ok) throw result.exception;
state.recap = result.state.recap;
}),
],
});
const result: WorkflowResult = await tourRecap.run();
if (!result.ok) reportFailure(result.failedStep, result.error);around_step is so useful.One hook. Logs, timing, tracing. Every step.
Rich progress events. Tag the turn and block. Update the UI while work happens.
Saved the draft, then failed? The retry saves it again. Update the same record instead of creating a duplicate.
Keep partial state, the failed step, and the original exception.
Share the workflow’s context by default. A task needs a different document? Override that step’s context.
Go make something awesome.
Devin Fitzsimons
Senior Engineer, AI · CompanyCam
fitzsimons.dev