← max_tokens

Why we chose Temporal to manage AI agents instead of Celery

An AI agent in production looks like a regular background task only in a demo.

In a demo, everything is linear: the user asks a question, the model answers, a tool is called, and the result comes back. In a real product, this quickly turns into a long-running process: multiple LLM calls, tools, external APIs, human approval, timeouts, retries, and branches you cannot fully define in advance.

Every step can fail. The model returns invalid JSON. A provider responds with a rate limit. A tool hangs. A worker dies during deployment. A user clicks approve eight hours later, when the original process is no longer alive in memory.

At first, we looked at this as a set of background tasks. It seemed logical: there is Python, there is Celery, there is a broker, and there are workers. But the deeper we went into agentic architecture, the stronger the feeling became that we were using the wrong primitive.

An agent is not shaped like a task.

An agent is shaped like a process.

That is why we chose Temporal to orchestrate these processes.

Where Celery starts to creak

Celery fits the task queue model well: a task arrives in a queue, a worker executes it, and the result is stored. Send an email. Compress an image. Recalculate a counter. Run a short background job.

Long-running tasks are also possible in Celery. It has retries, ETA/countdown, chains, groups, chords, and periodic tasks through Celery beat. It is not “just a simple queue with no features.”

The problem is not that Celery is weak. The problem is the shape of the work.

In a regular task queue, the middle of a task is not a first-class entity. If a process consists of ten steps, waits for an external event, and must survive a worker restart, you have to model that middle yourself.

Usually, this leads to an agent_runs table: status, current step, attempts, next_retry_at, intermediate results, waiting flags. Every task starts with “read where we left off” and ends with “write where we got to.” After some time, a cron job or watchdog appears next to it, looking for stuck runs and trying to decide what to do with them.

In effect, you are writing a state machine by hand.

Orchestration also starts to spread out. Celery Canvas gives you chain, group, chord, and callbacks, but an agent loop rarely looks like a static graph. The model may choose one tool, then another, then ask for clarification, then decide it has enough data. As a result, the “what happens next” logic starts living in the tails of tasks and callbacks instead of in one readable loop.

A failure in the middle of a multi-step process turns into an investigation. The worker died between “the tool completed” and “the result was saved.” What has already happened in the external world? Is it safe to repeat the step? What if it was a webhook, an email, or a payment charge?

Retries also end up in the wrong place. Celery can retry a task. But an agent often needs to retry one specific step inside a long-running process: wait and retry after a rate limit, re-ask the model after an invalid response, or stop immediately on an auth error without meaningless retries. You can get per-step retries in Celery by making each step its own task with its own retry policy. But that is exactly the fragmentation above: the process scatters back into a chain of tasks and callbacks.

And then there is the human in the loop. “Wait for approval” in the task queue model usually turns into polling, delayed tasks, or a separate state layer. A process that should simply stand still and wait for a day is not a natural object for Celery.

Can all of this be built on top of Celery?

Yes.

The question is whether you want to write durable orchestration yourself.

Celery vs Temporal: a bare task plus hand-built state and watchdogs, versus workflow, activities, replay and signals

What Temporal does differently

Temporal is not “a better queue.” It is a runtime for long-running processes.

It does not serialize your function’s memory. Instead, Temporal keeps an Event History: workflow started, activity scheduled, activity completed with result, timer fired, signal received. When a worker crashes or a new version is deployed, another worker starts the workflow code from the beginning and replays that history.

If an Activity has already completed and its result has been recorded in history, replay does not call it again. The Workflow receives the recorded result and continues to the place where it stopped.

From the outside, this feels like memory recovery. In reality, Temporal restores state not from a memory snapshot, but through replay.

This creates one requirement: the Workflow must be deterministic. Given the same history, it must make the same decisions. That is why LLM calls, HTTP requests, databases, system time, and randomness do not live directly inside the Workflow. They go into Activities or special Temporal APIs.

The separation is simple:

  • The Workflow decides what should happen next.
  • The Activity performs work that may be non-deterministic.

For an agent, this is almost the perfect boundary.

The agent loop itself becomes a Workflow: choose the next step, check the exit condition, wait for a human, decide which tool to call next. Everything non-deterministic becomes an Activity: model call, tool call, HTTP request, database read, message send.

LLMs are non-deterministic by nature, but that does not conflict with Temporal. You should not call the model directly from Workflow code. You can call an Activity that talks to the model. The Activity result is written to Event History, and during replay the Workflow sees the same model response it saw the first time.

What this gives an agent

First: state looks like code, not like a status table.

Conversation history, intermediate results, iteration counters, and the current branch can be regular Workflow variables. Temporal restores them through replay. The agent_runs table you kept just to checkpoint and recover orchestration is no longer needed. External storage can still earn its place for other reasons (product queries, large payloads, reporting, integrations), just not as the place you reconstruct a run from.

Second: the agent loop reads like a loop.

A while loop with a model call, response parsing, and tool dispatch stays a while loop in one file. Not a graph of callbacks. Not a set of tasks where each task schedules the next one at the end. Six months later, a new person on the team will still be able to read it.

@workflow.defn
class AgentWorkflow:
    @workflow.run
    async def run(self, task: str) -> str:
        history = [task]
        while True:
            step = await workflow.execute_activity(call_model, history,
                start_to_close_timeout=timedelta(minutes=2))
            if step.done:
                return step.answer
            tool_result = await workflow.execute_activity(run_tool, step.call,
                start_to_close_timeout=timedelta(minutes=5))
            history.append(tool_result)

