Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

# The AI SDK team owns the AI integration samples and their tests. We add
# @temporalio/sdk too, so the SDK team can continue to manage repo-wide concerns.
/claude_agent_sdk_plugin/ @temporalio/sdk @temporalio/ai-sdk
/deepagents_plugin/ @temporalio/sdk @temporalio/ai-sdk
/google_adk_agents/ @temporalio/sdk @temporalio/ai-sdk
/google_genai/ @temporalio/sdk @temporalio/ai-sdk
Expand All @@ -27,6 +28,7 @@
/litellm_activity/ @temporalio/sdk @temporalio/ai-sdk
/openai_agents/ @temporalio/sdk @temporalio/ai-sdk
/strands_plugin/ @temporalio/sdk @temporalio/ai-sdk
/tests/claude_agent_sdk_plugin/ @temporalio/sdk @temporalio/ai-sdk
/tests/deepagents_plugin/ @temporalio/sdk @temporalio/ai-sdk
/tests/google_adk_agents/ @temporalio/sdk @temporalio/ai-sdk
/tests/google_genai/ @temporalio/sdk @temporalio/ai-sdk
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Some examples require extra dependencies. See each sample's directory for specif
* [batch_sliding_window](batch_sliding_window) - Batch processing with a sliding window of child workflows.
* [bedrock](bedrock) - Orchestrate a chatbot with Amazon Bedrock.
* [bedrock_agentcore/strands_agent](bedrock_agentcore/strands_agent) - Run a AWS Strands Agent with Temporal Plugin on AgentCore Worker.
* [claude_agent_sdk_plugin](claude_agent_sdk_plugin) - Run Claude Code agents as durable workflows with the Claude Agent SDK.
* [cloud_export_to_parquet](cloud_export_to_parquet) - Set up schedule workflow to process exported files on an hourly basis
* [context_propagation](context_propagation) - Context propagation through workflows/activities via interceptor.
* [custom_converter](custom_converter) - Use a custom payload converter to handle custom types.
Expand Down
48 changes: 48 additions & 0 deletions claude_agent_sdk_plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Claude Agent SDK Samples

⚠️ **Pre-release**: this integration and its interfaces may change before General Availability.

