Declaring capabilities
Everything a workflow body uses — activities, approvals, mailboxes, updates, state cells — is declared once with the definition module, and called directly inside the handler:
import { Effect, Schema } from "effect";
import { defineActivity, defineDeferred } from "@springbird/effect-temporal/definition";
export const Charge = defineActivity("charge", {
payload: { orderId: Schema.String },
success: Schema.String,
});
export const Approval = defineDeferred("order/approval", {
success: Schema.String,
});
const OrderFlowLive = OrderFlow.toLayer((payload) =>
Effect.gen(function* () {
const receipt = yield* Charge({ orderId: payload.orderId });
const approver = yield* Approval.await;
return `${receipt}:by:${approver}`;
}),
);One declaration is the whole contract: the workflow bundle calls it, the worker implements it, every client drives it. A misspelled name or drifted payload shape is a compile error.
The one seam: WorkflowOps
Every in-handler operation requires exactly one service, WorkflowOps — the seam an engine implements (one operation per primitive kind). The handler itself imports nothing engine-shaped: no engine-sandbox, no @temporalio/*. The only sandbox-side import an application has is workflowBundle, in the bundle's entry file, from @springbird/effect-temporal/bundle.
- On Temporal —
workflowBundleprovides the TemporalWorkflowOpsautomatically: activity calls become real Temporal activities,await/takeblock on signals in history,setpublishes to a query,sleepis a durable timer,executeChildisstartChild,continueAsNewis continue-as-new,versionrecords patch markers. - In tests —
makeTestWorkflowOps(the testing module) provides an in-memoryWorkflowOps, so the same handler function runs in a plain unit test with no engine, no sandbox, no server.
The surface
| Declaration | Inside the handler | Outside the handler |
|---|---|---|
defineActivity(name, { payload, success?, error?, options? }) | yield* Charge(payload) — typed success, typed error channel | implemented on the worker: handle(Charge, impl) + implementActivities |
defineDeferred(name, { success }) | yield* Approval.await | wf.completeDeferred(Approval, id, exit), wf.deferredState(Approval, id) |
defineMailbox(name, { payload }) | yield* Priority.take / yield* Priority.poll | wf.offerMailbox(Priority, id, payload) |
defineUpdate(name, { payload, success, error }) | yield* SetAmount.take — respond exactly once | wf.executeUpdate(SetAmount, id, payload) |
defineState(name, { value }) | yield* Status.set(value) | wf.readStateCell(Status, id) |
And the operations that need no declaration — all from the same module, all requiring only WorkflowOps:
| Operation | What it does | Guide |
|---|---|---|
sleep({ name, duration }) | durable named timer | Timers |
sleepUntil({ name, timestamp }) | durable timer to an absolute instant; no-op when past | Timers |
continueAsNew(workflow, payload, options?) | end this run, start a fresh one with the payload | Continue-as-new |
executeChild(workflow, payload, { discard? }) | start a child workflow, awaited or fire-and-forget | Child workflows |
version(site, names) | patch-marker branch by name | Versioning |
versioned(site, { v1: run1, v2: run2 }) | patch-marker branch, run-table form | Versioning |
evolved(current, legacy, migrate) | schema evolution across in-flight runs | Versioning |
The declaration is the only symbol you ever name. Every client-side surface — the WorkflowClient service, the standalone engine-client operations, the in-memory test world, the fake client, the live harness — takes the declaration directly. Each declaration still carries its underlying primitive (Approval.deferred, Priority.mailbox, SetAmount.update, Status.cell; a defined activity is its TypedActivity projection) for engine-level code, and every surface accepts that too. The decoded types are named with PayloadOf<typeof Charge>, SuccessOf<…>, ErrorOf<…> from this module.
Coming from 0.3.x or earlier
The pre-0.4.0 modules — /typed-activity, /versioning, the make constructors of /mailbox, /update, /state-cell, and the per-primitive calls in /engine-sandbox (callActivity, takeMailbox, takeUpdate, setStateCell, sleepUntil, continueAsNew) — were deprecated in 0.4.0 and removed in 0.5.0. Each has a replacement in the tables above; the prefer-definition lint rule names it at every stale import, so migrating is running the linter.
Schemas must be context-free
A declaration's schemas cross the ops seam with their service requirements erased — a schema that needs decoding or encoding services would defect at runtime. Use plain, self-contained schemas at declaration boundaries.
Wire identity is the name
The explicit name string — "charge", "order/approval" — is the identity on the wire: the Temporal activity type, signal payload discriminator, query key, patch-marker site. Renaming a variable, moving a declaration to another module, or restructuring the handler never changes the wire; changing the name does, and is a versioning event.
Evolving a declaration
Two axes, two tools, both in the definition module:
- Logic changes at a code site:
version(site, names)(branch by name) orversioned(site, cases)(run table) — patch markers under Temporal, so in-flight histories replay the code they recorded. See Versioning. - Data changes in a declared schema:
evolved(current, legacy, migrate)— decode tries the newest shape first, migrates legacy wire forward through a pure function, and handlers only ever see the newest Type. See Schema evolution.