Agent Executors

Run a stateful agent runtime, such as the Claude Agent SDK, inside a DurableAgent

An agent executor lets a DurableAgent hand its whole reasoning loop to an external agent runtime, such as the Claude Agent SDK. Dapr Agents still provides the parts around that loop: the durable workflow, triggers over pub/sub or HTTP, state, human approval, streaming and tracing.

Executors and LLM clients

A DurableAgent gets its intelligence from one of two sources. You pass exactly one of them; passing both raises an error.

llm= (chat client)executor= (agent executor)
Base classChatClientBaseAgentExecutorBase
What it doesReturns one completion for a list of messagesRuns a whole agent turn: it reasons, calls tools and loops until it has an answer
Who owns the loopDurableAgent, one workflow activity per LLM call and per tool callThe executor, inside a single run_executor workflow activity
Who owns conversation stateDapr Agents memoryThe executor’s session, identified by a session_id
ExamplesDaprChatClient, OpenAIChatClient, AnthropicChatClientEchoAgentExecutor, ClaudeAgentExecutor

Choose an executor when you want an existing agent runtime, with its own tools, planning and context handling, and still want it to run durably under Dapr. Choose an LLM client when you want Dapr Agents to control every step.

How an executor runs

When a DurableAgent with an executor is triggered, its workflow calls one activity, run_executor. The activity calls executor.run(task, session_id=..., context=...) and reads the stream of AgentEvent values it yields:

Event typeContentWhat DurableAgent does with it
text_deltaA piece of assistant textPublishes it to the stream, if the run is streaming
messageA complete assistant messageAdds it to the agent’s message history
tool_call{id, name, arguments}Records it in the tool history
tool_result{tool_call_id, result}Records the result against the matching call
sessionA checkpoint from the executorSaves the workflow state, including the session_id
completeThe final assistant messageEnds the run and returns this message as the result
errorAn error messageFails the run with an AgentError
pausedThe tool call waiting for approvalStarts a human approval, then resumes the run (see Human approval)

The stream always ends with exactly one complete, error or paused event. The session_id is saved on the workflow entry, so a retried activity continues the same session.

The event types and helpers live in dapr_agents.agents.executors and are also exported from dapr_agents:

from dapr_agents import AgentEvent, AgentExecutorBase, ToolCallDecision

The minimal executor: EchoAgentExecutor

EchoAgentExecutor needs no LLM and no API key. It sends the prompt back as text_delta, message, session and complete events. Use it to try the executor flow, or copy it as a starting point for your own executor.

from dapr_agents import DurableAgent, EchoAgentExecutor
from dapr_agents.workflow.runners import AgentRunner


async def main() -> None:
    agent = DurableAgent(
        name="Echo",
        role="Echo Assistant",
        goal="Repeat user input through the AgentExecutorBase event stream.",
        executor=EchoAgentExecutor(chunk_size=8),
    )

    runner = AgentRunner()
    try:
        result = await runner.run(agent, payload={"task": "hello, agent executor"})
        print(result)
    finally:
        runner.shutdown(agent)

The full example is in examples/10-agent-executor-echo.

Writing your own executor

Subclass AgentExecutorBase and implement two methods:

  • async def run(self, prompt, *, session_id=None, context=None): an async generator that yields AgentEvent values and ends with a complete or error event. Report failures as an error event rather than raising.
  • async def get_session(self, session_id): returns the stored state of a session, or None if the session is unknown.

Set the class attribute supports_tool_approval = True only if your executor can end a run with a paused event and resume it from context["tool_decisions"].

Claude Agent SDK executor

ClaudeAgentExecutor runs the Claude Agent SDK agent loop (the loop behind Claude Code) inside a DurableAgent.

Install

The executor needs the optional claude extra:

pip install "dapr-agents[claude]"

import dapr_agents works without the extra. Only accessing ClaudeAgentExecutor loads the SDK, and without the extra it raises an ImportError that tells you which package to install.

Authentication

The CLI authenticates with the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable. On a developer machine, it can also use the login of an installed claude CLI. In containers and Kubernetes pods, pass the credential through env and load it from a secret:

import os

from dapr_agents import ClaudeAgentExecutorConfig

config = ClaudeAgentExecutorConfig(
    env={"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"]},
)

Create a Claude agent

from dapr_agents import (
    ClaudeAgentExecutor,
    ClaudeAgentExecutorConfig,
    DurableAgent,
    tool,
)
from dapr_agents.workflow.runners import AgentRunner


@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"It is sunny in {city}."


