Getting started
Install the package:
pnpm add @springbird/effect-temporal # or npm / yarn / buneffect, @temporalio/client, and @temporalio/workflow are peer dependencies (modern package managers install them for you). You will also want @temporalio/worker to run a worker and @temporalio/testing for the test harness — both optional peers, used only where you use them.
Effect version
effect-temporal targets Effect v4 and pins its effect peer exactly (currently 4.0.0): the engine implements interfaces from effect/workflow (still @stability unstable upstream), whose API can move between releases. Match the pinned version; each release of this package states the one effect version it is built and tested against.
A Temporal deployment has three kinds of process, and this package has a module for each:
- the workflow bundle — deterministic code Temporal replays; its entry file uses
@springbird/effect-temporal/bundle, its handlers only@springbird/effect-temporal/definition - the worker — runs the bundle and your activities; registers via
@springbird/effect-temporal/activities - clients — ordinary Node processes that start and observe workflows; use
@springbird/effect-temporal/client
1. Define a workflow
A definition is a tag, a payload schema, an idempotency key, and success/error schemas. Both sides share this one module — keep it free of @temporalio/* imports.
// definitions.ts — shared by the bundle and every client
import { Schema } from "effect";
import * as Workflow from "effect/workflow/Workflow";
import { defineActivity } from "@springbird/effect-temporal/definition";
export const Reserve = defineActivity("reserve", {
payload: { sku: Schema.String, quantity: Schema.Finite },
success: Schema.String,
error: Schema.TaggedStruct("OutOfStock", { sku: Schema.String }),
options: { startToCloseTimeout: "1 minute", retry: { maximumAttempts: 3 } },
});
export const OrderFlow = Workflow.make("orderFlow", {
payload: { orderId: Schema.String, sku: Schema.String },
idempotencyKey: ({ orderId }) => orderId,
success: Schema.String,
error: Reserve.errorSchema,
});2. Author the body
The body is an Effect that runs inside the Temporal workflow sandbox. Workflows register themselves with Workflow.toLayer, and workflowBundle hosts every registration behind the bundle's default export — the same authoring that runs on Effect's cluster and in-memory engines.
// workflows.ts — the workflow bundle (Temporal's workflowsPath points here)
import { Effect } from "effect";
import { workflowBundle } from "@springbird/effect-temporal/bundle";
import { sleep } from "@springbird/effect-temporal/definition";
import { OrderFlow, Reserve } from "./definitions.js";
const OrderFlowLive = OrderFlow.toLayer((payload) =>
Effect.gen(function* () {
// A declared activity, called directly: payload validated, result
// decoded, typed failure lands in the error channel. Retries are
// Temporal's, per the declaration's options.
const reservation = yield* Reserve({ sku: payload.sku, quantity: 1 });
yield* sleep({ name: "cooling-off", duration: "1 minute" });
return `reserved:${reservation}`;
}),
);
export default workflowBundle(OrderFlowLive); // Layer.mergeAll(...) for more3. Implement the activities and run a worker
Activities are Effects too, bound to the same definitions. Every worker running these workflows also registers the attach bridge (makeEffectWorkflowActivities).
// worker.ts
import { Effect } from "effect";
import { Client, Connection } from "@temporalio/client";
import { Worker } from "@temporalio/worker";
import {
handle,
implementActivities,
makeEffectWorkflowActivities,
type ActivityRunner,
} from "@springbird/effect-temporal/activities";
import { Reserve } from "./definitions.js";
// How your worker executes activity Effects — plug in your runtime,
// spans, and error reporting here.
const runner: ActivityRunner<never> = {
run: (_name, _payload, effect) => Effect.runPromiseExit(effect),
};
const client = new Client({ connection: await Connection.connect() });
const worker = await Worker.create({
taskQueue: "orders",
workflowsPath: new URL("./workflows.js", import.meta.url).pathname,
activities: {
...implementActivities(runner, [
handle(Reserve, ({ sku, quantity }) => Effect.succeed(`${sku}x${quantity}`)),
]),
...makeEffectWorkflowActivities(client),
},
});
await worker.run();4. Start it from anywhere
WorkflowClient is the one client service: configure it once with a Temporal client and a default task queue.
// api.ts — any ordinary Node process
import { Effect } from "effect";
import { Client, Connection } from "@temporalio/client";
import { layerWorkflowClient, WorkflowClient } from "@springbird/effect-temporal/client";
import { OrderFlow } from "./definitions.js";
const program = Effect.gen(function* () {
const wf = yield* WorkflowClient;
// Typed success/error; repeated calls with the same orderId attach to the
// same execution and return the original result.
return yield* wf.execute(OrderFlow, { orderId: "ord_123", sku: "sku-9" });
});
const client = new Client({ connection: await Connection.connect() });
await Effect.runPromise(
program.pipe(Effect.provide(layerWorkflowClient({ client, taskQueue: "orders" }))),
);That's the whole loop: definition → body → worker → client, with schemas holding every boundary.
Where to next
- The runnable examples — each boots its own local Temporal dev server (
pnpm run build, thenpnpm --dir examples/<name> start):examples/order-sagais the one-shot saga (typed activities, compensation, approval, queryable state, idempotent attach, cancellation);examples/subscriptionis the long-lived entity (billing cycles, typed updates, mailbox cancellation, continue-as-new). - Defining workflows: idempotency, execution ids, start semantics.
- Declaring capabilities: the
definitionmodule — one declaration per capability, one seam (WorkflowOps) engines implement. - Activities: declared activities, raw calls, failure and retry semantics.
- Testing your app: the in-memory runtime, the typed fake client, and the live harness.
- Lint rules: catch the authoring footguns mechanically.