Skip to main content
These patterns are distilled from production Smithers workflows. They are safe defaults for new projects and scale well as workflows grow in complexity.

Project Structure

For small workflows (1-5 tasks), a single file is fine. For larger projects, split concerns across files:
my-workflow/
  package.json
  tsconfig.json
  workflow.tsx          # Workflow definition
  agents.ts             # Agent configuration
  schemas.ts            # All Zod schemas in one place
  prompts/
    analyze.mdx         # MDX prompt templates
    review.mdx
  lib/
    helpers.ts           # Shared utility functions
For workflows with many tasks (10+), consider splitting tasks into component files:
my-workflow/
  package.json
  tsconfig.json
  bunfig.toml            # MDX preload config (if using MDX prompts)
  preload.ts
  workflow.tsx
  agents.ts
  schemas.ts
  components/
    Discover.tsx
    Implement.tsx
    Review.tsx
    Report.tsx
  prompts/
    discover.mdx
    implement.mdx
    review.mdx
  lib/
    render.ts            # MDX-to-text renderer
    helpers.ts

Single-File Pattern

For prototyping or simple workflows, keep everything in one file:
// workflow.tsx
import { createSmithers, Task } from "smithers-orchestrator";
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const { Workflow, smithers } = createSmithers({
  analysis: z.object({ summary: z.string(), risk: z.enum(["low", "medium", "high"]) }),
  report: z.object({ title: z.string(), body: z.string() }),
});

const analyst = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  instructions: "You are a code analyst. Return structured JSON.",
});

export default smithers((ctx) => (
  <Workflow name="quick-review">
    <Task id="analyze" output="analysis" agent={analyst}>
      {`Analyze: ${ctx.input.target}`}
    </Task>
    <Task id="report" output="report">
      {{ title: "Review Complete", body: ctx.output("analysis", { nodeId: "analyze" }).summary }}
    </Task>
  </Workflow>
));

Schema Organization

Keep all schemas in a centralized schemas.ts file. This makes it easy to see the full data model at a glance:
// schemas.ts
import { z } from "zod";

export const ticketSchema = z.object({
  id: z.string(),
  title: z.string(),
  description: z.string(),
  priority: z.enum(["low", "medium", "high"]),
});

export const schemas = {
  discover: z.object({
    tickets: z.array(ticketSchema).max(5),
  }),
  implement: z.object({
    summary: z.string(),
    filesChanged: z.array(z.string()),
    testsAdded: z.number(),
  }),
  review: z.object({
    approved: z.boolean(),
    feedback: z.string(),
    suggestions: z.array(z.string()),
  }),
  report: z.object({
    title: z.string(),
    body: z.string(),
    totalTickets: z.number(),
    totalApproved: z.number(),
  }),
};
Then use it in your workflow:
// workflow.tsx
import { createSmithers, Task, Sequence } from "smithers-orchestrator";
import { schemas } from "./schemas";

const { Workflow, smithers } = createSmithers(schemas);

Task ID Naming Conventions

Task IDs must be unique within a workflow and should be descriptive. Follow these conventions: Simple tasks: use a short, descriptive name.
<Task id="analyze" output="analysis" agent={analyst}>
Dynamic tasks (generated from arrays): use a prefix with a unique identifier.
{tickets.map((ticket) => (
  <Task key={ticket.id} id={`${ticket.id}:implement`} output="implement" agent={implementer}>
    {`Implement ticket ${ticket.id}: ${ticket.title}`}
  </Task>
))}
Iteration-aware tasks (inside Ralph): the task ID stays the same across iterations. Smithers differentiates them by the iteration column.
<Ralph until={approved} maxIterations={3}>
  <Task id="review" output="review" agent={reviewer}>
    Review the implementation.
  </Task>
</Ralph>
Naming pattern: {entity}:{action} for dynamic tasks, plain {action} for single tasks.
analyze              -- single analysis task
ticket-42:implement  -- implementing ticket 42
ticket-42:review     -- reviewing ticket 42
report               -- final report

Agent Configuration

Centralize agent setup in agents.ts. This makes it easy to swap between API and CLI agents:
// agents.ts
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { read, grep, bash, edit, write } from "smithers-orchestrator/tools";

