Mailboxes
A mailbox is a durable inbound message queue for a workflow — the repeated-signal counterpart to the one-shot deferred. Deliveries ride a Temporal signal (recorded in history, so consumption is deterministic on replay) and buffer until the workflow takes them.
// definitions — shared by the workflow body and every offering side
import { defineMailbox } from "@springbird/effect-temporal/definition";
export const StateUpdates = defineMailbox("state-updates", {
payload: Schema.Union([
Schema.Struct({ op: Schema.Literal("set"), key: Schema.String, value: Schema.Finite }),
Schema.Struct({ op: Schema.Literal("del"), key: Schema.String }),
Schema.Struct({ op: Schema.Literal("finish") }),
]),
});Taking messages (workflow side)
.take durably awaits the next message, in delivery order. The long-lived entity loop is the canonical shape:
const StateDemoLive = StateDemo.toLayer(() =>
Effect.gen(function* () {
const state = new Map<string, number>();
while (true) {
const update = yield* StateUpdates.take;
if (update.op === "finish") break;
if (update.op === "set") state.set(update.key, update.value);
else state.delete(update.key);
yield* StateSnapshot.set(Object.fromEntries(state));
}
return "done";
}),
);The claim happens synchronously on the taking fiber after the wait, so an interrupted take never steals a message from a later one. Race a take against a timer for deadline patterns — see Timers & approvals.
.poll takes without waiting — None when the buffer is empty. Its canonical use is draining buffers before continueAsNew, since buffered messages do not survive the run change.
Offering messages
Offering sides address the declaration itself — the same StateUpdates the handler takes from. From a client — via the WorkflowClient service:
const wf = yield* WorkflowClient;
yield* wf.offerMailbox(StateUpdates, workflowId, { op: "set", key: "a", value: 1 });From another workflow (workflow → workflow — an engine-level operation, Temporal-only):
import { offerMailbox } from "@springbird/effect-temporal/engine-sandbox";
yield* offerMailbox(Reports, { workflowId: orchestratorId, payload: report });Every offering surface also accepts the underlying primitive (StateUpdates.mailbox) — the declaration is just the one symbol you never need to unwrap.
Offers are fire-and-forget: offering to a closed or unknown execution is a no-op — the receiver finishing first is a normal race, matching DurableDeferred.done. On the workflow side, any other delivery failure is also swallowed (logged as a worker warning, never fatal to the offering run); a mailbox offer is not a delivery guarantee. When the sender must know the message was handled, use an update instead.
Ordering and durability
- Messages are delivered in signal order, per mailbox.
- Buffered messages survive worker crashes and replays — they are history, not memory.
- Buffered messages do not survive
continueAsNew— drain first. - A message that fails the payload schema — raw signal access, a drifted producer, a schema tightened while old messages sat buffered in history — is dropped at take time with a worker-log warning, matching the fire-and-forget delivery contract rather than poisoning the run. (
.polldrops the same way.)