---
title: Effect API
description: Build Smithers workflows as first-class graph values, no JSX or React.
---

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

```ts
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:

```ts
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`:

```ts
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.

```ts
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.

```ts
G.worktree({
  id: "review-shard",
  path: "scratch/review",
  children: G.sequence(read, summarize),
});
```

## Reuse

Static reuse is just a graph value:

```ts
const reviewShard = G.sequence(read, summarize);
```

Parameterized reuse is a function returning a graph value:

```ts
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:

```ts
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)`:

```ts
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:

```ts
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](/components/task#retry) 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).