These samples demonstrate the [Temporal Claude Agent SDK integration](https://github.com/temporalio/ai-integrations/tree/main/python/claude_agent_sdk), which runs [Claude Code](https://code.claude.com/docs/en/agent-sdk/overview) agents as Temporal Workflows. You write ordinary Claude Agent SDK code in a Workflow, and Claude Code runs unmodified. If a Worker fails, the agent picks up where it left off, and approvals, hooks, and custom tools run as durable Workflow code.

## Samples

| Category | Sample | What it shows |
| --- | --- | --- |
| Agent Chat | [agent_chat](agent_chat) | A multi-turn chat driven by Updates, with Continue-As-New that resumes Claude's session in the next run. Start here. |
| Tools | [tools](tools) | One custom tool computed in the Workflow and one that runs an Activity, both via `@tool` and `create_sdk_mcp_server`. |
| Streaming | [streaming](streaming) | Token-by-token output through a Workflow Stream, with or without a subscriber. |
| Hooks | [hooks](hooks) | A `PreToolUse` hook that blocks Bash commands that delete files and records each one through an Activity. |
| Human in the Loop | [human_in_the_loop](human_in_the_loop) | `can_use_tool` waits durably for a person's Update: approve, deny, or cancel. |
| Structured Output | [structured_output](structured_output) | A JSON-schema result validated as a Pydantic model at the Workflow boundary. |
| Fork Session | [fork_session](fork_session) | One conversation forked with `fork_session` into parallel branches, each with a session ID from Claude Code. |
| Multi-Agent | [multi_agent](multi_agent) | Claude Code's native subagents: a coordinator delegating to a researcher and a writer. |
| Plugin with Custom Transport | [plugin_with_custom_transport](plugin_with_custom_transport) | A Claude Code plugin with a skill and an MCP server, copied into a Docker Sandbox (`sbx`) by a custom connector that runs Claude Code there. |
| Observability | [open_telemetry](open_telemetry) | One OpenTelemetry trace across the caller, the Workflow, its Activities, and Claude Code's model and tool spans. |

Most samples run Claude Code as a subprocess of the Worker. `plugin_with_custom_transport` runs it in a Docker Sandbox instead.

## Worker restarts

Every sample finishes its turn if its Worker stops mid-turn. The Workflow resumes the session at the turn's prompt and sends "Continue from where you left off.", the message Claude Code itself sends to continue an interrupted turn. Claude Code then redoes the turn. `human_in_the_loop` remembers each decision, so the redone turn doesn't ask again for a tool call a person already decided.

The next Worker needs the session. The subprocess samples keep it on the Worker's host, and `plugin_with_custom_transport` keeps it in its sandbox on the Worker's machine.

## Prerequisites

Install the [Temporal CLI](https://docs.temporal.io/cli), then the dependencies:

```bash
uv sync --group claude-agent-sdk
```

The `claude-agent-sdk` package bundles Claude Code, so you don't need to install it separately. Each sample's README has the commands to run it.

## Tests

The tests run every sample against the scripted API and a local dev server, with no credentials or Docker:

```bash
uv run --group claude-agent-sdk pytest tests/claude_agent_sdk_plugin
```

They're skipped on Windows, where the default transport's shell scripts don't run.
Empty file.
69 changes: 69 additions & 0 deletions claude_agent_sdk_plugin/agent_chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Agent Chat

A multi-turn chat with Claude that lives in one Workflow. Each message is a `send_message` Update that returns Claude's reply, and the `end_conversation` Signal ends the chat. Start here.

## What this shows

- **A chat is just a Workflow.** You write ordinary `ClaudeSDKClient` code, and each message becomes a durable Update with Claude's reply as the result.
- **Chats can run forever.** The Workflow continues as new every few turns, so its history stays small no matter how long the conversation gets.
- **Claude doesn't forget.** Only the session ID crosses to the next run. Claude Code reloads the full conversation from its own session file, so nothing is lost and nothing large is stored in Temporal.

## Run it

All commands run from the repository root. Start a Temporal dev server in a separate terminal:

```bash
temporal server start-dev
```

Start the Worker in another terminal.

```bash
uv run --group claude-agent-sdk claude_agent_sdk_plugin/agent_chat/run_worker.py
```

The Worker runs offline: everything runs for real except the model, which replies with how many replies came before. To use the real model, put `ANTHROPIC_API_KEY=...` in front of the command.

### Chat

Start the chat:

```bash
temporal workflow start \
--type ChatWorkflow \
--task-queue claude-agent-sdk-agent-chat \
--workflow-id claude-agent-sdk-agent-chat \
--input '{}'
```

Send messages, one at a time:

```bash
temporal workflow update execute \
--workflow-id claude-agent-sdk-agent-chat \
--name send_message \
--input '"Hi, my name is Ada."'
```

Each command prints Claude's reply. Send four messages to cross into a new run, then check that Claude remembers.

Each run takes three messages. After the third reply the run is full and hands off to the next one. A message sent in that moment is rejected with `The chat is continuing as new. Send it again.`, so if your fourth message fails, send it again.

Once the fourth reply arrives, check:

- **Live:** ask "What is my name?" and Claude still knows it.
- **Offline:** the fourth reply is `"Offline reply 4 in this conversation."`. The scripted model only counts the earlier replies in the request Claude Code sends, so the 4 shows Claude Code reloaded them in the new run.

See the runs, one `ContinuedAsNew` and one `Running`:

```bash
temporal workflow list --query 'WorkflowId="claude-agent-sdk-agent-chat"'
```

End the chat:

```bash
temporal workflow signal \
--workflow-id claude-agent-sdk-agent-chat \
--name end_conversation
```
Empty file.
35 changes: 35 additions & 0 deletions claude_agent_sdk_plugin/agent_chat/run_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import asyncio
import os
from typing import Any

from temporalio.claude_agent_sdk import ClaudeAgentSDKPlugin
from temporalio.client import Client
from temporalio.worker import Worker

from claude_agent_sdk_plugin.agent_chat.workflow import ChatWorkflow
from claude_agent_sdk_plugin.offline import Answer, offline_api_without_key


def offline_reply(request: dict[str, Any]) -> str:
earlier = sum(message["role"] == "assistant" for message in request["messages"])
return f"Offline reply {earlier + 1} in this conversation."


OFFLINE_ANSWERS: dict[str, Answer] = {"": offline_reply}


async def main() -> None:
client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"))

with offline_api_without_key(OFFLINE_ANSWERS):
worker = Worker(
client,
task_queue="claude-agent-sdk-agent-chat",
workflows=[ChatWorkflow],
plugins=[ClaudeAgentSDKPlugin()],
)
await worker.run()


if __name__ == "__main__":
asyncio.run(main())
139 changes: 139 additions & 0 deletions claude_agent_sdk_plugin/agent_chat/workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import asyncio
from dataclasses import dataclass

from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
CLIConnectionError,
ResultMessage,
SystemMessage,
UserMessage,
)
from temporalio import workflow
from temporalio.claude_agent_sdk import TemporalTransport
from temporalio.exceptions import ApplicationError

# The text Claude Code sends itself to continue an interrupted turn.
CONTINUE = "Continue from where you left off."


@dataclass
class ChatInput:
session_id: str | None = None
turns_per_run: int = 3
reply_id: str | None = None # The session's last message, to redo a turn from


@workflow.defn
class ChatWorkflow:
@workflow.init
def __init__(self, input: ChatInput) -> None:
self.session_id = input.session_id
self.turns_per_run = input.turns_per_run
Comment thread
patbeqo marked this conversation as resolved.
Resolved
self.reply_id = input.reply_id
self.turns = 0
self.ended = False
self.client = self.new_client()
self.connected = False
self.lock = asyncio.Lock()

@workflow.run
async def run(self, input: ChatInput) -> None:
await workflow.wait_condition(self.run_is_done)

await self.client.disconnect()

if self.ended:
return

workflow.continue_as_new(
ChatInput(self.session_id, self.turns_per_run, self.reply_id)
)

def new_client(self, prompt_id: str | None = None) -> ClaudeSDKClient:
"""A client that starts Claude Code with a new session, or resumes the session, from prompt_id if given."""
options = ClaudeAgentOptions(
setting_sources=[],
# Required to receive each message's UUID in the response stream.
extra_args={"replay-user-messages": None},
resume=self.session_id, # None starts a new session.
resume_session_at=prompt_id,
)

# The default SubprocessConnector keeps session files on the Worker host,
# so only that host can resume. With Workers on several hosts, pass
# activity_options={"connect": {"task_queue": ...}} with a task queue
# only that host polls.
return ClaudeSDKClient(options=options, transport=TemporalTransport(options))

@workflow.update
async def send_message(self, message: str) -> str:
"""Answer the message in the session, redoing the turn if the connection is lost."""
self.turns += 1

# One turn at a time, so each message gets its own reply.
async with self.lock:
prompt_id = None # The message's, once Claude Code saved it
reply_id = None # The latest reply's in this turn

while True:
try:
# Claude Code starts on the first message, and again after a redo.
if not self.connected:
await self.client.connect()
self.connected = True

await self.client.query(CONTINUE if prompt_id else message)

async for response in self.client.receive_response():
if (
isinstance(response, SystemMessage)
and response.subtype == "init"
):
self.session_id = response.data["session_id"]
elif (
isinstance(response, UserMessage)
and response.uuid
and not prompt_id
):
prompt_id = response.uuid
elif isinstance(response, AssistantMessage):
reply_id = response.uuid
elif isinstance(response, ResultMessage):
self.reply_id = reply_id
return response.result or "\n".join(response.errors or [])

raise RuntimeError("Claude Code ended the turn without a result")

except CLIConnectionError:
await self.client.disconnect()
self.connected = False
# Redo the turn from the message if Claude Code saved it. Otherwise
# send it again from the last reply, or in a new session, so a
# copy Claude Code saved without telling us is dropped.
if not prompt_id and not self.reply_id:
self.session_id = None
self.client = self.new_client(prompt_id or self.reply_id)

@send_message.validator
def validate_send_message(self, message: str) -> None:
if self.ended:
raise ApplicationError("The conversation has ended")

if self.run_is_full():
raise ApplicationError("The chat is continuing as new. Send it again.")

@workflow.signal
def end_conversation(self) -> None:
self.ended = True

def run_is_done(self) -> bool:
"""The chat ended or this run is full, and every message has its reply."""
return (self.ended or self.run_is_full()) and workflow.all_handlers_finished()

def run_is_full(self) -> bool:
return (
self.turns >= self.turns_per_run
or workflow.info().is_continue_as_new_suggested()
)
49 changes: 49 additions & 0 deletions claude_agent_sdk_plugin/fork_session/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Fork Session

Answer a prompt once, then try several follow-ups at the same time, each in its own fork of that conversation. Every fork starts from the same history, and the original session stays as it was.

## What this shows

- **Branching a conversation.** `fork_session=True` with `resume` starts a new session from a copy of an existing one, as in the Claude Agent SDK.
- **Session IDs from Claude Code.** The Workflow reads each session's ID from Claude Code's `init` message, including each fork's. If the connection is lost, it resumes that session, or starts it again if Claude Code hadn't yet started it.
- **Forks in parallel.** Each fork is its own Claude Code. The Workflow runs them together with `asyncio.gather`.

## Run it

All commands run from the repository root. Start a Temporal dev server in a separate terminal:

```bash
temporal server start-dev
```

Start the Worker in another terminal:

```bash
uv run --group claude-agent-sdk claude_agent_sdk_plugin/fork_session/run_worker.py
```

The Worker runs offline: everything runs for real except the model, which answers with fixed text. To use the real model, put `ANTHROPIC_API_KEY=...` in front of the command.

Run the Workflow from a third terminal, with the prompt and a list of follow-ups:

```bash
temporal workflow execute \
--type ForkSessionWorkflow \
--task-queue claude-agent-sdk-fork-session \
--workflow-id fork-session \
--input '"Plan a weekend in Lisbon."' \
--input '["Redo it on a tight budget.", "Redo it with no budget limit."]'
```

## Expected behavior

The result has one branch per follow-up, each with its own session ID. Offline, it is:

```json
[
{"answer": "Walk Alfama, catch a free fado night, take the tram to Belém.", "prompt": "Redo it on a tight budget.", "session_id": "8889ec93-..."},
{"answer": "A private fado dinner, then a yacht along the Tagus to Belém.", "prompt": "Redo it with no budget limit.", "session_id": "3a14ddda-..."}
]
```

Each fork's model request has the weekend plan from the base conversation, and neither has the other fork's follow-up. To continue a branch later, start a client with `resume` set to its session ID.
Empty file.
Loading
Loading