Skip to main content
What if your workflow could change shape while it was running? Not “skip a step” — actually grow new tasks that didn’t exist a moment ago, because a previous task just produced the information that makes them possible. That’s what Smithers does, and it does it with a tool you already know: React. But forget everything you know about React and the DOM. Think of React here as a compiler, not a UI library. Your JSX compiles into an execution plan. And because React re-renders when data changes, the plan re-compiles every time a task finishes.

The Core Insight

Here is a workflow with two tasks, except the second one doesn’t exist yet:
export default smithers((ctx) => {
  const analysis = ctx.outputMaybe(outputs.analysis, { nodeId: "analyze" });

  return (
    <Workflow name="pipeline">
      <Task id="analyze" output={outputs.analysis} agent={analyst}>
        Analyze the codebase.
      </Task>

      {analysis ? (
        <Task id="fix" output={outputs.fix} agent={coder}>
          {`Fix: ${analysis.issues.join(", ")}`}
        </Task>
      ) : null}
    </Workflow>
  );
});
Frame 1: analysis is undefined. Only analyze is mounted. The plan has one task. Frame 2 (after analyze completes): analysis has a value. Both tasks are mounted. The plan grew. Read that again. The second task doesn’t just “wait” — it literally does not exist in the execution plan until the first task produces a result. The plan is not static. It unfolds over time as each render cycle reveals new tasks. This is the key insight: your workflow is a function of its own outputs.

The Render-Schedule-Execute Loop

Every Smithers run follows this cycle:
┌─────────────────────────────────────────────┐
│  1. RENDER   Build JSX tree with current ctx │
│  2. EXTRACT  Walk tree → TaskDescriptor[]    │
│  3. SCHEDULE Find ready tasks                │
│  4. EXECUTE  Run ready tasks as Effects      │
│  5. PERSIST  Write outputs to SQLite         │
│  6. DETECT   Did the mounted task set change?│
│              If yes → go to 1                │
│              If all done → finish             │
└─────────────────────────────────────────────┘
Step 6 is where the magic lives. After persisting outputs, Smithers re-renders the JSX tree. If ctx.outputMaybe() now returns a value it didn’t before, new tasks may mount. The engine detects this by comparing the set of mounted task IDs between frames and continues the loop. Why does this matter? Because tasks appear in the plan at the moment their preconditions are met — not a moment before. You never declare “task B depends on task A.” You write a conditional, and the dependency emerges from the render.

React as a Workflow Compiler

You might be wondering: why React? Why not just a function that returns a list of tasks? Because React gives you a reconciler — a well-tested engine for turning a tree of declarations into a structured result. Smithers implements a custom React reconciler that produces an in-memory HostElement tree instead of DOM nodes. The reconciler is used purely for tree construction:
  • React.createElement() calls build the component tree
  • The reconciler resolves props, children, and conditional branches
  • The result is walked to extract TaskDescriptor objects
  • Those descriptors drive scheduling and execution
That machinery gives Smithers several properties for free. Let’s walk through them.

Conditional Mounting

Standard JSX conditions control which tasks exist in the plan:
{analysis?.hasIssues ? (
  <Task id="fix" output={outputs.fix} agent={coder}>Fix the issues.</Task>
) : null}
This is not a “skip” — the task literally does not exist in the execution plan until the condition is met. No node in the graph. No placeholder. Nothing.

Component Composition

Here’s where it gets interesting. Large workflows decompose into reusable components — the same way large UIs do:
function ReviewCycle({ ticket }: { ticket: Ticket }) {
  const ctx = useCtx();
  const review = ctx.latest("review", `${ticket.id}:review`);

  return (
    <Loop until={review?.approved} maxIterations={5}>
      <Sequence>
        <Implement ticket={ticket} />
        <Validate ticket={ticket} />
        <Review ticket={ticket} />
      </Sequence>
    </Loop>
  );
}

// Use it in the main workflow
<Workflow name="multi-ticket">
  {tickets.map((ticket) => (
    <ReviewCycle key={ticket.id} ticket={ticket} />
  ))}