Third: retries become precise.

Each Activity can have its own policy: backoff for rate limits, a limited number of attempts for a flaky call, zero retries for authorization errors. Invalid JSON can be validated inside the Activity and treated as a retryable failure. But a retry re-runs the same call with the same input; re-asking the model with the error fed back into the prompt is a turn of the Workflow loop, not the retry policy. Where a plain retry fits, the policy handles repetition, not hand-written wrapper code around every LLM call.

Fourth: human-in-the-loop stops being a workaround.

A Workflow can wait for a Signal from a user for hours or days without occupying a worker: while it waits it holds no worker memory, and the Signal wakes it through replay. A Query can show the current state at any moment while workers are alive to answer it: which step the agent is on, what it is waiting for, and what it has already done. For a request that must be validated and answered, there is an Update alongside the Signal.

Fifth: execution becomes visible.

In the Temporal Web UI, you can see the Event History of a specific run: Activity events, retries, signals, timers, pending Activities, and the place where the Workflow is currently waiting. For an agentic system, this is extremely important. Otherwise, debugging quickly turns into “stitch together logs from three services by trace ID and guess what happened.”

Temporal does not remove the need for care

A bad mistake in conversations about Temporal is imagining it as a platform that magically makes external side effects exactly-once.

It does not.

If an Activity has completed and its result has been written to Event History, replay will not call it again. But the Activity itself can be executed more than once because of a retry, timeout, heartbeat timeout, or a failure before the result is committed.

That means everything that changes the external world must be idempotent: charges, emails, webhooks, object creation in external APIs. You need idempotency keys, deduplication, and proper external identifiers.

Temporal handles orchestration retries.

Correctness of side effects remains the responsibility of your code.

There is also a second boundary: Workflow History should not become a log of everything in the universe.

You should not write every token from an LLM stream, megabytes of raw API responses, or endless debug logs into Event History. Temporal has limits on payloads, history transaction size, and number of events. For large data, it is better to store the object in external storage and pass a reference to the Workflow. A single run holds on the order of tens of thousands of events, and Temporal warns well before the ceiling, so for long dialogs and loops you roll it over with Continue-As-New or child Workflows.

Temporal is good for durable state and large process steps. For token streaming, SSE, WebSocket, or an event bus with a short replay window is a better fit.

Deploying a new version is not free either. Replay only works while the changed Workflow code still agrees with the old history; reorder or remove steps in a running Workflow and replay hits a non-determinism error. For incompatible changes, Temporal gives you Worker Versioning and the patching API, so old runs keep replaying on the old logic while new runs take the new path.

Why not just keep Celery next to it

Temporal and Celery are not mutually exclusive. You can orchestrate an agent run in Temporal and send heavy work to separate workers — whether that is Celery, Kubernetes jobs, or your own worker pool.

But retry logic and responsibility for side effects should live in one place. Otherwise, it is easy to end up with double wrapping: Temporal retries an Activity, Celery retries the task inside it, the external API receives two requests, and then you have to investigate who is responsible.

For ourselves, we chose a simple rule: Temporal owns the process. Everything that is a step in that process should be visible in its Event History. If there is heavy computational work inside a step, it can be moved elsewhere, but the orchestration boundary remains in Temporal.

Where Temporal is not needed

Temporal should not be dragged everywhere.

For simple CRUD, a synchronous request-response flow, or a single background task without state, it is overkill. You pay for a separate service, database, operations, SDK constraints, and the discipline of determinism. If a task simply sends an email or recalculates a cache, a task queue is usually simpler.

The chat token stream itself is also not Temporal’s territory. Temporal can orchestrate a chat session or a long-running job, but you should not write every token into Workflow History. Tokens should flow through SSE, WebSocket, or an event bus. Temporal should store durable state: which run was started, which steps completed, which final result was recorded, and where a human response is needed.

Temporal is useful where there are multiple steps, long duration, state, and a requirement to survive failures.

A payment pipeline. A saga with compensations. A long import. Human approval. An agent that calls tools, waits for external events, and must continue after deployment.

Conclusion

For us, the choice came down to one question: what is the shape of an agent?

If it is a short task, use a task queue and do not overcomplicate things. Celery, RQ, and Sidekiq are perfectly fine for that shape of work.

If it is a stateful process that must survive restarts, retries, human waiting, and non-deterministic model responses, you need a durable orchestration model.

We chose Temporal not because Celery is bad.

We chose Temporal because, for us, an agent run became a process, not a task.

The price is real: a separate service, the discipline of determinism, versioned deploys. We pay it because that orchestration is the part we would otherwise write by hand.

Fewer infrastructure workarounds.

More time for the product itself.

Sources

<|endoftext|> · 3 028 tok · finish_reason: stop

// top_k · nearest neighbors

  1. [0] 0.643 Orca vs Herdr: task isolation or live terminal control
  2. [1] 0.608 Herdr: a control room for agents in the terminal
  3. [2] 0.589 Graphify and MemPalace: an agent needs a project map and a decision history, not 'memory'

cosine of embeddings · scale 0–1 absolute · computed at build

integrity: sha256 12b6fc78…

tokens · o200k_base