- Temporal
- /
- Managing conversation history in a Temporal AI agent chat
Managing conversation history in a Temporal AI agent
TL;DR Long-running conversations built on Temporal can end up split across two unrelated workflow executions, with no single query that returns the whole transcript. The cause is Temporal's 50MB event history limit and 51,200-event cap. Once a workflow approaches either, it has to Continue-As-New, and Continue-As-New doesn't carry the transcript forward. Reconstructing a transcript from the event history directly is also fragile, since it's designed for execution replay, not chat rendering. The fix is to keep transcript storage separate from workflow execution entirely, with a delivery layer surfacing new messages to the frontend as they arrive.
Picture an AI agent working a complex support ticket: pulling order history, running account diagnostics, and checking inventory across several systems. Then it hits a wall: Temporal's event history limits force a Continue-As-New, and from that point on, no single query returns the full transcript. The transcript is split across two workflow executions, with no way to stitch it back together.
Temporal is a strong foundation for multi-turn AI agents. A workflow can span a full conversation, handling each user message as a signal and maintaining context across arbitrary pauses between messages. The execution durability and state management are genuinely useful.
The transcript is where it breaks down, though: it gets split across executions.
Why your transcript ends up split across two executions
Two things need to go wrong for the transcript to end up split across executions. First, the workflow needs to hit a hard limit. Second, the resulting migration needs to lose track of the transcript along the way.
Temporal's size and event-count limits
Temporal's event history has a hard limit of 50MB per workflow run, with a separate cap of 51,200 events. For conversational agents, both limits are reachable.
A ten-turn conversation with substantial context (system prompt, prior turns, and tool call results) can approach 40KB of raw message content per workflow run. Each event also carries Temporal's own metadata, such as event ID, timestamps, and event type, on top of that raw payload. The real footprint in the event history ends up larger than the raw content alone suggests.
Longer conversations, or agents that make multiple tool calls per turn, reach these limits faster than that ten-turn baseline. Once close to either ceiling, a workflow has to migrate its state into a fresh execution. Otherwise, Temporal simply refuses new events past the limit: no new signal, no new activity result, nothing gets through.
How Continue-As-New works
That migration path is called Continue-As-New. It's designed to keep a long-running workflow alive indefinitely, with minimal friction. But it treats the transcript the same way it treats everything else in the workflow's state, and that's where the problems start.
Continue-As-New closes the workflow's current execution, carries forward only the state it needs, and starts a fresh execution under the same workflow type. Temporal treats this as a clean handoff: the new execution picks up without issue, and nothing about the workflow's own logic breaks.
The transcript is the exception to that clean handoff. Continue-As-New gives the new execution a fresh event history, with nothing carried over except whatever state you explicitly pass forward. If that state is just a summary or the last few turns, the earlier messages exist only in the old execution's event history.
They sit under the old workflow ID, and querying the current workflow ID returns only what happened since the migration. The full transcript now lives split across two executions, with no single query that returns all of it.
Why you can't just reconstruct the transcript from Temporal's event history
The seemingly obvious solution is to query Temporal's event history directly for the transcript. But this doesn't hold up.
Temporal's event history records execution events: activities started and completed, signals received, timers fired, workflow state transitions. Each event includes its payload: the inputs and outputs of activities, the content of signals.
Take a simple exchange: the user sends a message, and the agent replies. The user's message arrives as a signal payload. The agent's reply is an activity output.
Both are now part of the event history, but reconstructing the conversation transcript from them isn't simple. It means filtering out everything else in the event history, extracting just those payloads, and putting them back in the right order.
This is technically possible, but fragile for three reasons:
Event schemas change across deploys.
Signal payloads don't have a stable shape across different versions of the workflow.
Activity outputs include execution metadata alongside the content you care about.
And all of this points to the same conclusion: the event history is designed for observability and execution replay, not for rendering a chat interface.
The fix: separate the transcript from the workflow entirely
The approach that scales is to keep the transcript outside Temporal's event history entirely. That way, the workflow carries forward just enough context to continue the conversation. And the full transcript lives alongside the agent in a durable session layer (the delivery layer this cluster describes more broadly), not in workflow events.
This keeps the event history lean, which means that each workflow execution stays well within the 50MB limit. And Continue-As-New becomes a clean migration between executions rather than a continuity problem to solve.
Here's what that separation looks like in code. The activity publishes each message directly to the delivery layer, so Continue-As-New only needs to carry forward the conversation ID, not the transcript itself. Skip ahead if the idea alone is enough.
from temporalio import workflow
from temporalio.exceptions import ContinueAsNewError
@workflow.defn
class ConversationWorkflow:
def __init__(self) -> None:
self.event_count = 0
@workflow.run
async def run(self, conversation_id: str) -> None:
while True:
await workflow.wait_condition(lambda: len(self.pending_signals) > 0)
message = self.pending_signals.pop(0)
# The activity publishes the message and the response
# directly to the delivery layer, keyed to conversation_id
await workflow.execute_activity(
handle_message_and_publish,
{"conversation_id": conversation_id, "message": message},
start_to_close_timeout=timedelta(seconds=30),
)
self.event_count += 1
# Only conversation_id needs to carry forward --
# the transcript already lives in the delivery layer
if self.event_count >= 100:
raise ContinueAsNewError(conversation_id)In the code above, handle_message_and_publish is the activity that pushes both the message and the response to the delivery layer as they happen. All that Continue-As-New carries forward is conversation_id, since the transcript already lives outside of the workflow by the time the migration happens.
This durable session layer works through a persistent session keyed to the conversation ID. Each time the agent produces a response, the activity publishes to that session. The frontend subscribes and receives new messages as they complete.
Because the transcript lives in the session itself, a user opening the chat for the first time gets the full prior context. A user who dropped and reconnected resumes exactly where they left off. The workflow doesn't need to know about either case.
And when Continue-As-New runs mid-conversation, the session is unaffected. It's keyed to the conversation ID, not the workflow execution ID. So the user's browser stays subscribed and keeps receiving messages without knowing a new execution has started.
Keeping workflow execution and transcript delivery separate is what makes the architecture hold up under real conditions. A workflow that goes through Continue-As-New doesn't break the conversation. A client that disconnects and reconnects doesn't lose the transcript. Each piece can fail and recover without the others needing to know.
Temporal and AI Transport: solving session continuity across Continue-As-New
Ably AI Transport is a documented Temporal integration that keeps the session decoupled from the workflow execution ID entirely. The session itself is keyed to the conversation ID, not any single workflow run, so Continue-As-New never disrupts it.
Within that session, Ably publishes each Temporal activity's output as a Step: AI Transport's basic unit of published output delivered to every subscribed client. Each Step is keyed to the activity's own Temporal activity ID, not the session ID.
When Continue-As-New starts a fresh execution, the session ID doesn't change. The session keeps flowing without the frontend needing to know a new workflow run started.
Each device or client subscribed to that session gets the full transcript on connect, then live updates as new steps publish. A client that reconnects after a Continue-As-New gets caught up automatically, since the transcript lives in the session rather than in Temporal's event history.
Visit the Ably AI Transport overview, read the documentation, or sign up free to start building. For a broader overview of why Temporal workflows need a frontend delivery layer, see Why Temporal workflows need a frontend delivery layer.
Frequently asked questions
Can I use Continue-As-New's carried-over state to store a conversation summary instead of the full transcript?
Yes, and it's a reasonable middle ground. The state passed to Continue-As-New can carry forward a summary or the last few turns rather than the complete transcript, keeping the workflow itself lean. The tradeoff is that a summary loses the exact original wording. Any feature depending on verbatim transcript replay, such as audits or exact quote retrieval, still needs a store outside the workflow.
Does storing conversation history outside Temporal defeat the purpose of using Temporal at all?
No. Temporal still owns what it's good at: durable execution of the agent's task logic, retries, and crash recovery for the workflow itself. Moving the conversation transcript out doesn't touch that. It recognizes that a transcript store and Temporal's event history solve different problems. Pointing the event history at both was overloading it beyond what it was designed for.
How do I estimate how many turns I can handle before I need Continue-As-New?
Start by dividing 50MB by the average event size for your workflow, factoring in Temporal's envelope overhead alongside the raw payload. A ten-turn conversation with heavy context can approach 40KB of raw content. Envelope overhead alone can push the practical ceiling well below what the raw math suggests. Test against your own workflow's actual event sizes rather than relying on a fixed turn count.
Does a user reconnecting after Continue-As-New lose their conversation history?
Not if the transcript lives in the session rather than in Temporal's event history. The session is keyed to the conversation ID, not the workflow execution ID. A reconnecting client subscribes to the same session and gets the full prior context, regardless of how many times Continue-As-New has run underneath it.
Can I query Temporal for the conversation's current state without breaking anything?
Yes, querying is safe, since queries don't mutate the workflow. Just don't rely on query results as your source for the transcript. A state query returns a snapshot, not the sequence of turns that produced it. Anything needing the full ordered transcript still needs to come from wherever the transcript is actually stored.
Recommended Articles
Redis pub/sub limitations for Temporal frontend delivery
Redis pub/sub is fire-and-forget. Events published while a client is disconnected are gone permanently. Why it falls short for Temporal workflows and what to use instead.
Delivering Temporal workflow output to multiple devices
Polling and Redis pub/sub both fail the same way with Temporal: the second device joins broken. Here's what actually solves it.
Reaching browsers reliably with Temporal and durable sessions
Temporal keeps backend workflows crash-proof, but getting results to a browser reliably is a separate job. Here's the durable session layer that closes it.