</Workflow>
Each <ReviewCycle> is a self-contained workflow fragment with its own loop, its own state lookups, and its own conditional logic. This is standard React composition — but it’s building an execution plan, not a UI.

Dynamic Task Generation

Because JSX is just JavaScript, you can generate tasks from runtime data:
// Generate tasks from runtime data
{repos.map((repo) => (
  <Task key={repo.id} id={`${repo.id}:analyze`} output={outputs.analysis} agent={analyst}>
    {`Analyze ${repo.name}`}
  </Task>
))}
The plan adapts to whatever data is available at render time. Ten repos? Ten tasks. A hundred? A hundred. You don’t decide ahead of time.

Custom Hooks

Smithers provides useCtx() — a React hook that returns the workflow context. If you’ve written custom hooks before, you already know what to do. Build on top of it to extract common patterns.

Extracting Output Logic

function useReviewState(ticketId: string) {
  const ctx = useCtx();
  const claudeReview = ctx.latest("review", `${ticketId}:review-claude`);
  const codexReview = ctx.latest("review", `${ticketId}:review-codex`);

  return {
    claudeReview,
    codexReview,
    allApproved: !!claudeReview?.approved && !!codexReview?.approved,
    issues: [
      ...(claudeReview?.issues ?? []),
      ...(codexReview?.issues ?? []),
    ],
  };
}

// Clean component code
function ReviewFix({ ticket }: { ticket: Ticket }) {
  const { allApproved, issues } = useReviewState(ticket.id);

  return (
    <Task
      id={`${ticket.id}:review-fix`}
      output={outputs.reviewFix}
      agent={codex}
      skipIf={allApproved || issues.length === 0}
    >
      <ReviewFixPrompt issues={issues} />
    </Task>
  );
}
Notice what happened: the messy “check two reviewers, merge their issues” logic moved into a hook. The component just asks: are we approved? What are the issues? The hook is testable, reusable, and keeps the workflow component clean.

Encapsulating Iteration Patterns

function useIterationFeedback(ticketId: string) {
  const ctx = useCtx();

  return {
    previousImplement: ctx.latest("implement", `${ticketId}:implement`),
    previousValidation: ctx.latest("validate", `${ticketId}:validate`),
    isFirstIteration: ctx.iteration === 0,
    iterationCount: ctx.iterationCount("implement", `${ticketId}:implement`),
  };
}

Conditional Workflow Fragments

function useFeatureFlags() {
  const ctx = useCtx();
  return {
    parallelReview: ctx.input.enableParallelReview !== false,
    maxReviewRounds: ctx.input.maxReviewRounds ?? 3,
    skipValidation: ctx.input.skipValidation === true,
  };
}

function Pipeline({ ticket }: { ticket: Ticket }) {
  const flags = useFeatureFlags();

  return (
    <Loop until={approved} maxIterations={flags.maxReviewRounds}>
      <Sequence>
        <Implement ticket={ticket} />
        {!flags.skipValidation && <Validate ticket={ticket} />}
        {flags.parallelReview ? (
          <ParallelReview ticket={ticket} />
        ) : (
          <SingleReview ticket={ticket} />
        )}
      </Sequence>
    </Loop>
  );
}
Feature flags in a workflow. No special DSL. Just props and conditionals.

React Patterns That Work

Because Smithers uses a real React reconciler, many standard React patterns work as-is. If you’ve used them in a UI, you can use them in a workflow.

Props and Children

function AgentTask({ id, agent, children, ...taskProps }: AgentTaskProps) {
  return (
    <Task id={id} output={outputs.result} agent={agent} retries={2} timeoutMs={300000} {...taskProps}>
      {children}
    </Task>
  );
}

Render Props

function WithAnalysis({ children }: { children: (analysis: Analysis) => React.ReactNode }) {
  const ctx = useCtx();
  const analysis = ctx.outputMaybe(outputs.analysis, { nodeId: "analyze" });
  if (!analysis) return null;
  return <>{children(analysis)}</>;
}

