* Add initial skill for testing, which is simply Steve's skill (#1) * Add initial skill for testing, which is simply Steve's skill * Rename skill to 'temporal-dev' and update version Updated skill name and version for Temporal Python. * Use claude to merge Steve's, Max's, and Mason's skills. (#2) * Use claude to merge Steve's, Max's, and Mason's skills. Did a review pass using claude's skill devlopment skills * Add missing things from Steve * trigger tweaks * Add in common gotchas from Johann * add simple feedback mechanism (#3) * Change skill name to kebab-case, for compatibility with Amp and Cline (#7) * Clean up references/core/ai-integration.md * Clean up references/core/common-gotchas.md * Clean up references/core/common-gotchas.md * Clean up references/core/determinism.md * Clean up references/core/determinism.md * Update error-reference.md * Update interactive-workflows.md * Clean up patterns.md * Cut shell scripts * Edit troubleshooting.md * remove interceptors for now * remove dynamic workflows * clarify on heartbeating of async activity completions, and prompt it a bit in relation to signals * Improve references/python/advanced-features.md * Use explicit namespace in connect * remove duplicated content from determinism.md, clean up * Improve references/python/data-handling.md * Prefer start_to_close_timeout * don't explicitely provide defaults for retry policies * error-handling.md cleanup * move idempotency patterns to patterns.md * remove multi-param activities * small edits * Unify sandbox stuff into one file * local activities aren't experimental * Clean up references/python/sync-vs-async.md * Cleanup observability.md, remove duplicated search attributes * Cut otel for now * cut a lot of duplicate stuff from python gotchas, address comments * de-duplicate content * Lots of improvements to testing * cleanup to top level of skill (like CLI install instructions), and to top-level of python * Improve patterns.md * clean up ai-patterns.md * Update readme with installation instructions * remove ts directory * De-couple core from python and TypeScript as much as possible * Remove TypeScript hints * add prompting for feedback at startup - wait for ethan on slack channel * shorten url * Update slack channel * Automated pass over on python cleanup & deduplication * Remove multi-patching from Python, since its obvious, dont waste tokens on it. (#34) * Add TypeScript (#31) Adds initial support for TypeScript to the skill --------- Co-authored-by: James Watkins-Harvey <mjameswh@users.noreply.github.com> Co-authored-by: Chris Olszewski <chrisdolszewski@gmail.com> * Fix typos and reference links (#36) * Fix typos and reference links * 2 more typo fixes * quick edit to readme (#37) * Fix saga compensations to run under cancellation protection (#43) When a workflow is cancelled mid-saga, compensations must run in a cancellation-protected scope, otherwise they are immediately cancelled before they can execute. - Python: wrap compensation loop in asyncio.shield() so it runs even when the workflow receives a CancelledError - TypeScript: wrap compensation loop in CancellationScope.nonCancellable() so it runs even when the root scope is cancelled (per official docs: "Cleanup logic must be in a nonCancellable scope") - TypeScript: also fix compensation registration order — register BEFORE calling the activity (was already correct in Python) Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Update readme for public preview (#45) * a few more readme tweaks (#46) * Add MIT License to the project (#47) * Add Go (supersedes other PR) (#38) * progress on go * Go translation workflow completed. * missed a few spots * Manual edits * Address feedback * Add gotcha about anonymous local activities * Sample code for payload converter * clarify sdk protection mechanisms * Setup CODEOWNERS to AI SDK team (#48) * Align version number in SKILL.md and plugin.json. (#49) --------- Co-authored-by: James Watkins-Harvey <mjameswh@users.noreply.github.com> Co-authored-by: Chris Olszewski <chrisdolszewski@gmail.com> Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
16 KiB
Go SDK Patterns
Signals
In Go, signals are received via channels, not handler functions.
func OrderWorkflow(ctx workflow.Context) (string, error) {
approved := false
var items []string
approveCh := workflow.GetSignalChannel(ctx, "approve")
addItemCh := workflow.GetSignalChannel(ctx, "add-item")
// Listen for signals in a goroutine so workflow can proceed
workflow.Go(ctx, func(ctx workflow.Context) {
for {
selector := workflow.NewSelector(ctx)
selector.AddReceive(approveCh, func(c workflow.ReceiveChannel, more bool) {
c.Receive(ctx, &approved)
})
selector.AddReceive(addItemCh, func(c workflow.ReceiveChannel, more bool) {
var item string
c.Receive(ctx, &item)
items = append(items, item)
})
selector.Select(ctx)
}
})
// Wait for approval
workflow.Await(ctx, func() bool { return approved })
return fmt.Sprintf("Processed %d items", len(items)), nil
}
Blocking receive from a single channel
When waiting on a single signal, no Selector is needed:
var approveInput ApproveInput
workflow.GetSignalChannel(ctx, "approve").Receive(ctx, &approveInput)
Queries
Important: Queries must NOT modify workflow state. Query handlers run outside workflow context -- do not call workflow.Go(), workflow.NewChannel(), or any blocking workflow functions.
func StatusWorkflow(ctx workflow.Context) error {
currentState := "started"
progress := 0
err := workflow.SetQueryHandler(ctx, "get-status", func() (string, error) {
return currentState, nil
})
if err != nil {
return err
}
err = workflow.SetQueryHandler(ctx, "get-progress", func() (int, error) {
return progress, nil
})
if err != nil {
return err
}
// Workflow logic updates currentState and progress as it runs
currentState = "running"
for i := 0; i < 100; i++ {
progress = i
err := workflow.ExecuteActivity(
workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: time.Minute,
}),
ProcessItem, i,
).Get(ctx, nil)
if err != nil {
currentState = "failed"
return err
}
}
currentState = "done"
return nil
}
Updates
func OrderWorkflow(ctx workflow.Context) (int, error) {
var items []string
err := workflow.SetUpdateHandlerWithOptions(
ctx,
"add-item",
func(ctx workflow.Context, item string) (int, error) {
// Handler can mutate workflow state and return a value
items = append(items, item)
return len(items), nil
},
workflow.UpdateHandlerOptions{
Validator: func(ctx workflow.Context, item string) error {
if item == "" {
return fmt.Errorf("item cannot be empty")
}
if len(items) >= 100 {
return fmt.Errorf("order is full")
}
return nil
},
},
)
if err != nil {
return 0, err
}
// Block until cancelled
_ = ctx.Done().Receive(ctx, nil)
return len(items), nil
}
Important: Validators must NOT mutate workflow state or do anything blocking (no activities, sleeps, or other commands). They are read-only, similar to query handlers. Return an error to reject the update; return nil to accept.
Child Workflows
func ParentWorkflow(ctx workflow.Context, orders []Order) ([]string, error) {
cwo := workflow.ChildWorkflowOptions{
WorkflowExecutionTimeout: 30 * time.Minute,
}
ctx = workflow.WithChildOptions(ctx, cwo)
var results []string
for _, order := range orders {
var result string
err := workflow.ExecuteChildWorkflow(ctx, ProcessOrderWorkflow, order).Get(ctx, &result)
if err != nil {
return nil, err
}
results = append(results, result)
}
return results, nil
}
Child Workflow Options
import enumspb "go.temporal.io/api/enums/v1"
cwo := workflow.ChildWorkflowOptions{
WorkflowID: fmt.Sprintf("child-%s", workflow.GetInfo(ctx).WorkflowExecution.ID),
// ParentClosePolicy - what happens to child when parent closes
// PARENT_CLOSE_POLICY_TERMINATE (default), PARENT_CLOSE_POLICY_ABANDON, PARENT_CLOSE_POLICY_REQUEST_CANCEL
ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON,
WorkflowExecutionTimeout: 10 * time.Minute,
WorkflowTaskTimeout: time.Minute,
}
ctx = workflow.WithChildOptions(ctx, cwo)
future := workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, input)
// Wait for child to start (important for ABANDON policy)
if err := future.GetChildWorkflowExecution().Get(ctx, nil); err != nil {
return err
}
Handles to External Workflows
func CoordinatorWorkflow(ctx workflow.Context, targetWorkflowID string) error {
// Signal an external workflow
err := workflow.SignalExternalWorkflow(ctx, targetWorkflowID, "", "data-ready", payload).Get(ctx, nil)
if err != nil {
return err
}
// Cancel an external workflow
err = workflow.RequestCancelExternalWorkflow(ctx, targetWorkflowID, "").Get(ctx, nil)
return err
}
Parallel Execution
Use workflow.Go to launch parallel work and workflow.Selector to collect results.
func ParallelWorkflow(ctx workflow.Context, items []string) ([]string, error) {
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
})
// Launch activities in parallel
futures := make([]workflow.Future, len(items))
for i, item := range items {
futures[i] = workflow.ExecuteActivity(actCtx, ProcessItem, item)
}
// Collect all results
results := make([]string, len(items))
for i, future := range futures {
if err := future.Get(ctx, &results[i]); err != nil {
return nil, err
}
}
return results, nil
}
Using workflow.Go for background goroutines
ch := workflow.NewChannel(ctx)
workflow.Go(ctx, func(ctx workflow.Context) {
// Background work
var result string
_ = workflow.ExecuteActivity(actCtx, SomeActivity).Get(ctx, &result)
ch.Send(ctx, result)
})
var result string
ch.Receive(ctx, &result)
Selector Pattern
workflow.Selector replaces Go's native select -- required for deterministic workflow execution. Use it to wait on multiple channels, futures, and timers simultaneously.
func ApprovalWorkflow(ctx workflow.Context) (string, error) {
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
})
var outcome string
signalCh := workflow.GetSignalChannel(ctx, "approve")
actFuture := workflow.ExecuteActivity(actCtx, AutoReviewActivity)
// Cancel timer if signal or activity wins
timerCtx, cancelTimer := workflow.WithCancel(ctx)
timer := workflow.NewTimer(timerCtx, 24*time.Hour)
selector := workflow.NewSelector(ctx)
// Branch 1: Signal received
selector.AddReceive(signalCh, func(c workflow.ReceiveChannel, more bool) {
var approved bool
c.Receive(ctx, &approved)
cancelTimer()
if approved {
outcome = "approved-by-signal"
} else {
outcome = "rejected-by-signal"
}
})
// Branch 2: Activity completed
selector.AddFuture(actFuture, func(f workflow.Future) {
var result string
_ = f.Get(ctx, &result)
cancelTimer()
outcome = result
})
// Branch 3: Timeout
selector.AddFuture(timer, func(f workflow.Future) {
if err := f.Get(ctx, nil); err == nil {
outcome = "timed-out"
}
// If timer was cancelled, err is CanceledError -- ignore
})
selector.Select(ctx) // Blocks until one branch fires
return outcome, nil
}
Key points:
AddReceive(channel, callback)-- fires when a channel has a message (must consume withc.Receive)AddFuture(future, callback)-- fires when a future resolves (once per Selector)AddDefault(callback)-- fires immediately if nothing else is readySelect(ctx)-- blocks until one branch fires; call multiple times to process multiple events
Continue-as-New
func LongRunningWorkflow(ctx workflow.Context, state WorkflowState) (string, error) {
for {
state = processBatch(ctx, state)
if state.IsComplete {
return "done", nil
}
// Check if history is getting large
if workflow.GetInfo(ctx).GetContinueAsNewSuggested() {
return "", workflow.NewContinueAsNewError(ctx, LongRunningWorkflow, state)
}
}
}
Drain signals before continue-as-new to avoid signal loss:
for {
var signalVal string
ok := signalChan.ReceiveAsync(&signalVal)
if !ok {
break
}
// process signal
}
return "", workflow.NewContinueAsNewError(ctx, LongRunningWorkflow, state)
Cancellation Handling
Use ctx.Done() to detect cancellation and workflow.NewDisconnectedContext for cleanup that must run even after cancellation.
func MyWorkflow(ctx workflow.Context) error {
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: time.Hour,
})
err := workflow.ExecuteActivity(actCtx, LongRunningActivity).Get(ctx, nil)
if err != nil && temporal.IsCanceledError(ctx.Err()) {
// Workflow was cancelled -- run cleanup with a disconnected context
workflow.GetLogger(ctx).Info("Workflow cancelled, running cleanup")
disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx)
disconnectedCtx = workflow.WithActivityOptions(disconnectedCtx, workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
})
_ = workflow.ExecuteActivity(disconnectedCtx, CleanupActivity).Get(disconnectedCtx, nil)
return err // Return CanceledError
}
return err
}
Saga Pattern (Compensations)
Important: Compensation activities should be idempotent -- they may be retried (as with ALL activities).
Use workflow.NewDisconnectedContext when running compensations so they execute even if the workflow is cancelled.
func OrderWorkflow(ctx workflow.Context, order Order) (string, error) {
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
})
var compensations []func(ctx workflow.Context) error
// Helper to run all compensations in reverse, using a disconnected context
// so compensations run even if the workflow is cancelled.
runCompensations := func() {
disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx)
compCtx := workflow.WithActivityOptions(disconnectedCtx, workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
})
for i := len(compensations) - 1; i >= 0; i-- {
if err := compensations[i](compCtx); err != nil {
workflow.GetLogger(ctx).Error("Compensation failed", "error", err)
}
}
}
// Register compensation BEFORE running the activity.
// If the activity completes the effect but fails on return,
// we still need the compensation.
compensations = append(compensations, func(ctx workflow.Context) error {
return workflow.ExecuteActivity(ctx, ReleaseInventoryIfReserved, order).Get(ctx, nil)
})
if err := workflow.ExecuteActivity(actCtx, ReserveInventory, order).Get(ctx, nil); err != nil {
runCompensations()
return "", err
}
compensations = append(compensations, func(ctx workflow.Context) error {
return workflow.ExecuteActivity(ctx, RefundPaymentIfCharged, order).Get(ctx, nil)
})
if err := workflow.ExecuteActivity(actCtx, ChargePayment, order).Get(ctx, nil); err != nil {
runCompensations()
return "", err
}
if err := workflow.ExecuteActivity(actCtx, ShipOrder, order).Get(ctx, nil); err != nil {
runCompensations()
return "", err
}
return "Order completed", nil
}
Wait Condition with Timeout
func ApprovalWorkflow(ctx workflow.Context) (string, error) {
approved := false
// Set up signal handler
workflow.Go(ctx, func(ctx workflow.Context) {
workflow.GetSignalChannel(ctx, "approve").Receive(ctx, &approved)
})
// Wait with 24-hour timeout -- returns (conditionMet, error)
conditionMet, err := workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool {
return approved
})
if err != nil {
return "", err
}
if conditionMet {
return "approved", nil
}
return "auto-rejected due to timeout", nil
}
Without timeout:
err := workflow.Await(ctx, func() bool { return ready })
Waiting for All Handlers to Finish
Signal and update handlers may run activities asynchronously. Use workflow.Await with workflow.AllHandlersFinished before completing or continuing-as-new to prevent the workflow from closing while handlers are still running.
func MyWorkflow(ctx workflow.Context) (string, error) {
// ... register handlers, main workflow logic ...
// Before exiting, wait for all handlers to finish
err := workflow.Await(ctx, func() bool {
return workflow.AllHandlersFinished(ctx)
})
if err != nil {
return "", err
}
return "done", nil
}
Activity Heartbeat Details
WHY:
- Support activity cancellation -- Cancellations are delivered via heartbeat; activities that don't heartbeat won't know they've been cancelled
- Resume progress after worker failure -- Heartbeat details persist across retries
WHEN:
- Cancellable activities -- Any activity that should respond to cancellation
- Long-running activities -- Track progress for resumability
- Checkpointing -- Save progress periodically
func ProcessLargeFile(ctx context.Context, filePath string) (string, error) {
// Recover from previous attempt
startIdx := 0
if activity.HasHeartbeatDetails(ctx) {
if err := activity.GetHeartbeatDetails(ctx, &startIdx); err == nil {
startIdx++ // Resume from next item
}
}
lines := readFileLines(filePath)
for i := startIdx; i < len(lines); i++ {
processLine(lines[i])
// Heartbeat with progress -- if cancelled, ctx will be cancelled
activity.RecordHeartbeat(ctx, i)
if ctx.Err() != nil {
// Activity was cancelled
cleanup()
return "", ctx.Err()
}
}
return "completed", nil
}
Timers
func TimerWorkflow(ctx workflow.Context) (string, error) {
// Simple sleep
err := workflow.Sleep(ctx, time.Hour)
if err != nil {
return "", err
}
// Timer as a Future -- for use with Selector
timerCtx, cancelTimer := workflow.WithCancel(ctx)
timer := workflow.NewTimer(timerCtx, 30*time.Minute)
// Cancel the timer when no longer needed
cancelTimer()
return "Timer fired", nil
}
Local Activities
Purpose: Reduce latency for short, lightweight operations by skipping the task queue. ONLY use these when necessary for performance. Do NOT use these by default, as they are not durable and distributed.
func MyWorkflow(ctx workflow.Context) (string, error) {
lao := workflow.LocalActivityOptions{
StartToCloseTimeout: 5 * time.Second,
}
ctx = workflow.WithLocalActivityOptions(ctx, lao)
var result string
err := workflow.ExecuteLocalActivity(ctx, QuickLookup, "key").Get(ctx, &result)
if err != nil {
return "", err
}
return result, nil
}