Skip to main content
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, onMaxReached }) repeats a fragment until the predicate returns true. Nested loops are not supported. onMaxReached accepts 'fail' or 'return-last' and controls behavior when maxIterations is exceeded; when omitted the loop returns the last iteration’s outputs rather than failing.
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",
      }),
      // else: report (omitting `else` means the match falls through to the next sibling in the sequence)
    }),
    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. For example, G.scope('api', makeReviewShard(...)) produces step IDs api.read and api.summarize in the database. 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 is typed with an input schema so TypeScript can infer each step’s input type. At runtime the schema is not read or validated; steps receive the host workflow’s input directly. The host workflow’s input type must be assignable to the fragment’s input schema. This is enforced at compile time: TypeScript will error if you mount a fragment whose input schema has fields the host workflow’s input doesn’t satisfy.

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 (see JSX Task options for the shared option shape).
  • All graph values support .pipe(...fns) for future data-last combinators.
  • 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).