n4nAI

How to build long-running agents with Temporal and LLMs

Step-by-step guide to building temporal long-running agents with LLMs: durable workflows, human signals, resilient inference fallback, Python.

n4n Team3 min read594 words

Audio narration

Coming soon — every post will get a voice note here.

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() and random; use workflow.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 update feature (instead of raw signals) if you need request/response semantics from external callers.
  • Set explicit start_to_close_timeout on 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.

Tagstemporallong-running-agentsworkflow-enginellm-agents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All long-running & asynchronous agent workflows posts →