Skip to content

Signals & LLM Usage

Signals let you control running workflow instances from the outside — pause, resume, cancel, inject data, or trigger custom logic. Combined with LLM usage tracking, you get full observability into AI-powered orchestrations.

Signal Overview

A signal is an asynchronous message sent to a running workflow instance. Signals are stored in an inbox and processed by the scheduler on the next tick — they are durable, idempotent, and order-preserving.

Durable

Signals are persisted in the database. They survive server restarts and are never lost.

Idempotent

Each signal has a unique ID and a delivered flag. Once processed, it won't be re-delivered.

Atomic

Signals are enqueued with an atomic check — if the target instance is already in a terminal state, the signal is rejected.

Interceptable

Sequences can define an on_signal interceptor to run custom logic when any signal arrives.

Signal Types

SignalEffectPayloadReversible
pauseTransitions instance to Paused state. Execution aborts after current step completes.NoneYes (resume)
resumeTransitions from Paused back to Scheduled. Execution continues.NoneN/A
cancelMarks cancellable nodes as Cancelled. Respects non-cancellable scopes.NoneNo
update_contextMerges payload into the instance's execution context.JSON object (ExecutionContext)Yes (send again)
customUser-defined. Delivered to on_signal interceptor or wait_for_input steps.Any JSONDepends on logic

Sending Signals

Via REST API

POST /instances/{instance_id}/signals

{
  "signal_type": "pause"
}

Response:
{
  "signal_id": "550e8400-e29b-41d4-a716-446655440000"
}

Update Context Signal

POST /instances/{instance_id}/signals

{
  "signal_type": "update_context",
  "payload": {
    "approved": true,
    "reviewer": "alice@example.com",
    "notes": "Looks good, proceed with deployment."
  }
}

The payload is merged into the instance context. Subsequent steps can access these values via template interpolation.

Custom Signal

POST /instances/{instance_id}/signals

{
  "signal_type": "approval_received",
  "payload": {
    "decision": "approved",
    "amount": 5000
  }
}

Via CLI

# Pause a running instance
orch8 signal <instance-id> pause

# Resume a paused instance
orch8 signal <instance-id> resume

# Cancel an instance
orch8 signal <instance-id> cancel

# Update context with JSON payload
orch8 signal <instance-id> update_context '{"approved": true}'

# Send custom signal
orch8 signal <instance-id> my_custom_event '{"key": "value"}'

Scoped Cancellation

Not all work should stop when a cancel signal arrives. Orch8 supports three mechanisms for protecting critical work:

1. Per-Step Flag: cancellable: false

Mark individual steps as non-cancellable. They will continue to execute even after a cancel signal.

{
  "type": "step",
  "id": "send-receipt",
  "handler": "http_request",
  "cancellable": false,
  "params": { "url": "https://api.stripe.com/v1/receipts", "method": "POST" }
}

2. CancellationScope Block

Wrap a group of steps in a cancellation scope. All steps inside are protected from cancel signals.

3. Finally Branch (try-catch)

Steps in the finally_block branch of a try-catch block are always non-cancellable — they run regardless of success, failure, or cancellation.

When a cancel signal arrives, the scheduler identifies all non-cancellable nodes. Only cancellable nodes transition to Cancelled. If non-cancellable work remains active, the instance continues running until that work completes.

Human-in-the-Loop

The wait_for_input step type pauses execution until a custom signal matching a specific pattern is received. This enables approval workflows, human review gates, and interactive processes.

Sequence Definition

{
  "type": "step",
  "id": "wait-for-approval",
  "handler": "human_review",
  "params": { "review_data": "Pending approval" },
  "wait_for_input": {
    "prompt": "Review and approve or reject.",
    "timeout": 86400000
  }
}

Completing the Wait

POST /instances/{instance_id}/signals

{
  "signal_type": "human_input:approval",
  "payload": {
    "approved": true,
    "reviewer": "manager@company.com"
  }
}

The signal payload becomes the step output, accessible to subsequent steps via template interpolation.

Example: Expense Approval

[
  {
    "type": "step",
    "id": "notify-manager",
    "handler": "http_request",
    "params": {
      "url": "https://slack.com/api/chat.postMessage",
      "method": "POST",
      "body": {
        "channel": "#approvals",
        "text": "Expense ${{context.data.amount}} needs approval."
      }
    }
  },
  {
    "type": "step",
    "id": "wait-approval",
    "handler": "human_review",
    "params": { "review_data": "Expense approval pending" },
    "wait_for_input": {
      "prompt": "Approve or reject the expense.",
      "timeout": 86400000,
      "store_as": "expense_decision"
    }
  },
  {
    "type": "router",
    "id": "process-decision",
    "routes": [
      {
        "condition": "context.data.expense_decision == 'approve'",
        "blocks": [{ "type": "step", "id": "reimburse", "handler": "reimburse_expense" }]
      }
    ],
    "default": [{ "type": "step", "id": "reject-email", "handler": "send_rejection" }]
  }
]

