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
| Signal | Effect | Payload | Reversible |
|---|---|---|---|
| pause | Transitions instance to Paused state. Execution aborts after current step completes. | None | Yes (resume) |
| resume | Transitions from Paused back to Scheduled. Execution continues. | None | N/A |
| cancel | Marks cancellable nodes as Cancelled. Respects non-cancellable scopes. | None | No |
| update_context | Merges payload into the instance's execution context. | JSON object (ExecutionContext) | Yes (send again) |
| custom | User-defined. Delivered to on_signal interceptor or wait_for_input steps. | Any JSON | Depends 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
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.