Repository navigation
Add Claude Agent SDK samples #403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
8e55d40
Add Claude Agent SDK samples
patbeqo 7a09333
feat: hardening
patbeqo 4b31f6a
feat: redo a turn cut off by a Worker restart in every sample
patbeqo 83e9e5d
fix: sort imports and pass wait_for_stage for temporalio 1.35
patbeqo 51079b8
chore: depend on temporalio-claude-agent-sdk from PyPI
patbeqo 90f06f5
fix: ask again for a tool call in a redone turn
patbeqo 36a8d89
fix: use the real model only with ANTHROPIC_API_KEY
patbeqo 0ac1300
chore: depend on temporalio-claude-agent-sdk 0.0.2
patbeqo afa941e
chore: let the AI SDK team own the Claude Agent SDK samples
patbeqo 7a4061e
feat: docs support
patbeqo 324c082
Merge branch 'main' into claude-agent-sdk-samples
patbeqo 0252aa3
Merge branch 'main' into claude-agent-sdk-samples
patbeqo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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() | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.