From e9c32ec1da007df851825ba4687e5048fee7db94 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 21 May 2026 14:53:35 -0700 Subject: [PATCH 01/10] Add Strands Agents integration guide Adds a Python integration guide for Strands Agents and surfaces it in the integrations index, sidebar, and /develop/python landing page. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/develop/python/index.mdx | 1 + docs/develop/python/integrations/index.mdx | 1 + .../python/integrations/strands-agents.mdx | 592 ++++++++++++++++++ sidebars.js | 1 + 4 files changed, 595 insertions(+) create mode 100644 docs/develop/python/integrations/strands-agents.mdx diff --git a/docs/develop/python/index.mdx b/docs/develop/python/index.mdx index acad5e2c13..1bcb48c861 100644 --- a/docs/develop/python/index.mdx +++ b/docs/develop/python/index.mdx @@ -81,6 +81,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui ## [Integrations](/develop/python/integrations) - [Braintrust integration](/develop/python/integrations/braintrust) +- [Strands Agents integration](/develop/python/integrations/strands-agents) ## Temporal Python Technical Resources diff --git a/docs/develop/python/integrations/index.mdx b/docs/develop/python/integrations/index.mdx index e0f3fb8d8a..8bdad96bd4 100644 --- a/docs/develop/python/integrations/index.mdx +++ b/docs/develop/python/integrations/index.mdx @@ -28,6 +28,7 @@ The following integrations are available between the Temporal Python SDK and thi | LangSmith | Observability | [smith.langchain.com](https://docs.smith.langchain.com/) | [Guide](./langsmith.mdx) | | OpenAI Agents SDK | Agent framework | [openai.github.io](https://openai.github.io/openai-agents-python/) | [Guide](https://github.com/temporalio/sdk-python/blob/main/temporalio/contrib/openai_agents/README.md) | | Pydantic AI | Agent framework | [ai.pydantic.dev](https://ai.pydantic.dev/) | [Guide](https://ai.pydantic.dev/durable_execution/temporal/) | +| Strands Agents | Agent framework | [strandsagents.com](https://strandsagents.com/) | [Guide](./strands-agents.mdx) | | Tenuo | Governance | [tenuo.ai](https://tenuo.ai/docs) | [Guide](https://tenuo.ai/temporal) | These integrations are built on the Temporal Python SDK's [Plugin system](/develop/plugins-guide), which you can also diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx new file mode 100644 index 0000000000..788b99ca90 --- /dev/null +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -0,0 +1,592 @@ +--- +id: strands-agents +title: Strands Agents integration +sidebar_label: Strands Agents +toc_max_heading_level: 2 +keywords: + - ai + - agents + - strands + - strands agents + - durable execution + - ai workflows +tags: + - Strands Agents + - Python SDK + - Temporal SDKs +description: + Run Strands Agents AI workflows with durable execution using the Temporal Python SDK and Strands plugin. +--- + +Temporal's integration with [Strands Agents](https://strandsagents.com/) gives your Strands agents durable execution, +automatic retries, and timeouts via the Temporal platform. The plugin routes Strands model invocations, tool calls, MCP +tool calls, and hooks through Temporal Activities, so every step the agent takes is recorded in workflow history. + +:::info + +The Temporal Python SDK integration with Strands Agents is currently at an experimental release stage. The API may +change in future versions. + +::: + +Code snippets in this guide are taken from the +[Strands Agents plugin samples](https://github.com/temporalio/samples-python/tree/main/strands_plugin). Refer to the +samples for the complete code. + +## Prerequisites + +- This guide assumes you are already familiar with Strands Agents. If you aren't, refer to the + [Strands Agents documentation](https://strandsagents.com/) for more details. +- If you are new to Temporal, we recommend reading [Understanding Temporal](/evaluate/understanding-temporal) or taking + the [Temporal 101](https://learn.temporal.io/courses/temporal_101/) course. +- Ensure you have set up your local development environment by following the + [Set up your local development environment](/develop/python/set-up-your-local-python) 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 Strands Agents support (requires `temporalio` 1.28.0 or later): + +```bash +uv add "temporalio[strands-agents]" +``` + +or with pip: + +```bash +pip install "temporalio[strands-agents]" +``` + +## Quickstart + +Define a Workflow that holds a `TemporalAgent`, then register `StrandsPlugin` on the Worker: + +```python +import asyncio +from datetime import timedelta + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.worker import Worker + + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent(start_to_close_timeout=timedelta(seconds=60)) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) + + +async def main() -> None: + client = await Client.connect("localhost:7233") + worker = Worker( + client, + task_queue="strands", + workflows=[MyWorkflow], + plugins=[StrandsPlugin()], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Start the Workflow from a client: + +```python +import asyncio + +from temporalio.client import Client + +from workflow import MyWorkflow + + +async def main() -> None: + client = await Client.connect("localhost:7233") + result = await client.execute_workflow( + MyWorkflow.run, + "Hello", + id="strands-quickstart", + task_queue="strands", + ) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::warning + +Inside a Workflow, always call `agent.invoke_async(message)` — not `agent(message)`. The synchronous form spawns a +worker thread, which the Workflow sandbox blocks. + +::: + +## Models + +`StrandsPlugin(models=...)` takes a mapping of `name → factory`. Each factory is called lazily on first use (on the +Worker, outside the Workflow sandbox) and the constructed model is cached for the Worker's lifetime. If `models` is +omitted, the plugin registers a single `BedrockModel()` factory under the name `"bedrock"`, matching Strands' own +implicit default. Select a model per agent with `TemporalAgent(model="name", ...)`: + +```python +from strands.models.anthropic import AnthropicModel +from strands.models.bedrock import BedrockModel + +# Workflow +@workflow.defn +class MultiModelWorkflow: + def __init__(self) -> None: + self.agent_a = TemporalAgent( + model="claude", + start_to_close_timeout=timedelta(seconds=60), + ) + self.agent_b = TemporalAgent( + model="bedrock", + start_to_close_timeout=timedelta(seconds=60), + ) + +# Worker +Worker(..., plugins=[StrandsPlugin(models={ + "claude": lambda: AnthropicModel(client_args={"api_key": "..."}), + "bedrock": lambda: BedrockModel(), +})]) +``` + +Each `TemporalAgent` carries its own Activity options (timeouts, retry policy, task queue, streaming topic) and +dispatches to the shared model Activity, which resolves the model name against the registered factories at runtime. A +name not present in `models` raises `ValueError` inside the Activity. + +## Tools + +Wrap non-deterministic tools as Temporal Activities, register them with the Worker, and pass them to the agent through +`workflow.activity_as_tool`: + +```python +from strands_tools import shell +from temporalio import activity +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.contrib.strands import workflow as strands_workflow + + +@activity.defn +async def fetch_user(user_id: str) -> dict: + ... + + +@activity.defn(name="shell") +async def shell_activity(command: str) -> dict: + return shell.shell(command=command, non_interactive=True) + + +# Workflow +agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[ + strands_workflow.activity_as_tool(fetch_user, start_to_close_timeout=timedelta(seconds=30)), + strands_workflow.activity_as_tool(shell_activity, start_to_close_timeout=timedelta(seconds=15)), + ], +) + +# Worker +Worker( + ..., + activities=[fetch_user, shell_activity], + plugins=[StrandsPlugin()], +) +``` + +If you're using built-in `strands_tools`, wrap them in a thin async function decorated with `@activity.defn` so they +run as Temporal Activities. + +## Hooks + +Strands' [hook system](https://strandsagents.com/) lets you subscribe callbacks to events in the agent lifecycle — +invocation start and end, model call before and after, tool call before and after, and message added. Pass +`hooks=[MyHookProvider()]` to `TemporalAgent`; single-agent hook events fire in Workflow context, so deterministic +callbacks just work: + +```python +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterToolCallEvent +from temporalio import workflow + + +class AuditHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback(AfterToolCallEvent, self._on_tool_call) + + def _on_tool_call(self, event: AfterToolCallEvent) -> None: + workflow.logger.info(f"tool {event.tool_use['name']} finished") + + +agent = TemporalAgent(start_to_close_timeout=timedelta(seconds=60), hooks=[AuditHook()]) +``` + +:::warning + +Hook callbacks run in Workflow context, so they must be +[deterministic](/develop/python/workflows/basics#workflow-logic-requirements) — no `time.time()`, `uuid.uuid4()`, or +I/O. For callbacks that need I/O (audit logging, metrics, alerting), use `workflow.activity_as_hook()` to dispatch the +work as a Temporal Activity: + +::: + +```python +from temporalio import activity +from temporalio.contrib.strands.workflow import activity_as_hook + + +@activity.defn +async def persist_tool_call(tool_name: str) -> None: + # I/O safely in an activity. + ... + + +class AuditHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback( + AfterToolCallEvent, + activity_as_hook( + persist_tool_call, + activity_input=lambda event: event.tool_use["name"], + start_to_close_timeout=timedelta(seconds=10), + ), + ) +``` + +`activity_input` extracts serializable values from the event to pass as the Activity's input. Use a dataclass or +Pydantic model for multiple values. This is needed because hook events hold references to the `Agent`, `AgentTool` +instances, and other objects that don't cross the Activity boundary. + +## Human-in-the-loop interrupts + +Strands offers two human-in-the-loop surfaces; both work with the plugin. In each case, `agent.invoke_async()` returns +`AgentResult(stop_reason="interrupt", interrupts=[...])` instead of raising. Pair this with a Signal handler that +supplies responses, then resume by calling `agent.invoke_async(responses)`. + +### Hook-based interrupts + +A hook on an interruptible event (for example, `BeforeToolCallEvent`) can pause the agent by calling +`event.interrupt(name, reason=...)`. The hook runs in Workflow context, so it must be deterministic — no I/O. + +```python +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import BeforeToolCallEvent +from temporalio import workflow + + +class ApprovalHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback(BeforeToolCallEvent, self._gate) + + def _gate(self, event: BeforeToolCallEvent) -> None: + if event.interrupt("approval", reason="confirm delete") != "approve": + event.cancel_tool = "denied" + + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[delete_thing], + hooks=[ApprovalHook()], + ) + self._approval: str | None = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + if result.stop_reason == "interrupt": + await workflow.wait_condition(lambda: self._approval is not None) + result = await self.agent.invoke_async([ + {"interruptResponse": {"interruptId": result.interrupts[0].id, "response": self._approval}} + ]) + return str(result) +``` + +### Tool-body interrupts + +A `@strands.tool` function can raise `InterruptException(Interrupt(...))` directly. The agent stops with the interrupt, +and the Workflow handles the resume the same way as for hooks: + +```python +from strands import tool +from strands.interrupt import Interrupt, InterruptException + + +@tool +def delete_thing(name: str) -> str: + raise InterruptException( + Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + ) +``` + +The same works from an `activity_as_tool`-wrapped Activity. The plugin's failure converter preserves the `Interrupt` +payload across the Activity boundary, so `AgentResult.interrupts` is populated just like the in-Workflow case: + +```python +from strands.interrupt import Interrupt, InterruptException +from temporalio import activity +from temporalio.contrib.strands.workflow import activity_as_tool + + +@activity.defn +async def delete_thing(name: str) -> str: + if not await policy.is_authorized(name): + raise InterruptException( + Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + ) + await storage.delete(name) + return f"deleted {name}" + + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[activity_as_tool(delete_thing, start_to_close_timeout=timedelta(seconds=10))], + ) +``` + +:::warning + +Activity-tool interrupts rely on the plugin's failure converter, which is installed via the client's data converter. +Attach `StrandsPlugin` to the **client** (not just the Worker) for them to work — Workers built from that client pick +up the plugin automatically: + +```python +client = await Client.connect("localhost:7233", plugins=[StrandsPlugin()]) +Worker(client, task_queue="strands", workflows=[MyWorkflow], activities=[delete_thing]) +``` + +::: + +## Structured output + +Like Strands' `Agent`, `TemporalAgent` supports structured output with `structured_output_model`. The plugin defaults +to the [`pydantic_data_converter`](/develop/python/best-practices/data-handling/data-conversion), so Pydantic types +serialize cleanly across the Activity and Workflow boundary: + +```python +from pydantic import BaseModel + + +class PersonInfo(BaseModel): + name: str + age: int + + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + structured_output_model=PersonInfo, + ) + + @workflow.run + async def run(self, prompt: str) -> PersonInfo: + result = await self.agent.invoke_async(prompt) + return result.structured_output +``` + +## Streaming + +To forward model chunks to external consumers, pass `streaming_topic="..."` to `TemporalAgent` and host a +`WorkflowStream` on the Workflow. Each `StreamEvent` is published on the named topic from inside the model Activity; +subscribers read via `WorkflowStreamClient`. Chunks are batched on `streaming_batch_interval` (default 100ms): + +```python +from temporalio.contrib.workflow_streams import WorkflowStream, WorkflowStreamClient + + +# Workflow +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.stream = WorkflowStream() + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + streaming_topic="events", + ) + + +# Client +async for item in WorkflowStreamClient.create(client, workflow_id).subscribe( + ["events"], result_type=StreamEvent, +): + print(item.data) +``` + +## MCP + +`StrandsPlugin(mcp_clients=...)` takes a mapping of `name → MCPClient factory`, mirroring the `models=` pattern. The +plugin registers a per-server `{name}-call-tool` Activity and connects at Worker startup to enumerate tools. +Workflow-side, `TemporalMCPClient(server="name")` is a pure handle: it references the server by name and carries the +per-call Activity options. + +```python +from datetime import timedelta + +from mcp import StdioServerParameters, stdio_client +from strands.tools.mcp.mcp_client import MCPClient +from temporalio import workflow +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent, TemporalMCPClient + + +# Workflow +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + echo = TemporalMCPClient(server="echo", start_to_close_timeout=timedelta(seconds=30)) + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[echo], + ) + + +# Worker +Worker( + ..., + plugins=[StrandsPlugin( + mcp_clients={ + "echo": lambda: MCPClient( + lambda: stdio_client( + StdioServerParameters(command="...", args=[...]), + ), + ), + }, + )], +) +``` + +Each factory returns a fully configured `MCPClient`, so you can pass options like `tool_filters`, `prefix`, +`elicitation_callback`, or `tasks_config` to it. + +:::note + +The plugin connects to each MCP server once at Worker startup to enumerate tools. The schema is frozen for the +Worker's lifetime; restart Workers to pick up MCP-server changes. If a server is unavailable at startup, the Worker +fails to start. + +::: + +## Retries + +`TemporalAgent` disables Strands' built-in `ModelRetryStrategy` so retries are handled exclusively by Temporal. +Configure retries via `retry_policy` on `TemporalAgent`, and on the Activity options accepted by +`workflow.activity_as_tool`, `workflow.activity_as_hook`, and `TemporalMCPClient`: + +```python +from temporalio.common import RetryPolicy + + +TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + retry_policy=RetryPolicy(maximum_attempts=3), +) +``` + +Passing `retry_strategy=...` to `TemporalAgent(...)` raises `ValueError`; remove the argument (or pass +`retry_strategy=None`) and put the retry config on the Activity options instead. + +## Continue-as-new + +A chat-style Workflow accumulates message history with every turn and eventually hits Temporal's per-Workflow history +limit. Use [continue-as-new](/develop/python/workflows/continue-as-new) to start a fresh execution while carrying +`agent.messages` forward as input: + +```python +from dataclasses import dataclass, field + +from strands.types.content import Messages +from temporalio import workflow + + +@dataclass +class ChatInput: + messages: Messages = field(default_factory=list) + + +@workflow.defn +class ChatWorkflow: + def __init__(self) -> None: + self._pending: list[str] = [] + self._done = False + + @workflow.signal + def user_says(self, prompt: str) -> None: + self._pending.append(prompt) + + @workflow.signal + def end_chat(self) -> None: + self._done = True + + @workflow.run + async def run(self, input: ChatInput) -> None: + agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + messages=list(input.messages), + ) + while True: + await workflow.wait_condition(lambda: self._pending or self._done) + if self._done: + return + await agent.invoke_async(self._pending.pop(0)) + if workflow.info().is_continue_as_new_suggested(): + workflow.continue_as_new(ChatInput(messages=agent.messages)) +``` + +## Observability + +`StrandsPlugin` composes cleanly with the [OpenTelemetry plugin](/develop/python/observability#tracing). Register +`OpenTelemetryPlugin` on the client (Workers built from that client pick it up automatically) and `StrandsPlugin` on +the Worker. You'll get OTel spans around the model, tool, and MCP Activities the plugin schedules, plus any spans +Strands itself emits inside `invoke_async`: + +```python +import opentelemetry.trace +from temporalio.client import Client +from temporalio.contrib.opentelemetry import OpenTelemetryPlugin, create_tracer_provider +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + + +opentelemetry.trace.set_tracer_provider(create_tracer_provider()) + +client = await Client.connect("localhost:7233", plugins=[OpenTelemetryPlugin()]) + +Worker( + client, + task_queue="strands", + workflows=[MyWorkflow], + plugins=[StrandsPlugin()], +) +``` + +Set the tracer provider before connecting the client. + +## Snapshots + +`TemporalAgent.take_snapshot()` and `TemporalAgent.load_snapshot()` raise `NotImplementedError`. Temporal's event +history already persists Workflow state durably at a finer granularity than Strands snapshots, so calling either +inside a Workflow is redundant. + +## Samples + +The [Strands Agents plugin samples](https://github.com/temporalio/samples-python/tree/main/strands_plugin) demonstrate +all supported patterns end-to-end. diff --git a/sidebars.js b/sidebars.js index e606b87475..8a9400b787 100644 --- a/sidebars.js +++ b/sidebars.js @@ -621,6 +621,7 @@ module.exports = { 'develop/python/integrations/braintrust', 'develop/python/integrations/langgraph', 'develop/python/integrations/langsmith', + 'develop/python/integrations/strands-agents', ], }, ], From 882a0c5de2ef22c698b5c9da1f3d01af5331e2a7 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 21 May 2026 15:51:25 -0700 Subject: [PATCH 02/10] Fix broken links in Strands Agents integration guide Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/develop/python/integrations/strands-agents.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index 788b99ca90..920dc716de 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -378,7 +378,7 @@ Worker(client, task_queue="strands", workflows=[MyWorkflow], activities=[delete_ ## Structured output Like Strands' `Agent`, `TemporalAgent` supports structured output with `structured_output_model`. The plugin defaults -to the [`pydantic_data_converter`](/develop/python/best-practices/data-handling/data-conversion), so Pydantic types +to the [`pydantic_data_converter`](/develop/python/data-handling/data-conversion), so Pydantic types serialize cleanly across the Activity and Workflow boundary: ```python @@ -553,7 +553,7 @@ class ChatWorkflow: ## Observability -`StrandsPlugin` composes cleanly with the [OpenTelemetry plugin](/develop/python/observability#tracing). Register +`StrandsPlugin` composes cleanly with the [OpenTelemetry plugin](/develop/python/platform/observability#tracing). Register `OpenTelemetryPlugin` on the client (Workers built from that client pick it up automatically) and `StrandsPlugin` on the Worker. You'll get OTel spans around the model, tool, and MCP Activities the plugin schedules, plus any spans Strands itself emits inside `invoke_async`: From d36f6e7df6263f5e61e0f4117544cb1936b718bc Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 21 May 2026 15:56:24 -0700 Subject: [PATCH 03/10] Trigger Vercel build Co-Authored-By: Claude Opus 4.7 (1M context) From 859aba5ad0e65b076839b4b998e5bfe4aadb3744 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Fri, 29 May 2026 14:17:55 -0700 Subject: [PATCH 04/10] use task-based headings --- .../python/integrations/strands-agents.mdx | 180 ++++++++++-------- 1 file changed, 98 insertions(+), 82 deletions(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index 920dc716de..f297626dbf 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -19,8 +19,9 @@ description: --- Temporal's integration with [Strands Agents](https://strandsagents.com/) gives your Strands agents durable execution, -automatic retries, and timeouts via the Temporal platform. The plugin routes Strands model invocations, tool calls, MCP -tool calls, and hooks through Temporal Activities, so every step the agent takes is recorded in workflow history. +automatic retries, and timeouts via the Temporal platform. The plugin routes model invocations, tool calls, MCP tool +calls, and hooks through Temporal Activities, so every step the agent takes is recorded in Workflow history and can +survive crashes, restarts, and infrastructure failures. :::info @@ -35,13 +36,13 @@ samples for the complete code. ## Prerequisites -- This guide assumes you are already familiar with Strands Agents. If you aren't, refer to the +- This guide assumes you are already familiar with Strands Agents. If you are not, refer to the [Strands Agents documentation](https://strandsagents.com/) for more details. -- If you are new to Temporal, we recommend reading [Understanding Temporal](/evaluate/understanding-temporal) or taking +- If you are new to Temporal, read [Understanding Temporal](/evaluate/understanding-temporal) or take the [Temporal 101](https://learn.temporal.io/courses/temporal_101/) course. -- Ensure you have set up your local development environment by following the - [Set up your local development environment](/develop/python/set-up-your-local-python) guide. When you're done, leave - the Temporal development server running if you want to test your code locally. +- Set up your local development environment by following the + [Set up your local development environment](/develop/python/set-up-your-local-python) guide. Leave the Temporal + development server running if you want to test your code locally. ## Install the plugin @@ -57,9 +58,13 @@ or with pip: pip install "temporalio[strands-agents]" ``` -## Quickstart +## Run a Strands agent with durable execution -Define a Workflow that holds a `TemporalAgent`, then register `StrandsPlugin` on the Worker: +The following example runs a Strands agent inside a Temporal Workflow. Model calls execute as Temporal Activities, which +means they get automatic retries, timeouts, and durable execution. If the Worker process crashes mid-conversation, +Temporal replays the Workflow and resumes from the last completed Activity. + +Define a Workflow that creates a `TemporalAgent` and invokes it with a prompt: ```python import asyncio @@ -97,7 +102,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -Start the Workflow from a client: +Start the Workflow from a separate client script: ```python import asyncio @@ -124,17 +129,19 @@ if __name__ == "__main__": :::warning -Inside a Workflow, always call `agent.invoke_async(message)` — not `agent(message)`. The synchronous form spawns a +Inside a Workflow, always call `agent.invoke_async(message)`, not `agent(message)`. The synchronous form spawns a worker thread, which the Workflow sandbox blocks. ::: -## Models +## Choose and configure models + +By default, `StrandsPlugin` registers a single `BedrockModel()` factory under the name `"bedrock"`, matching Strands' +own default. To use a different model, or to give different agents access to different models, pass a `models` mapping +to `StrandsPlugin` when you create the Worker. -`StrandsPlugin(models=...)` takes a mapping of `name → factory`. Each factory is called lazily on first use (on the -Worker, outside the Workflow sandbox) and the constructed model is cached for the Worker's lifetime. If `models` is -omitted, the plugin registers a single `BedrockModel()` factory under the name `"bedrock"`, matching Strands' own -implicit default. Select a model per agent with `TemporalAgent(model="name", ...)`: +Each entry maps a name to a factory function. The factory is called lazily on first use and the constructed model is +cached for the Worker's lifetime. Select a model per agent with `TemporalAgent(model="name", ...)`: ```python from strands.models.anthropic import AnthropicModel @@ -160,20 +167,19 @@ Worker(..., plugins=[StrandsPlugin(models={ })]) ``` -Each `TemporalAgent` carries its own Activity options (timeouts, retry policy, task queue, streaming topic) and -dispatches to the shared model Activity, which resolves the model name against the registered factories at runtime. A -name not present in `models` raises `ValueError` inside the Activity. +A model name not present in the `models` mapping raises `ValueError` inside the Activity. -## Tools +## Run non-deterministic tools as Activities -Wrap non-deterministic tools as Temporal Activities, register them with the Worker, and pass them to the agent through -`workflow.activity_as_tool`: +Strands tools that perform I/O, access external services, or produce non-deterministic results need to run as Temporal +Activities rather than inline in the Workflow. Wrap each tool in an `@activity.defn` function, register the Activities +on the Worker, and pass them to the agent using `activity_as_tool`: ```python from strands_tools import shell from temporalio import activity from temporalio.contrib.strands import StrandsPlugin, TemporalAgent -from temporalio.contrib.strands import workflow as strands_workflow +from temporalio.contrib.strands.workflow import activity_as_tool @activity.defn @@ -190,8 +196,8 @@ async def shell_activity(command: str) -> dict: agent = TemporalAgent( start_to_close_timeout=timedelta(seconds=60), tools=[ - strands_workflow.activity_as_tool(fetch_user, start_to_close_timeout=timedelta(seconds=30)), - strands_workflow.activity_as_tool(shell_activity, start_to_close_timeout=timedelta(seconds=15)), + activity_as_tool(fetch_user, start_to_close_timeout=timedelta(seconds=30)), + activity_as_tool(shell_activity, start_to_close_timeout=timedelta(seconds=15)), ], ) @@ -203,15 +209,17 @@ Worker( ) ``` -If you're using built-in `strands_tools`, wrap them in a thin async function decorated with `@activity.defn` so they +If you are using built-in `strands_tools`, wrap them in a thin async function decorated with `@activity.defn` so they run as Temporal Activities. -## Hooks +## React to agent lifecycle events + +Strands' [hook system](https://strandsagents.com/) lets you subscribe callbacks to events in the agent lifecycle, such +as invocation start/end, model call before/after, tool call before/after, and message added. Use hooks to add logging, +metrics, or custom logic at each stage. -Strands' [hook system](https://strandsagents.com/) lets you subscribe callbacks to events in the agent lifecycle — -invocation start and end, model call before and after, tool call before and after, and message added. Pass -`hooks=[MyHookProvider()]` to `TemporalAgent`; single-agent hook events fire in Workflow context, so deterministic -callbacks just work: +Pass `hooks=[MyHookProvider()]` to `TemporalAgent`. Hook callbacks fire in Workflow context, so deterministic callbacks +work without any extra setup: ```python from strands.hooks import HookProvider, HookRegistry @@ -233,12 +241,14 @@ agent = TemporalAgent(start_to_close_timeout=timedelta(seconds=60), hooks=[Audit :::warning Hook callbacks run in Workflow context, so they must be -[deterministic](/develop/python/workflows/basics#workflow-logic-requirements) — no `time.time()`, `uuid.uuid4()`, or -I/O. For callbacks that need I/O (audit logging, metrics, alerting), use `workflow.activity_as_hook()` to dispatch the -work as a Temporal Activity: +[deterministic](/develop/python/workflows/basics#workflow-logic-requirements). Do not use `time.time()`, `uuid.uuid4()`, +or I/O inside hook callbacks. ::: +For callbacks that need I/O (audit logging, metrics, alerting), use `activity_as_hook` to dispatch the work as a +Temporal Activity: + ```python from temporalio import activity from temporalio.contrib.strands.workflow import activity_as_hook @@ -246,7 +256,6 @@ from temporalio.contrib.strands.workflow import activity_as_hook @activity.defn async def persist_tool_call(tool_name: str) -> None: - # I/O safely in an activity. ... @@ -262,20 +271,22 @@ class AuditHook(HookProvider): ) ``` -`activity_input` extracts serializable values from the event to pass as the Activity's input. Use a dataclass or -Pydantic model for multiple values. This is needed because hook events hold references to the `Agent`, `AgentTool` -instances, and other objects that don't cross the Activity boundary. +The `activity_input` parameter extracts serializable values from the event to pass as the Activity's input. Use a +dataclass or Pydantic model for multiple values. This is needed because hook events hold references to `Agent`, +`AgentTool` instances, and other objects that cannot cross the Activity boundary. + +## Add human approval gates -## Human-in-the-loop interrupts +Some agent actions, such as deleting resources or sending messages, may require human approval before proceeding. Strands +offers two ways to interrupt an agent and wait for a response. Both work with the plugin. -Strands offers two human-in-the-loop surfaces; both work with the plugin. In each case, `agent.invoke_async()` returns -`AgentResult(stop_reason="interrupt", interrupts=[...])` instead of raising. Pair this with a Signal handler that -supplies responses, then resume by calling `agent.invoke_async(responses)`. +In each case, `agent.invoke_async()` returns `AgentResult(stop_reason="interrupt", interrupts=[...])` instead of +raising. Pair this with a Signal handler that supplies responses, then resume by calling `agent.invoke_async(responses)`. -### Hook-based interrupts +### Interrupt from a hook -A hook on an interruptible event (for example, `BeforeToolCallEvent`) can pause the agent by calling -`event.interrupt(name, reason=...)`. The hook runs in Workflow context, so it must be deterministic — no I/O. +A hook on an interruptible event such as `BeforeToolCallEvent` can pause the agent by calling +`event.interrupt(name, reason=...)`. The hook runs in Workflow context, so it must be deterministic. ```python from strands.hooks import HookProvider, HookRegistry @@ -317,10 +328,10 @@ class MyWorkflow: return str(result) ``` -### Tool-body interrupts +### Interrupt from a tool A `@strands.tool` function can raise `InterruptException(Interrupt(...))` directly. The agent stops with the interrupt, -and the Workflow handles the resume the same way as for hooks: +and the Workflow handles the resume the same way: ```python from strands import tool @@ -334,8 +345,8 @@ def delete_thing(name: str) -> str: ) ``` -The same works from an `activity_as_tool`-wrapped Activity. The plugin's failure converter preserves the `Interrupt` -payload across the Activity boundary, so `AgentResult.interrupts` is populated just like the in-Workflow case: +The same approach works from an `activity_as_tool`-wrapped Activity. The plugin's failure converter preserves the +`Interrupt` payload across the Activity boundary, so `AgentResult.interrupts` is populated the same way: ```python from strands.interrupt import Interrupt, InterruptException @@ -365,8 +376,8 @@ class MyWorkflow: :::warning Activity-tool interrupts rely on the plugin's failure converter, which is installed via the client's data converter. -Attach `StrandsPlugin` to the **client** (not just the Worker) for them to work — Workers built from that client pick -up the plugin automatically: +Attach `StrandsPlugin` to the **client** (not just the Worker) for Activity-tool interrupts to work. Workers built from +that client pick up the plugin automatically: ```python client = await Client.connect("localhost:7233", plugins=[StrandsPlugin()]) @@ -375,10 +386,10 @@ Worker(client, task_queue="strands", workflows=[MyWorkflow], activities=[delete_ ::: -## Structured output +## Return structured data from an agent -Like Strands' `Agent`, `TemporalAgent` supports structured output with `structured_output_model`. The plugin defaults -to the [`pydantic_data_converter`](/develop/python/data-handling/data-conversion), so Pydantic types +To have the agent return a typed object instead of free-form text, pass a `structured_output_model` to `TemporalAgent`. +The plugin defaults to the [`pydantic_data_converter`](/develop/python/data-handling/data-conversion), so Pydantic types serialize cleanly across the Activity and Workflow boundary: ```python @@ -404,11 +415,14 @@ class MyWorkflow: return result.structured_output ``` -## Streaming +## Stream agent output to clients -To forward model chunks to external consumers, pass `streaming_topic="..."` to `TemporalAgent` and host a -`WorkflowStream` on the Workflow. Each `StreamEvent` is published on the named topic from inside the model Activity; -subscribers read via `WorkflowStreamClient`. Chunks are batched on `streaming_batch_interval` (default 100ms): +For long-running agent calls, you may want to forward model output chunks to an external consumer as they arrive rather +than waiting for the full response. + +Pass `streaming_topic="..."` to `TemporalAgent` and host a `WorkflowStream` on the Workflow. Each `StreamEvent` is +published from inside the model Activity. Subscribers read events through `WorkflowStreamClient`. Chunks are batched on +`streaming_batch_interval` (default 100 ms): ```python from temporalio.contrib.workflow_streams import WorkflowStream, WorkflowStreamClient @@ -432,12 +446,14 @@ async for item in WorkflowStreamClient.create(client, workflow_id).subscribe( print(item.data) ``` -## MCP +## Connect to MCP servers + +If your agent needs access to tools provided by an [MCP](https://modelcontextprotocol.io/) server, configure the MCP +clients on the Worker and reference them by name in the Workflow. -`StrandsPlugin(mcp_clients=...)` takes a mapping of `name → MCPClient factory`, mirroring the `models=` pattern. The -plugin registers a per-server `{name}-call-tool` Activity and connects at Worker startup to enumerate tools. -Workflow-side, `TemporalMCPClient(server="name")` is a pure handle: it references the server by name and carries the -per-call Activity options. +`StrandsPlugin(mcp_clients=...)` takes a mapping of `name` to `MCPClient` factory, mirroring the `models` pattern. The +plugin registers a per-server Activity and connects at Worker startup to enumerate available tools. In the Workflow, +`TemporalMCPClient(server="name")` is a handle that references the server by name and carries per-call Activity options: ```python from datetime import timedelta @@ -480,16 +496,16 @@ Each factory returns a fully configured `MCPClient`, so you can pass options lik :::note The plugin connects to each MCP server once at Worker startup to enumerate tools. The schema is frozen for the -Worker's lifetime; restart Workers to pick up MCP-server changes. If a server is unavailable at startup, the Worker +Worker's lifetime. Restart Workers to pick up MCP server changes. If a server is unavailable at startup, the Worker fails to start. ::: -## Retries +## Configure retries -`TemporalAgent` disables Strands' built-in `ModelRetryStrategy` so retries are handled exclusively by Temporal. -Configure retries via `retry_policy` on `TemporalAgent`, and on the Activity options accepted by -`workflow.activity_as_tool`, `workflow.activity_as_hook`, and `TemporalMCPClient`: +`TemporalAgent` disables Strands' built-in `ModelRetryStrategy` so that retries are handled exclusively by Temporal. +Configure retries with `retry_policy` on `TemporalAgent` for model calls, and on the Activity options accepted by +`activity_as_tool`, `activity_as_hook`, and `TemporalMCPClient` for their respective calls: ```python from temporalio.common import RetryPolicy @@ -501,14 +517,15 @@ TemporalAgent( ) ``` -Passing `retry_strategy=...` to `TemporalAgent(...)` raises `ValueError`; remove the argument (or pass -`retry_strategy=None`) and put the retry config on the Activity options instead. +Passing `retry_strategy=...` to `TemporalAgent(...)` raises `ValueError`. Remove the argument (or pass +`retry_strategy=None`) and use `retry_policy` instead. -## Continue-as-new +## Handle long-running chat sessions -A chat-style Workflow accumulates message history with every turn and eventually hits Temporal's per-Workflow history -limit. Use [continue-as-new](/develop/python/workflows/continue-as-new) to start a fresh execution while carrying -`agent.messages` forward as input: +A chat-style Workflow accumulates message history with every turn. Over a long session, the Workflow's event history can +grow large enough to hit Temporal's per-Workflow history limit. To avoid this, use +[Continue-as-New](/develop/python/workflows/continue-as-new) to start a fresh Workflow execution while carrying the +agent's message history forward as input: ```python from dataclasses import dataclass, field @@ -551,12 +568,11 @@ class ChatWorkflow: workflow.continue_as_new(ChatInput(messages=agent.messages)) ``` -## Observability +## Add tracing with OpenTelemetry -`StrandsPlugin` composes cleanly with the [OpenTelemetry plugin](/develop/python/platform/observability#tracing). Register -`OpenTelemetryPlugin` on the client (Workers built from that client pick it up automatically) and `StrandsPlugin` on -the Worker. You'll get OTel spans around the model, tool, and MCP Activities the plugin schedules, plus any spans -Strands itself emits inside `invoke_async`: +To get distributed traces across model, tool, and MCP Activities, combine `StrandsPlugin` with the +[OpenTelemetry plugin](/develop/python/platform/observability#tracing). Register `OpenTelemetryPlugin` on the client and +`StrandsPlugin` on the Worker. Workers built from that client pick up the OpenTelemetry plugin automatically: ```python import opentelemetry.trace @@ -580,11 +596,11 @@ Worker( Set the tracer provider before connecting the client. -## Snapshots +## Snapshots are not supported `TemporalAgent.take_snapshot()` and `TemporalAgent.load_snapshot()` raise `NotImplementedError`. Temporal's event -history already persists Workflow state durably at a finer granularity than Strands snapshots, so calling either -inside a Workflow is redundant. +history already persists Workflow state durably at a finer granularity than Strands snapshots, so snapshots are +redundant inside a Workflow. ## Samples From c80a29381ae5e74e3127f71edd4b46b85b0a19a2 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Mon, 1 Jun 2026 16:43:39 -0700 Subject: [PATCH 05/10] add task-based headings, snipsync --- .../python/integrations/strands-agents.mdx | 732 ++++++++++++------ .../typescript/platform/observability.mdx | 22 +- 2 files changed, 516 insertions(+), 238 deletions(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index f297626dbf..6444b2d033 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -2,7 +2,7 @@ id: strands-agents title: Strands Agents integration sidebar_label: Strands Agents -toc_max_heading_level: 2 +toc_max_heading_level: 3 keywords: - ai - agents @@ -14,14 +14,13 @@ tags: - Strands Agents - Python SDK - Temporal SDKs -description: - Run Strands Agents AI workflows with durable execution using the Temporal Python SDK and Strands plugin. +description: Run Strands Agents AI workflows with durable execution using the Temporal Python SDK and Strands plugin. --- -Temporal's integration with [Strands Agents](https://strandsagents.com/) gives your Strands agents durable execution, -automatic retries, and timeouts via the Temporal platform. The plugin routes model invocations, tool calls, MCP tool -calls, and hooks through Temporal Activities, so every step the agent takes is recorded in Workflow history and can -survive crashes, restarts, and infrastructure failures. +Temporal's integration with [Strands Agents](https://strandsagents.com/) is a [Plugin](/develop/python/plugins) that +gives your Strands agents [Durable Execution](/temporal#durable-execution) via the Temporal platform. The plugin routes +model invocations, tool calls, MCP tool calls, and hooks through Temporal Activities, so every step the agent takes is +recorded in Workflow history and can survive crashes, restarts, and infrastructure failures. :::info @@ -34,17 +33,21 @@ Code snippets in this guide are taken from the [Strands Agents plugin samples](https://github.com/temporalio/samples-python/tree/main/strands_plugin). Refer to the samples for the complete code. -## Prerequisites +## Get started + +Install the plugin, then run a minimal Strands agent inside a Temporal Workflow. + +### Prerequisites - This guide assumes you are already familiar with Strands Agents. If you are not, refer to the [Strands Agents documentation](https://strandsagents.com/) for more details. -- If you are new to Temporal, read [Understanding Temporal](/evaluate/understanding-temporal) or take - the [Temporal 101](https://learn.temporal.io/courses/temporal_101/) course. +- If you are new to Temporal, read [Understanding Temporal](/evaluate/understanding-temporal) or take the + [Temporal 101](https://learn.temporal.io/courses/temporal_101/) course. - Set up your local development environment by following the [Set up your local development environment](/develop/python/set-up-your-local-python) guide. Leave the Temporal development server running if you want to test your code locally. -## Install the plugin +### Install the plugin Install the Temporal Python SDK with Strands Agents support (requires `temporalio` 1.28.0 or later): @@ -58,26 +61,28 @@ or with pip: pip install "temporalio[strands-agents]" ``` -## Run a Strands agent with durable execution +### Run a Strands agent with Durable Execution The following example runs a Strands agent inside a Temporal Workflow. Model calls execute as Temporal Activities, which means they get automatic retries, timeouts, and durable execution. If the Worker process crashes mid-conversation, Temporal replays the Workflow and resumes from the last completed Activity. -Define a Workflow that creates a `TemporalAgent` and invokes it with a prompt: +**1. Define the Workflow** -```python -import asyncio +Create a Workflow that holds a `TemporalAgent` and invokes it with a prompt. The `start_to_close_timeout` sets the +maximum time each model call Activity can run: + + +[strands_plugin/hello_world/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hello_world/workflow.py) +```py from datetime import timedelta from temporalio import workflow -from temporalio.client import Client -from temporalio.contrib.strands import StrandsPlugin, TemporalAgent -from temporalio.worker import Worker +from temporalio.contrib.strands import TemporalAgent @workflow.defn -class MyWorkflow: +class HelloWorldWorkflow: def __init__(self) -> None: self.agent = TemporalAgent(start_to_close_timeout=timedelta(seconds=60)) @@ -85,63 +90,102 @@ class MyWorkflow: async def run(self, prompt: str) -> str: result = await self.agent.invoke_async(prompt) return str(result) +``` + + +:::caution + +Inside a Workflow, always call `agent.invoke_async(message)`, not `agent(message)`. The synchronous form spawns a worker +thread, which the Workflow sandbox blocks. + +::: + +**2. Start a Worker** + +Create a Worker that registers the Workflow and the `StrandsPlugin`. The plugin automatically registers the Activities +that handle model calls: + + +[strands_plugin/hello_world/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hello_world/run_worker.py) +```py +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.hello_world.workflow import HelloWorldWorkflow async def main() -> None: - client = await Client.connect("localhost:7233") + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + worker = Worker( client, - task_queue="strands", - workflows=[MyWorkflow], - plugins=[StrandsPlugin()], + task_queue="strands-hello-world", + workflows=[HelloWorldWorkflow], ) + print("Worker started. Ctrl+C to exit.") await worker.run() if __name__ == "__main__": asyncio.run(main()) ``` + -Start the Workflow from a separate client script: +**3. Run the Workflow** -```python +Start the Workflow from a separate client script. This example sends the prompt "Write a haiku about durable execution" +and prints the agent's response: + + +[strands_plugin/hello_world/run_workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hello_world/run_workflow.py) +```py import asyncio +import os from temporalio.client import Client -from workflow import MyWorkflow +from strands_plugin.hello_world.workflow import HelloWorldWorkflow async def main() -> None: - client = await Client.connect("localhost:7233") + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + result = await client.execute_workflow( - MyWorkflow.run, - "Hello", - id="strands-quickstart", - task_queue="strands", + HelloWorldWorkflow.run, + "Write a haiku about durable execution.", + id="strands-hello-world", + task_queue="strands-hello-world", ) - print(result) + + print(f"Result: {result}") if __name__ == "__main__": asyncio.run(main()) ``` + -:::warning +## Build the agent -Inside a Workflow, always call `agent.invoke_async(message)`, not `agent(message)`. The synchronous form spawns a -worker thread, which the Workflow sandbox blocks. +Customize which model provider your agent uses, add tools that run as Activities, subscribe to lifecycle events with +hooks, and connect to MCP servers. -::: - -## Choose and configure models +### Choose and configure models -By default, `StrandsPlugin` registers a single `BedrockModel()` factory under the name `"bedrock"`, matching Strands' -own default. To use a different model, or to give different agents access to different models, pass a `models` mapping -to `StrandsPlugin` when you create the Worker. +By default, `StrandsPlugin` uses Strands' own default model (`BedrockModel`). To use a different model, pass a `models` +mapping to `StrandsPlugin` on the Worker. When you provide a custom `models` mapping, each `TemporalAgent` must specify +which model to use by name. -Each entry maps a name to a factory function. The factory is called lazily on first use and the constructed model is -cached for the Worker's lifetime. Select a model per agent with `TemporalAgent(model="name", ...)`: +Each entry in the mapping pairs a name with a factory function that creates a model provider (such as `AnthropicModel` +or `BedrockModel`). The provider is created on first use and reused for the Worker's lifetime: ```python from strands.models.anthropic import AnthropicModel @@ -167,171 +211,339 @@ Worker(..., plugins=[StrandsPlugin(models={ })]) ``` -A model name not present in the `models` mapping raises `ValueError` inside the Activity. +Each `TemporalAgent` carries its own Activity options (timeouts, retry policy, task queue, streaming topic) and +dispatches to a shared model Activity, which resolves the model name against the registered factories at runtime. A +model name not present in the `models` mapping raises `ValueError` inside the Activity. -## Run non-deterministic tools as Activities +### Run non-deterministic tools as Activities Strands tools that perform I/O, access external services, or produce non-deterministic results need to run as Temporal Activities rather than inline in the Workflow. Wrap each tool in an `@activity.defn` function, register the Activities -on the Worker, and pass them to the agent using `activity_as_tool`: - -```python -from strands_tools import shell -from temporalio import activity -from temporalio.contrib.strands import StrandsPlugin, TemporalAgent -from temporalio.contrib.strands.workflow import activity_as_tool +on the Worker, and pass them to the agent using `activity_as_tool`. +Define an Activity for the tool: + +[strands_plugin/tools/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/tools/workflow.py) +```py @activity.defn -async def fetch_user(user_id: str) -> dict: - ... +async def fetch_weather(city: str) -> dict: + """Stub weather lookup — replace with a real HTTP call in production.""" + return { + "city": city, + "temperature_f": 72, + "conditions": "sunny", + } +``` + +Pass the Activity to the agent in the Workflow using `activity_as_tool`: -@activity.defn(name="shell") -async def shell_activity(command: str) -> dict: - return shell.shell(command=command, non_interactive=True) + +[strands_plugin/tools/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/tools/workflow.py) +```py +@workflow.defn +class ToolsWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[ + letter_counter, + activity_as_tool( + fetch_weather, + start_to_close_timeout=timedelta(seconds=30), + ), + activity_as_tool( + environment_activity, + start_to_close_timeout=timedelta(seconds=30), + ), + ], + ) + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) +``` + -# Workflow -agent = TemporalAgent( - start_to_close_timeout=timedelta(seconds=60), - tools=[ - activity_as_tool(fetch_user, start_to_close_timeout=timedelta(seconds=30)), - activity_as_tool(shell_activity, start_to_close_timeout=timedelta(seconds=15)), - ], -) +Register the Activity functions on the Worker: -# Worker -Worker( - ..., - activities=[fetch_user, shell_activity], - plugins=[StrandsPlugin()], + +[strands_plugin/tools/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/tools/run_worker.py) +```py +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.tools.workflow import ( + ToolsWorkflow, + environment_activity, + fetch_weather, ) + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-tools", + workflows=[ToolsWorkflow], + activities=[fetch_weather, environment_activity], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) ``` + If you are using built-in `strands_tools`, wrap them in a thin async function decorated with `@activity.defn` so they run as Temporal Activities. -## React to agent lifecycle events +### React to agent lifecycle events Strands' [hook system](https://strandsagents.com/) lets you subscribe callbacks to events in the agent lifecycle, such as invocation start/end, model call before/after, tool call before/after, and message added. Use hooks to add logging, metrics, or custom logic at each stage. Pass `hooks=[MyHookProvider()]` to `TemporalAgent`. Hook callbacks fire in Workflow context, so deterministic callbacks -work without any extra setup: +work without any extra setup. -```python -from strands.hooks import HookProvider, HookRegistry -from strands.hooks.events import AfterToolCallEvent -from temporalio import workflow +For callbacks that need I/O (audit logging, metrics, alerting), use `activity_as_hook` to dispatch the work as a +Temporal Activity. The following example shows both patterns in one `HookProvider`. The `_record` callback runs +in Workflow context (deterministic), while `persist_tool_call` runs as an Activity (I/O-safe): + +[strands_plugin/hooks/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hooks/workflow.py) +```py +@activity.defn +async def persist_tool_call(tool_name: str) -> None: + # In production, write to a database / S3 / your audit pipeline. + activity.logger.info(f"audit: tool {tool_name} completed") +``` + + +[strands_plugin/hooks/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hooks/workflow.py) +```py class AuditHook(HookProvider): - def register_hooks(self, registry: HookRegistry) -> None: - registry.add_callback(AfterToolCallEvent, self._on_tool_call) - - def _on_tool_call(self, event: AfterToolCallEvent) -> None: - workflow.logger.info(f"tool {event.tool_use['name']} finished") + def __init__(self) -> None: + self.fired: list[str] = [] + def register_hooks(self, registry: HookRegistry, **kwargs: object) -> None: + registry.add_callback(AfterToolCallEvent, self._record) + registry.add_callback( + AfterToolCallEvent, + activity_as_hook( + persist_tool_call, + activity_input=lambda event: event.tool_use["name"], + start_to_close_timeout=timedelta(seconds=15), + ), + ) -agent = TemporalAgent(start_to_close_timeout=timedelta(seconds=60), hooks=[AuditHook()]) + def _record(self, event: AfterToolCallEvent) -> None: + self.fired.append(event.tool_use["name"]) ``` + :::warning Hook callbacks run in Workflow context, so they must be [deterministic](/develop/python/workflows/basics#workflow-logic-requirements). Do not use `time.time()`, `uuid.uuid4()`, -or I/O inside hook callbacks. +or I/O inside hook callbacks. Use `activity_as_hook` for anything that requires I/O. ::: -For callbacks that need I/O (audit logging, metrics, alerting), use `activity_as_hook` to dispatch the work as a -Temporal Activity: +The `activity_input` parameter extracts serializable values from the event to pass as the Activity's input. Use a +dataclass or Pydantic model for multiple values. This is needed because hook events hold references to `Agent`, +`AgentTool` instances, and other objects that cannot cross the Activity boundary. -```python -from temporalio import activity -from temporalio.contrib.strands.workflow import activity_as_hook +### Connect to MCP servers +If your agent needs access to tools provided by an [MCP](https://modelcontextprotocol.io/) server, configure the MCP +clients on the Worker and reference them by name in the Workflow. -@activity.defn -async def persist_tool_call(tool_name: str) -> None: - ... +`StrandsPlugin(mcp_clients=...)` takes a mapping of `name` to `MCPClient` factory, mirroring the `models` pattern. The +plugin registers a per-server Activity and connects at Worker startup to enumerate available tools. In the Workflow, +`TemporalMCPClient(server="name")` is a handle that references the server by name and carries per-call Activity options. +Define the Workflow with a `TemporalMCPClient`: -class AuditHook(HookProvider): - def register_hooks(self, registry: HookRegistry) -> None: - registry.add_callback( - AfterToolCallEvent, - activity_as_hook( - persist_tool_call, - activity_input=lambda event: event.tool_use["name"], - start_to_close_timeout=timedelta(seconds=10), - ), + +[strands_plugin/mcp/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/mcp/workflow.py) +```py +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent, TemporalMCPClient + + +@workflow.defn +class MCPWorkflow: + def __init__(self) -> None: + echo = TemporalMCPClient( + server="echo", + start_to_close_timeout=timedelta(seconds=30), + ) + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[echo], ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) ``` + -The `activity_input` parameter extracts serializable values from the event to pass as the Activity's input. Use a -dataclass or Pydantic model for multiple values. This is needed because hook events hold references to `Agent`, -`AgentTool` instances, and other objects that cannot cross the Activity boundary. +Register the MCP client factory on the Worker: -## Add human approval gates + +[strands_plugin/mcp/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/mcp/run_worker.py) +```py +# ... +from mcp import StdioServerParameters, stdio_client +from strands.tools.mcp.mcp_client import MCPClient +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker +# ... +def _make_echo_client() -> MCPClient: + return MCPClient( + lambda: stdio_client( + StdioServerParameters( + command=sys.executable, + args=[str(ECHO_SERVER)], + ) + ) + ) +# ... +async def main() -> None: + plugin = StrandsPlugin(mcp_clients={"echo": _make_echo_client}) + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-mcp", + workflows=[MCPWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() +``` + -Some agent actions, such as deleting resources or sending messages, may require human approval before proceeding. Strands -offers two ways to interrupt an agent and wait for a response. Both work with the plugin. +Each factory returns a fully configured `MCPClient`, so you can pass options like `tool_filters`, `prefix`, +`elicitation_callback`, or `tasks_config` to it. + +:::note + +The plugin connects to each MCP server once at Worker startup to enumerate tools. The schema is frozen for the Worker's +lifetime. Restart Workers to pick up MCP server changes. If a server is unavailable at startup, the Worker fails to +start. + +::: + +## Interact with the agent + +Control the shape of agent responses, stream output in real time, and pause the agent for human approval. + +### Add human approval gates + +Some agent actions, such as deleting resources or sending messages, may require human approval before proceeding. +Strands offers two ways to interrupt an agent and wait for a response. Both work with the plugin. In each case, `agent.invoke_async()` returns `AgentResult(stop_reason="interrupt", interrupts=[...])` instead of -raising. Pair this with a Signal handler that supplies responses, then resume by calling `agent.invoke_async(responses)`. +raising. Pair this with a Signal handler that supplies responses, then resume by calling +`agent.invoke_async(responses)`. -### Interrupt from a hook +#### Interrupt from a hook A hook on an interruptible event such as `BeforeToolCallEvent` can pause the agent by calling `event.interrupt(name, reason=...)`. The hook runs in Workflow context, so it must be deterministic. -```python -from strands.hooks import HookProvider, HookRegistry -from strands.hooks.events import BeforeToolCallEvent -from temporalio import workflow - +Define the approval hook: + +[strands_plugin/human_in_the_loop/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/human_in_the_loop/workflow.py) +```py class ApprovalHook(HookProvider): - def register_hooks(self, registry: HookRegistry) -> None: + def register_hooks(self, registry: HookRegistry, **kwargs: object) -> None: registry.add_callback(BeforeToolCallEvent, self._gate) def _gate(self, event: BeforeToolCallEvent) -> None: - if event.interrupt("approval", reason="confirm delete") != "approve": + if event.tool_use["name"] != "delete_file": + return + approval = event.interrupt( + "approval", + reason=f"approve delete of {event.tool_use['input']['path']}?", + ) + if approval != "approve": event.cancel_tool = "denied" +``` + +The Workflow waits for a Signal carrying the approval response, then resumes the agent: + +[strands_plugin/human_in_the_loop/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/human_in_the_loop/workflow.py) +```py @workflow.defn -class MyWorkflow: +class HumanInTheLoopWorkflow: def __init__(self) -> None: self.agent = TemporalAgent( start_to_close_timeout=timedelta(seconds=60), - tools=[delete_thing], + tools=[delete_file], hooks=[ApprovalHook()], ) - self._approval: str | None = None + self._approval: Optional[str] = None + self._pending_reason: Optional[str] = None @workflow.signal def approve(self, response: str) -> None: self._approval = response + @workflow.query + def pending_approval(self) -> Optional[str]: + return self._pending_reason + @workflow.run async def run(self, prompt: str) -> str: result = await self.agent.invoke_async(prompt) - if result.stop_reason == "interrupt": + while result.stop_reason == "interrupt": + interrupts = list(result.interrupts or []) + self._pending_reason = interrupts[0].reason if interrupts else None await workflow.wait_condition(lambda: self._approval is not None) - result = await self.agent.invoke_async([ - {"interruptResponse": {"interruptId": result.interrupts[0].id, "response": self._approval}} - ]) + response = self._approval + self._approval = None + self._pending_reason = None + responses: list[InterruptResponseContent] = [ + {"interruptResponse": {"interruptId": i.id, "response": response}} + for i in interrupts + ] + result = await self.agent.invoke_async(responses) return str(result) ``` + -### Interrupt from a tool +#### Interrupt from a tool A `@strands.tool` function can raise `InterruptException(Interrupt(...))` directly. The agent stops with the interrupt, -and the Workflow handles the resume the same way: +and the Workflow handles the resume the same way as for hooks: ```python from strands import tool @@ -346,32 +558,27 @@ def delete_thing(name: str) -> str: ``` The same approach works from an `activity_as_tool`-wrapped Activity. The plugin's failure converter preserves the -`Interrupt` payload across the Activity boundary, so `AgentResult.interrupts` is populated the same way: - -```python -from strands.interrupt import Interrupt, InterruptException -from temporalio import activity -from temporalio.contrib.strands.workflow import activity_as_tool +`Interrupt` payload across the Activity boundary, so `AgentResult.interrupts` is populated the same way. +Define the Activity that raises the interrupt: + +[strands_plugin/activity_interrupt/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/activity_interrupt/workflow.py) +```py @activity.defn async def delete_thing(name: str) -> str: - if not await policy.is_authorized(name): + if name not in _APPROVED: + _APPROVED.add(name) raise InterruptException( - Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + Interrupt( + id=f"delete:{name}", + name="approval", + reason=f"approve delete of protected resource '{name}'?", + ) ) - await storage.delete(name) return f"deleted {name}" - - -@workflow.defn -class MyWorkflow: - def __init__(self) -> None: - self.agent = TemporalAgent( - start_to_close_timeout=timedelta(seconds=60), - tools=[activity_as_tool(delete_thing, start_to_close_timeout=timedelta(seconds=10))], - ) ``` + :::warning @@ -379,30 +586,71 @@ Activity-tool interrupts rely on the plugin's failure converter, which is instal Attach `StrandsPlugin` to the **client** (not just the Worker) for Activity-tool interrupts to work. Workers built from that client pick up the plugin automatically: -```python -client = await Client.connect("localhost:7233", plugins=[StrandsPlugin()]) -Worker(client, task_queue="strands", workflows=[MyWorkflow], activities=[delete_thing]) + +[strands_plugin/activity_interrupt/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/activity_interrupt/run_worker.py) +```py +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.activity_interrupt.workflow import ( + ActivityInterruptWorkflow, + delete_thing, +) + + +async def main() -> None: + plugin = StrandsPlugin() + # The plugin MUST be on the client so its failure converter is installed. + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-activity-interrupt", + workflows=[ActivityInterruptWorkflow], + activities=[delete_thing], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) ``` + ::: -## Return structured data from an agent +### Return structured data from an agent To have the agent return a typed object instead of free-form text, pass a `structured_output_model` to `TemporalAgent`. The plugin defaults to the [`pydantic_data_converter`](/develop/python/data-handling/data-conversion), so Pydantic types serialize cleanly across the Activity and Workflow boundary: -```python -from pydantic import BaseModel + +[strands_plugin/structured_output/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/structured_output/workflow.py) +```py +from datetime import timedelta + +from pydantic import BaseModel, Field +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent class PersonInfo(BaseModel): - name: str - age: int + name: str = Field(description="Name of the person") + age: int = Field(description="Age of the person") + occupation: str = Field(description="Occupation of the person") @workflow.defn -class MyWorkflow: +class StructuredOutputWorkflow: def __init__(self) -> None: self.agent = TemporalAgent( start_to_close_timeout=timedelta(seconds=60), @@ -412,25 +660,34 @@ class MyWorkflow: @workflow.run async def run(self, prompt: str) -> PersonInfo: result = await self.agent.invoke_async(prompt) + assert isinstance(result.structured_output, PersonInfo) return result.structured_output ``` + -## Stream agent output to clients +### Stream agent output to clients For long-running agent calls, you may want to forward model output chunks to an external consumer as they arrive rather than waiting for the full response. Pass `streaming_topic="..."` to `TemporalAgent` and host a `WorkflowStream` on the Workflow. Each `StreamEvent` is published from inside the model Activity. Subscribers read events through `WorkflowStreamClient`. Chunks are batched on -`streaming_batch_interval` (default 100 ms): +`streaming_batch_interval` (default 100 ms). -```python -from temporalio.contrib.workflow_streams import WorkflowStream, WorkflowStreamClient +Define the Workflow with a `WorkflowStream` and a streaming topic: + + +[strands_plugin/streaming/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/streaming/workflow.py) +```py +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent +from temporalio.contrib.workflow_streams import WorkflowStream -# Workflow @workflow.defn -class MyWorkflow: +class StreamingWorkflow: def __init__(self) -> None: self.stream = WorkflowStream() self.agent = TemporalAgent( @@ -438,70 +695,73 @@ class MyWorkflow: streaming_topic="events", ) - -# Client -async for item in WorkflowStreamClient.create(client, workflow_id).subscribe( - ["events"], result_type=StreamEvent, -): - print(item.data) + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) ``` + -## Connect to MCP servers +Subscribe to the stream from a client: -If your agent needs access to tools provided by an [MCP](https://modelcontextprotocol.io/) server, configure the MCP -clients on the Worker and reference them by name in the Workflow. - -`StrandsPlugin(mcp_clients=...)` takes a mapping of `name` to `MCPClient` factory, mirroring the `models` pattern. The -plugin registers a per-server Activity and connects at Worker startup to enumerate available tools. In the Workflow, -`TemporalMCPClient(server="name")` is a handle that references the server by name and carries per-call Activity options: - -```python + +[strands_plugin/streaming/run_workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/streaming/run_workflow.py) +```py +import asyncio +import os from datetime import timedelta -from mcp import StdioServerParameters, stdio_client -from strands.tools.mcp.mcp_client import MCPClient -from temporalio import workflow -from temporalio.contrib.strands import StrandsPlugin, TemporalAgent, TemporalMCPClient +from strands.types.streaming import StreamEvent +from temporalio.client import Client +from temporalio.contrib.workflow_streams import WorkflowStreamClient +from strands_plugin.streaming.workflow import StreamingWorkflow -# Workflow -@workflow.defn -class MyWorkflow: - def __init__(self) -> None: - echo = TemporalMCPClient(server="echo", start_to_close_timeout=timedelta(seconds=30)) - self.agent = TemporalAgent( - start_to_close_timeout=timedelta(seconds=60), - tools=[echo], - ) +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + workflow_id = "strands-streaming" + + handle = await client.start_workflow( + StreamingWorkflow.run, + "Count from 1 to 5, one number per sentence.", + id=workflow_id, + task_queue="strands-streaming", + ) -# Worker -Worker( - ..., - plugins=[StrandsPlugin( - mcp_clients={ - "echo": lambda: MCPClient( - lambda: stdio_client( - StdioServerParameters(command="...", args=[...]), - ), - ), - }, - )], -) -``` + async def consume() -> None: + stream = WorkflowStreamClient.create(client, workflow_id) + async for item in stream.subscribe( + ["events"], + from_offset=0, + result_type=StreamEvent, + poll_cooldown=timedelta(milliseconds=50), + ): + event: StreamEvent = item.data + if "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + elif "messageStop" in event: + print() + return -Each factory returns a fully configured `MCPClient`, so you can pass options like `tool_filters`, `prefix`, -`elicitation_callback`, or `tasks_config` to it. + consume_task = asyncio.create_task(consume()) + result = await handle.result() + await asyncio.wait_for(consume_task, timeout=10.0) + print(f"Final result: {result}") -:::note -The plugin connects to each MCP server once at Worker startup to enumerate tools. The schema is frozen for the -Worker's lifetime. Restart Workers to pick up MCP server changes. If a server is unavailable at startup, the Worker -fails to start. +if __name__ == "__main__": + asyncio.run(main()) +``` + + +## Run in production -::: +Configure retry policies, handle long-running chat sessions, and add distributed tracing. -## Configure retries +### Configure retries `TemporalAgent` disables Strands' built-in `ModelRetryStrategy` so that retries are handled exclusively by Temporal. Configure retries with `retry_policy` on `TemporalAgent` for model calls, and on the Activity options accepted by @@ -520,18 +780,28 @@ TemporalAgent( Passing `retry_strategy=...` to `TemporalAgent(...)` raises `ValueError`. Remove the argument (or pass `retry_strategy=None`) and use `retry_policy` instead. -## Handle long-running chat sessions +### Handle long-running chat sessions A chat-style Workflow accumulates message history with every turn. Over a long session, the Workflow's event history can grow large enough to hit Temporal's per-Workflow history limit. To avoid this, use [Continue-as-New](/develop/python/workflows/continue-as-new) to start a fresh Workflow execution while carrying the -agent's message history forward as input: +agent's message history forward as input. -```python +In this example, each user turn arrives as a Workflow [Update](/develop/python/workflows/message-passing#updates), so the caller gets +the agent's reply back from the same call. The `run` method creates the agent, then waits until either the chat ends +or Temporal suggests continue-as-new. When it does, the Workflow drains any in-flight updates and starts a fresh +execution with the agent's accumulated messages: + + +[strands_plugin/continue_as_new/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/continue_as_new/workflow.py) +```py +import asyncio from dataclasses import dataclass, field +from datetime import timedelta from strands.types.content import Messages from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent @dataclass @@ -542,33 +812,45 @@ class ChatInput: @workflow.defn class ChatWorkflow: def __init__(self) -> None: - self._pending: list[str] = [] self._done = False + self._lock = asyncio.Lock() + self._agent: TemporalAgent | None = None - @workflow.signal - def user_says(self, prompt: str) -> None: - self._pending.append(prompt) + @workflow.update + async def turn(self, prompt: str) -> str: + await workflow.wait_condition(lambda: self._agent is not None) + async with self._lock: + assert self._agent is not None + result = await self._agent.invoke_async(prompt) + return str(result).strip() @workflow.signal def end_chat(self) -> None: self._done = True + @workflow.query + def messages(self) -> Messages: + return list(self._agent.messages) if self._agent else [] + @workflow.run async def run(self, input: ChatInput) -> None: - agent = TemporalAgent( + self._agent = TemporalAgent( start_to_close_timeout=timedelta(seconds=60), messages=list(input.messages), ) - while True: - await workflow.wait_condition(lambda: self._pending or self._done) - if self._done: - return - await agent.invoke_async(self._pending.pop(0)) - if workflow.info().is_continue_as_new_suggested(): - workflow.continue_as_new(ChatInput(messages=agent.messages)) + + await workflow.wait_condition( + lambda: self._done or workflow.info().is_continue_as_new_suggested() + ) + + await workflow.wait_condition(workflow.all_handlers_finished) + + if not self._done: + workflow.continue_as_new(ChatInput(messages=self._agent.messages)) ``` + -## Add tracing with OpenTelemetry +### Add tracing with OpenTelemetry To get distributed traces across model, tool, and MCP Activities, combine `StrandsPlugin` with the [OpenTelemetry plugin](/develop/python/platform/observability#tracing). Register `OpenTelemetryPlugin` on the client and @@ -596,13 +878,13 @@ Worker( Set the tracer provider before connecting the client. -## Snapshots are not supported +### Snapshots are not supported `TemporalAgent.take_snapshot()` and `TemporalAgent.load_snapshot()` raise `NotImplementedError`. Temporal's event history already persists Workflow state durably at a finer granularity than Strands snapshots, so snapshots are redundant inside a Workflow. -## Samples +### Samples The [Strands Agents plugin samples](https://github.com/temporalio/samples-python/tree/main/strands_plugin) demonstrate all supported patterns end-to-end. diff --git a/docs/develop/typescript/platform/observability.mdx b/docs/develop/typescript/platform/observability.mdx index ee6d4b7e26..a5c7132c16 100644 --- a/docs/develop/typescript/platform/observability.mdx +++ b/docs/develop/typescript/platform/observability.mdx @@ -290,17 +290,15 @@ However, they differ from Activities in important ways: Explicitly declaring a sink's interface is optional but is useful for ensuring type safety in subsequent steps: -[sinks/src/workflows.ts](https://github.com/temporalio/samples-typescript/blob/main/sinks/src/workflows.ts) +[packages/test/src/workflows/log-sink-tester.ts](https://github.com/temporalio/sdk-typescript/blob/main/packages/test/src/workflows/log-sink-tester.ts) ```ts -import { log, proxySinks, Sinks } from '@temporalio/workflow'; +import type { Sinks } from '@temporalio/workflow'; -export interface AlertSinks extends Sinks { - alerter: { - alert(message: string): void; +export interface CustomLoggerSinks extends Sinks { + customLogger: { + info(message: string): void; }; } - -export type MySinks = AlertSinks; ``` @@ -353,14 +351,12 @@ main().catch((err) => { #### Proxy and call a sink function from a Workflow -[sinks/src/workflows.ts](https://github.com/temporalio/samples-typescript/blob/main/sinks/src/workflows.ts) +[packages/test/src/workflows/log-sample.ts](https://github.com/temporalio/sdk-typescript/blob/main/packages/test/src/workflows/log-sample.ts) ```ts -const { alerter } = proxySinks(); +import * as wf from '@temporalio/workflow'; -export async function sinkWorkflow(): Promise { - log.info('Workflow Execution started'); - alerter.alert('alerter: Workflow Execution started'); - return 'Hello, Temporal!'; +export async function logSampleWorkflow(): Promise { + wf.log.info('Workflow execution started'); } ``` From d2233fb50c1a2081caab818d37e1faef05d17a77 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Mon, 1 Jun 2026 16:56:25 -0700 Subject: [PATCH 06/10] broken link fix --- .../python/integrations/strands-agents.mdx | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index 6444b2d033..c7e4aeea3c 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -17,7 +17,7 @@ tags: description: Run Strands Agents AI workflows with durable execution using the Temporal Python SDK and Strands plugin. --- -Temporal's integration with [Strands Agents](https://strandsagents.com/) is a [Plugin](/develop/python/plugins) that +Temporal's integration with [Strands Agents](https://strandsagents.com/) is a [Plugin](/develop/plugins-guide) that gives your Strands agents [Durable Execution](/temporal#durable-execution) via the Temporal platform. The plugin routes model invocations, tool calls, MCP tool calls, and hooks through Temporal Activities, so every step the agent takes is recorded in Workflow history and can survive crashes, restarts, and infrastructure failures. @@ -73,7 +73,9 @@ Create a Workflow that holds a `TemporalAgent` and invokes it with a prompt. The maximum time each model call Activity can run: + [strands_plugin/hello_world/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hello_world/workflow.py) + ```py from datetime import timedelta @@ -91,6 +93,7 @@ class HelloWorldWorkflow: result = await self.agent.invoke_async(prompt) return str(result) ``` + :::caution @@ -106,7 +109,9 @@ Create a Worker that registers the Workflow and the `StrandsPlugin`. The plugin that handle model calls: + [strands_plugin/hello_world/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hello_world/run_worker.py) + ```py import asyncio import os @@ -137,6 +142,7 @@ async def main() -> None: if __name__ == "__main__": asyncio.run(main()) ``` + **3. Run the Workflow** @@ -145,7 +151,9 @@ Start the Workflow from a separate client script. This example sends the prompt and prints the agent's response: + [strands_plugin/hello_world/run_workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hello_world/run_workflow.py) + ```py import asyncio import os @@ -171,6 +179,7 @@ async def main() -> None: if __name__ == "__main__": asyncio.run(main()) ``` + ## Build the agent @@ -224,7 +233,9 @@ on the Worker, and pass them to the agent using `activity_as_tool`. Define an Activity for the tool: + [strands_plugin/tools/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/tools/workflow.py) + ```py @activity.defn async def fetch_weather(city: str) -> dict: @@ -235,12 +246,15 @@ async def fetch_weather(city: str) -> dict: "conditions": "sunny", } ``` + Pass the Activity to the agent in the Workflow using `activity_as_tool`: + [strands_plugin/tools/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/tools/workflow.py) + ```py @workflow.defn class ToolsWorkflow: @@ -265,12 +279,15 @@ class ToolsWorkflow: result = await self.agent.invoke_async(prompt) return str(result) ``` + Register the Activity functions on the Worker: + [strands_plugin/tools/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/tools/run_worker.py) + ```py import asyncio import os @@ -306,6 +323,7 @@ async def main() -> None: if __name__ == "__main__": asyncio.run(main()) ``` + If you are using built-in `strands_tools`, wrap them in a thin async function decorated with `@activity.defn` so they @@ -321,21 +339,26 @@ Pass `hooks=[MyHookProvider()]` to `TemporalAgent`. Hook callbacks fire in Workf work without any extra setup. For callbacks that need I/O (audit logging, metrics, alerting), use `activity_as_hook` to dispatch the work as a -Temporal Activity. The following example shows both patterns in one `HookProvider`. The `_record` callback runs -in Workflow context (deterministic), while `persist_tool_call` runs as an Activity (I/O-safe): +Temporal Activity. The following example shows both patterns in one `HookProvider`. The `_record` callback runs in +Workflow context (deterministic), while `persist_tool_call` runs as an Activity (I/O-safe): + [strands_plugin/hooks/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hooks/workflow.py) + ```py @activity.defn async def persist_tool_call(tool_name: str) -> None: # In production, write to a database / S3 / your audit pipeline. activity.logger.info(f"audit: tool {tool_name} completed") ``` + + [strands_plugin/hooks/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/hooks/workflow.py) + ```py class AuditHook(HookProvider): def __init__(self) -> None: @@ -355,6 +378,7 @@ class AuditHook(HookProvider): def _record(self, event: AfterToolCallEvent) -> None: self.fired.append(event.tool_use["name"]) ``` + :::warning @@ -381,7 +405,9 @@ plugin registers a per-server Activity and connects at Worker startup to enumera Define the Workflow with a `TemporalMCPClient`: + [strands_plugin/mcp/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/mcp/workflow.py) + ```py from datetime import timedelta @@ -406,12 +432,15 @@ class MCPWorkflow: result = await self.agent.invoke_async(prompt) return str(result) ``` + Register the MCP client factory on the Worker: + [strands_plugin/mcp/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/mcp/run_worker.py) + ```py # ... from mcp import StdioServerParameters, stdio_client @@ -445,6 +474,7 @@ async def main() -> None: print("Worker started. Ctrl+C to exit.") await worker.run() ``` + Each factory returns a fully configured `MCPClient`, so you can pass options like `tool_filters`, `prefix`, @@ -479,7 +509,9 @@ A hook on an interruptible event such as `BeforeToolCallEvent` can pause the age Define the approval hook: + [strands_plugin/human_in_the_loop/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/human_in_the_loop/workflow.py) + ```py class ApprovalHook(HookProvider): def register_hooks(self, registry: HookRegistry, **kwargs: object) -> None: @@ -495,12 +527,15 @@ class ApprovalHook(HookProvider): if approval != "approve": event.cancel_tool = "denied" ``` + The Workflow waits for a Signal carrying the approval response, then resumes the agent: + [strands_plugin/human_in_the_loop/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/human_in_the_loop/workflow.py) + ```py @workflow.defn class HumanInTheLoopWorkflow: @@ -538,6 +573,7 @@ class HumanInTheLoopWorkflow: result = await self.agent.invoke_async(responses) return str(result) ``` + #### Interrupt from a tool @@ -563,7 +599,9 @@ The same approach works from an `activity_as_tool`-wrapped Activity. The plugin' Define the Activity that raises the interrupt: + [strands_plugin/activity_interrupt/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/activity_interrupt/workflow.py) + ```py @activity.defn async def delete_thing(name: str) -> str: @@ -578,6 +616,7 @@ async def delete_thing(name: str) -> str: ) return f"deleted {name}" ``` + :::warning @@ -587,7 +626,9 @@ Attach `StrandsPlugin` to the **client** (not just the Worker) for Activity-tool that client pick up the plugin automatically: + [strands_plugin/activity_interrupt/run_worker.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/activity_interrupt/run_worker.py) + ```py import asyncio import os @@ -623,6 +664,7 @@ async def main() -> None: if __name__ == "__main__": asyncio.run(main()) ``` + ::: @@ -634,7 +676,9 @@ The plugin defaults to the [`pydantic_data_converter`](/develop/python/data-hand serialize cleanly across the Activity and Workflow boundary: + [strands_plugin/structured_output/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/structured_output/workflow.py) + ```py from datetime import timedelta @@ -663,6 +707,7 @@ class StructuredOutputWorkflow: assert isinstance(result.structured_output, PersonInfo) return result.structured_output ``` + ### Stream agent output to clients @@ -677,7 +722,9 @@ published from inside the model Activity. Subscribers read events through `Workf Define the Workflow with a `WorkflowStream` and a streaming topic: + [strands_plugin/streaming/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/streaming/workflow.py) + ```py from datetime import timedelta @@ -700,12 +747,15 @@ class StreamingWorkflow: result = await self.agent.invoke_async(prompt) return str(result) ``` + Subscribe to the stream from a client: + [strands_plugin/streaming/run_workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/streaming/run_workflow.py) + ```py import asyncio import os @@ -755,6 +805,7 @@ async def main() -> None: if __name__ == "__main__": asyncio.run(main()) ``` + ## Run in production @@ -787,13 +838,15 @@ grow large enough to hit Temporal's per-Workflow history limit. To avoid this, u [Continue-as-New](/develop/python/workflows/continue-as-new) to start a fresh Workflow execution while carrying the agent's message history forward as input. -In this example, each user turn arrives as a Workflow [Update](/develop/python/workflows/message-passing#updates), so the caller gets -the agent's reply back from the same call. The `run` method creates the agent, then waits until either the chat ends -or Temporal suggests continue-as-new. When it does, the Workflow drains any in-flight updates and starts a fresh -execution with the agent's accumulated messages: +In this example, each user turn arrives as a Workflow [Update](/develop/python/workflows/message-passing#updates), so +the caller gets the agent's reply back from the same call. The `run` method creates the agent, then waits until either +the chat ends or Temporal suggests continue-as-new. When it does, the Workflow drains any in-flight updates and starts a +fresh execution with the agent's accumulated messages: + [strands_plugin/continue_as_new/workflow.py](https://github.com/temporalio/samples-python/blob/main/strands_plugin/continue_as_new/workflow.py) + ```py import asyncio from dataclasses import dataclass, field @@ -848,6 +901,7 @@ class ChatWorkflow: if not self._done: workflow.continue_as_new(ChatInput(messages=self._agent.messages)) ``` + ### Add tracing with OpenTelemetry From be4014f72b8943d9cc5cdc60de4adc7adf4f7e5f Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Mon, 1 Jun 2026 17:32:57 -0700 Subject: [PATCH 07/10] small copyedit and config adjustment --- .../python/integrations/strands-agents.mdx | 15 ++++---- src/theme/Admonition/index.js | 37 ++++++++++--------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index c7e4aeea3c..85ad81226c 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -381,7 +381,7 @@ class AuditHook(HookProvider): -:::warning +:::caution Hook callbacks run in Workflow context, so they must be [deterministic](/develop/python/workflows/basics#workflow-logic-requirements). Do not use `time.time()`, `uuid.uuid4()`, @@ -480,7 +480,7 @@ async def main() -> None: Each factory returns a fully configured `MCPClient`, so you can pass options like `tool_filters`, `prefix`, `elicitation_callback`, or `tasks_config` to it. -:::note +:::info The plugin connects to each MCP server once at Worker startup to enumerate tools. The schema is frozen for the Worker's lifetime. Restart Workers to pick up MCP server changes. If a server is unavailable at startup, the Worker fails to @@ -619,11 +619,14 @@ async def delete_thing(name: str) -> str: -:::warning +:::caution Activity-tool interrupts rely on the plugin's failure converter, which is installed via the client's data converter. -Attach `StrandsPlugin` to the **client** (not just the Worker) for Activity-tool interrupts to work. Workers built from -that client pick up the plugin automatically: +Attach `StrandsPlugin` to the **client** (not just the Worker) for Activity-tool interrupts to work. + +::: + +Workers built from that client pick up the plugin automatically: @@ -667,8 +670,6 @@ if __name__ == "__main__": -::: - ### Return structured data from an agent To have the agent return a typed object instead of free-form text, pass a `structured_output_model` to `TemporalAgent`. diff --git a/src/theme/Admonition/index.js b/src/theme/Admonition/index.js index 247e58a509..8c105681c8 100644 --- a/src/theme/Admonition/index.js +++ b/src/theme/Admonition/index.js @@ -1,8 +1,8 @@ -import React from "react"; -import clsx from "clsx"; -import { ThemeClassNames } from "@docusaurus/theme-common"; -import Translate from "@docusaurus/Translate"; -import styles from "./styles.module.css"; +import React from 'react'; +import clsx from 'clsx'; +import { ThemeClassNames } from '@docusaurus/theme-common'; +import Translate from '@docusaurus/Translate'; +import styles from './styles.module.css'; function NoteIcon() { return ( @@ -108,7 +108,7 @@ function CopyCodeIcon() { // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style const AdmonitionConfigs = { note: { - infimaClassName: "secondary", + infimaClassName: 'secondary', iconComponent: NoteIcon, label: ( @@ -117,7 +117,7 @@ const AdmonitionConfigs = { ), }, tip: { - infimaClassName: "success", + infimaClassName: 'success', iconComponent: TipIcon, label: ( @@ -126,7 +126,7 @@ const AdmonitionConfigs = { ), }, danger: { - infimaClassName: "danger", + infimaClassName: 'danger', iconComponent: DangerIcon, label: ( @@ -147,7 +147,7 @@ const AdmonitionConfigs = { ), }, caution: { - infimaClassName: "warning", + infimaClassName: 'warning', iconComponent: CautionIcon, label: ( React.isValidElement(item) && item.props?.mdxType === "mdxAdmonitionTitle" + (item) => React.isValidElement(item) && item.props?.mdxType === 'mdxAdmonitionTitle' ); const rest = <>{items.filter((item) => item !== mdxAdmonitionTitle)}; return { @@ -231,7 +232,7 @@ export default function Admonition(props) { className={clsx( ThemeClassNames.common.admonition, ThemeClassNames.common.admonitionType(props.type), - "alert", + 'alert', `alert--${typeConfig.infimaClassName}`, styles.admonition )} From f3e5463ca12f0e7c92494d3cacacb1e79d35179e Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Tue, 2 Jun 2026 09:07:36 -0700 Subject: [PATCH 08/10] Update docs/develop/python/integrations/strands-agents.mdx --- docs/develop/python/integrations/strands-agents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index 85ad81226c..3a87181b68 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -17,7 +17,7 @@ tags: description: Run Strands Agents AI workflows with durable execution using the Temporal Python SDK and Strands plugin. --- -Temporal's integration with [Strands Agents](https://strandsagents.com/) is a [Plugin](/develop/plugins-guide) that +Temporal's integration with [Strands Agents](https://strandsagents.com/) is an [SDK Plugin](/develop/plugins-guide) that gives your Strands agents [Durable Execution](/temporal#durable-execution) via the Temporal platform. The plugin routes model invocations, tool calls, MCP tool calls, and hooks through Temporal Activities, so every step the agent takes is recorded in Workflow history and can survive crashes, restarts, and infrastructure failures. From e3a9a0f19fad55c2fd269a325d6f574ce038ad7b Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Tue, 2 Jun 2026 09:07:49 -0700 Subject: [PATCH 09/10] Update docs/develop/python/integrations/strands-agents.mdx --- docs/develop/python/integrations/strands-agents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index 3a87181b68..7fda37027c 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -19,7 +19,7 @@ description: Run Strands Agents AI workflows with durable execution using the Te Temporal's integration with [Strands Agents](https://strandsagents.com/) is an [SDK Plugin](/develop/plugins-guide) that gives your Strands agents [Durable Execution](/temporal#durable-execution) via the Temporal platform. The plugin routes -model invocations, tool calls, MCP tool calls, and hooks through Temporal Activities, so every step the agent takes is +model invocations, tool calls, MCP tool calls, and hooks through Temporal Activities, so every step your agent takes is recorded in Workflow history and can survive crashes, restarts, and infrastructure failures. :::info From bd32e140d5b33ffe789af663786ed6c8252b8be8 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Tue, 2 Jun 2026 09:08:01 -0700 Subject: [PATCH 10/10] Update docs/develop/python/integrations/strands-agents.mdx --- docs/develop/python/integrations/strands-agents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/develop/python/integrations/strands-agents.mdx b/docs/develop/python/integrations/strands-agents.mdx index 7fda37027c..f4734daced 100644 --- a/docs/develop/python/integrations/strands-agents.mdx +++ b/docs/develop/python/integrations/strands-agents.mdx @@ -331,7 +331,7 @@ run as Temporal Activities. ### React to agent lifecycle events -Strands' [hook system](https://strandsagents.com/) lets you subscribe callbacks to events in the agent lifecycle, such +Strands' [hook system](https://strandsagents.com/docs/user-guide/concepts/agents/hooks/) lets you subscribe callbacks to events in the agent lifecycle, such as invocation start/end, model call before/after, tool call before/after, and message added. Use hooks to add logging, metrics, or custom logic at each stage.