A Workflow that only runs is a job. A Workflow you can pause, question and re-instruct while it runs is something else, and upstream names it: "Workflows can be thought of as stateful web services that can receive messages." Today the agent grows endpoints — and keeps them across Worker deaths.
Temporal supports three types of messages. The definitions are upstream's, kept exact:
| Signal | Query | Update | |
|---|---|---|---|
| Durable? | Yes — recorded as WorkflowExecutionSignaled; delivered even if no Worker is alive |
No — answered by a live Worker, nothing persisted | Yes once accepted — WorkflowExecutionUpdateAccepted, then ...Completed |
| Returns a value? | No | Yes | Yes — result or error |
| May mutate state? | Yes | No — "must not mutate" | Yes |
| In history? | Yes | Never | Accepted: yes. Rejected by a validator: no |
| Needs a Worker to be up? | No — the call returns when the Service accepts it | Yes | Yes — the client waits for the Worker to accept |
| Works on a closed Workflow? | No | Yes, within the retention period | No |
For reads, "you normally want to do a Query, because Queries are efficient — they never add entries to the Workflow Event History." For writes the question is whether the caller must know the write took effect: fire-and-forget is a Signal; "clients need a result or exception without performing a query" is an Update.
Handlers are methods on the Workflow class, decorated @workflow.signal, @workflow.query or @workflow.update. They run inside the Workflow sandbox, so every determinism rule from module 3 applies to them. Behind the scenes Temporal runs a loop: "every time the Workflow wakes up ... it will process messages in the order they were received, followed by making progress in the Workflow's main method."
"This execution is on a single thread — while this means you don't have to worry about parallelism, you do need to worry about concurrency if you have written Signal and Update handlers that can block." A def handler is atomic; an async def handler is not (see "Handlers interleave").
Note the ordering: messages are processed before the first run of the main method, which happens with Signal-With-Start, a backlogged Task Queue, or a message that lands right after a continue-as-new. So initialize state in __init__ — decorate it @workflow.init if it needs the Workflow's input — and never rely on the main method having run.
Signals for control, a Query for status, an Update for a change the caller must see confirmed:
import asyncio
from dataclasses import dataclass
from temporalio import workflow
@dataclass
class AgentInput:
goal: str
@workflow.defn
class AgentRun:
@workflow.init
def __init__(self, input: AgentInput) -> None:
self.goal = input.goal
self.step = 0
self.paused = False
self.cancelled = False
self.injected: list[str] = []
self.processed_command_ids: set[str] = set()
self.lock = asyncio.Lock()
@workflow.run
async def run(self, input: AgentInput) -> str:
while not self.cancelled and not self.done():
# pause: the main loop blocks here; nothing polls, no Worker is required
await workflow.wait_condition(lambda: not self.paused or self.cancelled)
if self.cancelled:
break
await self.next_step() # LLM call, tool call: Activities (module 4)
self.step += 1
await workflow.wait_condition(workflow.all_handlers_finished)
return self.summary()
@workflow.signal
def pause(self) -> None:
self.paused = True
@workflow.signal
def resume(self) -> None:
self.paused = False
@workflow.signal
def cancel_run(self) -> None:
self.cancelled = True
@workflow.signal
def inject_context(self, text: str, command_id: str) -> None:
if command_id in self.processed_command_ids:
return # a retried Signal; see "Message idempotency"
self.processed_command_ids.add(command_id)
self.injected.append(text)
@workflow.query
def status(self) -> dict:
return {"goal": self.goal, "step": self.step, "paused": self.paused}
@workflow.update
async def change_goal(self, goal: str, command_id: str) -> str:
if command_id in self.processed_command_ids:
return self.goal # already applied; answer, do not re-apply
async with self.lock: # serialize with anything else that touches goal
self.processed_command_ids.add(command_id)
self.goal = goal
self.injected.append(f"goal changed to: {goal}")
return self.goal
@change_goal.validator
def validate_goal(self, goal: str, command_id: str) -> None:
if not goal.strip():
raise ValueError("goal must not be empty")
workflow.wait_condition(fn) is how Workflow code waits for a message: it takes a function returning True or False, optionally a timeout, and resolves when a handler makes the condition true. Upstream lists its three jobs — wait for a Signal or Update to arrive, wait inside a handler until it is appropriate to continue, and wait in the main method until all handlers have finished. A Query handler must be a plain def: it cannot execute an Activity or await anything.
From the client, each verb is one call on the handle:
handle = client.get_workflow_handle_for(AgentRun.run, "agent-42")
await handle.signal(AgentRun.pause) # returns when the Service records it
print(await handle.query(AgentRun.status)) # {'goal': ..., 'step': 27, 'paused': True}
new_goal = await handle.execute_update(AgentRun.change_goal, args=["write the tests first", "cmd-9f1e"])
pause arrives at step 27. The handler flips a flag; the main loop blocks on wait_condition. Now every Worker can disappear. Nothing polls for this execution and no process holds its state — the WorkflowExecutionSignaled Event is in history, and history is the state.
Two days later resume arrives. A Worker picks up the Workflow Task, replays history to step 27 (module 2), sees paused become False, and runs step 28. No process waited two days, and the paused run consumed no Worker compute while it waited. This is durable stateful execution, not a job runner.
Note
Temporal also has an operator-level Workflow Pause (temporal workflow pause; self-hosted requires Server v1.30+ with frontend.WorkflowPauseEnabled, as of 2026-09 — see the upstream page). It stops Workflow Task dispatch from outside your code, and while paused "Updates and Queries are rejected". The agent's pause Signal is application logic; the operator control is an incident tool. Different layers, same word.
An Update is a two-stage write, and the validator is what makes the first stage cheap. "If it accepts, the Update will become part of your Workflow's history and the client will be notified that the operation has been Accepted. The Update handler will then run until it returns a value. If it rejects, the client will be informed that it was Rejected, and the Workflow will have no indication that it was ever requested, similar to a Query handler." Like a Query, a validator may not block, so it can check arguments and current state but not call an Activity.
On the client, execute_update waits for completion and returns the handler's value. start_update(..., wait_for_stage=WorkflowUpdateStage.ACCEPTED) returns a handle as soon as the Worker has accepted it, useful when the handler goes on to run Activities. Either way, a rejection or a handler failure surfaces as WorkflowUpdateFailedError in the caller:
from temporalio.client import WorkflowUpdateFailedError
try:
await handle.execute_update(AgentRun.change_goal, args=[" ", "cmd-9f20"])
except WorkflowUpdateFailedError as e:
print("rejected:", e.cause) # ValueError: goal must not be empty
What you raise inside the handler matters. An ApplicationError (or an Activity that exhausts a finite retry policy) fails the Update and the client gets the error; the Workflow keeps going. Any other exception "will cause a Workflow Task Failure. This means the Workflow will get stuck and will retry the handler periodically until the exception is fixed" — and the client waits. The same rule for Signals is harsher: an ApplicationError in a Signal handler fails the whole Workflow, so raise one there only for unrecoverable errors.
An async def handler can await an Activity, a Timer or a wait_condition, and the Python guide is explicit: "handler executions and your main Workflow method are all running concurrently, with switching occurring between them at await calls." Suppose change_goal grows: before swapping the goal it runs an Activity that rewrites context_summary for the new objective. At step 27 three messages arrive within a second — Update A change_goal("investigate latency"), Update B change_goal("switch model"), then a pause Signal — and the main loop, which checks is_continue_as_new_suggested() each step, decides to continue-as-new while A's handler is still awaiting its Activity.
What ordering is promised? Only that on each wake-up the Workflow processes messages "in the order they were received, followed by making progress in the Workflow's main method." Completion order is not promised. A's handler starts first and yields at its await; B's handler runs to its own await before A resumes.
Can two Updates interleave? Yes, at every await. If A resumes after B has written self.goal, A overwrites it with the older goal and returns "investigate latency" to a client that saw B accepted later. The pause Signal is a plain def, so it runs atomically between two of those switches and only sets a flag the main loop reads. Upstream's word for this is reentrant.
What happens at continue-as-new? "Your main Workflow method can return or continue-as-new while a handler is still waiting on an async task, such as an Activity result." The handler is cut off: its Activity result is never applied, the new run's AgentState carries whichever goal was written last, and the client that called execute_update for A gets a Not Found error. Temporal's guidance: "If you use Updates or Signals, don't call Continue-as-New from the handlers. Instead, wait for your handlers to finish in your main Workflow before you run continue_as_new" — that is, await workflow.wait_condition(workflow.all_handlers_finished) before the call, so A completes or fails first.
How do you make it safe? Four moves; the code above already makes two. Serialize the critical section with asyncio.Lock, which the sandbox provides deterministically, so B's read-modify-write cannot start until A's has finished and each Update sees a consistent goal. Dedupe on command_id in processed_command_ids, so a client retry of A does not run the Activity again. Reject conflicting work in the validator — a goal equal to the current one, or a change while self.paused is set — because a rejected Update leaves no Event and no handler to drain. And never continue-as-new from a handler; the main loop drains, then snapshots. Lab 5.5 stages the race.
Three notes from the upstream safe-handler sample.
Drain before you finish. "You should generally finish running all handlers before the Workflow run completes or continues as new." The Worker warns when a run ends with unfinished handlers; @workflow.update(unfinished_policy=workflow.HandlerUnfinishedPolicy.ABANDON) silences the warning per handler if abandoning is what you mean.
Serialize handlers that share state. Guard the section with asyncio.Lock(), as change_goal does, or have handlers enqueue work for the main loop — the hello_signal pattern with asyncio.Queue.
Deliver exactly once. Updates are deduplicated by the Service on their Update ID (a UUID by default, settable by you), but "Update ID deduplication by the server is per Workflow run", so across continue-as-new you keep your own record. Signals carry no ID: put an idempotency key in the payload and dedupe in Workflow code. Both kinds also dedupe retried client calls by request ID automatically.
The client that sends inject_context is an API server. Its connection to the Service breaks after the Service recorded WorkflowExecutionSignaled but before the acknowledgement arrived; the client's retry loop sends the Signal again. Now self.injected holds the note twice and the model reads it twice. The same shape hits change_goal: the Update completed, the response was lost, the client re-sends, and a second goal changed to: note lands in the context.
Temporal cannot dedupe this for you: a Signal has no identity beyond its payload, and an Update's server-side dedupe holds only within one run and only for the client's own request id, not a new request built after a reconnect. Upstream's design-pattern page for Signal-With-Start shows the fix as the first line of the handler: the Workflow keeps a set of processed operation ids and ignores a Signal whose id it has seen. AgentRun does the same with command_id, chosen by the caller once per command and reused on every retry — a UUID minted when the user pressed the button, not when the request was sent:
if command_id in self.processed_command_ids:
return # duplicate: ignore, or for an Update, return the earlier answer
self.processed_command_ids.add(command_id)
Three consequences. The set is Workflow state, so it is rebuilt by replay and survives any Worker death; nothing external is consulted. It must ride along in the AgentState snapshot at continue-as-new (module 6), or the first Signal after a new run starts is an undetected duplicate. And it grows: bound it — keep the last few hundred ids, or ids younger than the longest client retry window. Lab 5.4 retries a Signal on purpose and reads the two WorkflowExecutionSignaled Events beside the one appended note.
Finally, messaging a Workflow that may not exist yet. Signal-With-Start (start_workflow(..., start_signal="inject_context", start_signal_args=[...])) signals a running execution or starts one and signals it, atomically. Update-With-Start (execute_update_with_start_workflow with a WithStartWorkflowOperation and an explicit WorkflowIDConflictPolicy) does the same for Updates — but "Update-With-Start is not atomic": if the Update cannot be delivered because no Worker is available, the Workflow still starts (as of 2026-09; see upstream page).
A Worker is offline when a user sends a message and asks for status. Decide which operation—Signal, Query, or Update—fits each request. In Lab 5, use history to test which requests were recorded.
Lab 5 is in labs/05-signals-queries-updates/:
pause, resume and cancel_run Signals and a status Query to the agent loop.change_goal as an Update with a validator; show the rejected call as WorkflowUpdateFailedError in the client, and confirm the rejection left no Event in history.inject_context with the same command_id after a simulated broken connection; two WorkflowExecutionSignaled Events, one note in status().change_goal Updates and a pause within a second while the handler awaits an Activity; read the two WorkflowExecutionUpdateAccepted Events and the final goal, then add the lock and drain before continue-as-new.Lab page: Open the guided lab — the exercises, every source file, and the local setup steps.
This page adapts material from Temporal's MIT-licensed documentation and samples (© Temporal Technologies Inc.; © Uber Technologies, Inc.). Adapted text is rewritten for this course; the upstream pages are the reference of record and may have changed since the commit linked here. Temporal is a trademark of Temporal Technologies; this course is independent and not endorsed by Temporal.