---
title: "Temporal Signals: Human-in-the-Loop and Manual Approval Workflows"
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."
url: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-manual/
section: knowledge
date: 2026-02-22
categories: ["workflow-orchestration"]
tags: ["temporal","signals","human-in-the-loop","approval","workflow-communication","timeouts"]
skills: ["temporal-signal-handling","approval-workflow-design","human-in-the-loop-patterns"]
tools: ["temporal","go"]
levels: ["intermediate"]
word_count: 1677
formats:
  json: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-manual/index.json
  html: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-signals-manual/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Temporal+Signals%3A+Human-in-the-Loop+and+Manual+Approval+Workflows
---


# Temporal Signals

Workflows often need input after they have started. A deployment workflow pauses for human approval. An expense workflow waits for a manager's signature. An incident response workflow escalates after a timeout. Temporal signals are the mechanism for delivering external input to a running workflow.

A signal is a message sent to a workflow from outside -- 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.

## Signals vs Queries

Temporal has two mechanisms for communicating with running workflows. They serve different purposes.

**Signals** 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.

**Queries** read workflow state without changing it. A query might return the current approval status, the list of completed steps, or the workflow's progress percentage. Queries are read-only and must not modify workflow state or block.

| | Signals | 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.

## Receiving Signals in Go

A workflow receives signals through a channel, similar to Go's native channels but integrated with Temporal's deterministic execution model.

```go
func SimpleSignalWorkflow(ctx workflow.Context) (string, error) {
    signalCh := workflow.GetSignalChannel(ctx, "my-signal")

    var signalValue string
    signalCh.Receive(ctx, &signalValue)

    return fmt.Sprintf("received: %s", 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`.

For workflows that need to handle multiple signal types, use a `Selector`:

```go
func MultiSignalWorkflow(ctx workflow.Context) (string, error) {
    approvalCh := workflow.GetSignalChannel(ctx, "approval-signal")
    cancelCh := workflow.GetSignalChannel(ctx, "cancel-signal")

    var decision string

    selector := workflow.NewSelector(ctx)
    selector.AddReceive(approvalCh, func(c workflow.ReceiveChannel, more bool) {
        var signal ApprovalSignal
        c.Receive(ctx, &signal)
        decision = signal.Decision
    })
    selector.AddReceive(cancelCh, func(c workflow.ReceiveChannel, more bool) {
        var signal CancelSignal
        c.Receive(ctx, &signal)
        decision = "cancelled"
    })
    selector.Select(ctx)

    return decision, nil
}
```

`Selector` is Temporal's equivalent of Go'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.

## Waiting 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.

```go
func ApprovalWorkflow(ctx workflow.Context, req ApprovalRequest) (ApprovalResult, error) {
    signalCh := workflow.GetSignalChannel(ctx, "approval-signal")

    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, &signal)
    })

    // Wait for cancellation signal
    selector.AddReceive(workflow.GetSignalChannel(ctx, "cancel-signal"), func(c workflow.ReceiveChannel, more bool) {
        var cancel CancelSignal
        c.Receive(ctx, &cancel)
        signal = ApprovalSignal{Decision: "rejected", Comment: "cancelled: " + 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: "rejected",
            Reason:   fmt.Sprintf("no response within %d minutes", 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 -- even if the workflow is replayed after a crash, the timer and signal are replayed from history, producing the same result.

## The Approval Workflow: Complete Example

Here is a complete deployment approval workflow from the [companion repo](https://github.com/statherm/temporal-examples). It receives a deployment request, notifies the approver, waits for a decision, and either proceeds or aborts.

```go
type DeployApprovalRequest struct {
    Service       string
    Version       string
    Environment   string
    Requester     string
    Approver      string
    TimeoutMinutes int
}