// Usage
<WithAnalysis>
  {(analysis) => (
    <Task id="fix" output={outputs.fix} agent={coder}>
      {`Fix: ${analysis.summary}`}
    </Task>
  )}
</WithAnalysis>

Higher-Order Components

function withRetry<P>(Component: React.FC<P>, retries: number) {
  return function RetryWrapper(props: P) {
    // The HOC adds default retry behavior
    return <Component {...props} retries={retries} retryPolicy={{ backoff: "exponential" }} />;
  };
}

const ResilientImplement = withRetry(Implement, 3);

React Context for Configuration

const AgentContext = React.createContext<{ reviewer: Agent; coder: Agent }>(null!);

function useAgents() {
  return React.useContext(AgentContext);
}

// Provide agents at the workflow level
<AgentContext.Provider value={{ reviewer: claude, coder: codex }}>
  <Workflow name="configurable">
    <ReviewCycle ticket={ticket} />
  </Workflow>
</AgentContext.Provider>

// Consume inside components
function Review({ ticket }: { ticket: Ticket }) {
  const { reviewer } = useAgents();
  return (
    <Task id={`${ticket.id}:review`} output={outputs.review} agent={reviewer}>
      Review the implementation.
    </Task>
  );
}

What Doesn’t Apply

Now for the part that might trip you up. Smithers renders the JSX tree fresh on each frame with a new ctx. There is no persistent component state between frames. So some React features you’re used to simply don’t apply:
React featureWorks in Smithers?Why
Component compositionYesTree construction
useContext / custom contextYesAvailable during render
useCtx() and custom hooksYesRead from workflow context
Conditional renderingYesControls plan evolution
React.memoNo effectEach frame is fresh
useStateNo effectNo persistent state between frames
useEffectNo effectNo mount/unmount lifecycle
useRefNo effectRefs reset each frame
This is intentional, not a limitation. Workflow state lives in SQLite, not in React component state. The ctx object is the single source of truth during each render, and it’s built from persisted outputs. If you find yourself reaching for useState, stop — the answer is almost certainly ctx.outputMaybe() or ctx.latest().

Why This Matters

So why go through all this? Because the reactive model gives Smithers three properties that static DAG definitions cannot provide.

1. Plans That Adapt

The workflow plan is not fixed upfront. It evolves as tasks complete:
// Phase 1: Only discovery runs
<Task id="discover" output={outputs.tickets} agent={planner}>
  Break this project into tickets.
</Task>

// Phase 2: After discovery, tasks appear for each ticket
{tickets?.map((ticket) => (
  <ReviewCycle key={ticket.id} ticket={ticket} />
))}
The number and shape of tasks depends on what the planner discovers. A static DAG would need to know the ticket count upfront. You don’t.

2. Natural Data Dependencies

Instead of declaring edges between nodes in a graph DSL, data dependencies are expressed as normal JavaScript:
const analysis = ctx.outputMaybe(outputs.analysis, { nodeId: "analyze" });

// This task can't mount until analysis exists — the dependency is implicit
{analysis ? <Task id="fix" ...>{`Fix: ${analysis.issues}`}</Task> : null}
The dependency graph emerges from the render — no explicit wiring needed. If you can write an if statement, you can express a dependency.

3. Reuse Through Composition

React’s component model means workflow logic is reusable without special framework support:
// A reusable review cycle — works for any ticket type
function ReviewCycle({ ticket, maxRounds = 3 }) { ... }

// A reusable approval gate — works for any action
function RequireApproval({ id, title, children }) { ... }

// Compose them freely
<Workflow name="release">
  {tickets.map((t) => <ReviewCycle ticket={t} />)}
  <RequireApproval id="ship" title="Ship to prod?">
    <Task id="deploy" agent={deployer}>Deploy.</Task>
  </RequireApproval>
</Workflow>
This is not a plugin system or a macro language. It’s standard TypeScript components with standard composition rules. You already know how to do this.

Next Steps

  • Workflows Overview — The big picture of what workflows are and how they work.
  • Workflow State — The ctx API and how data flows between tasks.
  • Control Flow — The four control-flow primitives that structure execution.