Deep Agents integration
Temporal's integration with Deep Agents makes an existing LangChain
Deep Agent durable by adding one plugin. Build your agent with create_deep_agent(...) inside a Workflow, add
DeepAgentsPlugin to your Client or Worker, and each LLM call and each I/O tool call becomes a Temporal Activity —
while the agent's control loop runs, and deterministically replays, inside the Workflow.
The code you already wrote against deepagents doesn't change. Sub-agents, planning and todo state, the filesystem
middleware, human-in-the-loop interrupts, and agent.ainvoke(...) all keep working. On top of that you get
crash durability, automatic retries and timeouts on every model and tool call, resumable human-in-the-loop, and
bounded Workflow history for long-running agents.
The temporalio.contrib.deepagents plugin is experimental and its API may change in future versions.
Code snippets in this guide are taken from the Deep Agents plugin samples. Refer to the samples for the complete code.
Prerequisites
- This guide assumes you are already familiar with Deep Agents. If you aren't, refer to the Deep Agents documentation for more details.
- If you are new to Temporal, we recommend reading Understanding Temporal or taking the Temporal 101 course.
- Ensure you have set up your local development environment by following the Set up your local development environment guide. When you're done, leave the Temporal development server running if you want to test your code locally.
Install the plugin
Install the Temporal Python SDK with Deep Agents support:
uv add "temporalio[deepagents]"
or with pip:
pip install "temporalio[deepagents]"
The integration is experimental and ships with an upcoming Temporal Python SDK release. It requires Python 3.11 or
newer, the same floor that deepagents sets.
Hello World
A Deep Agent becomes durable without changing the agent code itself. The Workflow below builds a vanilla
create_deep_agent(...) and drives it with await agent.ainvoke(...) — exactly the code you would write outside
Temporal. Because model= is a bare "provider:name" string, the plugin auto-routes the model call through the
deepagents.invoke_model Activity, so the LLM call gets Temporal-managed retries and timeouts while the agent's control
loop replays deterministically in the Workflow.
deepagents_plugin/hello_world/workflow.py
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from deepagents import create_deep_agent
@workflow.defn
class HelloWorldAgent:
@workflow.run
async def run(self, question: str) -> str:
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
system_prompt="You are a helpful assistant. Answer concisely.",
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": question}]}
)
return result["messages"][-1].content
DeepAgentsPlugin is a client-level plugin: add it to Client.connect(...) and the SDK propagates it to any Worker
built from that Client. Add it on exactly one side. The plugin registers the deepagents.* Activities and installs the
LangChain-aware data converter, so the Worker needs no other wiring.
deepagents_plugin/hello_world/run_worker.py
import asyncio
import os
from temporalio.client import Client
from temporalio.contrib.deepagents import DeepAgentsPlugin
from temporalio.worker import Worker
from deepagents_plugin.hello_world.workflow import HelloWorldAgent
async def main() -> None:
client = await Client.connect(
os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"),
plugins=[DeepAgentsPlugin()],
)
worker = Worker(
client,
task_queue="deepagents-hello-world",
workflows=[HelloWorldAgent],
)
print("Worker started. Ctrl+C to exit.")
await worker.run()
if __name__ == "__main__":
asyncio.run(main())
Use model_activity_options on the plugin to control the timeout and retry policy for each model call. API keys live on
the Worker via the model provider (LangChain's init_chat_model by default), never in Workflow inputs or history —
the Workflow ships only the model name, and the Worker builds the real client. LLM-SDK-side retries are disabled so that
Temporal owns retries and timeouts.
Start the Workflow like any other:
deepagents_plugin/hello_world/run_workflow.py
import asyncio
import os
from temporalio.client import Client
from deepagents_plugin.hello_world.workflow import HelloWorldAgent
async def main() -> None:
client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"))
result = await client.execute_workflow(
HelloWorldAgent.run,
"What is Temporal in one sentence?",
id="deepagents-hello-world",
task_queue="deepagents-hello-world",
)
print(f"Result: {result}")
if __name__ == "__main__":
asyncio.run(main())
Choose Workflow or Activity execution per tool
A Deep Agent holds its tools in-Workflow. A tool that only reads or writes agent state is pure and belongs there; a tool that does real I/O must not run in Workflow code. The plugin gives you two explicit ways to move a tool's work into an Activity:
activity_as_tool(my_activity, ...)surfaces an existing@activity.defnfunction as a Deep Agents tool without re-declaring it.tool_as_activity(tool, ...)wraps a LangChain tool whose body does I/O so its execution runs as adeepagents.invoke_toolActivity.
An unwrapped, non-builtin tool runs in-Workflow and the plugin warns at construction, so that choice is never silent.
Deep Agents' pure built-ins (write_todos, the state-backed file tools) stay in-Workflow by design.
This sample wraps an existing activity and an I/O tool, and constructs the model explicitly as TemporalModel(...) to
show the non-auto path:
deepagents_plugin/react_agent/workflow.py
@activity.defn
async def get_weather(city: str) -> str:
"""Return the current weather for a city."""
# A real implementation would call a weather API here; this is a stand-in.
return f"It is sunny and 22C in {city}."
deepagents_plugin/react_agent/workflow.py
@tool
def web_search(query: str) -> str:
"""Search the web for a query and return a short result."""
# Real I/O (an HTTP call) would go here; wrapped with tool_as_activity so it
# runs in an activity, not in workflow code.
return f"Top result for {query!r}: Temporal makes code durable."
@workflow.defn
class ReactAgent:
@workflow.run
async def run(self, question: str) -> str:
weather_tool = activity_as_tool(
get_weather,
start_to_close_timeout=timedelta(seconds=30),
)
search_tool = tool_as_activity(
web_search,
start_to_close_timeout=timedelta(seconds=30),
)
agent = create_deep_agent(
model=TemporalModel(model="anthropic:claude-sonnet-4-5"),
tools=[weather_tool, search_tool],
system_prompt=(
"You are a research assistant. Use the get_weather and "
"web_search tools when they help answer the question."
),
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": question}]}
)
return result["messages"][-1].content
Durable backends
A Deep Agent's built-in file tools (write_file, read_file, ls, and so on) delegate to a backend. The default
StateBackend keeps files in agent state — pure Workflow state, replay-safe, and needs no wrapping. A FilesystemBackend,
LocalShellBackend, or StoreBackend touches real resources, which must not happen from Workflow code.
Wrap such a backend with TemporalBackend(inner, activity_options=...) so each file or shell operation the agent's tools
invoke becomes a deepagents.backend_op Activity instead of running in the Workflow. The agent code is unchanged; only
the backend is wrapped.
deepagents_plugin/filesystem_backend/workflow.py
from datetime import timedelta
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend
from temporalio.contrib.deepagents import TemporalBackend
@workflow.defn
class FilesystemAgent:
@workflow.run
async def run(self, root_dir: str, instruction: str) -> str:
# Wrap the real-I/O backend so every file op runs in an activity.
backend = TemporalBackend(
# virtual_mode roots every path the agent uses under root_dir, so the
# agent's file tools stay sandboxed to this working directory.
FilesystemBackend(root_dir=root_dir, virtual_mode=True),
activity_options={"start_to_close_timeout": timedelta(seconds=30)},
)
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
# TemporalBackend delegates the backend protocol to the wrapped
# backend at runtime, which the static type can't see through.
backend=backend, # type: ignore[arg-type]
system_prompt=(
"You are a file-savvy assistant. Use the write_file and "
"read_file tools to complete the task."
),
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": instruction}]}
)
return result["messages"][-1].content
Sub-agents
A coordinator built with create_deep_agent(..., subagents=[...]) delegates to its sub-agents via the built-in task
tool. Deep Agents builds each sub-agent as a separate graph, but they inherit the parent's model object by default.
Because the plugin makes that model object durable, every sub-agent's model call is automatically durable too — you wire
the plugin once and the whole agent tree is covered, with no per-sub-agent wiring.
deepagents_plugin/subagents/workflow.py
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from deepagents import create_deep_agent
@workflow.defn
class SubagentsWorkflow:
@workflow.run
async def run(self, question: str) -> str:
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
system_prompt=(
"You are a research coordinator. Delegate deep investigation to "
"the researcher sub-agent via the task tool, then synthesize a "
"final answer."
),
subagents=[
{
"name": "researcher",
"description": "Researches a topic in depth and reports findings.",
"system_prompt": "You research topics thoroughly and report back.",
}
],
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": question}]}
)
return result["messages"][-1].content
Human-in-the-loop
create_deep_agent(..., interrupt_on=...) makes the agent pause before a guarded tool runs. With an in-Workflow
InMemorySaver checkpointer, LangGraph does not raise out of ainvoke — it returns the current state with an
__interrupt__ entry describing the pending approval. Because the agent loop runs in the Workflow, that pause surfaces
directly in Workflow code.
The plugin adds no shim here; the native LangGraph resume protocol is used as-is. The recommended Temporal mapping is to
expose the pending approval via a Query and resume via an
Update that feeds the human's decision back with
Command(resume={"decisions": [...]}). The InMemorySaver is replay-safe because its state lives in the Workflow's own
memory, rehydrated by deterministic replay, and the stable Workflow ID is used as the thread_id.
deepagents_plugin/human_in_the_loop/workflow.py
from datetime import timedelta
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from deepagents import create_deep_agent
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from temporalio.contrib.deepagents import tool_as_activity
@workflow.defn
class HumanInTheLoopAgent:
def __init__(self) -> None:
self._pending: str | None = None
self._decision: str | None = None
self._resumed = False
@workflow.run
async def run(self, city: str) -> str:
def book_trip(city: str) -> str:
"""Book a trip to a city (requires human approval)."""
return f"Booked a trip to {city}."
trip_tool = tool_as_activity(
book_trip, start_to_close_timeout=timedelta(seconds=30)
)
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
tools=[trip_tool],
interrupt_on={"book_trip": True},
checkpointer=InMemorySaver(),
)
config = RunnableConfig(configurable={"thread_id": workflow.info().workflow_id})
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": f"Book a trip to {city}."}]},
config=config,
)
# LangGraph returns (not raises) the pending approval under __interrupt__.
pending = result.get("__interrupt__")
if pending:
self._pending = str(getattr(pending[0], "value", pending[0]))
# Block until a client approves/rejects via the `resume` update.
await workflow.wait_condition(lambda: self._resumed)
# No longer paused: the query goes back to reporting None.
self._pending = None
result = await agent.ainvoke(
Command(resume={"decisions": [{"type": self._decision}]}),
config=config,
)
return result["messages"][-1].content
@workflow.query
def pending_approval(self) -> str | None:
"""Return the pending approval prompt, or ``None`` if not paused."""
return self._pending
@workflow.update
async def resume(self, decision: str) -> None:
"""Resume the paused agent with ``"approve"`` or ``"reject"``."""
self._decision = decision
self._resumed = True
@resume.validator
def validate_resume(self, decision: str) -> None:
# Runs before the update is accepted, keeping invalid decisions out of
# workflow history entirely. Only the decisions this workflow feeds to
# `Command(resume=...)` are allowed.
if decision not in ("approve", "reject"):
raise ValueError('decision must be "approve" or "reject"')
Continue-as-new
Long conversations bloat Workflow history until it hits Temporal's limits. run_deep_agent(agent, input, continue_as_new_after=N, state_snapshot=...) snapshots state and continues into a fresh run once history passes N
events and the agent still has pending todos.
- Carries forward: the accumulated messages and the model/tool result cache, so an LLM or tool call completed before the continue-as-new is not re-run afterward.
- Does not carry forward: anything held only in an in-memory checkpointer's own structures beyond the messages/todos snapshot.
Your @workflow.run method must accept the carried state — its signature is run(self, input, state_snapshot=None).
On a continue-as-new, run_deep_agent re-invokes the method with args=[input, snapshot], so input must be passed
straight through, not re-wrapped, or the carried conversation is corrupted.
deepagents_plugin/continue_as_new/workflow.py
from typing import Any
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from deepagents import create_deep_agent
from temporalio.contrib.deepagents import run_deep_agent
@workflow.defn
class LongResearchAgent:
@workflow.run
async def run(
self, input: dict[str, Any], state_snapshot: dict | None = None
) -> str:
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
system_prompt=(
"You are a research agent. Break large tasks into todos and work "
"through them until the research is complete."
),
)
result = await run_deep_agent(
agent,
# ``input`` is the messages mapping. Pass it through unchanged: on a
# continue-as-new, run_deep_agent re-invokes this method with the
# carried input as its first arg, so re-wrapping it here would nest a
# dict where a message is expected and corrupt the conversation.
input,
# Continue-as-new once history passes this many events and the agent
# still has pending todos. Tune to your model's turn size.
continue_as_new_after=10_000,
state_snapshot=state_snapshot,
)
return result["messages"][-1].content
Streaming
Constructing the plugin with DeepAgentsPlugin(streaming_topic="...") flips model dispatch from
deepagents.invoke_model to deepagents.invoke_model_streaming: the streaming Activity coalesces chunk batches and
publishes them to a WorkflowStream
topic for external subscribers, while the aggregated final message is still returned to the Workflow. The durable result
is identical to the non-streaming path.
Streaming is async-only, so the Workflow drives an explicit TemporalModel.astream(...) and hosts a WorkflowStream
so external subscribers can attach by Workflow ID.
deepagents_plugin/streaming/workflow.py
from temporalio import workflow
from temporalio.contrib.workflow_streams import WorkflowStream
with workflow.unsafe.imports_passed_through():
from langchain_core.messages import HumanMessage
from temporalio.contrib.deepagents import TemporalModel
STREAMING_TOPIC = "model-chunks"
@workflow.defn
class StreamingWorkflow:
def __init__(self) -> None:
# Host the stream so the publish-Signal handler is registered before the
# streaming activity (the external publisher) starts publishing.
self.stream = WorkflowStream()
@workflow.run
async def run(self, prompt: str) -> str:
model = TemporalModel(model="anthropic:claude-sonnet-4-5")
parts: list[str] = []
async for chunk in model.astream([HumanMessage(content=prompt)]):
parts.append(str(chunk.content))
return "".join(parts)
Compose with an observability plugin
This plugin carries no tracing context of its own. For observability, compose it with an observability plugin such as
temporalio.contrib.langsmith or
temporalio.contrib.opentelemetry. Register the observability plugin before DeepAgentsPlugin so it can capture
the LLM calls that DeepAgentsPlugin runs as Activities. The Workflow itself is an ordinary Deep Agent — the tracing
comes entirely from composing the plugins on the Client.
deepagents_plugin/langsmith_tracing/workflow.py
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from deepagents import create_deep_agent
@workflow.defn
class TracedAgent:
@workflow.run
async def run(self, question: str) -> str:
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
system_prompt="You are a helpful assistant.",
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": question}]}
)
return result["messages"][-1].content
For agents built directly as LangGraph graphs rather than a compiled Deep Agent, see the LangGraph integration.
Runtime behavior and limitations
- Auto-routing is Workflow-scoped. While a Worker built with this plugin is running, the plugin patches Deep Agents'
model-resolution seam so a bare
model="provider:name"string is auto-routed through an Activity, regardless of how you importedcreate_deep_agent. This seam is shared by the agent and every sub-agent, and it only rewrites the model when resolved inside a Workflow, so importingdeepagentson a plain client or Activity Worker is unaffected, and the patched seam is restored when the Worker stops. PassTemporalModel("provider:name")yourself if you would rather be explicit. - Built-ins stay in-Workflow. Deep Agents' pure built-in tools (
write_todos, state-backed file tools) run in-Workflow by design and do not need wrapping. Only tools and backends that do real I/O should be moved to Activities. - Durable checkpointers are not replay-safe. The default in-Workflow
InMemorySaveris rehydrated for free by deterministic replay. A durable checkpointer that does its own I/O is not replay-safe from inside a Workflow, and the plugin warns if you pass one — prefer the snapshot plus continue-as-new path above.
Samples
The Deep Agents plugin samples demonstrate all supported patterns, including tool choice, durable backends, sub-agents, human-in-the-loop, continue-as-new, streaming, and observability composition.