type ApprovalSignal struct {
    Decision string // "approved" or "rejected"
    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: &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("send notification: %w", err)
    }

    // Step 2: Wait for decision
    signalCh := workflow.GetSignalChannel(ctx, "approval-decision")

    var signal ApprovalSignal
    var timedOut bool

    selector := workflow.NewSelector(ctx)
    selector.AddReceive(signalCh, func(c workflow.ReceiveChannel, more bool) {
        c.Receive(ctx, &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("approval timed out after %d minutes", 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 == "approved",
        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 -- it costs nothing while waiting, and survives server restarts.

## Sending Signals

Signals can be sent from multiple sources.

**Temporal CLI:**

```bash
# Approve a deployment
temporal workflow signal \
    --workflow-id deploy-approval-web-api-v2.1.0 \
    --name approval-decision \
    --input '{"Decision":"approved","Approver":"alice@example.com","Comment":"LGTM"}'

# Reject a deployment
temporal workflow signal \
    --workflow-id deploy-approval-web-api-v2.1.0 \
    --name approval-decision \
    --input '{"Decision":"rejected","Approver":"bob@example.com","Comment":"Not ready for prod"}'
```

**Go SDK:**

```go
func SendApproval(ctx context.Context, c client.Client, workflowID string, decision ApprovalSignal) error {
    return c.SignalWorkflow(ctx, workflowID, "", "approval-decision", decision)
}
```

The empty string for the run ID means "signal the latest run of this workflow." If you need to signal a specific run, pass the run ID.

**HTTP API (via a web service):**

```go
func handleApproval(w http.ResponseWriter, r *http.Request) {
    workflowID := r.URL.Query().Get("workflow_id")
    decision := r.FormValue("decision")
    comment := r.FormValue("comment")

    err := temporalClient.SignalWorkflow(r.Context(), workflowID, "", "approval-decision", ApprovalSignal{
        Decision: decision,
        Approver: r.Header.Get("X-User-Email"),
        Comment:  comment,
    })
    if err != nil {
        http.Error(w, "failed to send signal", http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "Decision recorded: %s", decision)
}
```

This is how you connect a web UI or Slack bot to a Temporal approval workflow. The web service receives the human's decision and forwards it as a signal.

## Signal Payload Design

Keep signal payloads small and focused. Signals are persisted in the workflow's event history and replayed during recovery.

**Do:**

```go
type ApprovalSignal struct {
    Decision string `json:"decision"`
    Approver string `json:"approver"`
    Comment  string `json:"comment"`
}
```

**Do not:**

```go
// Too large -- full objects bloat event history
type ApprovalSignal struct {
    Decision      string
    FullUserProfile UserProfile  // Don't embed large objects
    AttachedFiles  []FileContent // Don'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.

Version 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:

```go
// 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 ("") is handled gracefully
}
```

## Testing Signal Workflows

The test suite supports signals through `env.SignalWorkflow`:

```go
func (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("approval-decision", ApprovalSignal{
            Decision: "approved",
            Approver: "alice@example.com",
            Comment:  "Ship it",
        })
    }, time.Millisecond)

    s.env.ExecuteWorkflow(DeployApprovalWorkflow, DeployApprovalRequest{
        Service:        "web-api",
        Version:        "2.1.0",
        Environment:    "production",
        Requester:      "bob@example.com",
        Approver:       "alice@example.com",
        TimeoutMinutes: 60,
    })

    s.Require().True(s.env.IsWorkflowCompleted())
    s.Require().NoError(s.env.GetWorkflowError())

    var result DeployApprovalResult
    s.Require().NoError(s.env.GetWorkflowResult(&result))
    s.Require().True(result.Approved)
    s.Require().Equal("alice@example.com", 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.

Test the timeout path by not sending any signal and letting the timer fire:

```go
func (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:        "web-api",
        Version:        "2.1.0",
        Environment:    "production",
        Requester:      "bob@example.com",
        Approver:       "alice@example.com",
        TimeoutMinutes: 60,
    })

    s.Require().True(s.env.IsWorkflowCompleted())
    s.Require().NoError(s.env.GetWorkflowError())

    var result DeployApprovalResult
    s.Require().NoError(s.env.GetWorkflowResult(&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.

## Real-World Patterns

Signals unlock several common patterns beyond simple approvals.

**Deployment 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.

**Expense approvals with escalation.** An expense workflow waits for manager approval. If no response within 24 hours, it sends an escalation signal to the manager's manager. After 48 hours with no response, it auto-rejects and notifies finance.

**Incident 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.

**Content 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.

**Container lifecycle with approval.** Combining signals with the [container lifecycle workflow](../temporal-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.

All 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 -- 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](../temporal-signals-automated/).

