Building temporal long-running agents requires treating LLM inference as a durable, retryable side effect rather than a blocking inline call. Temporal workflows persist execution state across crashes and deploys, while LLMs add latency, non-determinism, and provider outages that can span days of agent runtime. This how-to shows a concrete Python pattern to orchestrate an agent that loops, waits for human approval, and resumes without losing context.
Step 1: Separate orchestration from non-deterministic I/O
Temporal replays your workflow code from the event history. Any call that touches the network or randomness must live in an activity. The workflow defines the deterministic state machine; activities do the LLM calls and tool execution.
Define a minimal activity that wraps an OpenAI-compatible chat completion:
from temporalio import activity
from openai import OpenAI
@activity.defn
async def call_llm(messages: list[dict]) -> str:
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0.2,
)
return resp.choices[0].message.content
The workflow itself never imports openai. That keeps replay safe.
Step 2: Implement the agent loop
A typical agent runs a reasoning loop: call the model, parse a tool request, execute the tool, feed the result back. Encode this as a workflow method with a fixed max iteration cap to avoid infinite loops.
from temporalio import workflow
from dataclasses import dataclass
@dataclass
class AgentState:
messages: list[dict]
iterations: int = 0
@workflow.defn
class ResearchAgent:
@workflow.run
async def run(self, init_prompt: str) -> str:
state = AgentState(messages=[{"role": "user", "content": init_prompt}])
while state.iterations < 10:
state.iterations += 1
reply = await workflow.execute_activity(
call_llm,
state.messages,
start_to_close_timeout=30,
)
state.messages.append({"role": "assistant", "content": reply})
if "FINAL_ANSWER" in reply:
return reply
if reply.startswith("TOOL:"):
tool_out = await workflow.execute_activity(
run_tool, reply, start_to_close_timeout=60,
)
state.messages.append({"role": "user", "content": tool_out})
return "MAX_ITERATIONS_REACHED"
The run_tool activity is a plain function that dispatches to your internal APIs. Keep it idempotent—Temporal may replay it.
Writing idempotent tool activities
@activity.defn
async def run_tool(cmd: str) -> str:
name, arg = cmd[5:].split("|", 1)
if name == "lookup_invoice":
return db.get_invoice(arg) # read-only, safe to replay
if name == "send_email":
key = activity.info().activity_id
return email.send(arg, idempotency_key=key)
Using the activity ID as an idempotency key ensures that a replayed send_email does not fire twice.
Step 3: Insert long pauses and human signals
Long-running agent tasks often need external approval before taking irreversible actions (e.g., sending an email). Use workflow.wait_for_signal combined with a timer to bound the wait.
@workflow.signal
async def approve(self, ok: bool):
self._approved = ok
@workflow.run
async def run(self, init_prompt: str) -> str:
# ... loop from Step 2 ...
if reply.startswith("ACTION_REQUIRED:"):
self._approved = None
signal_future = workflow.wait_for_signal("approve")
timer = workflow.start_timer(24 * 3600) # 1 day
done, pending = await workflow.await_for_any(signal_future, timer)
if not done or not self._approved:
return "CANCELLED_NO_APPROVAL"
# proceed with action
This pattern lets temporal long-running agents sleep for days without holding a worker thread. The state is stored in the Temporal cluster, not in process memory.
Step 4: Harden LLM activities against provider failures
Providers throttle and degrade. A workflow that runs for a week cannot afford a transient 429 to kill it. Set a retry policy on the activity and route through a gateway that fails over automatically.
from temporalio.common import RetryPolicy
await workflow.execute_activity(
call_llm,
state.messages,
start_to_close_timeout=30,
retry_policy=RetryPolicy(
initial_interval=2,
maximum_interval=60,
maximum_attempts=20,
non_retryable_error_types=["ValueError"],
),
)
Inside call_llm, point the OpenAI client at an inference gateway that honors fallback. For example, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and automatically routes around rate-limited providers, so a single base_url change prevents workflow failures during multi-day runs. Per-token metering also lets you attribute cost per agent execution.
client = OpenAI(
base_url="https://api.n4n.ai/v1",
api_key=os.environ["N4N_KEY"],
)
If you prefer self-hosted, implement similar fallback in your activity, but you then own the retry plumbing.
Step 5: Run the worker and verify end-to-end
Install dependencies and start a local Temporal dev server:
pip install temporalio openai
docker run -d --name temporal -p 7233:7233 temporalio/auto-setup:latest
Worker code:
from temporalio.client import Client
from temporalio.worker import Worker
async def main():
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue="agent-queue",
workflows=[ResearchAgent],
activities=[call_llm, run_tool],
)
await worker.run()
Start a workflow using the Temporal CLI:
temporal workflow start \
--task-queue agent-queue \
--type ResearchAgent \
--input '"Summarize the Q3 infra bill and flag anomalies"'
Verification: open the Temporal UI at localhost:8233. You should see the workflow execution with activities completing, signals arriving, and a final result. For the human-approval branch, send a signal:
temporal workflow signal --name approve --input 'true' --workflow-id <id>
Confirm the workflow resumes and returns within the expected timeout. Check worker logs for LLM activity retries; if you triggered a provider outage, the gateway fallback should mask it and the workflow should still complete. As a final check, query the workflow result via temporal workflow show --workflow-id <id> and confirm the output matches the agent’s FINAL_ANSWER contract.
Operational notes
- Keep workflow code free of
datetime.now()andrandom; useworkflow.now()or pass values via activities to stay deterministic. - Store large LLM transcripts in external blob storage and keep only references in workflow state to avoid bloating event history.
- Use Temporal’s
updatefeature (instead of raw signals) if you need request/response semantics from external callers. - Set explicit
start_to_close_timeouton every activity; never rely on defaults for LLM calls that can hang.
Temporal long-running agents are not a silver bullet—they force you to model the agent as an explicit state machine. That discipline pays off when a job runs for 72 hours and a worker dies at hour 50.