executor = ClaudeAgentExecutor(
    ClaudeAgentExecutorConfig(model="claude-sonnet-4-5")
)

agent = DurableAgent(
    name="WeatherClaude",
    role="Weather Assistant",
    goal="Answer weather questions",
    instructions=["Be concise."],
    executor=executor,
    tools=[get_weather],
)


async def main() -> None:
    runner = AgentRunner()
    try:
        result = await runner.run(agent, payload={"task": "What's the weather in Paris?"})
        print(result)
    finally:
        runner.shutdown(agent)

Host the agent the same way as any other DurableAgent: runner.run(...), runner.subscribe(...) or runner.serve(...).

What the agent passes to the executor

Before every run, DurableAgent binds the executor to the agent’s own configuration, so you configure tools, prompts and hooks on the agent as usual:

Agent settingClaude setting
Rendered system prompt (role, goal, instructions, or system_prompt)system_prompt
AgentExecutionConfig.max_iterationsmax_turns
tools, including agents-as-tools and Dapr MCPServer toolsTools on the in-process MCP server (see Tools and MCP servers)
hooks.before_tool_callbefore_tool_call (see Human approval)
state (AgentStateConfig.store)A DaprSessionStore as session_store (see Durable sessions)

A value set explicitly in ClaudeAgentExecutorConfig wins over the agent’s value. Tools and hooks set on the config are used as well as the agent’s; a tool is skipped when the config already has one with the same name. The agent’s tools are bound again on every run, so tools loaded later are included.

Agents-as-tools and Dapr MCPServer tools normally schedule a child workflow from the workflow body. For an executor they run inside the run_executor activity, which starts the same child workflow through a workflow client and waits for it. A retried activity attaches to the child workflow the earlier attempt started. Agents in another Dapr app (target_app_id) and ask_user are not offered to the executor. Other tools that start their child workflow in another app, such as a tool from make_mcp_gateway_via_child_workflow_tool, return an error to Claude when called, because the workflow client can only start workflows in the agent’s own app.

Configuration

All settings are in the frozen dataclass ClaudeAgentExecutorConfig. Each field maps onto a ClaudeAgentOptions setting of the SDK.

FieldDefaultDescription
modelCLI defaultClaude model id, for example "claude-sonnet-4-5"
system_promptSDK defaultA system prompt string, or an SDK system-prompt preset dict
max_turnsNoneMaximum agent turns per run
max_budget_usdNoneSpending limit per run, in US dollars
permission_modeNoneSDK permission mode ("default", "acceptEdits", "plan", "bypassPermissions"). It is sent again on every resume, because the CLI does not restore it from the transcript
tools()Dapr Agents tools to offer to Claude (see Tools and MCP servers)
tool_server_name"dapr"Name of the in-process MCP server that carries tools
mcp_servers{}More MCP servers, as SDK config dicts (stdio, sse, http or sdk)
builtin_tools()Built-in Claude Code tools to turn on, such as "Read" or "Bash". The default turns them all off. None keeps the CLI’s default set
allowed_tools()More tool names Claude may call without asking, such as "mcp__github"
disallowed_tools()Tool names Claude must never call
before_tool_call()Dapr Agents before_tool_call hooks, applied to every tool call (see Human approval)
hooks{}Raw SDK hooks, such as {"PreToolUse": [HookMatcher(...)]}, added alongside the executor’s own hooks
session_storeNoneAn SDK SessionStore that keeps a copy of each session transcript, such as DaprSessionStore (see Durable sessions)
cwdPer-agent temp directory inside a DurableAgent, else the process working directoryWorking directory of the CLI
env{}Extra environment variables for the CLI, such as credentials
include_partial_messagesTrueEmit text_delta events for streamed text
setting_sources()Claude settings files to load ("user", "project", "local"). The default loads none, so settings on the host do not change agent runs
isolate_host_configTrueKeeps the host user’s Claude Code context out of agent runs: only the MCP servers configured here are used, and claude.ai connectors, auto memory and CLAUDE.md files (unless setting_sources is set) are skipped. Without it, a CLI logged in with a claude.ai account can load that account’s connectors and the memory of the git repository cwd is in. Values in env override it
cli_pathBundled CLIPath to a claude binary to use instead of the bundled one
extra_options{}Other ClaudeAgentOptions arguments, such as thinking, effort, agents or sandbox

The executor sets some options itself, so extra_options must not include resume, session_id, hooks, stderr, continue_conversation or fork_session. It must also not include options that have their own field in the table above: model, system_prompt, max_turns, max_budget_usd, permission_mode, tools (use builtin_tools), allowed_tools, disallowed_tools, mcp_servers, cwd, env, include_partial_messages, session_store, setting_sources, strict_mcp_config (use isolate_host_config) and cli_path. The config raises a ValueError if it does.

