- Temporal
- /
- Agent presence for Temporal workflows: showing status without polling
Agent presence for Temporal workflows: showing status without polling
TL;DR Temporal can tell you whether a workflow is running, paused, or failed, but only if you ask. There's no built-in way to push that status to a browser the moment it changes. Teams solve this today with scheduled pollers that add latency and waste resources for no real benefit. Ably AI Transport's agent presence gives you a self-reported, realtime status (idle, thinking, streaming, offline) pushed to every connected client the instant it changes, and a way to tell a clean finish from a crash.
A user starts a long-running AI task and waits. Is the agent thinking? Has it stalled? Did it crash? Without a realtime signal, the honest answer is: you can't tell, and neither can they.
What Temporal exposes, and why it isn't enough on its own
Temporal tracks workflow status precisely: Running, Paused, Completed, Failed, Terminated, Timed Out, Continued-As-New. You can query it with DescribeWorkflowExecution, or from your own Query handlers. This is accurate, and it's pull-only. Nothing tells your frontend when the status changes. You have to ask, repeatedly, to find out.
A developer on Temporal's own GitHub described the workaround teams reach for by default: a scheduled poller that periodically queries running workflows and writes the result to a database. Their own list of drawbacks is the whole problem: high latency (state changes aren't reflected immediately), resource waste (polling consumes resources whether or not anything changed), and inefficiency (queries execute even when there's nothing new to report).
Agent presence: a different, complementary signal
Ably AI Transport is a session layer that runs alongside Temporal, built to fill exactly this gap. Its agent presence feature isn't a Temporal concept at all. It's a native part of the session. Both ClientSession and AgentSession expose session.presence directly, with enter(), update(), leave(), get(), and subscribe(). The agent enters presence with an initial status, updates it as it moves through a turn (receiving a message, thinking, streaming, finishing), and leaves when it shuts down. Every connected client sees each change immediately.
// Agent side: enter presence, then update through the turn lifecycle
const session = createAgentSession({ client: ably, channelName, codec: UIMessageCodec });
await session.connect();
const run = session.createRun(invocation, { signal });
await session.presence.enter({ status: 'thinking' });
// ... call the LLM ...
await session.presence.update({ status: 'streaming' });
// ... stream the response ...
await session.presence.leave();// Client side: subscribe to the agent's self-reported status
session.presence.subscribe((member) => {
if (member.clientId === 'agent') {
console.log(`Agent is ${member.data.status}`);
}
});This is the piece Temporal's own status API can't give you: a push, not a pull, and it arrives the moment the agent's state actually changes. None of this is Temporal-specific: the same API works with any backend that can call the session methods.
Challenges to overcome with presence in production
Ably AI Transport's agent presence handles two things that matter in production: telling a clean finish from a crash, and staying reliable even when an agent's own report lags.
Telling a clean finish from a crash
An agent that finishes a turn cleanly calls presence.leave() itself. But real agents crash without warning, and presence still needs to say something useful when that happens.
A graceful shutdown calls presence.leave() and the agent vanishes from the presence set immediately. A crashed process never calls leave(), so Ably keeps it in the presence set until a timeout fires, currently around 15 seconds, then removes it automatically.
That gives you a real, usable signal: an agent that finishes cleanly disappears instantly. An agent that crashed lingers for several seconds before disappearing. Wiring a graceful shutdown path into your own agent code makes that fast disappearance the norm, since a genuine crash should be rare.
Presence alongside the observed fact: session.view.runs()
A presence update can lag behind a message, or simply not fire promptly if the agent's own reporting is wrong or delayed. For anything where that gap actually matters, session.view.runs() gives you the observed fact of which runs are active, independent of what the agent has reported.
const activeTurns = session.view.runs().some((r) => r.status === 'active' && r.clientId === 'agent');
const agentStatus = /* from presence.subscribe, above */;
const isStreaming = activeTurns;
const isIdle = agentStatus === 'idle' && !isStreaming;
const isOffline = !agentStatus;Presence and view.runs() are complementary, not redundant: presence reports what the agent says about itself, while view.runs() reports what's actually happening on the channel. Use view.runs() to drive run-level state (active, suspended, terminal), and use presence for the status the agent chooses to report.
Mapping agent presence onto a Temporal Activity
Wiring presence into a Temporal Activity means accounting for one thing that's unique to Temporal: retries. Temporal already retries failed Activities and restarts crashed workers without you writing that logic. An Activity enters presence when it starts, updates through the LLM call, and calls leave() when it completes normally.
from temporalio import activity
@activity.defn
async def run_agent_turn(session_config: dict, prompt: str) -> str:
session = create_agent_session(client=ably, channel_name=session_config["channel"])
await session.connect()
await session.presence.enter({"status": "thinking"})
try:
response = await call_llm(prompt)
await session.presence.update({"status": "streaming"})
await session.stream_response(response)
await session.presence.leave()
return response
except Exception:
# If this raises, the Activity fails and Temporal retries it on a
# new worker. Because leave() was never reached, the client sees
# the agent stay present for up to ~15 seconds, then go offline,
# then reappear as "thinking" when the retried attempt starts.
raiseIf the Activity dies mid-execution, Temporal retries it on a new worker. That new attempt re-enters presence. The user sees the agent go briefly offline and then reconnect, rather than watching a dead stream with no explanation.
AI Transport ships a dedicated entry point for exactly this combination: @ably/ai-transport/temporal, built around a Step primitive. A Step is a re-attemptable unit of an agent's output that Temporal's own retries treat correctly. A Step that fails and re-runs under the same ID supersedes the dead attempt on the channel, instead of leaving a duplicate. Steps keep retried output clean. Presence keeps the user informed while that's happening.
Checking whether anyone's still listening before you generate output
A client can join the presence set, not just the agent. That lets an agent check whether anyone is still listening before it generates output, and end the run or skip the next LLM call entirely if nobody is.
const members = await session.presence.get();
const hasListener = members.some((m) => m.clientId !== 'agent');
if (!hasListener) {
// No one's watching. Skip the LLM call and end the run.
return;
}This is a genuine cost saving, not just a UX nicety: a run that would otherwise keep generating output for a user who's already left never makes the LLM call at all.
Temporal and Ably AI Transport: complementary signals for agent health
Temporal's workflow status is the authoritative record of what an execution actually did: accurate, queryable, and the right place to look when you need ground truth. It's pull-only, though, and it can't tell you why an agent went quiet.
Ably AI Transport's agent presence is the signal your users actually see: pushed the moment it changes, self-reported at the agent level rather than the execution level. Paired with the leave-versus-timeout distinction, it can tell a clean finish from a crash. Query Temporal's status when you need to know what an execution actually did. Use presence for what the user sees while it's happening. Each layer covers what the other doesn't.
In practice, that division of labor is what good agent presence UX looks like: a typing indicator while the agent is thinking, a streaming animation once tokens start arriving, an offline badge the moment presence reports the agent has gone, and a "reconnected" message when a new attempt re-enters presence after a crash.
Ably's agent presence documentation covers the full API, edge cases, and pricing detail beyond what's in this piece, and is the place to start implementing this. For the broader picture of how Temporal and Ably AI Transport fit together, beyond just this one feature, read Temporal made execution durable. Ably makes sessions durable.
Does Temporal tell me if a workflow is stuck versus still running?
Yes, but only if you ask it. Temporal's status distinguishes Running from Failed, Timed Out, and other closed states, so a query will tell you the difference. What it won't do is notify you the moment a workflow gets stuck. You'd need to poll for that. Agent presence solves the notification half: a crash surfaces as a presence timeout within about 15 seconds, pushed to the client automatically, without a query.
How long does presence persist after a disconnect?
Around 15 seconds. A graceful leave() removes the agent from presence immediately; a crash is detected once that timeout fires.
What's the difference between presence and the view's active runs?
Presence is the agent's self-reported status. session.view.runs() is the observed fact of which runs are actually active. Combine both for the richest status.
Recommended Articles
Managing conversation history in a Temporal AI agent chat
A Temporal AI agent's conversation transcript can split across two workflow executions. Here's why, and how to keep transcript storage separate instead.
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.
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.