const MODEL = process.env.CLAUDE_MODEL ?? "claude-sonnet-4-20250514";

export const analyst = new Agent({
  model: anthropic(MODEL),
  instructions: "You are a senior code analyst. Return structured JSON.",
});

export const implementer = new Agent({
  model: anthropic(MODEL),
  instructions: "You are a senior engineer. Implement changes and return structured JSON.",
  tools: { read, grep, bash, edit, write },
});

export const reviewer = new Agent({
  model: anthropic(MODEL),
  instructions: "You are a strict code reviewer. Return structured JSON with approval status.",
  tools: { read, grep },
});

MDX Prompt Templates

For complex prompts, use MDX files. This keeps your JSX clean and lets you compose prompts with variables:
{/* prompts/review.mdx */}
Review the following implementation:

**Ticket**: {props.ticket.title}
**Description**: {props.ticket.description}

**Changes made**:
{props.summary}

**Files changed**:
{props.files.map(f => `- ${f}`).join("\n")}

Return JSON with:
- approved (boolean)
- feedback (string)
- suggestions (string[])
Render MDX to plain text before passing it to a task:
// lib/render.ts
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { MDXContent } from "mdx/types";

export function render(Component: MDXContent, props: Record<string, any> = {}): string {
  const html = renderToStaticMarkup(React.createElement(Component, props));
  return html
    .replace(/<\/?(p|div|h[1-6]|li|ul|ol|pre|blockquote|section)[^>]*>/gi, "\n")
    .replace(/<br\s*\/?>(?!\n)/gi, "\n")
    .replace(/<[^>]+>/g, "")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#x27;/g, "'")
    .replace(/&#x2F;/g, "/")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}
Enable MDX imports in Bun:
# bunfig.toml
preload = ["./preload.ts"]
// preload.ts
import { plugin, type BunPlugin } from "bun";
import mdx from "@mdx-js/esbuild";

plugin(mdx() as unknown as BunPlugin);
Use it in your component:
// components/Review.tsx
import { Task } from "smithers-orchestrator";
import { render } from "../lib/render";
import { reviewer } from "../agents";
import ReviewPrompt from "../prompts/review.mdx";

export function Review({ ticket, summary, files }: {
  ticket: { title: string; description: string };
  summary: string;
  files: string[];
}) {
  return (
    <Task id={`${ticket.title}:review`} output="review" agent={reviewer}>
      {render(ReviewPrompt, { ticket, summary, files })}
    </Task>
  );
}

Output Access Patterns

Use ctx.output() when you are certain the output exists (the task has completed). Use ctx.outputMaybe() for conditional rendering:
export default smithers((ctx) => {
  // outputMaybe returns undefined if not yet available -- safe for conditionals
  const analysis = ctx.outputMaybe("analysis", { nodeId: "analyze" });

  return (
    <Workflow name="example">
      <Sequence>
        <Task id="analyze" output="analysis" agent={analyst}>
          {`Analyze: ${ctx.input.description}`}
        </Task>

        {/* Only mounts after analyze completes */}
        {analysis ? (
          <Task id="report" output="report">
            {{ summary: analysis.summary, risk: analysis.risk }}
          </Task>
        ) : null}
      </Sequence>
    </Workflow>
  );
});
Use ctx.output() (throws on missing) only when you are certain the task has finished — for example, inside a static task that depends on a completed predecessor:
// Safe because report only mounts after analysis exists
{analysis ? (
  <Task id="report" output="report">
    {{ summary: ctx.output("analysis", { nodeId: "analyze" }).summary }}
  </Task>
) : null}

Environment-Based Configuration

Use environment variables for settings that change between development and production:
// agents.ts
const MODEL = process.env.CLAUDE_MODEL ?? "claude-sonnet-4-20250514";
const USE_CLI = process.env.USE_CLI_AGENTS === "1";
# Development
CLAUDE_MODEL=claude-sonnet-4-20250514 bun run workflow.tsx

# Production (use a more capable model)
CLAUDE_MODEL=claude-opus-4-20250514 bun run workflow.tsx

Next Steps

  • Tutorial — End-to-end tutorial using these patterns.
  • Best Practices — Higher-level guidelines for effective workflows.
  • Components — Reference for all JSX components.