By default the agent has no built-in Claude Code tools (no file access, no shell) and loads no settings files. Claude can only use the tools and MCP servers you configure.

Tools and MCP servers

Tools reach Claude in two ways:

  • Dapr Agents tools (the agent’s tools, plus any in the config’s tools; functions decorated with @tool, or any AgentTool) are served to Claude through an in-process MCP server. Claude sees them as mcp__<tool_server_name>__<tool name>, for example mcp__dapr__get_weather. Events and tool history use the plain tool name, get_weather.
  • MCP servers in mcp_servers are passed to the SDK, which connects to them. Add their tool names, or the whole server as mcp__<server>, to allowed_tools so Claude can call them without asking:
config = ClaudeAgentExecutorConfig(
    tools=(get_weather,),
    mcp_servers={
        "github": {
            "type": "http",
            "url": "https://api.githubcopilot.com/mcp/",
            "headers": {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"},
        },
    },
    allowed_tools=("mcp__github",),
)

Both kinds of tools run inside the run_executor activity, in the same process as the executor. They are not scheduled as separate workflow activities. See Limitations.

Durable sessions

The Claude CLI writes each session transcript to local disk. That is lost when a pod restarts or when a retried activity runs on another pod. To keep sessions, set a session_store. The SDK then stores a copy of the transcript in it, and when a session is resumed on a host without the local file, the SDK rebuilds the transcript from the store.

DaprSessionStore is a session store backed by a Dapr state store:

from dapr_agents import (
    ClaudeAgentExecutor,
    ClaudeAgentExecutorConfig,
    DaprSessionStore,
    DaprSessionStoreConfig,
)
from dapr_agents.storage.daprstores.stateservice import StateStoreService

session_store = DaprSessionStore(
    StateStoreService(store_name="agentstatestore"),
    config=DaprSessionStoreConfig(ttl_in_seconds=7 * 24 * 3600, project_key="assistant"),
)

executor = ClaudeAgentExecutor(
    ClaudeAgentExecutorConfig(
        model="claude-sonnet-4-5",
        session_store=session_store,
    )
)

If you don’t set session_store, DurableAgent attaches a DaprSessionStore on the agent’s workflow state store (AgentStateConfig.store). The executor accepts any object that implements the SDK SessionStore protocol, so you can also use your own store.

How DaprSessionStore stores a session:

  • Each session has one small manifest document and a list of chunk documents. A chunk holds up to max_chunk_bytes (256 KiB by default). Keep this below the value size limit of your state store. While the last chunk is less than half full, the next append rewrites it together with the new entries, so the number of chunks grows with the size of the transcript, not with the number of appends.
  • Appends are committed with ETag (first-write) concurrency, so readers never see half of an append. If a commit reports an error but actually reached the store (for example, the reply was lost), the store notices and keeps the append. Entries sent twice are skipped, based on their uuid.
  • Every append rewrites the manifest. It holds up to dedupe_window entry uuids (about 40 bytes each, so about 10 KB at the default) plus one id per chunk.
  • Transcripts are loaded in the order they were written. A read that races an append retries instead of failing.
  • The store needs a state store with ETag support where a first-write save without an ETag fails if the key already exists. This was verified with Redis (state.redis) and PostgreSQL (state.postgresql v2); check other stores before relying on them. ttl_in_seconds also needs TTL support.
  • continue_conversation (resume the most recent session without naming it) is not supported, because the store keeps no per-project index.
DaprSessionStoreConfig fieldDefaultDescription
key_prefix"claude-session:"Prefix for every key the store writes
max_chunk_bytes262144Largest serialized size of one chunk document
dedupe_window256How many recent entry uuids are remembered per session to skip repeats
max_commit_attempts10Attempts to commit the manifest when writers conflict
ttl_in_secondsNoneSliding TTL: a session expires this many seconds after its last append. Chunks get twice this TTL and are rewritten when they are older than it, so they never expire before the manifest that lists them
project_keyNoneScope for every key, replacing the SDK’s project key (derived from the CLI cwd). DurableAgent sets it to the agent name, so a session is found no matter which working directory a host uses

Session ids

Claude session ids are UUIDs. A caller id that is not a UUID is turned into a stable UUID, and events report that UUID as session_id. To continue a conversation in a later run, pass the session_id from the earlier result in the trigger payload:

result = await runner.run(
    agent,
    payload={"task": "And tomorrow?", "session_id": previous_session_id},
)

When the store already has the session, the executor resumes it. Otherwise it starts a new session with that id.

Human approval of tool calls

ClaudeAgentExecutor supports the same before_tool_call hooks as an LLM-driven DurableAgent, including RequireApproval. Set them on the agent with hooks=Hooks(before_tool_call=[...]) (or in before_tool_call on the executor config). They run in a Claude PreToolUse hook for every tool call, including calls to tools from mcp_servers:

Hook decisionWhat happens to the Claude tool call
Proceed() or NoneDapr Agents tools run. Tools from mcp_servers and built-in Claude Code tools still go through the CLI’s own permission rules, so they run only if they are in allowed_tools or allowed by permission_mode
Mutate(payload=...)The call runs with the new arguments
Deny(reason=...)The call is blocked and Claude is told why
Skip(result=...)For Dapr Agents tools, the tool doesn’t run and result is returned to Claude as the tool result, as for LLM-driven agents. Other tools can only be blocked, so Claude is told the call was skipped
RequireApproval(...)The call is deferred and the run pauses until a person decides
The hook raisesThe call is blocked

The hook gets a ToolHookContext. For Dapr Agents tools, step_name is the plain tool name (transfer_money) and source is the tool’s own source, the same value hooks see for LLM-driven agents: "local" for plain tools, "mcp" for Dapr MCPServer tools. For tools from mcp_servers, step_name is the full Claude name (mcp__github__create_issue) and source is "mcp". Built-in Claude Code tools have source "claude". payload holds the tool arguments and tool_call_id the id of the call.

from dapr_agents import (
    ClaudeAgentExecutor,
    ClaudeAgentExecutorConfig,
    DurableAgent,
    tool,
)
from dapr_agents.agents.configs import AgentApprovalConfig, AgentExecutionConfig
from dapr_agents.hooks import (
    HookDecision,
    Hooks,
    Proceed,
    RequireApproval,
    ToolHookContext,
)


@tool
def transfer_money(to: str, amount: int) -> str:
    """Transfer money to a person."""
    return f"Sent {amount} to {to}."


def approve_transfers(ctx: ToolHookContext) -> HookDecision:
    if ctx.step_name == "transfer_money":
        return RequireApproval(
            timeout_seconds=3600,
            instructions=f"Approve transfer: {ctx.payload}",
        )
    return Proceed()


agent = DurableAgent(
    name="Payments",
    role="Payments assistant",
    instructions=["Call at most one tool at a time."],
    executor=ClaudeAgentExecutor(),
    tools=[transfer_money],
    hooks=Hooks(before_tool_call=[approve_transfers]),
    execution=AgentExecutionConfig(approval=AgentApprovalConfig()),
)

How an approval works:

  1. Claude calls transfer_money. The hook returns RequireApproval, so the executor defers the call. The tool does not run. The run ends with a paused event that holds the tool_call_id, the tool name, the arguments, the approval details and the tool’s source.
  2. The workflow saves its state and asks for approval through the channel set in AgentApprovalConfig: an ApprovalRequiredEvent over pub/sub, or the /hitl/approvals endpoints under runner.serve(). This is the same flow as for LLM-driven agents. The workflow then waits durably for the response or the timeout. No process has to stay up while it waits.
  3. When the decision arrives, the workflow calls run_executor again with the same session_id and the decision under context["tool_decisions"]. The decision records a hash of the arguments the approver saw, and the call is rejected if the arguments it would run with differ. The executor resumes the Claude session from the session store, possibly on another pod. The same tool call goes through the hook again and is allowed or denied based on the decision, and Claude continues the original task. If the call is rejected, Claude is told it was blocked, with the reason from the approver’s ApprovalResponseEvent, or “approval was not granted or timed out” when there is no reason or the approval timed out.
  4. A resumed run can pause again, for example when Claude makes another call that needs approval. The workflow repeats steps 2 and 3 until the run completes or fails, for at most max_approval_rounds approval rounds (AgentExecutionConfig, defaults to max_iterations). After that, the next paused call is rejected without asking anyone, and Claude is told the approval limit was reached. If Claude still asks for another approval, the workflow ends with a message saying the approval limit was reached. A call left paused that way, or by a workflow that was terminated while it waited, is rejected at the start of the next run on the same session, before the new task is sent.

The ApprovalRequiredEvent carries the tool’s source as the executor reported it: the tool’s own source for Dapr Agents tools, "mcp" for tools from mcp_servers and "claude" for built-in tools.

A decision applies only to the tool_call_id it was made for. When Claude makes several calls that need approval in parallel, only one is reported per pause. Claude issues the others again with new ids, and each of those pauses for its own approval. For fewer approval rounds, ask Claude in the system prompt to call approval-gated tools one at a time.

To drive a paused run yourself, outside DurableAgent, loop until the stream ends in complete or error:

from dapr_agents import AgentEvent, ToolCallDecision


async def run_with_approval(executor, task: str) -> AgentEvent:
    session_id = None
    context = None
    while True:
        last = None
        async for event in executor.run(task, session_id=session_id, context=context):
            session_id = event.session_id or session_id
            last = event
        if last is None or last.type != "paused":
            return last  # "complete" or "error"
        call = last.content
        # ask_a_human is your own approval step (UI, chat message, ticket, ...).
        approved = await ask_a_human(call["name"], call["arguments"])
        decision = ToolCallDecision(tool_call_id=call["tool_call_id"], approved=approved)
        context = {"tool_decisions": {decision.tool_call_id: decision.to_dict()}}

Streaming

With include_partial_messages=True (the default), the executor emits text_delta events as Claude writes. When the agent runs with streaming, DurableAgent publishes each delta to the run’s stream, the same way it does for a streaming LLM client. Use AgentRunner.run_stream(...), or AgentExecutionConfig(streaming=True) with a stream listener, to read it. Thinking text and subagent output are not streamed.

Observability

Executor runs are traced with the Dapr Agents OpenTelemetry instrumentation, like LLM-driven agents. Each run_executor activity gets an invoke_agent {agent} span, with one execute_tool {tool} child span per tool call the executor reports. Spans carry OpenInference and GenAI attributes: the agent name, session.id, token usage, the model, and executor.cost_usd, executor.session_total_cost_usd, executor.num_turns and executor.stop_reason. Errors set error.type. A tool call that is waiting for approval ends its span with tool.completed=false.

DurableAgent also saves the usage of each run in the workflow state, in the executor_usage list of the workflow entry (one record per run that completed or paused).

The final complete or paused event also reports what the run cost, in its metadata:

KeyDescription
cost_usdCost of this run: the session total now, minus the total at the start of the run. The starting total is read from the session store, so this is the cost of just this run only when a session_store is set (always the case under a DurableAgent with a state store). Without one, a resumed run reports the session total here
session_total_cost_usdTotal cost of the session, across all runs and resumes
usageToken usage reported by the SDK for this run
model_usageUsage per model
num_turnsTurn count from the SDK. It counts across resumes
stop_reason, terminal_reasonWhy the run stopped

The executor also sends the Claude CLI’s stderr to the Python logger dapr_agents.agents.executors.claude at DEBUG level, and adds its last lines to error messages when a run fails before Claude returns a result.

Errors

The executor reports failures as error events, never as exceptions. The error text includes the SDK’s details, such as the result subtype (for example error_max_turns or error_max_budget_usd) and the error messages. If the CLI can’t be found, the message says how to install it. DurableAgent turns an error event into a failed run.

Limitations

  • Tools run in one activity. The executor, its tools and its MCP calls all run inside one run_executor activity. Dapr Workflow does not checkpoint each tool call, and the activity retry policy applies to the whole run, not to each tool.
  • At-least-once execution. Like any workflow activity, run_executor can run more than once, for example when a pod fails during a run. Tools may then run again. A retried attempt resumes the session the failed attempt saved and sends the task again, so Claude sees it twice. Make tools with side effects idempotent, or put them behind approval. A run that is retried after a resume has already finished does not start the task again: the executor returns the final answer from the stored transcript. If the earlier attempt stopped after the approved tool ran but before Claude answered, the retry continues the session from the tool result. Failures that would repeat on every attempt, such as reaching max_turns or max_budget_usd, a prompt that is too long, or an authentication error, fail the workflow right away instead of being retried.
  • Long runs hold an activity open. A Claude run can take minutes. Set the activity retry and timeout policies with that in mind. Waiting for approval does not hold an activity open.
  • Package size and platforms. The SDK wheel includes the Claude Code CLI (about 100 MB), and there is no wheel for musl Linux. See Install.
  • Same options on every resume. A resumed session must use the same tools, MCP servers, hooks and permission mode. If the tool that was deferred is gone when the session resumes, the run fails.

Example

The examples/13-agent-executor-claude example runs a DurableAgent with ClaudeAgentExecutor. It uses Dapr Agents tools, a Dapr-backed session store and a tool that needs human approval.

Further reading

Last modified October 22, 2026: Upmerge v1.18 into v1.19 (9805cc1cf)