Inter-Workflow Signals

A running workflow can signal another workflow instance using the built-in send_signal step handler. This enables coordination between independent workflow instances.

{
  "type": "step",
  "id": "notify-parent",
  "handler": "send_signal",
  "params": {
    "instance_id": "{{context.data.parent_instance_id}}",
    "signal_type": "child_completed",
    "payload": {
      "result": "{{steps.process.output}}",
      "child_id": "{{context.data.instance_id}}"
    }
  }
}

Safety: The handler validates that the target instance exists, belongs to the same tenant, and is not in a terminal state. An atomic enqueue_signal_if_active operation prevents race conditions.

LLM Usage Tracking

Every llm_call step returns token usage data alongside the model response. This enables cost tracking, quota enforcement, and usage analytics across all supported providers.

Response Format

// Step output from any llm_call step
{
  "provider": "openai",
  "model": "gpt-4o",
  "message": {
    "role": "assistant",
    "content": "Here is your summary...",
    "tool_calls": []
  },
  "finish_reason": "stop",
  "usage": {
    "prompt_tokens": 1250,
    "completion_tokens": 340,
    "total_tokens": 1590
  }
}

Failover Response

When using the providers failover array, the response includes which providers were attempted:

{
  "provider": "openai",
  "model": "gpt-4o",
  "message": { "role": "assistant", "content": "..." },
  "finish_reason": "stop",
  "usage": {
    "prompt_tokens": 800,
    "completion_tokens": 200,
    "total_tokens": 1000
  },
  "tried": ["anthropic", "openai"]
}

The tried array shows that Anthropic was attempted first (and failed with a transient error) before OpenAI succeeded.

Supported Providers

OpenAI
Anthropic
Deepseek
Gemini
Groq
Together
Mistral
Perplexity
Qwen
OpenRouter

All OpenAI-compatible providers use the same protocol. Anthropic uses the native Messages API. Usage fields are normalized to the same format regardless of provider.

Usage Aggregation Patterns

LLM usage is stored in step outputs. Here are patterns for aggregating and acting on usage data within workflows.

Pattern 1: Accumulate in Context

Use a post-LLM step to accumulate token counts in the execution context:

[
  {
    "type": "step",
    "id": "ai-summarize",
    "handler": "llm_call",
    "params": {
      "provider": "openai",
      "model": "gpt-4o",
      "api_key_env": "OPENAI_API_KEY",
      "messages": [{ "role": "user", "content": "Summarize: {{context.data.document}}" }]
    }
  },
  {
    "type": "step",
    "id": "track-usage",
    "handler": "transform",
    "params": {
      "output": {
        "total_tokens_used": "{{context.data.total_tokens_used + steps.ai-summarize.output.usage.total_tokens}}",
        "calls_made": "{{context.data.calls_made + 1}}"
      }
    }
  }
]

Pattern 2: Cost Gate

Halt execution if accumulated cost exceeds a budget:

{
  "type": "router",
  "id": "check-budget",
  "routes": [
    {
      "condition": "context.data.total_tokens_used > 100000",
      "blocks": [{ "type": "step", "id": "budget-exceeded-alert", "handler": "log",
                   "params": { "message": "Budget exceeded", "level": "warn" } }]
    }
  ],
  "default": [{ "type": "step", "id": "next-ai-step", "handler": "llm_call",
                "params": { "provider": "openai", "model": "gpt-4o" } }]
}

Pattern 3: Report Usage via Webhook

Send usage data to an external analytics service after each LLM call:

{
  "type": "step",
  "id": "report-usage",
  "handler": "http_request",
  "params": {
    "url": "https://analytics.internal/api/llm-usage",
    "method": "POST",
    "body": {
      "provider": "{{steps.ai-summarize.output.provider}}",
      "model": "{{steps.ai-summarize.output.model}}",
      "tokens": "{{steps.ai-summarize.output.usage}}"
    }
  }
}

Pattern 4: Per-Instance Usage Summary

Query step outputs after completion to get total usage for an instance:

GET /instances/{instance_id}/outputs

# Filter for llm_call steps and sum usage.total_tokens
# across all step outputs where type = "llm_call"

CLI Reference

The orch8 signal command sends signals from your terminal.

Usage: orch8 signal <INSTANCE_ID> <SIGNAL_TYPE> [PAYLOAD]

Arguments:
  <INSTANCE_ID>   Target instance UUID
  <SIGNAL_TYPE>   One of: pause, resume, cancel, update_context, or any custom string
  [PAYLOAD]       Optional JSON string (required for update_context)

Examples:
  orch8 signal abc-123 pause
  orch8 signal abc-123 resume
  orch8 signal abc-123 cancel
  orch8 signal abc-123 update_context '{"key": "value"}'
  orch8 signal abc-123 human_input:approval '{"approved": true}'

Tip: Custom signal types with the prefix human_input: are routed towait_for_input steps matching that pattern. Other custom signals go to the on_signal interceptor.