Temporal Workflows skill for Claude Code
Durable processes that survive deploys and replays. Claude keeps Temporal workflow code deterministic and pushes every side effect into idempotent activities with explicit timeouts, heartbeats and non-retryable business errors. It models sagas with compensations, uses signals, queries and updates correctly, continues long loops as new, and treats every workflow code change as a versioning decision proven by replaying stored production histories in CI.
- name
temporal-workflows - size261 lines · ≈2,850 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and reviews durable executions on Temporal - splitting deterministic workflow code from side-effecting activities, timeouts and retry policies, heartbeats, idempotent activities, signals, queries and updates, sagas with compensation, continue-as-new for long histories, safe code changes with patching and Worker Versioning, replay tests against real histories, and time-skipping tests, in the TypeScript, Python and Go SDKs. Use when writing Temporal workflows or activities, hitting a non-determinism error, deploying workflow code changes, or deciding whether Temporal fits. Try asking
- Model our order fulfilment as a Temporal workflow with refunds on failure
- We got a nondeterminism error after deploying. Fix it without breaking running workflows
- Review these Temporal activities for missing timeouts and retry problems
Use it when
- Writing or reviewing Temporal workflows and activities
- A deploy caused nondeterminism errors in running workflows
- Adding signals, queries, updates or human approval steps
- Deciding between Temporal and a plain job queue
Skip it when
- Work is short, independent jobs on a queue — use background-jobs
- You are designing choreographed events between services — use event-driven-architecture
How to check the result
Time-skipping tests cover the happy and compensation paths, replay tests against stored production histories pass with the new code, and staging runs show no workflow task failures.
The common mistake it prevents
Reordering or adding activity calls in a workflow that has executions in flight without a patch or a pinned deployment version, which fails every running execution on replay.
Done well means: workflow code is deterministic and only orchestrates, every side effect is an idempotent activity with an explicit timeout, every change to workflow logic ships behind a patch or a pinned Worker Deployment Version, and a replay test against real production histories passes before deploy.
Fit check
| Situation | Temporal? |
|---|---|
| Multi-step process with retries, waits of minutes to months, human approval, compensation | Yes |
| Order fulfilment, payments orchestration, provisioning, onboarding, data pipelines with checkpoints | Yes |
| Fire-and-forget job that runs in seconds and can simply retry | No; a job queue is simpler (background-jobs) |
| Sub-millisecond request path | No; workflow start adds a round trip to the cluster |
| Pure event fan-out between services | No; use a broker |
Workflow
- [ ] 1. Inventory SDK, server or Cloud, task queues, existing versioning approach
- [ ] 2. Draw the process: steps, waits, external messages, compensations
- [ ] 3. Write activities first: idempotent, with timeouts and non-retryable errors
- [ ] 4. Write the workflow: deterministic orchestration only
- [ ] 5. Add signals, queries, or updates for external interaction
- [ ] 6. Test with the time-skipping environment; add replay tests
- [ ] 7. Plan the deploy: patch or Worker Versioning; confirm with replay
1. Inventory
npm ls @temporalio/workflow @temporalio/worker 2>/dev/null; pip show temporalio 2>/dev/null | head -2
grep -rn "patched\|GetVersion\|deprecatePatch\|deprecate_patch" src/ | head
temporal workflow list --query 'ExecutionStatus="Running"' --limit 20
Long-running workflows already in flight constrain every change you make; count them per workflow type before editing that type.
2. Determinism rules
Workflow code is replayed from event history on any worker at any time. It must produce the same commands in the same order on every replay.
| Forbidden in workflow code | Use instead |
|---|---|
| Network, database, file IO, logging to external sinks | An activity |
Date.now(), time.time(), time.Now() | Workflow time (TS sandbox makes Date deterministic; Python workflow.now(); Go workflow.Now(ctx)) |
Random numbers, uuid4() from the stdlib | SDK deterministic helpers (TS uuid4() from @temporalio/workflow, Python workflow.uuid4() / workflow.random(), Go workflow.SideEffect) |
Threads, goroutines, native select, asyncio.create_task on other loops | workflow.Go, workflow.NewSelector, Promise.all, Python asyncio.gather inside the workflow |
setTimeout / sleep from the language | sleep() / workflow.sleep() / workflow.Sleep() (durable timers) |
| Iterating Go maps, reading env vars or global mutable state | Sorted keys; pass config as workflow input |
| Changing the order or type of activity calls in a running workflow | A patch or a new Worker Deployment Version |
3. Activities
// activities.ts — normal Node code; may do IO
import { Context, ApplicationFailure } from "@temporalio/activity";
export async function chargeCard(input: { orderId: string; amountCents: number }) {
const idempotencyKey = `charge-${input.orderId}`; // stable across retries
const res = await payments.charge({ ...input, idempotencyKey });
if (res.declined) {
throw ApplicationFailure.nonRetryable("card declined", "CardDeclined");
}
return { chargeId: res.id };
}
export async function exportRows(input: { jobId: string; fromOffset: number }) {
const ctx = Context.current();
let offset = ctx.info.heartbeatDetails?.offset ?? input.fromOffset; // resume after retry
while (offset < total) {
await writeBatch(offset);
offset += 1000;
ctx.heartbeat({ offset }); // also delivers cancellation
}
}
- Activities are at-least-once. Derive idempotency keys from workflow ID plus a step name and pass them to downstream APIs.
- Always set
startToCloseTimeout(max time for one attempt). AddheartbeatTimeoutfor anything longer than about a minute and heartbeat well inside it. - The default retry policy retries forever with exponential backoff (1 s initial,
coefficient 2, capped at 100 x initial). Cap
maximumAttemptsor set ascheduleToCloseTimeoutfor calls where unbounded retry makes no sense, and list business errors innonRetryableErrorTypes. - Keep payloads small (each payload is limited to about 2 MB). Pass IDs or object-store keys, not files.
4. Workflow
// workflows.ts — deterministic orchestration only
import { proxyActivities, defineSignal, defineQuery, defineUpdate, setHandler, condition, sleep, log } from "@temporalio/workflow";
import type * as acts from "./activities";
const { reserveStock, releaseStock, chargeCard, refundCharge, shipOrder } = proxyActivities<typeof acts>({
startToCloseTimeout: "30 seconds",
retry: { maximumAttempts: 10, nonRetryableErrorTypes: ["CardDeclined", "OutOfStock"] },
});
export const cancelSignal = defineSignal("cancel");
export const statusQuery = defineQuery<string>("status");
export const addressUpdate = defineUpdate<string, [string]>("changeAddress");
export async function orderWorkflow(order: Order): Promise<string> {
let status = "started";
let cancelled = false;
let address = order.address;
setHandler(cancelSignal, () => { cancelled = true; });
setHandler(statusQuery, () => status);
setHandler(addressUpdate, (a) => { address = a; return address; }, {
validator: (a) => { if (status === "shipped") throw new Error("already shipped"); },
});
const compensations: Array<() => Promise<unknown>> = [];
try {
await reserveStock(order.id, order.items);
compensations.unshift(() => releaseStock(order.id));
const { chargeId } = await chargeCard({ orderId: order.id, amountCents: order.total });
compensations.unshift(() => refundCharge(chargeId));
status = "paid";
// wait up to 1 hour for a cancel before shipping (durable timer)
if (await condition(() => cancelled, "1 hour")) throw new Error("cancelled by customer");
await shipOrder(order.id, address);
status = "shipped";
return status;
} catch (err) {
log.warn("compensating", { orderId: order.id });
for (const undo of compensations) await undo(); // compensations are activities too
status = "compensated";
throw err;
}
}
Start it with a business ID so duplicates are rejected:
await client.workflow.start(orderWorkflow, {
taskQueue: "orders",
workflowId: `order-${order.id}`, // dedupe: one workflow per order
args: [order],
});
Message types:
| Need | Use | Notes |
|---|---|---|
| Fire-and-forget input that changes state | Signal | Async, durable, no return value |
| Read current state | Query | Must not mutate state or call activities |
| Request with validation and a result | Update | Validator rejects before history is written |
| Start if absent, then send | Signal-with-start / update-with-start | Removes the start/send race |
5. Long-running workflows
- Event history has hard limits (51,200 events or 50 MB); the server warns from about
10,240 events. Loops that run forever (subscriptions, polling, entity workflows) call
continue-as-new when the SDK reports it is suggested (TS
workflowInfo().continueAsNewSuggested, Pythonworkflow.info().is_continue_as_new_suggested()), carrying state as input. - Drain signal handlers before continuing as new, or messages received in that task are
lost; wait with
condition(allHandlersFinished)in TS or the equivalent. - Child workflows partition very large fan-outs (for example 1,000 children of 1,000 activities each) and give each part its own history.
6. Changing workflow code safely
A change that alters which commands a workflow emits breaks replay of running executions
(NondeterminismError / “nondeterministic workflow”). Pick one:
| Option | Use when |
|---|---|
| No versioning needed | Change only affects activity implementations, or no executions of this type are running |
Patching (patched("id"), workflow.patched, workflow.GetVersion) | Small logic changes; old and new paths coexist in code until old executions finish, then deprecatePatch, then remove |
| Worker Versioning (GA; Pinned or Auto-Upgrade per workflow type) | Frequent deploys; Pinned workflows finish on the build they started on, so no patches needed for short workflows; requires running old and new worker versions side by side (rainbow deploys) |
| New workflow type name | Large rewrites; route new starts to the new type and let the old drain |
import { patched, deprecatePatch } from "@temporalio/workflow";
if (patched("fraud-check-before-charge")) {
await fraudCheck(order.id);
}
// later, once no pre-patch executions remain: deprecatePatch("fraud-check-before-charge")
Auto-Upgrade workflows still need patching for incompatible changes.
7. Tests
import { TestWorkflowEnvironment } from "@temporalio/testing";
import { Worker } from "@temporalio/worker";
import { ApplicationFailure } from "@temporalio/common";
const env = await TestWorkflowEnvironment.createTimeSkipping();
const worker = await Worker.create({
connection: env.nativeConnection,
taskQueue: "test",
workflowsPath: require.resolve("./workflows"),
activities: { ...fakeActivities, chargeCard: async () => { throw ApplicationFailure.nonRetryable("x", "CardDeclined"); } },
});
await worker.runUntil(async () => {
await expect(env.client.workflow.execute(orderWorkflow, { taskQueue: "test", workflowId: "t1", args: [order] }))
.rejects.toThrow();
// assert releaseStock fake was called
});
await env.teardown();
Replay tests catch non-determinism before deploy:
temporal workflow show --workflow-id order-123 --output json > histories/order-123.json
await Worker.runReplayHistory({ workflowsPath: require.resolve("./workflows") }, history);
Keep a few histories per workflow type (happy path, compensation, long wait) in the repo and replay them in CI on every workflow change.
For Python and Go equivalents of every snippet above (activities, timers, signals, updates, patching, continue-as-new, test environments, replayers), read references/sdk-cheatsheet.md when the project is not TypeScript.
Gotchas
- Importing activity implementations (not just types) into workflow files pulls Node modules into the workflow sandbox and fails the bundle.
- Python workflow files run in a sandbox; import third-party modules through
workflow.unsafe.imports_passed_through(). - A workflow that catches every error and retries in a loop builds history fast; let the activity retry policy do retries.
temporal workflow resetandterminatechange running business processes; run them only with explicit user confirmation and a reason recorded.- Sensitive data lands in event history in plain text unless a payload codec encrypts it.
Verification
- Unit and time-skipping tests pass, including the compensation path.
- Replay tests against stored production histories pass with the new code.
temporal workflow describe --workflow-id <id>on a staging run shows expected pending activities and no task failures.- After deploy, watch for workflow task failures with non-determinism errors; on the first one, roll back the worker and add a patch.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/sdk-cheatsheet.md
# Temporal SDK cheatsheet: TypeScript, Python, Go
Equivalent constructs across the three most used SDKs. Names reflect the TypeScript SDK
1.x, Python SDK 1.x (`temporalio`), and Go SDK (`go.temporal.io/sdk`). When a signature
detail matters, confirm it against the SDK reference for the installed version.
## Calling activities from a workflow
| | Code |
|---|---|
| TS | `const { charge } = proxyActivities<typeof acts>({ startToCloseTimeout: "30s", retry: { maximumAttempts: 5 } }); await charge(input);` |
| Python | `await workflow.execute_activity(charge, input, start_to_close_timeout=timedelta(seconds=30), retry_policy=RetryPolicy(maximum_attempts=5))` |
| Go | `ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{StartToCloseTimeout: 30 * time.Second, RetryPolicy: &temporal.RetryPolicy{MaximumAttempts: 5}}); err := workflow.ExecuteActivity(ctx, Charge, input).Get(ctx, &out)` |
Local activities (short, same-worker, no separate task): TS `proxyLocalActivities`,
Python `workflow.execute_local_activity`, Go `workflow.ExecuteLocalActivity`. Use only for
calls well under a few seconds.
## Non-retryable failures
| | Code |
|---|---|
| TS | `throw ApplicationFailure.nonRetryable("declined", "CardDeclined")` |
| Python | `raise ApplicationError("declined", type="CardDeclined", non_retryable=True)` |
| Go | `return temporal.NewNonRetryableApplicationError("declined", "CardDeclined", nil)` |
## Heartbeats
| | Code |
|---|---|
| TS | `Context.current().heartbeat({ offset })`; resume from `Context.current().info.heartbeatDetails` |
| Python | `activity.heartbeat(offset)`; resume from `activity.info().heartbeat_details` |
| Go | `activity.RecordHeartbeat(ctx, offset)`; resume with `activity.HasHeartbeatDetails` / `activity.GetHeartbeatDetails` |
## Time, timers, randomness
| | Now | Sleep | Random / UUID |
|---|---|---|---|
| TS | `Date.now()` (sandboxed, deterministic) | `await sleep("1 hour")` | `Math.random()` (sandboxed), `uuid4()` |
| Python | `workflow.now()` | `await asyncio.sleep(3600)` (becomes a durable timer inside workflow code) | `workflow.random()`, `workflow.uuid4()` |
| Go | `workflow.Now(ctx)` | `workflow.Sleep(ctx, time.Hour)` | `workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { return uuid.NewString() })` |
## Waiting for a condition
| | Code |
|---|---|
| TS | `const ok = await condition(() => approved, "24 hours")` (false on timeout) |
| Python | `await workflow.wait_condition(lambda: self.approved, timeout=timedelta(hours=24))` (raises `asyncio.TimeoutError` on timeout) |
| Go | `ok, err := workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool { return approved })` |
## Signals, queries, updates
TS:
```ts
export const approve = defineSignal<[string]>("approve");
export const status = defineQuery<string>("status");
export const setLimit = defineUpdate<number, [number]>("setLimit");
setHandler(approve, (by) => { approvedBy = by; });
setHandler(status, () => state);
setHandler(setLimit, (n) => (limit = n), { validator: (n) => { if (n < 0) throw new Error("negative"); } });
```
Python:
```python
@workflow.defn
class Approval:
@workflow.signal
def approve(self, by: str) -> None: self.approved_by = by
@workflow.query
def status(self) -> str: return self.state
@workflow.update
def set_limit(self, n: int) -> int:
self.limit = n
return n
@set_limit.validator
def validate_limit(self, n: int) -> None:
if n < 0: raise ValueError("negative")
```
Go:
```go
ch := workflow.GetSignalChannel(ctx, "approve")
workflow.SetQueryHandler(ctx, "status", func() (string, error) { return state, nil })
workflow.SetUpdateHandlerWithOptions(ctx, "setLimit",
func(ctx workflow.Context, n int) (int, error) { limit = n; return n, nil },
workflow.UpdateHandlerOptions{Validator: func(ctx workflow.Context, n int) error {
if n < 0 { return errors.New("negative") }; return nil }})
```
Client side: TS `handle.signal(approve, "ann")`, `handle.query(status)`,
`handle.executeUpdate(setLimit, { args: [10] })`; Python `await handle.signal(Approval.approve, "ann")`,
`await handle.execute_update(Approval.set_limit, 10)`; Go `c.SignalWorkflow`,
`c.QueryWorkflow`, `c.UpdateWorkflow`.
## Patching
| | Add | Deprecate (after old executions finish) |
|---|---|---|
| TS | `if (patched("id")) { new } else { old }` | `deprecatePatch("id"); new` |
| Python | `if workflow.patched("id"): new else: old` | `workflow.deprecate_patch("id")` |
| Go | `v := workflow.GetVersion(ctx, "id", workflow.DefaultVersion, 1); if v == 1 { new } else { old }` | Raise min version: `workflow.GetVersion(ctx, "id", 1, 1)` |
Lifecycle: add patch, deploy, wait until no executions started before the patch remain
(use a visibility query on start time), deprecate, deploy, wait again, remove.
## Continue-as-new
| | Check | Do |
|---|---|---|
| TS | `workflowInfo().continueAsNewSuggested` | `await continueAsNew<typeof wf>(state)` |
| Python | `workflow.info().is_continue_as_new_suggested()` | `workflow.continue_as_new(state)` |
| Go | `workflow.GetInfo(ctx).GetContinueAsNewSuggested()` | `return workflow.NewContinueAsNewError(ctx, Wf, state)` |
Wait for in-flight signal and update handlers first (TS `await condition(allHandlersFinished)`,
Python `await workflow.wait_condition(workflow.all_handlers_finished)`, Go
`workflow.Await(ctx, func() bool { return workflow.AllHandlersFinished(ctx) })`).
## Test environments
| | Time-skipping env | Run |
|---|---|---|
| TS | `await TestWorkflowEnvironment.createTimeSkipping()` | `Worker.create({ connection: env.nativeConnection, ... })`, `worker.runUntil(...)` |
| Python | `async with await WorkflowEnvironment.start_time_skipping() as env:` | `async with Worker(env.client, task_queue=..., workflows=[...], activities=[...]):` |
| Go | `var s testsuite.WorkflowTestSuite; env := s.NewTestWorkflowEnvironment()` | `env.OnActivity(Charge, mock.Anything, mock.Anything).Return(out, nil); env.ExecuteWorkflow(Wf, in)` |
## Replay
| | Code |
|---|---|
| TS | `await Worker.runReplayHistory({ workflowsPath: require.resolve("./workflows") }, history)` |
| Python | `await Replayer(workflows=[OrderWorkflow]).replay_workflow(WorkflowHistory.from_json(wf_id, json_text))` |
| Go | `r := worker.NewWorkflowReplayer(); r.RegisterWorkflow(OrderWorkflow); err := r.ReplayWorkflowHistoryFromJSONFile(nil, "histories/order-123.json")` |
Export histories with `temporal workflow show --workflow-id <id> --output json`.
## Python sandbox note
Workflow modules are re-imported in a sandbox. Import non-deterministic or heavy modules
used only for types or activities through:
```python
with workflow.unsafe.imports_passed_through():
from .activities import charge
import pydantic
```
## Go-specific determinism
- Never use native goroutines, channels, `select`, or `time.After` in workflow code; use
`workflow.Go`, `workflow.NewChannel`, `workflow.NewSelector`, `workflow.NewTimer`.
- Never range over a map to issue commands; sort the keys first.
- Run the `workflowcheck` static analyzer (from the Go SDK contrib tools) in CI to catch
common determinism violations.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.