Skip to main content

Documentation Index

Fetch the complete documentation index at: https://smithers.sh/llms.txt

Use this file to discover all available pages before exploring further.

The Effect API is the lower-level authoring surface for teams that already model application logic with Effect, Layer, and Schema. It uses the same Smithers runtime as JSX: steps are persisted in SQLite, completed work is not re-run on resume, outputs are schema-validated, and dependencies drive scheduling. The difference is authoring style: every step, approval, sequence, parallel block, match, branch, loop, worktree, and scope is an ordinary value you can export, return from a function, or compose with other graph values. Use JSX for most workflows. Use the Effect API when your workflow lives inside an Effect service, you want step bodies to return Effect values directly, or you need a React-free API for generated workflow definitions.

Minimal workflow

import { Smithers } from "smithers-orchestrator";
import { Effect, Schema } from "effect";

const inputSchema = Schema.Struct({
  repo: Schema.String,
  sha: Schema.String,
});

const analysisSchema = Schema.Struct({
  summary: Schema.String,
  risk: Schema.Literal("low", "medium", "high"),
});

const reportSchema = Schema.Struct({
  markdown: Schema.String,
});

const G = Smithers.workflow({
  name: "repo-review",
  input: inputSchema,
});

const analyze = G.step("analyze", {
  output: analysisSchema,
  timeout: "2m",
  retry: { maxAttempts: 3, backoff: "exponential", initialDelay: "1s" },
  run: ({ input, heartbeat }) =>
    Effect.gen(function* () {
      heartbeat({ phase: "analyzing" });
      yield* Effect.log(`Reviewing ${input.repo}@${input.sha}`);
      return { summary: "Found one risky migration.", risk: "medium" as const };
    }),
});

const report = G.step("report", {
  needs: { analyze },
  output: reportSchema,
  run: ({ analyze }) => ({
    markdown: `# Review\n\n${analyze.summary}\n\nRisk: ${analyze.risk}`,
  }),
});

export const reviewWorkflow = G.from(G.sequence(analyze, report));

const result = await Effect.runPromise(
  reviewWorkflow
    .execute(
      { repo: "acme/api", sha: "abc123" },
      { runId: "review-abc123" },
    )
    .pipe(Effect.provide(Smithers.sqlite({ filename: "smithers.db" }))),
);
Smithers.workflow(opts) returns a typed handle G. Every constructor (G.step, G.approval, G.sequence, G.parallel, G.match, G.branch, G.loop, G.worktree, G.scope) returns a graph value. G.from(graph) finalizes the workflow into something execute-able. execute() returns an Effect. The success value is the decoded output of the final graph node: a step output for a single step, the last child for a sequence, a tuple for a parallel block. If the run stops on an approval or timer, the success value is the normal RunResult with a waiting status.

Steps and dependencies

Steps are values:
const analyze = G.step("analyze", {
  output: analysisSchema,
  run: ({ input }) => analyzeRepo(input.repo, input.sha),
});
input is typed from the workflow’s input schema. The step’s output type is inferred from the output schema and flows into anything that lists this step in needs:
const report = G.step("report", {
  needs: { analyze },
  output: reportSchema,
  run: ({ analyze }) => ({
    markdown: renderReport(analyze.summary, analyze.risk),
  }),
});
Step IDs are durable. Changing an ID creates a new task and leaves the old persisted output behind. A step can return a plain value, a Promise, or an Effect; Smithers decodes the result with the step’s output schema before writing it. The step context includes input, dependency values, executionId, stepId, attempt, iteration, signal, heartbeat(data), and lastHeartbeat.

Control flow

Use G.sequence(...nodes) for ordered work and G.parallel(...nodes, { maxConcurrency }) for concurrent work. G.parallel returns a tuple of child results. G.match(source, { when, then, else }) selects between two statically-known branches based on a completed step’s output. Both branches are compiled into the graph; only the matching branch executes. G.branch({ condition, needs, then, else }) is the same shape but the predicate runs against an arbitrary needs context, not a single source step. G.loop({ id, children, until, maxIterations }) repeats a fragment until the predicate returns true. Nested loops are not supported.
export const reviewWorkflow = G.from(
  G.sequence(
    analyze,
    G.match(analyze, {
      when: (analysis) => analysis.risk === "high",
      then: G.approval("approve-high-risk", {
        needs: { analyze },
        request: ({ analyze }) => ({
          title: "Approve high-risk review",
          summary: analyze.summary,
        }),
        onDeny: "fail",
      }),
    }),
    report,
  ),
);

Worktrees

G.worktree({ id, path, branch, skipIf, needs, children }) runs children inside a git worktree. The worktree is created before the children execute and torn down afterward.
G.worktree({
  id: "review-shard",
  path: "scratch/review",
  children: G.sequence(read, summarize),
});

Reuse

Static reuse is just a graph value:
const reviewShard = G.sequence(read, summarize);
Parameterized reuse is a function returning a graph value:
const makeReviewShard = (params: { path: string }) =>
  G.sequence(
    G.step("read", {
      output: diffSchema,
      run: ({ input }) => readDiff(input.repo, params.path),
    }),
    G.step("summarize", {
      needs: { read },
      output: summarySchema,
      run: ({ read }) => summarizeDiff(read),
    }),
  );
Multi-mount reuse is G.scope(instanceId, fragment). The compiler applies instanceId. as a durable ID prefix to every step and approval inside the fragment. The same fragment can be mounted under multiple scopes without collision:
G.parallel(
  G.scope("api", makeReviewShard({ path: "packages/api" })),
  G.scope("web", makeReviewShard({ path: "apps/web" })),
);

Cross-workflow fragments

For graph fragments that need to live across workflows with different inputs, build them with Smithers.fragment(inputSchema):
const F = Smithers.fragment(diffInputSchema);

const readDiff = F.step("read-diff", {
  output: diffSchema,
  run: ({ input }) => readDiff(input.path),
});

const summarize = F.step("summarize", {
  needs: { readDiff },
  output: summarySchema,
  run: ({ readDiff }) => summarizeDiff(readDiff),
});

export const reviewShard = F.sequence(readDiff, summarize);
Smithers.fragment exposes the same constructors as a workflow handle (step, approval, sequence, parallel, match, branch, loop, worktree, scope), but no from — fragments are values, not workflows. Compile happens when they’re mounted into a real workflow:
const G = Smithers.workflow({ name: "repo-review", input: workflowInputSchema });

export const reviewWorkflow = G.from(
  G.parallel(
    G.scope("api", reviewShard),
    G.scope("web", reviewShard),
  ),
);
A fragment’s input schema is read by every step inside it. The host workflow’s input must be assignable to the fragment’s input schema.

Pipe

Every graph value has a .pipe(...fns) method. It’s left-to-right function application — the same shape as Effect.pipe:
graph.pipe(f, g, h)
// equivalent to
h(g(f(graph)))
No pipeable combinators ship in V1; the method is forward-compatibility for future data-last forms.

Operational notes

  • Provide exactly one persistence layer with Effect.provide(Smithers.sqlite({ filename })).
  • Keep step IDs stable across releases; use new IDs for materially different work.
  • Use heartbeat() in long-running steps and honor signal in external calls.
  • Use retry, retryPolicy, timeout, skipIf, and cache the same way you would on JSX tasks.
  • Prefer idempotent step bodies. For external side effects, use executionId, stepId, and attempt when constructing idempotency keys.
  • G.match is graph topology selection — both branches must be statically knowable so durable IDs stay stable across resume. It is not Effect’s Match module (which is runtime value pattern matching).