{"page":{"agent_metadata":{"content_type":"guide","outputs":["implement-signal-based-workflows","build-approval-workflows","handle-signal-timeouts","send-signals-from-external-systems"],"prerequisites":["temporal-go-workflow-basics"]},"categories":["workflow-orchestration"],"content_plain":"Temporal Signals# Workflows often need input after they have started. A deployment workflow pauses for human approval. An expense workflow waits for a manager\u0026rsquo;s signature. An incident response workflow escalates after a timeout. Temporal signals are the mechanism for delivering external input to a running workflow.\nA signal is a message sent to a workflow from outside \u0026ndash; from another workflow, from a CLI command, from an HTTP endpoint, or from any system that has the Temporal client. The workflow receives the signal, processes it, and continues execution. Signals are durable: if the worker crashes after a signal is sent but before the workflow processes it, the signal is replayed when the worker restarts.\nSignals vs Queries# Temporal has two mechanisms for communicating with running workflows. They serve different purposes.\nSignals deliver data that changes workflow behavior. A signal mutates workflow state. When a workflow receives an approval signal, it proceeds. When it receives a rejection signal, it compensates and exits. Signals are write operations.\nQueries read workflow state without changing it. A query might return the current approval status, the list of completed steps, or the workflow\u0026rsquo;s progress percentage. Queries are read-only and must not modify workflow state or block.\nSignals Queries Direction External to workflow External to workflow Effect Mutates state, advances execution Read-only, no side effects Blocking Workflow can wait for signals Must return immediately Durability Persisted in event history Not persisted Use case Approvals, cancellations, input Status checks, progress reports Use signals when the workflow needs to do something different based on external input. Use queries when you need to inspect what the workflow is doing without affecting it.\nReceiving Signals in Go# A workflow receives signals through a channel, similar to Go\u0026rsquo;s native channels but integrated with Temporal\u0026rsquo;s deterministic execution model.\nfunc SimpleSignalWorkflow(ctx workflow.Context) (string, error) { signalCh := workflow.GetSignalChannel(ctx, \u0026#34;my-signal\u0026#34;) var signalValue string signalCh.Receive(ctx, \u0026amp;signalValue) return fmt.Sprintf(\u0026#34;received: %s\u0026#34;, signalValue), nil }workflow.GetSignalChannel returns a channel bound to a signal name. Receive blocks the workflow until a signal with that name arrives. The signal payload is deserialized into signalValue.\nFor workflows that need to handle multiple signal types, use a Selector:\nfunc MultiSignalWorkflow(ctx workflow.Context) (string, error) { approvalCh := workflow.GetSignalChannel(ctx, \u0026#34;approval-signal\u0026#34;) cancelCh := workflow.GetSignalChannel(ctx, \u0026#34;cancel-signal\u0026#34;) var decision string selector := workflow.NewSelector(ctx) selector.AddReceive(approvalCh, func(c workflow.ReceiveChannel, more bool) { var signal ApprovalSignal c.Receive(ctx, \u0026amp;signal) decision = signal.Decision }) selector.AddReceive(cancelCh, func(c workflow.ReceiveChannel, more bool) { var signal CancelSignal c.Receive(ctx, \u0026amp;signal) decision = \u0026#34;cancelled\u0026#34; }) selector.Select(ctx) return decision, nil }Selector is Temporal\u0026rsquo;s equivalent of Go\u0026rsquo;s select statement. It blocks until one of the registered channels has data, then executes the corresponding callback. This lets a single workflow wait for multiple signal types simultaneously.\nWaiting with Timeouts# A workflow that waits for human input indefinitely is dangerous. People forget, go on vacation, or the approval becomes irrelevant. Combine signals with timers using Selector to implement timeouts.\nfunc ApprovalWorkflow(ctx workflow.Context, req ApprovalRequest) (ApprovalResult, error) { signalCh := workflow.GetSignalChannel(ctx, \u0026#34;approval-signal\u0026#34;) var signal ApprovalSignal var timedOut bool selector := workflow.NewSelector(ctx) // Wait for approval signal selector.AddReceive(signalCh, func(c workflow.ReceiveChannel, more bool) { c.Receive(ctx, \u0026amp;signal) }) // Wait for cancellation signal selector.AddReceive(workflow.GetSignalChannel(ctx, \u0026#34;cancel-signal\u0026#34;), func(c workflow.ReceiveChannel, more bool) { var cancel CancelSignal c.Receive(ctx, \u0026amp;cancel) signal = ApprovalSignal{Decision: \u0026#34;rejected\u0026#34;, Comment: \u0026#34;cancelled: \u0026#34; + cancel.Reason} }) // Timeout timerFuture := workflow.NewTimer(ctx, time.Duration(req.TimeoutMinutes)*time.Minute) selector.AddFuture(timerFuture, func(f workflow.Future) { timedOut = true }) selector.Select(ctx) if timedOut { return ApprovalResult{ Decision: \u0026#34;rejected\u0026#34;, Reason: fmt.Sprintf(\u0026#34;no response within %d minutes\u0026#34;, req.TimeoutMinutes), AutoDecided: true, }, nil } return ApprovalResult{ Decision: signal.Decision, Reason: signal.Comment, ApprovedBy: signal.Approver, AutoDecided: false, }, nil }The Selector waits for whichever comes first: an approval signal, a cancellation signal, or the timer firing. If the timer wins, the workflow auto-rejects. This pattern is safe \u0026ndash; even if the workflow is replayed after a crash, the timer and signal are replayed from history, producing the same result.\nThe Approval Workflow: Complete Example# Here is a complete deployment approval workflow from the companion repo. It receives a deployment request, notifies the approver, waits for a decision, and either proceeds or aborts.\ntype DeployApprovalRequest struct { Service string Version string Environment string Requester string Approver string TimeoutMinutes int } type ApprovalSignal struct { Decision string // \u0026#34;approved\u0026#34; or \u0026#34;rejected\u0026#34; Approver string Comment string } type DeployApprovalResult struct { Approved bool Approver string Comment string AutoDecided bool } func DeployApprovalWorkflow(ctx workflow.Context, req DeployApprovalRequest) (DeployApprovalResult, error) { actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ StartToCloseTimeout: 30 * time.Second, RetryPolicy: \u0026amp;temporal.RetryPolicy{ MaximumAttempts: 3, }, }) // Step 1: Send notification to approver err := workflow.ExecuteActivity(actCtx, SendApprovalNotification, NotificationRequest{ Recipient: req.Approver, Service: req.Service, Version: req.Version, Environment: req.Environment, Requester: req.Requester, }).Get(ctx, nil) if err != nil { return DeployApprovalResult{}, fmt.Errorf(\u0026#34;send notification: %w\u0026#34;, err) } // Step 2: Wait for decision signalCh := workflow.GetSignalChannel(ctx, \u0026#34;approval-decision\u0026#34;) var signal ApprovalSignal var timedOut bool selector := workflow.NewSelector(ctx) selector.AddReceive(signalCh, func(c workflow.ReceiveChannel, more bool) { c.Receive(ctx, \u0026amp;signal) }) selector.AddFuture( workflow.NewTimer(ctx, time.Duration(req.TimeoutMinutes)*time.Minute), func(f workflow.Future) { timedOut = true }, ) selector.Select(ctx) if timedOut { // Notify requester of timeout _ = workflow.ExecuteActivity(actCtx, SendTimeoutNotification, TimeoutNotificationRequest{ Recipient: req.Requester, Service: req.Service, }).Get(ctx, nil) return DeployApprovalResult{ Approved: false, Comment: fmt.Sprintf(\u0026#34;approval timed out after %d minutes\u0026#34;, req.TimeoutMinutes), AutoDecided: true, }, nil } // Step 3: Notify requester of decision _ = workflow.ExecuteActivity(actCtx, SendDecisionNotification, DecisionNotificationRequest{ Recipient: req.Requester, Service: req.Service, Decision: signal.Decision, Approver: signal.Approver, Comment: signal.Comment, }).Get(ctx, nil) return DeployApprovalResult{ Approved: signal.Decision == \u0026#34;approved\u0026#34;, Approver: signal.Approver, Comment: signal.Comment, AutoDecided: false, }, nil }This workflow is started when someone requests a deployment. It sends a notification, then durably waits. The workflow can wait for hours, days, or weeks \u0026ndash; it costs nothing while waiting, and survives server restarts.\nSending Signals# Signals can be sent from multiple sources.\nTemporal CLI:\n# Approve a deployment temporal workflow signal \\ --workflow-id deploy-approval-web-api-v2.1.0 \\ --name approval-decision \\ --input \u0026#39;{\u0026#34;Decision\u0026#34;:\u0026#34;approved\u0026#34;,\u0026#34;Approver\u0026#34;:\u0026#34;alice@example.com\u0026#34;,\u0026#34;Comment\u0026#34;:\u0026#34;LGTM\u0026#34;}\u0026#39; # Reject a deployment temporal workflow signal \\ --workflow-id deploy-approval-web-api-v2.1.0 \\ --name approval-decision \\ --input \u0026#39;{\u0026#34;Decision\u0026#34;:\u0026#34;rejected\u0026#34;,\u0026#34;Approver\u0026#34;:\u0026#34;bob@example.com\u0026#34;,\u0026#34;Comment\u0026#34;:\u0026#34;Not ready for prod\u0026#34;}\u0026#39;Go SDK:\nfunc SendApproval(ctx context.Context, c client.Client, workflowID string, decision ApprovalSignal) error { return c.SignalWorkflow(ctx, workflowID, \u0026#34;\u0026#34;, \u0026#34;approval-decision\u0026#34;, decision) }The empty string for the run ID means \u0026ldquo;signal the latest run of this workflow.\u0026rdquo; If you need to signal a specific run, pass the run ID.\nHTTP API (via a web service):\nfunc handleApproval(w http.ResponseWriter, r *http.Request) { workflowID := r.URL.Query().Get(\u0026#34;workflow_id\u0026#34;) decision := r.FormValue(\u0026#34;decision\u0026#34;) comment := r.FormValue(\u0026#34;comment\u0026#34;) err := temporalClient.SignalWorkflow(r.Context(), workflowID, \u0026#34;\u0026#34;, \u0026#34;approval-decision\u0026#34;, ApprovalSignal{ Decision: decision, Approver: r.Header.Get(\u0026#34;X-User-Email\u0026#34;), Comment: comment, }) if err != nil { http.Error(w, \u0026#34;failed to send signal\u0026#34;, http.StatusInternalServerError) return } fmt.Fprintf(w, \u0026#34;Decision recorded: %s\u0026#34;, decision) }This is how you connect a web UI or Slack bot to a Temporal approval workflow. The web service receives the human\u0026rsquo;s decision and forwards it as a signal.\nSignal Payload Design# Keep signal payloads small and focused. Signals are persisted in the workflow\u0026rsquo;s event history and replayed during recovery.\nDo:\ntype ApprovalSignal struct { Decision string `json:\u0026#34;decision\u0026#34;` Approver string `json:\u0026#34;approver\u0026#34;` Comment string `json:\u0026#34;comment\u0026#34;` }Do not:\n// Too large -- full objects bloat event history type ApprovalSignal struct { Decision string FullUserProfile UserProfile // Don\u0026#39;t embed large objects AttachedFiles []FileContent // Don\u0026#39;t send file contents }If the workflow needs additional data after receiving a signal, have it call an activity to fetch it. The signal should contain only IDs and decisions.\nVersion your signal types when you evolve them. Add new fields as optional (pointer types or zero-value defaults). Never remove or rename fields that existing running workflows might expect:\n// v1: original type ApprovalSignal struct { Decision string Approver string } // v2: added Comment (optional, zero value is safe) type ApprovalSignal struct { Decision string Approver string Comment string // New in v2, zero value (\u0026#34;\u0026#34;) is handled gracefully }Testing Signal Workflows# The test suite supports signals through env.SignalWorkflow:\nfunc (s *ApprovalTestSuite) TestApproval_Approved() { s.env.OnActivity(SendApprovalNotification, mock.Anything, mock.Anything).Return(nil) s.env.OnActivity(SendDecisionNotification, mock.Anything, mock.Anything).Return(nil) // Register a callback to send the signal after the workflow starts waiting s.env.RegisterDelayedCallback(func() { s.env.SignalWorkflow(\u0026#34;approval-decision\u0026#34;, ApprovalSignal{ Decision: \u0026#34;approved\u0026#34;, Approver: \u0026#34;alice@example.com\u0026#34;, Comment: \u0026#34;Ship it\u0026#34;, }) }, time.Millisecond) s.env.ExecuteWorkflow(DeployApprovalWorkflow, DeployApprovalRequest{ Service: \u0026#34;web-api\u0026#34;, Version: \u0026#34;2.1.0\u0026#34;, Environment: \u0026#34;production\u0026#34;, Requester: \u0026#34;bob@example.com\u0026#34;, Approver: \u0026#34;alice@example.com\u0026#34;, TimeoutMinutes: 60, }) s.Require().True(s.env.IsWorkflowCompleted()) s.Require().NoError(s.env.GetWorkflowError()) var result DeployApprovalResult s.Require().NoError(s.env.GetWorkflowResult(\u0026amp;result)) s.Require().True(result.Approved) s.Require().Equal(\u0026#34;alice@example.com\u0026#34;, result.Approver) }RegisterDelayedCallback schedules the signal to be sent after a small delay, simulating the signal arriving while the workflow is waiting. Without this, the signal would arrive before the workflow reaches its Select call.\nTest the timeout path by not sending any signal and letting the timer fire:\nfunc (s *ApprovalTestSuite) TestApproval_Timeout() { s.env.OnActivity(SendApprovalNotification, mock.Anything, mock.Anything).Return(nil) s.env.OnActivity(SendTimeoutNotification, mock.Anything, mock.Anything).Return(nil) // No signal sent -- the timer will fire s.env.ExecuteWorkflow(DeployApprovalWorkflow, DeployApprovalRequest{ Service: \u0026#34;web-api\u0026#34;, Version: \u0026#34;2.1.0\u0026#34;, Environment: \u0026#34;production\u0026#34;, Requester: \u0026#34;bob@example.com\u0026#34;, Approver: \u0026#34;alice@example.com\u0026#34;, TimeoutMinutes: 60, }) s.Require().True(s.env.IsWorkflowCompleted()) s.Require().NoError(s.env.GetWorkflowError()) var result DeployApprovalResult s.Require().NoError(s.env.GetWorkflowResult(\u0026amp;result)) s.Require().False(result.Approved) s.Require().True(result.AutoDecided) }The test environment fast-forwards timers automatically. A 60-minute timeout completes instantly in tests.\nReal-World Patterns# Signals unlock several common patterns beyond simple approvals.\nDeployment gates. A CI pipeline starts a workflow, which runs pre-deployment checks, then waits for a signal before proceeding to production. The signal can come from a Slack button, a web dashboard, or another workflow.\nExpense approvals with escalation. An expense workflow waits for manager approval. If no response within 24 hours, it sends an escalation signal to the manager\u0026rsquo;s manager. After 48 hours with no response, it auto-rejects and notifies finance.\nIncident escalation. An incident workflow starts with L1 support. If not acknowledged within 15 minutes, it escalates to L2 via signal. L2 can signal back with a resolution or escalate further. Each escalation resets the timeout.\nContent review pipelines. A content submission workflow sends the draft for review. Reviewers signal with approve, request-changes, or reject. On request-changes, the workflow waits for a new submission signal and loops back to review.\nContainer lifecycle with approval. Combining signals with the container lifecycle workflow: before stopping a production container for snapshotting, the workflow sends a notification and waits for approval. This adds a human gate to an otherwise automated process.\nAll of these patterns share the same structure: do some automated work, wait for a signal with a timeout, act on the decision. The durability guarantee means the workflow never loses its place \u0026ndash; even across deployments, crashes, and server migrations. For automated signal workflows where the signal comes from another system rather than a human, see Temporal Signals: Automated.\n","date":"2026-02-22","description":"Implement Temporal signal-based workflows for human approvals, manual gates, and external input with timeout handling, selector patterns, and real-world approval workflow examples in Go.","lastmod":"2026-02-22","levels":["intermediate"],"reading_time_minutes":8,"section":"knowledge","skills":["temporal-signal-handling","approval-workflow-design","human-in-the-loop-patterns"],"tags":["temporal","signals","human-in-the-loop","approval","workflow-communication","timeouts"],"title":"Temporal Signals: Human-in-the-Loop and Manual Approval Workflows","tools":["temporal","go"],"url":"https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-manual/","word_count":1677}}