* Finalize draft for 0037-standalone-activities-go * Update Standalone Activities documentation for Go SDK Edits throughout Go standalone activities --------- Co-authored-by: skill-sync[bot] <skill-sync[bot]@users.noreply.github.com> Co-authored-by: Donald Pinckney <donald.pinckney@temporal.io>
20 KiB
Temporal OpenAI Agents SDK Integration (Python)
Overview
The Temporal Python SDK ships a contrib module that runs OpenAI Agents SDK agents as durable Temporal Workflows. Model calls execute as Temporal Activities; tools can be Activities, Nexus stubs, or workflow-resident @function_tools; MCP servers, sandbox backends, and OpenTelemetry export are layered on top.
The integration is delivered as a Temporal plugin: OpenAIAgentsPlugin from temporalio.contrib.openai_agents, registered on both the client and the worker via plugins=[...].
For language-agnostic AI/LLM patterns (centralized retries, multi-agent orchestration, when to put a tool in an Activity vs. the workflow) see references/core/ai-patterns.md. For Python-side LLM patterns that apply when not using this plugin (Pydantic data converter, generic LLM activity, max_retries=0 on the raw OpenAI client) see references/python/ai-patterns.md — note that the plugin already configures Pydantic serialization for you.
Install
The integration lives at temporalio.contrib.openai_agents; use it by installing temporalio[openai-agents] to get the extra OpenAI Agents SDK dep.
from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters
Register the plugin
Pass OpenAIAgentsPlugin to Client.connect(..., plugins=[...]). Register it on both the worker process and the client process — the worker uses it to host the model activity; the client uses it to keep payload serialization compatible.
from datetime import timedelta
from temporalio.client import Client
from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters
from temporalio.worker import Worker
client = await Client.connect(
"localhost:7233",
plugins=[
OpenAIAgentsPlugin(
model_params=ModelActivityParameters(
start_to_close_timeout=timedelta(seconds=30)
)
),
],
)
The 30-second timeout above is the example from the README, not a documented default. Pick a value sized to your model and prompt.
With the plugin registered, four things happen automatically:
- Pydantic types serialize correctly across activity boundaries.
- OpenAI Agents tracing context propagates between workflow and activity.
- The model-invocation activity is registered with every Temporal worker.
- The OpenAI Agents SDK is reconfigured so its model calls run as Temporal activities.
A durable agent
Inside a workflow, write standard OpenAI Agents SDK code — Agent, Runner.run. The plugin reroutes model calls through an Activity, so the agent loop is durable.
from temporalio import workflow
from agents import Agent, Runner
@workflow.defn
class HelloWorldAgent:
@workflow.run
async def run(self, prompt: str) -> str:
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
)
result = await Runner.run(agent, input=prompt)
return result.final_output
Register the workflow with a Worker exactly like any other Temporal workflow.
Tools
Two ways to wire a tool into an agent. Choose based on whether the tool has side effects.
Activities as tools — for I/O, retries, timeouts
Wrap a Temporal activity with temporalio.contrib.openai_agents.workflow.activity_as_tool to expose it to the agent. Each invocation runs as a Temporal Activity, with retries and timeouts governed by the ActivityOptions you pass.
from dataclasses import dataclass
from datetime import timedelta
from temporalio import activity, workflow
from temporalio.contrib import openai_agents
from agents import Agent, Runner
@dataclass
class Weather:
city: str
temperature_range: str
conditions: str
@activity.defn
async def get_weather(city: str) -> Weather:
return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.")
@workflow.defn
class WeatherAgent:
@workflow.run
async def run(self, question: str) -> str:
agent = Agent(
name="Weather Assistant",
instructions="You are a helpful weather agent.",
tools=[
openai_agents.workflow.activity_as_tool(
get_weather,
start_to_close_timeout=timedelta(seconds=10),
),
],
)
result = await Runner.run(starting_agent=agent, input=question)
return result.final_output
Just like with standard @function_tool declarations, if your Activity-as-tool has a RunContextWrapper[T] as the first parameter, then it will receive the OpenAI Agents context wrapper. However, unlike with a @function_tool,
it will only be a read-only copy of the OpenAI Agents context — mutations from the tool body are not visible to other tools or to the agent!
@activity.defn
async def get_weather(ctx: RunContextWrapper[MyState], city: str) -> Weather:
state: MyState = ctx.context
# Now we have **read-only** access to state which is shared across tool invocations.
pass
Note that the initial run context comes from the context=... argument you pass to Runner.run(), and is None by default.
@function_tool — for deterministic, in-process tools
For pure computations or tools that mutate agent state, use the upstream @function_tool decorator. The tool runs as part of the workflow, so it must obey workflow determinism rules.
from temporalio import workflow
from agents import Agent, Runner, function_tool
@function_tool
def calculate_circle_area(radius: float) -> float:
return 3.14 * radius ** 2
@workflow.defn
class MathAssistantAgent:
@workflow.run
async def run(self, message: str) -> str:
agent = Agent(
name="Math Assistant",
instructions="You are a helpful math assistant.",
tools=[calculate_circle_area],
)
result = await Runner.run(agent, input=message)
return result.final_output
@function_tool bodies can read and update OpenAI Agents context:
@activity.defn
async def calculate_circle_area(ctx: RunContextWrapper[MyState], radius: float) -> float:
state: MyState = ctx.context
# Now we have **read-write** access to state which is shared across tool invocations.
pass
Note that the initial run context comes from the context=... argument you pass to Runner.run(), and is None by default.
In addition, since a @function_tool runs in the workflow, they can also call Temporal activities or other durable primitives themselves.
Don't put I/O, system clock, or sources of randomness inside a @function_tool body. Make it an @activity.defn and wrap with activity_as_tool instead.
Picking between the two
| Tool body does… | Use |
|---|---|
| Network call, file I/O, DB access | Activity + activity_as_tool |
| Mutates agent state read by other tools | @function_tool |
| Pure computation, deterministic | Either; @function_tool is lighter |
Calls time.time(), RNG, threads |
Activity + activity_as_tool |
MCP servers
MCP support comes in two flavors based on whether the server keeps session state between calls. Choose by examining the server's protocol, not by guessing.
- Stateless MCP server — each call is self-contained. Wrap with
StatelessMCPServerProvider(factory)and register on the plugin. - Stateful MCP server — session state persists between calls. Failure raises
ApplicationError; Temporal cannot auto-recover the lost server state, so you implement application-level retry.
Both wrappers work with MCPServerStdio, MCPServerSse, and MCPServerStreamableHttp transports.
Stateless MCP — worker setup
from agents.mcp import MCPServerStdio
from temporalio.contrib.openai_agents import (
ModelActivityParameters,
OpenAIAgentsPlugin,
StatelessMCPServerProvider,
)
filesystem_server = StatelessMCPServerProvider(
lambda: MCPServerStdio(
name="FileSystemServer",
params={
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"],
},
)
)
client = await Client.connect(
"localhost:7233",
plugins=[
OpenAIAgentsPlugin(
model_params=ModelActivityParameters(
start_to_close_timeout=timedelta(seconds=60)
),
mcp_server_providers=[filesystem_server],
),
],
)
Stateless MCP — workflow usage
Reference the server inside a workflow with openai_agents.workflow.stateless_mcp_server("Name"). The string must match the name= argument on the MCP server instance the factory creates.
from temporalio import workflow
from temporalio.contrib import openai_agents
from agents import Agent, Runner
@workflow.defn
class FileSystemWorkflow:
@workflow.run
async def run(self, query: str) -> str:
server = openai_agents.workflow.stateless_mcp_server("FileSystemServer")
agent = Agent(
name="File Assistant",
instructions="Use the filesystem tools to read files and answer questions.",
mcp_servers=[server],
)
result = await Runner.run(agent, input=query)
return result.final_output
Hosted MCP
For network-accessible MCP servers, the upstream HostedMCPTool (OpenAI Responses API hosting an MCP client) is also supported and avoids the stateless/stateful wrapping choice.
Sandbox support
Note
This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview.
The OpenAI Agents SDK's SandboxAgent runs commands inside a remote or local sandbox (e.g. Daytona, Docker, E2B, local Unix). With this integration, every sandbox operation — creating a session, exec, file I/O, PTY — dispatches as a Temporal activity, so sandbox work is durable like any other activity and sandbox session state survives worker restarts.
Naming gotcha: this is the Agents SDK sandbox (remote command execution), not the Temporal Python SDK workflow sandbox (determinism protection). They are unrelated.
Worker setup
Register one or more SandboxClientProvider("name", BaseSandboxClient()) on the plugin via sandbox_clients=[...]. Each provider's name becomes the prefix for its activities, so names must be unique.
from temporalio.contrib.openai_agents import (
OpenAIAgentsPlugin,
SandboxClientProvider,
ModelActivityParameters,
)
from agents.extensions.sandbox.daytona import DaytonaSandboxClient
from agents.extensions.sandbox.unix_local import UnixLocalSandboxClient
client = await Client.connect(
"localhost:7233",
plugins=[
OpenAIAgentsPlugin(
model_params=ModelActivityParameters(
start_to_close_timeout=timedelta(seconds=30)
),
sandbox_clients=[
SandboxClientProvider("daytona", DaytonaSandboxClient()),
SandboxClientProvider("local", UnixLocalSandboxClient()),
],
),
],
)
Workflow usage
Reference a registered backend in the workflow with temporal_sandbox_client("name"). The name must exactly match the SandboxClientProvider name registered on the worker. Pass it through RunConfig(sandbox=SandboxRunConfig(client=...)).
from temporalio import workflow
from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client
from agents import Runner
from agents.sandbox import SandboxAgent, SandboxRunConfig
from agents.run import RunConfig
@workflow.defn
class MyWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
agent = SandboxAgent(
name="Coding Assistant",
instructions="You are a helpful coding assistant with access to a sandbox.",
)
result = await Runner.run(
agent,
prompt,
run_config=RunConfig(
sandbox=SandboxRunConfig(
client=temporal_sandbox_client("daytona"),
options=DaytonaSandboxClientOptions(pause_on_exit=False),
),
),
)
return result.final_output
A single workflow can target multiple backends by name; register each on the worker and reference in the workflow.
Streaming
Note
This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview.
Streaming uses the upstream Runner.run_streamed API. Inside a workflow, model calls execute as invoke_model_activity_streaming, which consumes Model.stream_response and returns the collected list of native OpenAI response events. The workflow surfaces those events via RunResultStreaming.stream_events().
from agents import Agent, Runner
from agents.stream_events import RawResponsesStreamEvent
from temporalio import workflow
@workflow.defn
class MyAgent:
@workflow.run
async def run(self, prompt: str) -> str:
agent = Agent(name="Assistant", instructions="...")
result = Runner.run_streamed(agent, prompt)
async for event in result.stream_events():
if isinstance(event, RawResponsesStreamEvent):
raw_event = event.data
...
return result.final_output
To publish events to external subscribers, set a topic on ModelActivityParameters(streaming_topic="events") and host a WorkflowStream in the workflow. The topic is required when calling Runner.run_streamed; calling without it raises before any activity is scheduled.
Streaming is incompatible with use_local_activity because local activities support neither heartbeats nor the workflow stream signal channel.
Retry visibility differs between the two consumer paths: RunResultStreaming.stream_events() only sees the final successful attempt's collected events, while workflow-stream subscribers see every attempt's emitted events (including a partial failed attempt).
OpenTelemetry integration
Note
This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview.
Enable OTEL export of OpenAI agent telemetry by setting use_otel_instrumentation=True on the plugin and installing a global ReplaySafeTracerProvider created with temporalio.contrib.opentelemetry.create_tracer_provider. Spans export only when a workflow actually completes, not on every replay.
from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters
from temporalio.contrib.opentelemetry import create_tracer_provider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry import trace
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
tracer_provider = create_tracer_provider()
tracer_provider.add_span_processor(
SimpleSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(tracer_provider)
client = await Client.connect(
"localhost:7233",
plugins=[
OpenAIAgentsPlugin(
use_otel_instrumentation=True,
model_params=ModelActivityParameters(
start_to_close_timeout=timedelta(seconds=30)
),
),
],
)
OTEL extras need to be installed separately:
pip install openinference-instrumentation-openai-agents opentelemetry-sdk
If use_otel_instrumentation=True is set without the deps installed, the plugin raises ImportError with the exact install line. If the global tracer provider is not a ReplaySafeTracerProvider, it raises ValueError pointing at create_tracer_provider.
Direct opentelemetry.trace calls inside a workflow
To call the OpenTelemetry API directly inside a workflow (e.g. opentelemetry.trace.get_tracer(__name__).start_as_current_span(...)), allow OTel through the Python SDK's workflow sandbox using with_passthrough_modules("opentelemetry") on the runner:
from temporalio.worker import Worker
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions
worker = Worker(
client,
task_queue="my-task-queue",
workflows=[MyWorkflow],
workflow_runner=SandboxedWorkflowRunner(
SandboxRestrictions.default.with_passthrough_modules("opentelemetry"),
),
)
To get correct trace parenting, start an Agents SDK span with agents.custom_span(...) before opening any direct OTEL spans — the Agents-SDK span establishes the OTEL context that subsequent direct spans inherit from.
Starting a trace from the client
plugin.tracing_context() lets the client side open an Agents-SDK trace before calling execute_workflow, so the whole workflow run is part of one larger trace:
plugin = OpenAIAgentsPlugin(use_otel_instrumentation=True)
client = await Client.connect("localhost:7233", plugins=[plugin])
with plugin.tracing_context():
with trace("Customer support workflow"):
with custom_span("Workflow execution"):
await client.execute_workflow(
CustomerSupportAgent.run,
"Help me with my order",
id="customer-support-123",
task_queue="my-task-queue",
)
Feature support
The README's compatibility matrix, condensed:
| Area | Supported | Not supported |
|---|---|---|
| Model providers | OpenAI, LiteLLM | — |
| Model response | Runner.run; Runner.run_streamed (experimental) |
— |
| Tools | FunctionTool, WebSearchTool, FileSearchTool, HostedMCPTool, ImageGenerationTool, CodeInterpreterTool |
LocalShellTool, ComputerTool |
| MCP transports | MCPServerStdio, MCPServerSse, MCPServerStreamableHttp |
— |
| Guardrails | Code, Agent | — |
| Sessions | (in-workflow agent state) | SQLiteSession |
| Tracing | OpenAI platform; OpenTelemetry (Public Preview) | — |
| Voice | VoicePipeline (STT/TTS outside Temporal, agent loop durable) |
Realtime agents |
| Utilities | — | REPL |
Tool context propagation:
| Path | Receives context | Can update context |
|---|---|---|
Activity tool (activity_as_tool) |
Yes (copy) | No |
Function tool (@function_tool) |
Yes | Yes |
Common pitfalls
- Register the plugin on both client and worker. Skipping client-side registration breaks payload compatibility.
- Don't put I/O or non-deterministic code in
@function_toolbodies. Move it to an@activity.defnand wrap withactivity_as_tool. - Don't expect Temporal to auto-recover stateful MCP server sessions. A failed session raises
ApplicationError; implement your own application-level retry. - Don't enable streaming together with
use_local_activity. Local activities lack heartbeats and the workflow-stream signal channel. Use the standard activity path. - Don't call
Runner.run_streamedwithoutModelActivityParameters(streaming_topic="..."). It raises before any activity is scheduled. - MCP server names must match exactly between
MCPServerStdio(name="X")andstateless_mcp_server("X"). Same forSandboxClientProvider("Y", ...)andtemporal_sandbox_client("Y"). use_otel_instrumentation=TruerequiresReplaySafeTracerProvider. Settingtrace.set_tracer_provider(...)with anything else raisesValueError.- Activity-tool context is a read-only copy. A tool that needs to mutate agent state must be a
@function_tool.
Resources
references/core/ai-patterns.md— language-agnostic agent patterns (when to wrap a tool as an activity, centralized retry, multi-agent orchestration).references/python/ai-patterns.md— Python-side LLM patterns for when you are not using this plugin (Pydantic data converter, OpenAI clientmax_retries=0).references/python/determinism.mdandreferences/core/determinism.md— determinism rules that apply to@function_toolbodies and any in-workflow agent code.- Upstream samples —
temporalio/samples-python/openai_agents.