# OpenHands Docs
> Consolidated documentation context for LLMs (V1-only). Legacy V0 docs pages are intentionally excluded.
## OpenHands Software Agent SDK
### Software Agent SDK
Source: https://docs.openhands.dev/sdk.md
The OpenHands Software Agent SDK is a set of Python and REST APIs for building **agents that work with code**.
You can use the OpenHands Software Agent SDK for:
- One-off tasks, like building a README for your repo
- Routine maintenance tasks, like updating dependencies
- Major tasks that involve multiple agents, like refactors and rewrites
- OpenAI-compatible access to an OpenHands agent from chat UIs, IDEs, voice platforms, and other clients
You can even use the SDK to build new developer experiences—it’s the engine behind the [OpenHands CLI](/openhands/usage/cli/quick-start) and [OpenHands Cloud](/openhands/usage/cloud/openhands-cloud).
Get started with some examples or keep reading to learn more.
## Features
A unified Python API that enables you to run agents locally or in the cloud, define custom agent behaviors, and create custom tools.
Ready-to-use tools for executing Bash commands, editing files, browsing the web, integrating with MCP, and more.
A production-ready server that runs agents anywhere, including Docker and Kubernetes, while connecting seamlessly to the Python API.
Access the OpenHands agent via an OpenAI-compatible endpoint for chat UIs, IDEs, voice platforms, and other OpenAI-style clients.
## Why OpenHands Software Agent SDK?
### Emphasis on coding
While other agent SDKs (e.g. [LangChain](https://python.langchain.com/docs/tutorials/agents/)) are focused on more general use cases, like delivering chat-based support or automating back-office tasks, OpenHands is purpose-built for software engineering.
While some folks do use OpenHands to solve more general tasks (code is a powerful tool!), most of us use OpenHands to work with code.
### State-of-the-Art Performance
OpenHands is a top performer across a wide variety of benchmarks, including SWE-bench, SWT-bench, and multi-SWE-bench. The SDK includes a number of state-of-the-art agentic features developed by our research team, including:
- Task planning and decomposition
- Automatic context compression
- Security analysis
- Strong agent-computer interfaces
OpenHands has attracted researchers from a wide variety of academic institutions, and is [becoming the preferred harness](https://x.com/Alibaba_Qwen/status/1947766835023335516) for evaluating LLMs on coding tasks.
### Free and Open Source
OpenHands is also the leading open source framework for coding agents. It’s MIT-licensed, and can work with any LLM—including big proprietary LLMs like Claude and OpenAI, as well as open source LLMs like Qwen and Devstral.
Other SDKs (e.g. [Claude Code](https://github.com/anthropics/claude-agent-sdk-python)) are proprietary and lock you into a particular model. Given how quickly models are evolving, it’s best to stay model-agnostic!
## Get Started
Install the SDK, run your first agent, and explore the guides.
## Learn the SDK
Understand the SDK's architecture: agents, tools, workspaces, and more.
Explore the complete SDK API and source code.
## Build with Examples
Build local agents with custom tools and capabilities.
Run agents on remote servers with Docker sandboxing.
Automate repository tasks with agent-powered workflows.
## Community
Connect with the OpenHands community on Slack.
Contribute to the SDK or report issues on GitHub.
### openhands.sdk.agent
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.agent.md
### class Agent
Bases: `CriticMixin`, [`AgentBase`](#class-agentbase)
Main agent implementation for OpenHands.
The Agent class provides the core functionality for running AI agents that can
interact with tools, process messages, and execute actions. It inherits from
AgentBase and implements the agent execution logic. Critic-related functionality
is provided by CriticMixin.
#### Example
```pycon
>>> from openhands.sdk import LLM, Agent, Tool
>>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
>>> tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")]
>>> agent = Agent(llm=llm, tools=tools)
```
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### Methods
#### init_state()
Initialize conversation state.
Invariants enforced by this method:
- If a SystemPromptEvent is already present, it must be within the first 3
events (index 0 or 1 in practice; index 2 is included in the scan window
to detect a user message appearing before the system prompt).
- A user MessageEvent should not appear before the SystemPromptEvent.
These invariants keep event ordering predictable for downstream components
(condenser, UI, etc.) and also prevent accidentally materializing the full
event history during initialization.
#### model_post_init()
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
* Parameters:
* `self` – The BaseModel instance.
* `context` – The context.
#### step()
Taking a step in the conversation.
Typically this involves:
1. Making a LLM call
2. Executing the tool
3. Updating the conversation state with
LLM calls (role=”assistant”) and tool results (role=”tool”)
4.1 If conversation is finished, set state.execution_status to FINISHED
4.2 Otherwise, just return, Conversation will kick off the next step
If the underlying LLM supports streaming, partial deltas are forwarded to
`on_token` before the full response is returned.
NOTE: state will be mutated in-place.
### class AgentBase
Bases: `DiscriminatedUnionMixin`, `ABC`
Abstract base class for OpenHands agents.
Agents are stateless and should be fully defined by their configuration.
This base class provides the common interface and functionality that all
agent implementations must follow.
#### Properties
- `agent_context`: AgentContext | None
- `condenser`: CondenserBase | None
- `critic`: CriticBase | None
- `dynamic_context`: str | None
Get the dynamic per-conversation context.
This returns the context that varies between conversations, such as:
- Repository information and skills
- Runtime information (hosts, working directory)
- User-specific secrets and settings
- Conversation instructions
This content should NOT be included in the cached system prompt to enable
cross-conversation cache sharing. Instead, it is sent as a second content
block (without a cache marker) inside the system message.
* Returns:
The dynamic context string, or None if no context is configured.
- `filter_tools_regex`: str | None
- `include_default_tools`: list[str]
- `llm`: LLM
- `mcp_config`: dict[str, Any]
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `name`: str
Returns the name of the Agent.
- `prompt_dir`: str
Returns the directory where this class’s module file is located.
- `security_policy_filename`: str
- `static_system_message`: str
Compute the static portion of the system message.
This returns only the base system prompt template without any dynamic
per-conversation context. This static portion can be cached and reused
across conversations for better prompt caching efficiency.
* Returns:
The rendered system prompt template without dynamic context.
- `system_message`: str
Return the combined system message (static + dynamic).
- `system_prompt_filename`: str
- `system_prompt_kwargs`: dict[str, object]
- `tools`: list[Tool]
- `tools_map`: dictstr, [ToolDefinition]
Get the initialized tools map.
:raises RuntimeError: If the agent has not been initialized.
#### Methods
#### get_all_llms()
Recursively yield unique base-class LLM objects reachable from self.
- Returns actual object references (not copies).
- De-dupes by id(LLM).
- Cycle-safe via a visited set for all traversed objects.
- Only yields objects whose type is exactly LLM (no subclasses).
- Does not handle dataclasses.
#### init_state()
Initialize the empty conversation state to prepare the agent for user
messages.
Typically this involves adding system message
NOTE: state will be mutated in-place.
#### model_dump_succint()
Like model_dump, but excludes None fields by default.
#### model_post_init()
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
* Parameters:
* `self` – The BaseModel instance.
* `context` – The context.
#### abstractmethod step()
Taking a step in the conversation.
Typically this involves:
1. Making a LLM call
2. Executing the tool
3. Updating the conversation state with
LLM calls (role=”assistant”) and tool results (role=”tool”)
4.1 If conversation is finished, set state.execution_status to FINISHED
4.2 Otherwise, just return, Conversation will kick off the next step
If the underlying LLM supports streaming, partial deltas are forwarded to
`on_token` before the full response is returned.
NOTE: state will be mutated in-place.
#### Deprecated
Deprecated since version 1.11.0: Use [`static_system_message`](#class-static_system_message) for the cacheable system prompt and
[`dynamic_context`](#class-dynamic_context) for per-conversation content. This separation
enables cross-conversation prompt caching. Will be removed in 1.16.0.
#### WARNING
Using this property DISABLES cross-conversation prompt caching because
it combines static and dynamic content into a single string. Use
[`static_system_message`](#class-static_system_message) and [`dynamic_context`](#class-dynamic_context) separately
to enable caching.
#### Deprecated
Deprecated since version 1.11.0: This will be removed in 1.16.0. Use static_system_message for the cacheable system prompt and dynamic_context for per-conversation content. Using system_message DISABLES cross-conversation prompt caching because it combines static and dynamic content into a single string.
#### verify()
Verify that we can resume this agent from persisted state.
We do not merge configuration between persisted and runtime Agent
instances. Instead, we verify compatibility requirements and then
continue with the runtime-provided Agent.
Compatibility requirements:
- Agent class/type must match.
- Tools must match exactly (same tool names).
Tools are part of the system prompt and cannot be changed mid-conversation.
To use different tools, start a new conversation or use conversation forking
(see [https://github.com/OpenHands/OpenHands/issues/8560](https://github.com/OpenHands/OpenHands/issues/8560)).
All other configuration (LLM, agent_context, condenser, etc.) can be
freely changed between sessions.
* Parameters:
* `persisted` – The agent loaded from persisted state.
* `events` – Unused, kept for API compatibility.
* Returns:
This runtime agent (self) if verification passes.
* Raises:
`ValueError` – If agent class or tools don’t match.
### openhands.sdk.conversation
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.conversation.md
### class BaseConversation
Bases: `ABC`
Abstract base class for conversation implementations.
This class defines the interface that all conversation implementations must follow.
Conversations manage the interaction between users and agents, handling message
exchange, execution control, and state management.
#### Properties
- `confirmation_policy_active`: bool
- `conversation_stats`: ConversationStats
- `id`: UUID
- `is_confirmation_mode_active`: bool
Check if confirmation mode is active.
Returns True if BOTH conditions are met:
1. The conversation state has a security analyzer set (not None)
2. The confirmation policy is active
- `state`: ConversationStateProtocol
#### Methods
#### __init__()
Initialize the base conversation with span tracking.
#### abstractmethod ask_agent()
Ask the agent a simple, stateless question and get a direct LLM response.
This bypasses the normal conversation flow and does not modify, persist,
or become part of the conversation state. The request is not remembered by
the main agent, no events are recorded, and execution status is untouched.
It is also thread-safe and may be called while conversation.run() is
executing in another thread.
* Parameters:
`question` – A simple string question to ask the agent
* Returns:
A string response from the agent
#### abstractmethod close()
#### static compose_callbacks()
Compose multiple callbacks into a single callback function.
* Parameters:
`callbacks` – An iterable of callback functions
* Returns:
A single callback function that calls all provided callbacks
#### abstractmethod condense()
Force condensation of the conversation history.
This method uses the existing condensation request pattern to trigger
condensation. It adds a CondensationRequest event to the conversation
and forces the agent to take a single step to process it.
The condensation will be applied immediately and will modify the conversation
state by adding a condensation event to the history.
* Raises:
`ValueError` – If no condenser is configured or the condenser doesn’t
handle condensation requests.
#### abstractmethod execute_tool()
Execute a tool directly without going through the agent loop.
This method allows executing tools before or outside of the normal
conversation.run() flow. It handles agent initialization automatically,
so tools can be executed before the first run() call.
Note: This method bypasses the agent loop, including confirmation
policies and security analyzer checks. Callers are responsible for
applying any safeguards before executing potentially destructive tools.
This is useful for:
- Pre-run setup operations (e.g., indexing repositories)
- Manual tool execution for environment setup
- Testing tool behavior outside the agent loop
* Parameters:
* `tool_name` – The name of the tool to execute (e.g., “sleeptime_compute”)
* `action` – The action to pass to the tool executor
* Returns:
The observation returned by the tool execution
* Raises:
* `KeyError` – If the tool is not found in the agent’s tools
* `NotImplementedError` – If the tool has no executor
#### abstractmethod generate_title()
Generate a title for the conversation based on the first user message.
* Parameters:
* `llm` – Optional LLM to use for title generation. If not provided,
uses the agent’s LLM.
* `max_length` – Maximum length of the generated title.
* Returns:
A generated title for the conversation.
* Raises:
`ValueError` – If no user messages are found in the conversation.
#### static get_persistence_dir()
Get the persistence directory for the conversation.
* Parameters:
* `persistence_base_dir` – Base directory for persistence. Can be a string
path or Path object.
* `conversation_id` – Unique conversation ID.
* Returns:
String path to the conversation-specific persistence directory.
Always returns a normalized string path even if a Path was provided.
#### abstractmethod pause()
#### abstractmethod reject_pending_actions()
#### abstractmethod run()
Execute the agent to process messages and perform actions.
This method runs the agent until it finishes processing the current
message or reaches the maximum iteration limit.
#### abstractmethod send_message()
Send a message to the agent.
* Parameters:
* `message` – Either a string (which will be converted to a user message)
or a Message object
* `sender` – Optional identifier of the sender. Can be used to track
message origin in multi-agent scenarios. For example, when
one agent delegates to another, the sender can be set to
identify which agent is sending the message.
#### abstractmethod set_confirmation_policy()
Set the confirmation policy for the conversation.
#### abstractmethod set_security_analyzer()
Set the security analyzer for the conversation.
#### abstractmethod update_secrets()
### class Conversation
### class Conversation
Bases: `object`
Factory class for creating conversation instances with OpenHands agents.
This factory automatically creates either a LocalConversation or RemoteConversation
based on the workspace type provided. LocalConversation runs the agent locally,
while RemoteConversation connects to a remote agent server.
* Returns:
LocalConversation if workspace is local, RemoteConversation if workspace
is remote.
#### Example
```pycon
>>> from openhands.sdk import LLM, Agent, Conversation
>>> from openhands.sdk.plugin import PluginSource
>>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
>>> agent = Agent(llm=llm, tools=[])
>>> conversation = Conversation(
... agent=agent,
... workspace="./workspace",
... plugins=[PluginSource(source="github:org/security-plugin", ref="v1.0")],
... )
>>> conversation.send_message("Hello!")
>>> conversation.run()
```
### class ConversationExecutionStatus
Bases: `str`, `Enum`
Enum representing the current execution state of the conversation.
#### Methods
#### DELETING = 'deleting'
#### ERROR = 'error'
#### FINISHED = 'finished'
#### IDLE = 'idle'
#### PAUSED = 'paused'
#### RUNNING = 'running'
#### STUCK = 'stuck'
#### WAITING_FOR_CONFIRMATION = 'waiting_for_confirmation'
#### is_terminal()
Check if this status represents a terminal state.
Terminal states indicate the run has completed and the agent is no longer
actively processing. These are: FINISHED, ERROR, STUCK.
Note: IDLE is NOT a terminal state - it’s the initial state of a conversation
before any run has started. Including IDLE would cause false positives when
the WebSocket delivers the initial state update during connection.
* Returns:
True if this is a terminal status, False otherwise.
### class ConversationState
Bases: `OpenHandsModel`
#### Properties
- `activated_knowledge_skills`: list[str]
- `agent`: AgentBase
- `agent_state`: dict[str, Any]
- `blocked_actions`: dict[str, str]
- `blocked_messages`: dict[str, str]
- `confirmation_policy`: ConfirmationPolicyBase
- `env_observation_persistence_dir`: str | None
Directory for persisting environment observation files.
- `events`: [EventLog](#class-eventlog)
- `execution_status`: [ConversationExecutionStatus](#class-conversationexecutionstatus)
- `id`: UUID
- `max_iterations`: int
- `persistence_dir`: str | None
- `secret_registry`: [SecretRegistry](#class-secretregistry)
- `security_analyzer`: SecurityAnalyzerBase | None
- `stats`: ConversationStats
- `stuck_detection`: bool
- `workspace`: BaseWorkspace
#### Methods
#### acquire()
Acquire the lock.
* Parameters:
* `blocking` – If True, block until lock is acquired. If False, return
immediately.
* `timeout` – Maximum time to wait for lock (ignored if blocking=False).
-1 means wait indefinitely.
* Returns:
True if lock was acquired, False otherwise.
#### block_action()
Persistently record a hook-blocked action.
#### block_message()
Persistently record a hook-blocked user message.
#### classmethod create()
Create a new conversation state or resume from persistence.
This factory method handles both new conversation creation and resumption
from persisted state.
New conversation:
The provided Agent is used directly. Pydantic validation happens via the
cls() constructor.
Restored conversation:
The provided Agent is validated against the persisted agent using
agent.load(). Tools must match (they may have been used in conversation
history), but all other configuration can be freely changed: LLM,
agent_context, condenser, system prompts, etc.
* Parameters:
* `id` – Unique conversation identifier
* `agent` – The Agent to use (tools must match persisted on restore)
* `workspace` – Working directory for agent operations
* `persistence_dir` – Directory for persisting state and events
* `max_iterations` – Maximum iterations per run
* `stuck_detection` – Whether to enable stuck detection
* `cipher` – Optional cipher for encrypting/decrypting secrets in
persisted state. If provided, secrets are encrypted when
saving and decrypted when loading. If not provided, secrets
are redacted (lost) on serialization.
* Returns:
ConversationState ready for use
* Raises:
* `ValueError` – If conversation ID or tools mismatch on restore
* `ValidationError` – If agent or other fields fail Pydantic validation
#### static get_unmatched_actions()
Find actions in the event history that don’t have matching observations.
This method identifies ActionEvents that don’t have corresponding
ObservationEvents or UserRejectObservations, which typically indicates
actions that are pending confirmation or execution.
* Parameters:
`events` – List of events to search through
* Returns:
List of ActionEvent objects that don’t have corresponding observations,
in chronological order
#### locked()
Return True if the lock is currently held by any thread.
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### model_post_init()
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
* Parameters:
* `self` – The BaseModel instance.
* `context` – The context.
#### owned()
Return True if the lock is currently held by the calling thread.
#### pop_blocked_action()
Remove and return a hook-blocked action reason, if present.
#### pop_blocked_message()
Remove and return a hook-blocked message reason, if present.
#### release()
Release the lock.
* Raises:
`RuntimeError` – If the current thread doesn’t own the lock.
#### set_on_state_change()
Set a callback to be called when state changes.
* Parameters:
`callback` – A function that takes an Event (ConversationStateUpdateEvent)
or None to remove the callback
### class ConversationVisualizerBase
Bases: `ABC`
Base class for conversation visualizers.
This abstract base class defines the interface that all conversation visualizers
must implement. Visualizers can be created before the Conversation is initialized
and will be configured with the conversation state automatically.
The typical usage pattern:
1. Create a visualizer instance:
viz = MyVisualizer()
1. Pass it to Conversation: conv = Conversation(agent, visualizer=viz)
2. Conversation automatically calls viz.initialize(state) to attach the state
You can also pass the uninstantiated class if you don’t need extra args
: for initialization, and Conversation will create it:
: conv = Conversation(agent, visualizer=MyVisualizer)
Conversation will then calls MyVisualizer() followed by initialize(state)
#### Properties
- `conversation_stats`: ConversationStats | None
Get conversation stats from the state.
#### Methods
#### __init__()
Initialize the visualizer base.
#### create_sub_visualizer()
Create a visualizer for a sub-agent during delegation.
Override this method to support sub-agent visualization in multi-agent
delegation scenarios. The sub-visualizer will be used to display events
from the spawned sub-agent.
By default, returns None which means sub-agents will not have visualization.
Subclasses that support delegation (like DelegationVisualizer) should
override this method to create appropriate sub-visualizers.
* Parameters:
`agent_id` – The identifier of the sub-agent being spawned
* Returns:
A visualizer instance for the sub-agent, or None if sub-agent
visualization is not supported
#### final initialize()
Initialize the visualizer with conversation state.
This method is called by Conversation after the state is created,
allowing the visualizer to access conversation stats and other
state information.
Subclasses should not override this method, to ensure the state is set.
* Parameters:
`state` – The conversation state object
#### abstractmethod on_event()
Handle a conversation event.
This method is called for each event in the conversation and should
implement the visualization logic.
* Parameters:
`event` – The event to visualize
### class DefaultConversationVisualizer
Bases: [`ConversationVisualizerBase`](#class-conversationvisualizerbase)
Handles visualization of conversation events with Rich formatting.
Provides Rich-formatted output with semantic dividers and complete content display.
#### Methods
#### __init__()
Initialize the visualizer.
* Parameters:
* `highlight_regex` – Dictionary mapping regex patterns to Rich color styles
for highlighting keywords in the visualizer.
For example: (configuration object)
* `skip_user_messages` – If True, skip displaying user messages. Useful for
scenarios where user input is not relevant to show.
#### on_event()
Main event handler that displays events with Rich formatting.
### class EventLog
Bases: [`EventsListBase`](#class-eventslistbase)
Persistent event log with locking for concurrent writes.
This class provides thread-safe and process-safe event storage using
the FileStore’s locking mechanism. Events are persisted to disk and
can be accessed by index or event ID.
#### Methods
#### NOTE
For LocalFileStore, file locking via flock() does NOT work reliably
on NFS mounts or network filesystems. Users deploying with shared
storage should use alternative coordination mechanisms.
#### __init__()
#### append()
Append an event with locking for thread/process safety.
* Raises:
* `TimeoutError` – If the lock cannot be acquired within LOCK_TIMEOUT_SECONDS.
* `ValueError` – If an event with the same ID already exists.
#### get_id()
Return the event_id for a given index.
#### get_index()
Return the integer index for a given event_id.
### class EventsListBase
Bases: `Sequence`[`Event`], `ABC`
Abstract base class for event lists that can be appended to.
This provides a common interface for both local EventLog and remote
RemoteEventsList implementations, avoiding circular imports in protocols.
#### Methods
#### abstractmethod append()
Add a new event to the list.
### class LocalConversation
Bases: [`BaseConversation`](#class-baseconversation)
#### Properties
- `agent`: AgentBase
- `delete_on_close`: bool = True
- `id`: UUID
Get the unique ID of the conversation.
- `llm_registry`: LLMRegistry
- `max_iteration_per_run`: int
- `resolved_plugins`: list[ResolvedPluginSource] | None
Get the resolved plugin sources after plugins are loaded.
Returns None if plugins haven’t been loaded yet, or if no plugins
were specified. Use this for persistence to ensure conversation
resume uses the exact same plugin versions.
- `state`: [ConversationState](#class-conversationstate)
Get the conversation state.
It returns a protocol that has a subset of ConversationState methods
and properties. We will have the ability to access the same properties
of ConversationState on a remote conversation object.
But we won’t be able to access methods that mutate the state.
- `stuck_detector`: [StuckDetector](#class-stuckdetector) | None
Get the stuck detector instance if enabled.
- `workspace`: LocalWorkspace
#### Methods
#### __init__()
Initialize the conversation.
* Parameters:
* `agent` – The agent to use for the conversation.
* `workspace` – Working directory for agent operations and tool execution.
Can be a string path, Path object, or LocalWorkspace instance.
* `plugins` – Optional list of plugins to load. Each plugin is specified
with a source (github:owner/repo, git URL, or local path),
optional ref (branch/tag/commit), and optional repo_path for
monorepos. Plugins are loaded in order with these merge
semantics: skills override by name (last wins), MCP config
override by key (last wins), hooks concatenate (all run).
* `persistence_dir` – Directory for persisting conversation state and events.
Can be a string path or Path object.
* `conversation_id` – Optional ID for the conversation. If provided, will
be used to identify the conversation. The user might want to
suffix their persistent filestore with this ID.
* `callbacks` – Optional list of callback functions to handle events
* `token_callbacks` – Optional list of callbacks invoked for streaming deltas
* `hook_config` – Optional hook configuration to auto-wire session hooks.
If plugins are loaded, their hooks are combined with this config.
* `max_iteration_per_run` – Maximum number of iterations per run
* `visualizer` –
Visualization configuration. Can be:
- ConversationVisualizerBase subclass: Class to instantiate
> (default: ConversationVisualizer)
- ConversationVisualizerBase instance: Use custom visualizer
- None: No visualization
* `stuck_detection` – Whether to enable stuck detection
* `stuck_detection_thresholds` – Optional configuration for stuck detection
thresholds. Can be a StuckDetectionThresholds instance or
a dict with keys: ‘action_observation’, ‘action_error’,
‘monologue’, ‘alternating_pattern’. Values are integers
representing the number of repetitions before triggering.
* `cipher` – Optional cipher for encrypting/decrypting secrets in persisted
state. If provided, secrets are encrypted when saving and
decrypted when loading. If not provided, secrets are redacted
(lost) on serialization.
#### ask_agent()
Ask the agent a simple, stateless question and get a direct LLM response.
This bypasses the normal conversation flow and does not modify, persist,
or become part of the conversation state. The request is not remembered by
the main agent, no events are recorded, and execution status is untouched.
It is also thread-safe and may be called while conversation.run() is
executing in another thread.
* Parameters:
`question` – A simple string question to ask the agent
* Returns:
A string response from the agent
#### close()
Close the conversation and clean up all tool executors.
#### condense()
Synchronously force condense the conversation history.
If the agent is currently running, condense() will wait for the
ongoing step to finish before proceeding.
Raises ValueError if no compatible condenser exists.
#### property conversation_stats
#### execute_tool()
Execute a tool directly without going through the agent loop.
This method allows executing tools before or outside of the normal
conversation.run() flow. It handles agent initialization automatically,
so tools can be executed before the first run() call.
Note: This method bypasses the agent loop, including confirmation
policies and security analyzer checks. Callers are responsible for
applying any safeguards before executing potentially destructive tools.
This is useful for:
- Pre-run setup operations (e.g., indexing repositories)
- Manual tool execution for environment setup
- Testing tool behavior outside the agent loop
* Parameters:
* `tool_name` – The name of the tool to execute (e.g., “sleeptime_compute”)
* `action` – The action to pass to the tool executor
* Returns:
The observation returned by the tool execution
* Raises:
* `KeyError` – If the tool is not found in the agent’s tools
* `NotImplementedError` – If the tool has no executor
#### generate_title()
Generate a title for the conversation based on the first user message.
* Parameters:
* `llm` – Optional LLM to use for title generation. If not provided,
uses self.agent.llm.
* `max_length` – Maximum length of the generated title.
* Returns:
A generated title for the conversation.
* Raises:
`ValueError` – If no user messages are found in the conversation.
#### pause()
Pause agent execution.
This method can be called from any thread to request that the agent
pause execution. The pause will take effect at the next iteration
of the run loop (between agent steps).
Note: If called during an LLM completion, the pause will not take
effect until the current LLM call completes.
#### reject_pending_actions()
Reject all pending actions from the agent.
This is a non-invasive method to reject actions between run() calls.
Also clears the agent_waiting_for_confirmation flag.
#### run()
Runs the conversation until the agent finishes.
In confirmation mode:
- First call: creates actions but doesn’t execute them, stops and waits
- Second call: executes pending actions (implicit confirmation)
In normal mode:
- Creates and executes actions immediately
Can be paused between steps
#### send_message()
Send a message to the agent.
* Parameters:
* `message` – Either a string (which will be converted to a user message)
or a Message object
* `sender` – Optional identifier of the sender. Can be used to track
message origin in multi-agent scenarios. For example, when
one agent delegates to another, the sender can be set to
identify which agent is sending the message.
#### set_confirmation_policy()
Set the confirmation policy and store it in conversation state.
#### set_security_analyzer()
Set the security analyzer for the conversation.
#### update_secrets()
Add secrets to the conversation.
* Parameters:
`secrets` – Dictionary mapping secret keys to values or no-arg callables.
SecretValue = str | Callable[[], str]. Callables are invoked lazily
when a command references the secret key.
### class RemoteConversation
Bases: [`BaseConversation`](#class-baseconversation)
#### Properties
- `agent`: AgentBase
- `delete_on_close`: bool = False
- `id`: UUID
- `max_iteration_per_run`: int
- `state`: RemoteState
Access to remote conversation state.
- `workspace`: RemoteWorkspace
#### Methods
#### __init__()
Remote conversation proxy that talks to an agent server.
* Parameters:
* `agent` – Agent configuration (will be sent to the server)
* `workspace` – The working directory for agent operations and tool execution.
* `plugins` – Optional list of plugins to load on the server. Each plugin
is a PluginSource specifying source, ref, and repo_path.
* `conversation_id` – Optional existing conversation id to attach to
* `callbacks` – Optional callbacks to receive events (not yet streamed)
* `max_iteration_per_run` – Max iterations configured on server
* `stuck_detection` – Whether to enable stuck detection on server
* `stuck_detection_thresholds` – Optional configuration for stuck detection
thresholds. Can be a StuckDetectionThresholds instance or
a dict with keys: ‘action_observation’, ‘action_error’,
‘monologue’, ‘alternating_pattern’. Values are integers
representing the number of repetitions before triggering.
* `hook_config` – Optional hook configuration for session hooks
* `visualizer` –
Visualization configuration. Can be:
- ConversationVisualizerBase subclass: Class to instantiate
> (default: ConversationVisualizer)
- ConversationVisualizerBase instance: Use custom visualizer
- None: No visualization
* `secrets` – Optional secrets to initialize the conversation with
#### ask_agent()
Ask the agent a simple, stateless question and get a direct LLM response.
This bypasses the normal conversation flow and does not modify, persist,
or become part of the conversation state. The request is not remembered by
the main agent, no events are recorded, and execution status is untouched.
It is also thread-safe and may be called while conversation.run() is
executing in another thread.
* Parameters:
`question` – A simple string question to ask the agent
* Returns:
A string response from the agent
#### close()
Close the conversation and clean up resources.
Note: We don’t close self._client here because it’s shared with the workspace.
The workspace owns the client and will close it during its own cleanup.
Closing it here would prevent the workspace from making cleanup API calls.
#### condense()
Force condensation of the conversation history.
This method sends a condensation request to the remote agent server.
The server will use the existing condensation request pattern to trigger
condensation if a condenser is configured and handles condensation requests.
The condensation will be applied on the server side and will modify the
conversation state by adding a condensation event to the history.
* Raises:
`HTTPError` – If the server returns an error (e.g., no condenser configured).
#### property conversation_stats
#### execute_tool()
Execute a tool directly without going through the agent loop.
Note: This method is not yet supported for RemoteConversation.
Tool execution for remote conversations happens on the server side
during the normal agent loop.
* Parameters:
* `tool_name` – The name of the tool to execute
* `action` – The action to pass to the tool executor
* Raises:
`NotImplementedError` – Always, as this feature is not yet supported
for remote conversations.
#### generate_title()
Generate a title for the conversation based on the first user message.
* Parameters:
* `llm` – Optional LLM to use for title generation. If provided, its usage_id
will be sent to the server. If not provided, uses the agent’s LLM.
* `max_length` – Maximum length of the generated title.
* Returns:
A generated title for the conversation.
#### pause()
#### reject_pending_actions()
#### run()
Trigger a run on the server.
* Parameters:
* `blocking` – If True (default), wait for the run to complete by polling
the server. If False, return immediately after triggering the run.
* `poll_interval` – Time in seconds between status polls (only used when
blocking=True). Default is 1.0 second.
* `timeout` – Maximum time in seconds to wait for the run to complete
(only used when blocking=True). Default is 3600 seconds.
* Raises:
`ConversationRunError` – If the run fails or times out.
#### send_message()
Send a message to the agent.
* Parameters:
* `message` – Either a string (which will be converted to a user message)
or a Message object
* `sender` – Optional identifier of the sender. Can be used to track
message origin in multi-agent scenarios. For example, when
one agent delegates to another, the sender can be set to
identify which agent is sending the message.
#### set_confirmation_policy()
Set the confirmation policy for the conversation.
#### set_security_analyzer()
Set the security analyzer for the remote conversation.
#### property stuck_detector
Stuck detector for compatibility.
Not implemented for remote conversations.
#### update_secrets()
### class SecretRegistry
Bases: `OpenHandsModel`
Manages secrets and injects them into bash commands when needed.
The secret registry stores a mapping of secret keys to SecretSources
that retrieve the actual secret values. When a bash command is about to be
executed, it scans the command for any secret keys and injects the corresponding
environment variables.
Secret sources will redact / encrypt their sensitive values as appropriate when
serializing, depending on the content of the context. If a context is present
and contains a ‘cipher’ object, this is used for encryption. If it contains a
boolean ‘expose_secrets’ flag set to True, secrets are dunped in plain text.
Otherwise secrets are redacted.
Additionally, it tracks the latest exported values to enable consistent masking
even when callable secrets fail on subsequent calls.
#### Properties
- `secret_sources`: dict[str, SecretSource]
#### Methods
#### find_secrets_in_text()
Find all secret keys mentioned in the given text.
* Parameters:
`text` – The text to search for secret keys
* Returns:
Set of secret keys found in the text
#### get_secrets_as_env_vars()
Get secrets that should be exported as environment variables for a command.
* Parameters:
`command` – The bash command to check for secret references
* Returns:
Dictionary of environment variables to export (key -> value)
#### mask_secrets_in_output()
Mask secret values in the given text.
This method uses both the current exported values and attempts to get
fresh values from callables to ensure comprehensive masking.
* Parameters:
`text` – The text to mask secrets in
* Returns:
Text with secret values replaced by ``
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### model_post_init()
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
* Parameters:
* `self` – The BaseModel instance.
* `context` – The context.
#### update_secrets()
Add or update secrets in the manager.
* Parameters:
`secrets` – Dictionary mapping secret keys to either string values
or callable functions that return string values
### class StuckDetector
Bases: `object`
Detects when an agent is stuck in repetitive or unproductive patterns.
This detector analyzes the conversation history to identify various stuck patterns:
1. Repeating action-observation cycles
2. Repeating action-error cycles
3. Agent monologue (repeated messages without user input)
4. Repeating alternating action-observation patterns
5. Context window errors indicating memory issues
#### Properties
- `action_error_threshold`: int
- `action_observation_threshold`: int
- `alternating_pattern_threshold`: int
- `monologue_threshold`: int
- `state`: [ConversationState](#class-conversationstate)
- `thresholds`: StuckDetectionThresholds
#### Methods
#### __init__()
#### is_stuck()
Check if the agent is currently stuck.
Note: To avoid materializing potentially large file-backed event histories,
only the last MAX_EVENTS_TO_SCAN_FOR_STUCK_DETECTION events are analyzed.
If a user message exists within this window, only events after it are checked.
Otherwise, all events in the window are analyzed.
#### __init__()
### openhands.sdk.event
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.event.md
### class ActionEvent
Bases: [`LLMConvertibleEvent`](#class-llmconvertibleevent)
#### Properties
- `action`: Action | None
- `critic_result`: CriticResult | None
- `llm_response_id`: str
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `reasoning_content`: str | None
- `responses_reasoning_item`: ReasoningItemModel | None
- `security_risk`: SecurityRisk
- `source`: Literal['agent', 'user', 'environment']
- `summary`: str | None
- `thinking_blocks`: list[ThinkingBlock | RedactedThinkingBlock]
- `thought`: Sequence[TextContent]
- `tool_call`: MessageToolCall
- `tool_call_id`: str
- `tool_name`: str
- `visualize`: Text
Return Rich Text representation of this action event.
#### Methods
#### to_llm_message()
Individual message - may be incomplete for multi-action batches
### class AgentErrorEvent
Bases: [`ObservationBaseEvent`](#class-observationbaseevent)
Error triggered by the agent.
Note: This event should not contain model “thought” or “reasoning_content”. It
represents an error produced by the agent/scaffold, not model output.
#### Properties
- `error`: str
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: Literal['agent', 'user', 'environment']
- `visualize`: Text
Return Rich Text representation of this agent error event.
#### Methods
#### to_llm_message()
### class Condensation
Bases: [`Event`](#class-event)
This action indicates a condensation of the conversation history is happening.
#### Properties
- `forgotten_event_ids`: list[[EventID](#class-eventid)]
- `has_summary_metadata`: bool
Checks if both summary and summary_offset are present.
- `llm_response_id`: [EventID](#class-eventid)
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: SourceType
- `summary`: str | None
- `summary_event`: [CondensationSummaryEvent](#class-condensationsummaryevent)
Generates a CondensationSummaryEvent.
Since summary events are not part of the main event store and are generated
dynamically, this property ensures the created event has a unique and consistent
ID based on the condensation event’s ID.
* Raises:
`ValueError` – If no summary is present.
- `summary_offset`: int | None
- `visualize`: Text
Return Rich Text representation of this event.
This is a fallback implementation for unknown event types.
Subclasses should override this method to provide specific visualization.
#### Methods
#### apply()
Applies the condensation to a list of events.
This method removes events that are marked to be forgotten and returns a new
list of events. If the summary metadata is present (both summary and offset),
the corresponding CondensationSummaryEvent will be inserted at the specified
offset _after_ the forgotten events have been removed.
### class CondensationRequest
Bases: [`Event`](#class-event)
This action is used to request a condensation of the conversation history.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: SourceType
- `visualize`: Text
Return Rich Text representation of this event.
This is a fallback implementation for unknown event types.
Subclasses should override this method to provide specific visualization.
#### Methods
#### action
The action type, namely ActionType.CONDENSATION_REQUEST.
* Type:
str
### class CondensationSummaryEvent
Bases: [`LLMConvertibleEvent`](#class-llmconvertibleevent)
This event represents a summary generated by a condenser.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: SourceType
- `summary`: str
The summary text.
#### Methods
#### to_llm_message()
### class ConversationStateUpdateEvent
Bases: [`Event`](#class-event)
Event that contains conversation state updates.
This event is sent via websocket whenever the conversation state changes,
allowing remote clients to stay in sync without making REST API calls.
All fields are serialized versions of the corresponding ConversationState fields
to ensure compatibility with websocket transmission.
#### Properties
- `key`: str
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: Literal['agent', 'user', 'environment']
- `value`: Any
#### Methods
#### classmethod from_conversation_state()
Create a state update event from a ConversationState object.
This creates an event containing a snapshot of important state fields.
* Parameters:
* `state` – The ConversationState to serialize
* `conversation_id` – The conversation ID for the event
* Returns:
A ConversationStateUpdateEvent with serialized state data
#### classmethod validate_key()
#### classmethod validate_value()
### class Event
Bases: `DiscriminatedUnionMixin`, `ABC`
Base class for all events.
#### Properties
- `id`: str
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: Literal['agent', 'user', 'environment']
- `timestamp`: str
- `visualize`: Text
Return Rich Text representation of this event.
This is a fallback implementation for unknown event types.
Subclasses should override this method to provide specific visualization.
### class LLMCompletionLogEvent
Bases: [`Event`](#class-event)
Event containing LLM completion log data.
When an LLM is configured with log_completions=True in a remote conversation,
this event streams the completion log data back to the client through WebSocket
instead of writing it to a file inside the Docker container.
#### Properties
- `filename`: str
- `log_data`: str
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `model_name`: str
- `source`: Literal['agent', 'user', 'environment']
- `usage_id`: str
### class LLMConvertibleEvent
Bases: [`Event`](#class-event), `ABC`
Base class for events that can be converted to LLM messages.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### Methods
#### static events_to_messages()
Convert event stream to LLM message stream, handling multi-action batches
#### abstractmethod to_llm_message()
### class MessageEvent
Bases: [`LLMConvertibleEvent`](#class-llmconvertibleevent)
Message from either agent or user.
This is originally the “MessageAction”, but it suppose not to be tool call.
#### Properties
- `activated_skills`: list[str]
- `critic_result`: CriticResult | None
- `extended_content`: list[TextContent]
- `llm_message`: Message
- `llm_response_id`: str | None
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `reasoning_content`: str
- `sender`: str | None
- `source`: Literal['agent', 'user', 'environment']
- `thinking_blocks`: Sequence[ThinkingBlock | RedactedThinkingBlock]
Return the Anthropic thinking blocks from the LLM message.
- `visualize`: Text
Return Rich Text representation of this message event.
#### Methods
#### to_llm_message()
### class ObservationBaseEvent
Bases: [`LLMConvertibleEvent`](#class-llmconvertibleevent)
Base class for anything as a response to a tool call.
Examples include tool execution, error, user reject.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: Literal['agent', 'user', 'environment']
- `tool_call_id`: str
- `tool_name`: str
### class ObservationEvent
Bases: [`ObservationBaseEvent`](#class-observationbaseevent)
#### Properties
- `action_id`: str
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `observation`: Observation
- `visualize`: Text
Return Rich Text representation of this observation event.
#### Methods
#### to_llm_message()
### class PauseEvent
Bases: [`Event`](#class-event)
Event indicating that the agent execution was paused by user request.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: Literal['agent', 'user', 'environment']
- `visualize`: Text
Return Rich Text representation of this pause event.
### class SystemPromptEvent
Bases: [`LLMConvertibleEvent`](#class-llmconvertibleevent)
System prompt added by the agent.
The system prompt can optionally include dynamic context that varies between
conversations. When `dynamic_context` is provided, it is included as a
second content block in the same system message. Cache markers are NOT
applied here - they are applied by `LLM._apply_prompt_caching()` when
caching is enabled, ensuring provider-specific cache control is only added
when appropriate.
#### Properties
- `dynamic_context`: TextContent | None
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `source`: Literal['agent', 'user', 'environment']
- `system_prompt`: TextContent
- `tools`: list[ToolDefinition]
- `visualize`: Text
Return Rich Text representation of this system prompt event.
#### Methods
#### system_prompt
The static system prompt text (cacheable across conversations)
* Type:
openhands.sdk.llm.message.TextContent
#### tools
List of available tools
* Type:
list[openhands.sdk.tool.tool.ToolDefinition]
#### dynamic_context
Optional per-conversation context (hosts, repo info, etc.)
Sent as a second TextContent block inside the system message.
* Type:
openhands.sdk.llm.message.TextContent | None
#### to_llm_message()
Convert to a single system LLM message.
When `dynamic_context` is present the message contains two content
blocks: the static prompt followed by the dynamic context. Cache markers
are NOT applied here - they are applied by `LLM._apply_prompt_caching()`
when caching is enabled, which marks the static block (index 0) and leaves
the dynamic block (index 1) unmarked for cross-conversation cache sharing.
### class TokenEvent
Bases: [`Event`](#class-event)
Event from VLLM representing token IDs used in LLM interaction.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `prompt_token_ids`: list[int]
- `response_token_ids`: list[int]
- `source`: Literal['agent', 'user', 'environment']
### class UserRejectObservation
Bases: [`ObservationBaseEvent`](#class-observationbaseevent)
Observation when an action is rejected by user or hook.
This event is emitted when:
- User rejects an action during confirmation mode (rejection_source=”user”)
- A PreToolUse hook blocks an action (rejection_source=”hook”)
#### Properties
- `action_id`: str
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `rejection_reason`: str
- `rejection_source`: Literal['user', 'hook']
- `visualize`: Text
Return Rich Text representation of this user rejection event.
#### Methods
#### to_llm_message()
### openhands.sdk.llm
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.llm.md
### class CredentialStore
Bases: `object`
Store and retrieve OAuth credentials for LLM providers.
#### Properties
- `credentials_dir`: Path
Get the credentials directory, creating it if necessary.
#### Methods
#### __init__()
Initialize the credential store.
* Parameters:
`credentials_dir` – Optional custom directory for storing credentials.
Defaults to ~/.local/share/openhands/auth/
#### delete()
Delete stored credentials for a vendor.
* Parameters:
`vendor` – The vendor/provider name
* Returns:
True if credentials were deleted, False if they didn’t exist
#### get()
Get stored credentials for a vendor.
* Parameters:
`vendor` – The vendor/provider name (e.g., ‘openai’)
* Returns:
OAuthCredentials if found and valid, None otherwise
#### save()
Save credentials for a vendor.
* Parameters:
`credentials` – The OAuth credentials to save
#### update_tokens()
Update tokens for an existing credential.
* Parameters:
* `vendor` – The vendor/provider name
* `access_token` – New access token
* `refresh_token` – New refresh token (if provided)
* `expires_in` – Token expiry in seconds
* Returns:
Updated credentials, or None if no existing credentials found
### class ImageContent
Bases: `BaseContent`
#### Properties
- `image_urls`: list[str]
- `type`: Literal['image']
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### to_llm_dict()
Convert to LLM API format.
### class LLM
Bases: `BaseModel`, `RetryMixin`, `NonNativeToolCallingMixin`
Language model interface for OpenHands agents.
The LLM class provides a unified interface for interacting with various
language models through the litellm library. It handles model configuration,
API authentication,
retry logic, and tool calling capabilities.
#### Example
```pycon
>>> from openhands.sdk import LLM
>>> from pydantic import SecretStr
>>> llm = LLM(
... model="claude-sonnet-4-20250514",
... api_key=SecretStr("your-api-key"),
... usage_id="my-agent"
... )
>>> # Use with agent or conversation
```
#### Properties
- `api_key`: str | SecretStr | None
- `api_version`: str | None
- `aws_access_key_id`: str | SecretStr | None
- `aws_region_name`: str | None
- `aws_secret_access_key`: str | SecretStr | None
- `base_url`: str | None
- `caching_prompt`: bool
- `custom_tokenizer`: str | None
- `disable_stop_word`: bool | None
- `disable_vision`: bool | None
- `drop_params`: bool
- `enable_encrypted_reasoning`: bool
- `extended_thinking_budget`: int | None
- `extra_headers`: dict[str, str] | None
- `force_string_serializer`: bool | None
- `input_cost_per_token`: float | None
- `is_subscription`: bool
Check if this LLM uses subscription-based authentication.
Returns True when the LLM was created via LLM.subscription_login(),
which uses the ChatGPT subscription Codex backend rather than the
standard OpenAI API.
* Returns:
True if using subscription-based transport, False otherwise.
* Return type:
bool
- `litellm_extra_body`: dict[str, Any]
- `log_completions`: bool
- `log_completions_folder`: str
- `max_input_tokens`: int | None
- `max_message_chars`: int
- `max_output_tokens`: int | None
- `metrics`: [Metrics](#class-metrics)
Get usage metrics for this LLM instance.
* Returns:
Metrics object containing token usage, costs, and other statistics.
- `model`: str
- `model_canonical_name`: str | None
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `model_info`: dict | None
Returns the model info dictionary.
- `modify_params`: bool
- `native_tool_calling`: bool
- `num_retries`: int
- `ollama_base_url`: str | None
- `openrouter_app_name`: str
- `openrouter_site_url`: str
- `output_cost_per_token`: float | None
- `prompt_cache_retention`: str | None
- `reasoning_effort`: Literal['low', 'medium', 'high', 'xhigh', 'none'] | None
- `reasoning_summary`: Literal['auto', 'concise', 'detailed'] | None
- `retry_listener`: SkipJsonSchema[Callable[[int, int, BaseException | None], None] | None]
- `retry_max_wait`: int
- `retry_min_wait`: int
- `retry_multiplier`: float
- `safety_settings`: list[dict[str, str]] | None
- `seed`: int | None
- `stream`: bool
- `telemetry`: Telemetry
Get telemetry handler for this LLM instance.
* Returns:
Telemetry object for managing logging and metrics callbacks.
- `temperature`: float | None
- `timeout`: int | None
- `top_k`: float | None
- `top_p`: float | None
- `usage_id`: str
#### Methods
#### completion()
Generate a completion from the language model.
This is the method for getting responses from the model via Completion API.
It handles message formatting, tool calling, and response processing.
* Parameters:
* `messages` – List of conversation messages
* `tools` – Optional list of tools available to the model
* `_return_metrics` – Whether to return usage metrics
* `add_security_risk_prediction` – Add security_risk field to tool schemas
* `on_token` – Optional callback for streaming tokens
kwargs* – Additional arguments passed to the LLM API
* Returns:
LLMResponse containing the model’s response and metadata.
#### NOTE
Summary field is always added to tool schemas for transparency and
explainability of agent actions.
* Raises:
`ValueError` – If streaming is requested (not supported).
#### format_messages_for_llm()
Formats Message objects for LLM consumption.
#### format_messages_for_responses()
Prepare (instructions, input[]) for the OpenAI Responses API.
- Skips prompt caching flags and string serializer concerns
- Uses Message.to_responses_value to get either instructions (system)
or input items (others)
- Concatenates system instructions into a single instructions string
- For subscription mode, system prompts are prepended to user content
#### get_token_count()
#### is_caching_prompt_active()
Check if prompt caching is supported and enabled for current model.
* Returns:
True if prompt caching is supported and enabled for the given
: model.
* Return type:
boolean
#### classmethod load_from_env()
#### classmethod load_from_json()
#### model_post_init()
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
* Parameters:
* `self` – The BaseModel instance.
* `context` – The context.
#### reset_metrics()
Reset metrics and telemetry to fresh instances.
This is used by the LLMRegistry to ensure each registered LLM has
independent metrics, preventing metrics from being shared between
LLMs that were created via model_copy().
When an LLM is copied (e.g., to create a condenser LLM from an agent LLM),
Pydantic’s model_copy() does a shallow copy of private attributes by default,
causing the original and copied LLM to share the same Metrics object.
This method allows the registry to fix this by resetting metrics to None,
which will be lazily recreated when accessed.
#### responses()
Alternative invocation path using OpenAI Responses API via LiteLLM.
Maps Message[] -> (instructions, input[]) and returns LLMResponse.
* Parameters:
* `messages` – List of conversation messages
* `tools` – Optional list of tools available to the model
* `include` – Optional list of fields to include in response
* `store` – Whether to store the conversation
* `_return_metrics` – Whether to return usage metrics
* `add_security_risk_prediction` – Add security_risk field to tool schemas
* `on_token` – Optional callback for streaming deltas
kwargs* – Additional arguments passed to the API
#### NOTE
Summary field is always added to tool schemas for transparency and
explainability of agent actions.
#### restore_metrics()
#### classmethod subscription_login()
Authenticate with a subscription service and return an LLM instance.
This method provides subscription-based access to LLM models that are
available through chat subscriptions (e.g., ChatGPT Plus/Pro) rather
than API credits. It handles credential caching, token refresh, and
the OAuth login flow.
Currently supported vendors:
- “openai”: ChatGPT Plus/Pro subscription for Codex models
Supported OpenAI models:
- gpt-5.1-codex-max
- gpt-5.1-codex-mini
- gpt-5.2
- gpt-5.2-codex
* Parameters:
* `vendor` – The vendor/provider. Currently only “openai” is supported.
* `model` – The model to use. Must be supported by the vendor’s
subscription service.
* `force_login` – If True, always perform a fresh login even if valid
credentials exist.
* `open_browser` – Whether to automatically open the browser for the
OAuth login flow.
llm_kwargs* – Additional arguments to pass to the LLM constructor.
* Returns:
An LLM instance configured for subscription-based access.
* Raises:
* `ValueError` – If the vendor or model is not supported.
* `RuntimeError` – If authentication fails.
#### uses_responses_api()
Whether this model uses the OpenAI Responses API path.
#### vision_is_active()
### class LLMProfileStore
Bases: `object`
Standalone utility for persisting LLM configurations.
#### Methods
#### __init__()
Initialize the profile store.
* Parameters:
`base_dir` – Path to the directory where the profiles are stored.
If None is provided, the default directory is used, i.e.,
~/.openhands/profiles.
#### delete()
Delete an existing profile.
If the profile is not present in the profile directory, it does nothing.
* Parameters:
`name` – Name of the profile to delete.
* Raises:
`TimeoutError` – If the lock cannot be acquired.
#### list()
Returns a list of all profiles stored.
* Returns:
List of profile filenames (e.g., [“default.json”, “gpt4.json”]).
#### load()
Load an LLM instance from the given profile name.
* Parameters:
`name` – Name of the profile to load.
* Returns:
An LLM instance constructed from the profile configuration.
* Raises:
* `FileNotFoundError` – If the profile name does not exist.
* `ValueError` – If the profile file is corrupted or invalid.
* `TimeoutError` – If the lock cannot be acquired.
#### save()
Save a profile to the profile directory.
Note that if a profile name already exists, it will be overwritten.
* Parameters:
* `name` – Name of the profile to save.
* `llm` – LLM instance to save
* `include_secrets` – Whether to include the profile secrets. Defaults to False.
* Raises:
`TimeoutError` – If the lock cannot be acquired.
### class LLMRegistry
Bases: `object`
A minimal LLM registry for managing LLM instances by usage ID.
This registry provides a simple way to manage multiple LLM instances,
avoiding the need to recreate LLMs with the same configuration.
The registry also ensures that each registered LLM has independent metrics,
preventing metrics from being shared between LLMs that were created via
model_copy(). This is important for scenarios like creating a condenser LLM
from an agent LLM, where each should track its own usage independently.
#### Properties
- `registry_id`: str
- `retry_listener`: Callable[[int, int], None] | None
- `subscriber`: Callable[[[RegistryEvent](#class-registryevent)], None] | None
- `usage_to_llm`: MappingProxyType
Access the internal usage-ID-to-LLM mapping (read-only view).
#### Methods
#### __init__()
Initialize the LLM registry.
* Parameters:
`retry_listener` – Optional callback for retry events.
#### add()
Add an LLM instance to the registry.
This method ensures that the LLM has independent metrics before
registering it. If the LLM’s metrics are shared with another
registered LLM (e.g., due to model_copy()), fresh metrics will
be created automatically.
* Parameters:
`llm` – The LLM instance to register.
* Raises:
`ValueError` – If llm.usage_id already exists in the registry.
#### get()
Get an LLM instance from the registry.
* Parameters:
`usage_id` – Unique identifier for the LLM usage slot.
* Returns:
The LLM instance.
* Raises:
`KeyError` – If usage_id is not found in the registry.
#### list_usage_ids()
List all registered usage IDs.
#### notify()
Notify subscribers of registry events.
* Parameters:
`event` – The registry event to notify about.
#### subscribe()
Subscribe to registry events.
* Parameters:
`callback` – Function to call when LLMs are created or updated.
### class LLMResponse
Bases: `BaseModel`
Result of an LLM completion request.
This type provides a clean interface for LLM completion results, exposing
only OpenHands-native types to consumers while preserving access to the
raw LiteLLM response for internal use.
#### Properties
- `id`: str
Get the response ID from the underlying LLM response.
This property provides a clean interface to access the response ID,
supporting both completion mode (ModelResponse) and response API modes
(ResponsesAPIResponse).
* Returns:
The response ID from the LLM response
- `message`: [Message](#class-message)
- `metrics`: [MetricsSnapshot](#class-metricssnapshot)
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `raw_response`: ModelResponse | ResponsesAPIResponse
#### Methods
#### message
The completion message converted to OpenHands Message type
* Type:
[openhands.sdk.llm.message.Message](#class-message)
#### metrics
Snapshot of metrics from the completion request
* Type:
[openhands.sdk.llm.utils.metrics.MetricsSnapshot](#class-metricssnapshot)
#### raw_response
The original LiteLLM response (ModelResponse or
ResponsesAPIResponse) for internal use
* Type:
litellm.types.utils.ModelResponse | litellm.types.llms.openai.ResponsesAPIResponse
### class Message
Bases: `BaseModel`
#### Properties
- `contains_image`: bool
- `content`: Sequence[[TextContent](#class-textcontent) | [ImageContent](#class-imagecontent)]
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `name`: str | None
- `reasoning_content`: str | None
- `responses_reasoning_item`: [ReasoningItemModel](#class-reasoningitemmodel) | None
- `role`: Literal['user', 'system', 'assistant', 'tool']
- `thinking_blocks`: Sequence[[ThinkingBlock](#class-thinkingblock) | [RedactedThinkingBlock](#class-redactedthinkingblock)]
- `tool_call_id`: str | None
- `tool_calls`: list[[MessageToolCall](#class-messagetoolcall)] | None
#### Methods
#### classmethod from_llm_chat_message()
Convert a LiteLLMMessage (Chat Completions) to our Message class.
Provider-agnostic mapping for reasoning:
- Prefer message.reasoning_content if present (LiteLLM normalized field)
- Extract thinking_blocks from content array (Anthropic-specific)
#### classmethod from_llm_responses_output()
Convert OpenAI Responses API output items into a single assistant Message.
Policy (non-stream):
- Collect assistant text by concatenating output_text parts from message items
- Normalize function_call items to MessageToolCall list
#### to_chat_dict()
Serialize message for OpenAI Chat Completions.
* Parameters:
* `cache_enabled` – Whether prompt caching is active.
* `vision_enabled` – Whether vision/image processing is enabled.
* `function_calling_enabled` – Whether native function calling is enabled.
* `force_string_serializer` – Force string serializer instead of list format.
* `send_reasoning_content` – Whether to include reasoning_content in output.
Chooses the appropriate content serializer and then injects threading keys:
- Assistant tool call turn: role == “assistant” and self.tool_calls
- Tool result turn: role == “tool” and self.tool_call_id (with name)
#### to_responses_dict()
Serialize message for OpenAI Responses (input parameter).
Produces a list of “input” items for the Responses API:
- system: returns [], system content is expected in ‘instructions’
- user: one ‘message’ item with content parts -> input_text / input_image
(when vision enabled)
- assistant: emits prior assistant content as input_text,
and function_call items for tool_calls
- tool: emits function_call_output items (one per TextContent)
with matching call_id
#### to_responses_value()
Return serialized form.
Either an instructions string (for system) or input items (for other roles).
### class MessageToolCall
Bases: `BaseModel`
Transport-agnostic tool call representation.
One canonical id is used for linking across actions/observations and
for Responses function_call_output call_id.
#### Properties
- `arguments`: str
- `id`: str
- `name`: str
- `origin`: Literal['completion', 'responses']
- `costs`: list[Cost]
- `response_latencies`: list[ResponseLatency]
- `token_usages`: list[TokenUsage]
#### Methods
#### classmethod from_chat_tool_call()
Create a MessageToolCall from a Chat Completions tool call.
#### classmethod from_responses_function_call()
Create a MessageToolCall from a typed OpenAI Responses function_call item.
Note: OpenAI Responses function_call.arguments is already a JSON string.
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### to_chat_dict()
Serialize to OpenAI Chat Completions tool_calls format.
#### to_responses_dict()
Serialize to OpenAI Responses ‘function_call’ input item format.
#### add_cost()
#### add_response_latency()
#### add_token_usage()
Add a single usage record.
#### deep_copy()
Create a deep copy of the Metrics object.
#### diff()
Calculate the difference between current metrics and a baseline.
This is useful for tracking metrics for specific operations like delegates.
* Parameters:
`baseline` – A metrics object representing the baseline state
* Returns:
A new Metrics object containing only the differences since the baseline
#### get()
Return the metrics in a dictionary.
#### get_snapshot()
Get a snapshot of the current metrics without the detailed lists.
#### initialize_accumulated_token_usage()
#### log()
Log the metrics.
#### merge()
Merge ‘other’ metrics into this one.
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### classmethod validate_accumulated_cost()
### class MetricsSnapshot
Bases: `BaseModel`
A snapshot of metrics at a point in time.
Does not include lists of individual costs, latencies, or token usages.
#### Properties
- `accumulated_cost`: float
- `accumulated_token_usage`: TokenUsage | None
- `max_budget_per_task`: float | None
- `model_name`: str
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class OAuthCredentials
Bases: `BaseModel`
OAuth credentials for subscription-based LLM access.
#### Properties
- `access_token`: str
- `expires_at`: int
- `refresh_token`: str
- `type`: Literal['oauth']
- `vendor`: str
#### Methods
#### is_expired()
Check if the access token is expired.
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class OpenAISubscriptionAuth
Bases: `object`
Handle OAuth authentication for OpenAI ChatGPT subscription access.
#### Properties
- `vendor`: str
Get the vendor name.
#### Methods
#### __init__()
Initialize the OpenAI subscription auth handler.
* Parameters:
* `credential_store` – Optional custom credential store.
* `oauth_port` – Port for the local OAuth callback server.
#### create_llm()
Create an LLM instance configured for Codex subscription access.
* Parameters:
* `model` – The model to use (must be in OPENAI_CODEX_MODELS).
* `credentials` – OAuth credentials to use. If None, uses stored credentials.
* `instructions` – Optional instructions for the Codex model.
llm_kwargs* – Additional arguments to pass to LLM constructor.
* Returns:
An LLM instance configured for Codex access.
* Raises:
`ValueError` – If the model is not supported or no credentials available.
#### get_credentials()
Get stored credentials if they exist.
#### has_valid_credentials()
Check if valid (non-expired) credentials exist.
#### async login()
Perform OAuth login flow.
This starts a local HTTP server to handle the OAuth callback,
opens the browser for user authentication, and waits for the
callback with the authorization code.
* Parameters:
`open_browser` – Whether to automatically open the browser.
* Returns:
The obtained OAuth credentials.
* Raises:
`RuntimeError` – If the OAuth flow fails or times out.
#### logout()
Remove stored credentials.
* Returns:
True if credentials were removed, False if none existed.
#### async refresh_if_needed()
Refresh credentials if they are expired.
* Returns:
Updated credentials, or None if no credentials exist.
* Raises:
`RuntimeError` – If token refresh fails.
### class ReasoningItemModel
Bases: `BaseModel`
OpenAI Responses reasoning item (non-stream, subset we consume).
Do not log or render encrypted_content.
#### Properties
- `content`: list[str] | None
- `encrypted_content`: str | None
- `id`: str | None
- `status`: str | None
- `summary`: list[str]
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class RedactedThinkingBlock
Bases: `BaseModel`
Redacted thinking block for previous responses without extended thinking.
This is used as a placeholder for assistant messages that were generated
before extended thinking was enabled.
#### Properties
- `data`: str
- `type`: Literal['redacted_thinking']
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class RegistryEvent
Bases: `BaseModel`
#### Properties
- `llm`: [LLM](#class-llm)
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class RouterLLM
Bases: [`LLM`](#class-llm)
Base class for multiple LLM acting as a unified LLM.
This class provides a foundation for implementing model routing by
inheriting from LLM, allowing routers to work with multiple underlying
LLM models while presenting a unified LLM interface to consumers.
Key features:
- Works with multiple LLMs configured via llms_for_routing
- Delegates all other operations/properties to the selected LLM
- Provides routing interface through select_llm() method
#### Properties
- `active_llm`: [LLM](#class-llm) | None
- `llms_for_routing`: dict[str, [LLM](#class-llm)]
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `router_name`: str
#### Methods
#### completion()
This method intercepts completion calls and routes them to the appropriate
underlying LLM based on the routing logic implemented in select_llm().
* Parameters:
* `messages` – List of conversation messages
* `tools` – Optional list of tools available to the model
* `return_metrics` – Whether to return usage metrics
* `add_security_risk_prediction` – Add security_risk field to tool schemas
* `on_token` – Optional callback for streaming tokens
kwargs* – Additional arguments passed to the LLM API
#### NOTE
Summary field is always added to tool schemas for transparency and
explainability of agent actions.
#### model_post_init()
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
* Parameters:
* `self` – The BaseModel instance.
* `context` – The context.
#### abstractmethod select_llm()
Select which LLM to use based on messages and events.
This method implements the core routing logic for the RouterLLM.
Subclasses should analyze the provided messages to determine which
LLM from llms_for_routing is most appropriate for handling the request.
* Parameters:
`messages` – List of messages in the conversation that can be used
to inform the routing decision.
* Returns:
The key/name of the LLM to use from llms_for_routing dictionary.
#### classmethod set_placeholder_model()
Guarantee model exists before LLM base validation runs.
#### classmethod validate_llms_not_empty()
### class TextContent
Bases: `BaseContent`
#### Properties
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `text`: str
- `type`: Literal['text']
#### Methods
#### to_llm_dict()
Convert to LLM API format.
### class ThinkingBlock
Bases: `BaseModel`
Anthropic thinking block for extended thinking feature.
This represents the raw thinking blocks returned by Anthropic models
when extended thinking is enabled. These blocks must be preserved
and passed back to the API for tool use scenarios.
#### Properties
- `signature`: str | None
- `thinking`: str
- `type`: Literal['thinking']
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### openhands.sdk.security
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.security.md
### class AlwaysConfirm
Bases: [`ConfirmationPolicyBase`](#class-confirmationpolicybase)
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### should_confirm()
Determine if an action with the given risk level requires confirmation.
This method defines the core logic for determining whether user confirmation
is required before executing an action based on its security risk level.
* Parameters:
`risk` – The security risk level of the action to be evaluated.
Defaults to SecurityRisk.UNKNOWN if not specified.
* Returns:
True if the action requires user confirmation before execution,
False if the action can proceed without confirmation.
### class ConfirmRisky
Bases: [`ConfirmationPolicyBase`](#class-confirmationpolicybase)
#### Properties
- `confirm_unknown`: bool
- `threshold`: [SecurityRisk](#class-securityrisk)
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### should_confirm()
Determine if an action with the given risk level requires confirmation.
This method defines the core logic for determining whether user confirmation
is required before executing an action based on its security risk level.
* Parameters:
`risk` – The security risk level of the action to be evaluated.
Defaults to SecurityRisk.UNKNOWN if not specified.
* Returns:
True if the action requires user confirmation before execution,
False if the action can proceed without confirmation.
#### classmethod validate_threshold()
### class ConfirmationPolicyBase
Bases: `DiscriminatedUnionMixin`, `ABC`
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### abstractmethod should_confirm()
Determine if an action with the given risk level requires confirmation.
This method defines the core logic for determining whether user confirmation
is required before executing an action based on its security risk level.
* Parameters:
`risk` – The security risk level of the action to be evaluated.
Defaults to SecurityRisk.UNKNOWN if not specified.
* Returns:
True if the action requires user confirmation before execution,
False if the action can proceed without confirmation.
### class GraySwanAnalyzer
Bases: [`SecurityAnalyzerBase`](#class-securityanalyzerbase)
Security analyzer using GraySwan’s Cygnal API for AI safety monitoring.
This analyzer sends conversation history and pending actions to the GraySwan
Cygnal API for security analysis. The API returns a violation score which is
mapped to SecurityRisk levels.
Environment Variables:
: GRAYSWAN_API_KEY: Required API key for GraySwan authentication
GRAYSWAN_POLICY_ID: Optional policy ID for custom GraySwan policy
#### Example
```pycon
>>> from openhands.sdk.security.grayswan import GraySwanAnalyzer
>>> analyzer = GraySwanAnalyzer()
>>> risk = analyzer.security_risk(action_event)
```
#### Properties
- `api_key`: SecretStr | None
- `api_url`: str
- `history_limit`: int
- `low_threshold`: float
- `max_message_chars`: int
- `medium_threshold`: float
- `policy_id`: str | None
- `timeout`: float
#### Methods
#### close()
Clean up resources.
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### model_post_init()
Initialize the analyzer after model creation.
#### security_risk()
Analyze action for security risks using GraySwan API.
This method converts the conversation history and the pending action
to OpenAI message format and sends them to the GraySwan Cygnal API
for security analysis.
* Parameters:
`action` – The ActionEvent to analyze
* Returns:
SecurityRisk level based on GraySwan analysis
#### set_events()
Set the events for context when analyzing actions.
* Parameters:
`events` – Sequence of events to use as context for security analysis
#### validate_thresholds()
Validate that thresholds are properly ordered.
### class LLMSecurityAnalyzer
Bases: [`SecurityAnalyzerBase`](#class-securityanalyzerbase)
LLM-based security analyzer.
This analyzer respects the security_risk attribute that can be set by the LLM
when generating actions, similar to OpenHands’ LLMRiskAnalyzer.
It provides a lightweight security analysis approach that leverages the LLM’s
understanding of action context and potential risks.
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### security_risk()
Evaluate security risk based on LLM-provided assessment.
This method checks if the action has a security_risk attribute set by the LLM
and returns it. The LLM may not always provide this attribute but it defaults to
UNKNOWN if not explicitly set.
### class NeverConfirm
Bases: [`ConfirmationPolicyBase`](#class-confirmationpolicybase)
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### should_confirm()
Determine if an action with the given risk level requires confirmation.
This method defines the core logic for determining whether user confirmation
is required before executing an action based on its security risk level.
* Parameters:
`risk` – The security risk level of the action to be evaluated.
Defaults to SecurityRisk.UNKNOWN if not specified.
* Returns:
True if the action requires user confirmation before execution,
False if the action can proceed without confirmation.
### class SecurityAnalyzerBase
Bases: `DiscriminatedUnionMixin`, `ABC`
Abstract base class for security analyzers.
Security analyzers evaluate the risk of actions before they are executed
and can influence the conversation flow based on security policies.
This is adapted from OpenHands SecurityAnalyzer but designed to work
with the agent-sdk’s conversation-based architecture.
#### Methods
#### analyze_event()
Analyze an event for security risks.
This is a convenience method that checks if the event is an action
and calls security_risk() if it is. Non-action events return None.
* Parameters:
`event` – The event to analyze
* Returns:
ActionSecurityRisk if event is an action, None otherwise
#### analyze_pending_actions()
Analyze all pending actions in a conversation.
This method gets all unmatched actions from the conversation state
and analyzes each one for security risks.
* Parameters:
`conversation` – The conversation to analyze
* Returns:
List of tuples containing (action, risk_level) for each pending action
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### abstractmethod security_risk()
Evaluate the security risk of an ActionEvent.
This is the core method that analyzes an ActionEvent and returns its risk level.
Implementations should examine the action’s content, context, and potential
impact to determine the appropriate risk level.
* Parameters:
`action` – The ActionEvent to analyze for security risks
* Returns:
ActionSecurityRisk enum indicating the risk level
#### should_require_confirmation()
Determine if an action should require user confirmation.
This implements the default confirmation logic based on risk level
and confirmation mode settings.
* Parameters:
* `risk` – The security risk level of the action
* `confirmation_mode` – Whether confirmation mode is enabled
* Returns:
True if confirmation is required, False otherwise
### class SecurityRisk
Bases: `str`, `Enum`
Security risk levels for actions.
Based on OpenHands security risk levels but adapted for agent-sdk.
Integer values allow for easy comparison and ordering.
#### Properties
- `description`: str
Get a human-readable description of the risk level.
- `visualize`: Text
Return Rich Text representation of this risk level.
#### Methods
#### HIGH = 'HIGH'
#### LOW = 'LOW'
#### MEDIUM = 'MEDIUM'
#### UNKNOWN = 'UNKNOWN'
#### get_color()
Get the color for displaying this risk level in Rich text.
#### is_riskier()
Check if this risk level is riskier than another.
Risk levels follow the natural ordering: LOW is less risky than MEDIUM, which is
less risky than HIGH. UNKNOWN is not comparable to any other level.
To make this act like a standard well-ordered domain, we reflexively consider
risk levels to be riskier than themselves. That is:
for risk_level in list(SecurityRisk):
: assert risk_level.is_riskier(risk_level)
# More concretely:
assert SecurityRisk.HIGH.is_riskier(SecurityRisk.HIGH)
assert SecurityRisk.MEDIUM.is_riskier(SecurityRisk.MEDIUM)
assert SecurityRisk.LOW.is_riskier(SecurityRisk.LOW)
This can be disabled by setting the reflexive parameter to False.
* Parameters:
other ([SecurityRisk*](#class-securityrisk)) – The other risk level to compare against.
reflexive (bool*) – Whether the relationship is reflexive.
* Raises:
`ValueError` – If either risk level is UNKNOWN.
### openhands.sdk.tool
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.tool.md
### class Action
Bases: `Schema`, `ABC`
Base schema for input action.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `visualize`: Text
Return Rich Text representation of this action.
This method can be overridden by subclasses to customize visualization.
The base implementation displays all action fields systematically.
### class ExecutableTool
Bases: `Protocol`
Protocol for tools that are guaranteed to have a non-None executor.
This eliminates the need for runtime None checks and type narrowing
when working with tools that are known to be executable.
#### Properties
- `executor`: [ToolExecutor](#class-toolexecutor)[Any, Any]
- `name`: str
#### Methods
#### __init__()
### class FinishTool
Bases: `ToolDefinition[FinishAction, FinishObservation]`
Tool for signaling the completion of a task or conversation.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### Methods
#### classmethod create()
Create FinishTool instance.
* Parameters:
* `conv_state` – Optional conversation state (not used by FinishTool).
params* – Additional parameters (none supported).
* Returns:
A sequence containing a single FinishTool instance.
* Raises:
`ValueError` – If any parameters are provided.
#### name = 'finish'
### class Observation
Bases: `Schema`, `ABC`
Base schema for output observation.
#### Properties
- `ERROR_MESSAGE_HEADER`: ClassVar[str] = '[An error occurred during execution.]n'
- `content`: list[TextContent | ImageContent]
- `is_error`: bool
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `text`: str
Extract all text content from the observation.
* Returns:
Concatenated text from all TextContent items in content.
- `to_llm_content`: Sequence[TextContent | ImageContent]
Default content formatting for converting observation to LLM readable content.
Subclasses can override to provide richer content (e.g., images, diffs).
- `visualize`: Text
Return Rich Text representation of this observation.
Subclasses can override for custom visualization; by default we show the
same text that would be sent to the LLM.
#### Methods
#### classmethod from_text()
Utility to create an Observation from a simple text string.
* Parameters:
* `text` – The text content to include in the observation.
* `is_error` – Whether this observation represents an error.
kwargs* – Additional fields for the observation subclass.
* Returns:
An Observation instance with the text wrapped in a TextContent.
### class ThinkTool
Bases: `ToolDefinition[ThinkAction, ThinkObservation]`
Tool for logging thoughts without making changes.
#### Properties
- `model_config`: = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### Methods
#### classmethod create()
Create ThinkTool instance.
* Parameters:
* `conv_state` – Optional conversation state (not used by ThinkTool).
params* – Additional parameters (none supported).
* Returns:
A sequence containing a single ThinkTool instance.
* Raises:
`ValueError` – If any parameters are provided.
#### name = 'think'
### class Tool
Bases: `BaseModel`
Defines a tool to be initialized for the agent.
This is only used in agent-sdk for type schema for server use.
#### Properties
- `name`: str
- `params`: dict[str, Any]
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### classmethod validate_name()
Validate that name is not empty.
#### classmethod validate_params()
Convert None params to empty dict.
### class ToolAnnotations
Bases: `BaseModel`
Annotations to provide hints about the tool’s behavior.
Based on Model Context Protocol (MCP) spec:
[https://github.com/modelcontextprotocol/modelcontextprotocol/blob/caf3424488b10b4a7b1f8cb634244a450a1f4400/schema/2025-06-18/schema.ts#L838](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/caf3424488b10b4a7b1f8cb634244a450a1f4400/schema/2025-06-18/schema.ts#L838)
#### Properties
- `destructiveHint`: bool
- `idempotentHint`: bool
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `openWorldHint`: bool
- `readOnlyHint`: bool
- `title`: str | None
### class ToolDefinition
Bases: `DiscriminatedUnionMixin`, `ABC`, `Generic`
Base class for all tool implementations.
This class serves as a base for the discriminated union of all tool types.
All tools must inherit from this class and implement the .create() method for
proper initialization with executors and parameters.
Features:
- Normalize input/output schemas (class or dict) into both model+schema.
- Validate inputs before execute.
- Coerce outputs only if an output model is defined; else return vanilla JSON.
- Export MCP tool description.
#### Examples
Simple tool with no parameters:
: class FinishTool(ToolDefinition[FinishAction, FinishObservation]):
: @classmethod
def create(cls, conv_state=None,
` `
```
**
```
` `
params):
` `
> return [cls(name=”finish”, …, executor=FinishExecutor())]
Complex tool with initialization parameters:
: class TerminalTool(ToolDefinition[TerminalAction,
: TerminalObservation]):
@classmethod
def create(cls, conv_state,
` `
```
**
```
` `
params):
` `
> executor = TerminalExecutor(
> : working_dir=conv_state.workspace.working_dir,
> ` `
> ```
> **
> ```
> ` `
> params,
` `
> )
> return [cls(name=”terminal”, …, executor=executor)]
#### Properties
- `action_type`: type[[Action](#class-action)]
- `annotations`: [ToolAnnotations](#class-toolannotations) | None
- `description`: str
- `executor`: Annotated[[ToolExecutor](#class-toolexecutor) | None, SkipJsonSchema()]
- `meta`: dict[str, Any] | None
- `model_config`: ClassVar[ConfigDict] = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- `name`: ClassVar[str] = ''
- `observation_type`: type[[Observation](#class-observation)] | None
- `title`: str
#### Methods
#### action_from_arguments()
Create an action from parsed arguments.
This method can be overridden by subclasses to provide custom logic
for creating actions from arguments (e.g., for MCP tools).
* Parameters:
`arguments` – The parsed arguments from the tool call.
* Returns:
The action instance created from the arguments.
#### as_executable()
Return this tool as an ExecutableTool, ensuring it has an executor.
This method eliminates the need for runtime None checks by guaranteeing
that the returned tool has a non-None executor.
* Returns:
This tool instance, typed as ExecutableTool.
* Raises:
`NotImplementedError` – If the tool has no executor.
#### abstractmethod classmethod create()
Create a sequence of Tool instances.
This method must be implemented by all subclasses to provide custom
initialization logic, typically initializing the executor with parameters
from conv_state and other optional parameters.
* Parameters:
args** – Variable positional arguments (typically conv_state as first arg).
kwargs* – Optional parameters for tool initialization.
* Returns:
A sequence of Tool instances. Even single tools are returned as a sequence
to provide a consistent interface and eliminate union return types.
#### classmethod resolve_kind()
Resolve a kind string to its corresponding tool class.
* Parameters:
`kind` – The name of the tool class to resolve
* Returns:
The tool class corresponding to the kind
* Raises:
`ValueError` – If the kind is unknown
#### set_executor()
Create a new Tool instance with the given executor.
#### to_mcp_tool()
Convert a Tool to an MCP tool definition.
Allow overriding input/output schemas (usually by subclasses).
* Parameters:
* `input_schema` – Optionally override the input schema.
* `output_schema` – Optionally override the output schema.
#### to_openai_tool()
Convert a Tool to an OpenAI tool.
* Parameters:
* `add_security_risk_prediction` – Whether to add a security_risk field
to the action schema for LLM to predict. This is useful for
tools that may have safety risks, so the LLM can reason about
the risk level before calling the tool.
* `action_type` – Optionally override the action_type to use for the schema.
This is useful for MCPTool to use a dynamically created action type
based on the tool’s input schema.
#### NOTE
Summary field is always added to the schema for transparency and
explainability of agent actions.
#### to_responses_tool()
Convert a Tool to a Responses API function tool (LiteLLM typed).
For Responses API, function tools expect top-level keys:
(JSON configuration object)
* Parameters:
* `add_security_risk_prediction` – Whether to add a security_risk field
* `action_type` – Optional override for the action type
#### NOTE
Summary field is always added to the schema for transparency and
explainability of agent actions.
### class ToolExecutor
Bases: `ABC`, `Generic`
Executor function type for a Tool.
#### Methods
#### close()
Close the executor and clean up resources.
Default implementation does nothing. Subclasses should override
this method to perform cleanup (e.g., closing connections,
terminating processes, etc.).
### openhands.sdk.utils
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.utils.md
Utility functions for the OpenHands SDK.
### deprecated()
Return a decorator that deprecates a callable with explicit metadata.
Use this helper when you can annotate a function, method, or property with
@deprecated(…). It transparently forwards to `deprecation.deprecated()`
while filling in the SDK’s current version metadata unless custom values are
supplied.
### maybe_truncate()
Truncate the middle of content if it exceeds the specified length.
Keeps the head and tail of the content to preserve context at both ends.
Optionally saves the full content to a file for later investigation.
* Parameters:
* `content` – The text content to potentially truncate
* `truncate_after` – Maximum length before truncation. If None, no truncation occurs
* `truncate_notice` – Notice to insert in the middle when content is truncated
* `save_dir` – Working directory to save full content file in
* `tool_prefix` – Prefix for the saved file (e.g., “bash”, “browser”, “editor”)
* Returns:
Original content if under limit, or truncated content with head and tail
preserved and reference to saved file if applicable
### sanitize_openhands_mentions()
Sanitize @OpenHands mentions in text to prevent self-mention loops.
This function inserts a zero-width joiner (ZWJ) after the @ symbol in
@OpenHands mentions, making them non-clickable in GitHub comments while
preserving readability. The original case of the mention is preserved.
* Parameters:
`text` – The text to sanitize
* Returns:
Text with sanitized @OpenHands mentions (e.g., “@OpenHands” -> “@OpenHands”)
### Examples
```pycon
>>> sanitize_openhands_mentions("Thanks @OpenHands for the help!")
'Thanks @u200dOpenHands for the help!'
>>> sanitize_openhands_mentions("Check @openhands and @OPENHANDS")
'Check @u200dopenhands and @u200dOPENHANDS'
>>> sanitize_openhands_mentions("No mention here")
'No mention here'
```
### sanitized_env()
Return a copy of env with sanitized values.
PyInstaller-based binaries rewrite `LD_LIBRARY_PATH` so their vendored
libraries win. This function restores the original value so that subprocess
will not use them.
### warn_deprecated()
Emit a deprecation warning for dynamic access to a legacy feature.
Prefer this helper when a decorator is not practical—e.g. attribute accessors,
data migrations, or other runtime paths that must conditionally warn. Provide
explicit version metadata so the SDK reports consistent messages and upgrades
to `deprecation.UnsupportedWarning` after the removal threshold.
### openhands.sdk.workspace
Source: https://docs.openhands.dev/sdk/api-reference/openhands.sdk.workspace.md
### class BaseWorkspace
Bases: `DiscriminatedUnionMixin`, `ABC`
Abstract base class for workspace implementations.
Workspaces provide a sandboxed environment where agents can execute commands,
read/write files, and perform other operations. All workspace implementations
support the context manager protocol for safe resource management.
#### Example
```pycon
>>> with workspace:
... result = workspace.execute_command("echo 'hello'")
... content = workspace.read_file("example.txt")
```
#### Properties
- `working_dir`: Annotated[str, BeforeValidator(func=_convert_path_to_str, json_schema_input_type=PydanticUndefined), FieldInfo(annotation=NoneType, required=True, description='The working directory for agent operations and tool execution. Accepts both string paths and Path objects. Path objects are automatically converted to strings.')]
#### Methods
#### abstractmethod execute_command()
Execute a bash command on the system.
* Parameters:
* `command` – The bash command to execute
* `cwd` – Working directory for the command (optional)
* `timeout` – Timeout in seconds (defaults to 30.0)
* Returns:
Result containing stdout, stderr, exit_code, and other
: metadata
* Return type:
[CommandResult](#class-commandresult)
* Raises:
`Exception` – If command execution fails
#### abstractmethod file_download()
Download a file from the system.
* Parameters:
* `source_path` – Path to the source file on the system
* `destination_path` – Path where the file should be downloaded
* Returns:
Result containing success status and metadata
* Return type:
[FileOperationResult](#class-fileoperationresult)
* Raises:
`Exception` – If file download fails
#### abstractmethod file_upload()
Upload a file to the system.
* Parameters:
* `source_path` – Path to the source file
* `destination_path` – Path where the file should be uploaded
* Returns:
Result containing success status and metadata
* Return type:
[FileOperationResult](#class-fileoperationresult)
* Raises:
`Exception` – If file upload fails
#### abstractmethod git_changes()
Get the git changes for the repository at the path given.
* Parameters:
`path` – Path to the git repository
* Returns:
List of changes
* Return type:
list[GitChange]
* Raises:
`Exception` – If path is not a git repository or getting changes failed
#### abstractmethod git_diff()
Get the git diff for the file at the path given.
* Parameters:
`path` – Path to the file
* Returns:
Git diff
* Return type:
GitDiff
* Raises:
`Exception` – If path is not a git repository or getting diff failed
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### pause()
Pause the workspace to conserve resources.
For local workspaces, this is a no-op.
For container-based workspaces, this pauses the container.
* Raises:
`NotImplementedError` – If the workspace type does not support pausing.
#### resume()
Resume a paused workspace.
For local workspaces, this is a no-op.
For container-based workspaces, this resumes the container.
* Raises:
`NotImplementedError` – If the workspace type does not support resuming.
### class CommandResult
Bases: `BaseModel`
Result of executing a command in the workspace.
#### Properties
- `command`: str
- `exit_code`: int
- `stderr`: str
- `stdout`: str
- `timeout_occurred`: bool
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class FileOperationResult
Bases: `BaseModel`
Result of a file upload or download operation.
#### Properties
- `destination_path`: str
- `error`: str | None
- `file_size`: int | None
- `source_path`: str
- `success`: bool
#### Methods
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
### class LocalWorkspace
Bases: [`BaseWorkspace`](#class-baseworkspace)
Local workspace implementation that operates on the host filesystem.
LocalWorkspace provides direct access to the local filesystem and command execution
environment. It’s suitable for development and testing scenarios where the agent
should operate directly on the host system.
#### Example
```pycon
>>> workspace = LocalWorkspace(working_dir="/path/to/project")
>>> with workspace:
... result = workspace.execute_command("ls -la")
... content = workspace.read_file("README.md")
```
#### Methods
#### __init__()
Create a new model by parsing and validating input data from keyword arguments.
Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.
self is explicitly positional-only to allow self as a field name.
#### execute_command()
Execute a bash command locally.
Uses the shared shell execution utility to run commands with proper
timeout handling, output streaming, and error management.
* Parameters:
* `command` – The bash command to execute
* `cwd` – Working directory (optional)
* `timeout` – Timeout in seconds
* Returns:
Result with stdout, stderr, exit_code, command, and
: timeout_occurred
* Return type:
[CommandResult](#class-commandresult)
#### file_download()
Download (copy) a file locally.
For local systems, file download is implemented as a file copy operation
using shutil.copy2 to preserve metadata.
* Parameters:
* `source_path` – Path to the source file
* `destination_path` – Path where the file should be copied
* Returns:
Result with success status and file information
* Return type:
[FileOperationResult](#class-fileoperationresult)
#### file_upload()
Upload (copy) a file locally.
For local systems, file upload is implemented as a file copy operation
using shutil.copy2 to preserve metadata.
* Parameters:
* `source_path` – Path to the source file
* `destination_path` – Path where the file should be copied
* Returns:
Result with success status and file information
* Return type:
[FileOperationResult](#class-fileoperationresult)
#### git_changes()
Get the git changes for the repository at the path given.
* Parameters:
`path` – Path to the git repository
* Returns:
List of changes
* Return type:
list[GitChange]
* Raises:
`Exception` – If path is not a git repository or getting changes failed
#### git_diff()
Get the git diff for the file at the path given.
* Parameters:
`path` – Path to the file
* Returns:
Git diff
* Return type:
GitDiff
* Raises:
`Exception` – If path is not a git repository or getting diff failed
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### pause()
Pause the workspace (no-op for local workspaces).
Local workspaces have nothing to pause since they operate directly
on the host filesystem.
#### resume()
Resume the workspace (no-op for local workspaces).
Local workspaces have nothing to resume since they operate directly
on the host filesystem.
### class RemoteWorkspace
Bases: `RemoteWorkspaceMixin`, [`BaseWorkspace`](#class-baseworkspace)
Remote workspace implementation that connects to an OpenHands agent server.
RemoteWorkspace provides access to a sandboxed environment running on a remote
OpenHands agent server. This is the recommended approach for production deployments
as it provides better isolation and security.
#### Example
```pycon
>>> workspace = RemoteWorkspace(
... host="https://agent-server.example.com",
... working_dir="/workspace"
... )
>>> with workspace:
... result = workspace.execute_command("ls -la")
... content = workspace.read_file("README.md")
```
#### Properties
- `alive`: bool
Check if the remote workspace is alive by querying the health endpoint.
* Returns:
True if the health endpoint returns a successful response, False otherwise.
- `client`: Client
#### Methods
#### execute_command()
Execute a bash command on the remote system.
This method starts a bash command via the remote agent server API,
then polls for the output until the command completes.
* Parameters:
* `command` – The bash command to execute
* `cwd` – Working directory (optional)
* `timeout` – Timeout in seconds
* Returns:
Result with stdout, stderr, exit_code, and other metadata
* Return type:
[CommandResult](#class-commandresult)
#### file_download()
Download a file from the remote system.
Requests the file from the remote system via HTTP API and saves it locally.
* Parameters:
* `source_path` – Path to the source file on remote system
* `destination_path` – Path where the file should be saved locally
* Returns:
Result with success status and metadata
* Return type:
[FileOperationResult](#class-fileoperationresult)
#### file_upload()
Upload a file to the remote system.
Reads the local file and sends it to the remote system via HTTP API.
* Parameters:
* `source_path` – Path to the local source file
* `destination_path` – Path where the file should be uploaded on remote system
* Returns:
Result with success status and metadata
* Return type:
[FileOperationResult](#class-fileoperationresult)
#### git_changes()
Get the git changes for the repository at the path given.
* Parameters:
`path` – Path to the git repository
* Returns:
List of changes
* Return type:
list[GitChange]
* Raises:
`Exception` – If path is not a git repository or getting changes failed
#### git_diff()
Get the git diff for the file at the path given.
* Parameters:
`path` – Path to the file
* Returns:
Git diff
* Return type:
GitDiff
* Raises:
`Exception` – If path is not a git repository or getting diff failed
#### model_config = (configuration object)
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
#### model_post_init()
Override this method to perform additional initialization after __init__ and model_construct.
This is useful if you want to do some validation that requires the entire model to be initialized.
#### reset_client()
Reset the HTTP client to force re-initialization.
This is useful when connection parameters (host, api_key) have changed
and the client needs to be recreated with new values.
### class Workspace
### class Workspace
Bases: `object`
Factory entrypoint that returns a LocalWorkspace or RemoteWorkspace.
Usage:
: - Workspace(working_dir=…) -> LocalWorkspace
- Workspace(working_dir=…, host=”http://…”) -> RemoteWorkspace
### Agent
Source: https://docs.openhands.dev/sdk/arch/agent.md
The **Agent** component implements the core reasoning-action loop that drives autonomous task execution. It orchestrates LLM queries, tool execution, and context management through a stateless, event-driven architecture.
**Source:** [`openhands-sdk/openhands/sdk/agent/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/agent)
## Core Responsibilities
The Agent system has four primary responsibilities:
1. **Reasoning-Action Loop** - Query LLM to generate next actions based on conversation history
2. **Tool Orchestration** - Select and execute tools, handle results and errors
3. **Context Management** - Apply [skills](/sdk/guides/skill), manage conversation history via [condensers](/sdk/guides/context-condenser)
4. **Security Validation** - Analyze proposed actions for safety before execution via [security analyzer](/sdk/guides/security)
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 20, "rankSpacing": 50}} }%%
flowchart TB
subgraph Input[" "]
Events["Event History"]
Context["Agent Context Skills + Prompts"]
end
subgraph Core["Agent Core"]
Condense["Condenser History compression"]
Reason["LLM Query Generate actions"]
Security["Security Analyzer Risk assessment"]
end
subgraph Execution[" "]
Tools["Tool Executor Action → Observation"]
Results["Observation Events"]
end
Events --> Condense
Context -.->|Skills| Reason
Condense --> Reason
Reason --> Security
Security --> Tools
Tools --> Results
Results -.->|Feedback| Events
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Reason primary
class Condense,Security secondary
class Tools tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`Agent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/agent/agent.py)** | Main implementation | Stateless reasoning-action loop executor |
| **[`AgentBase`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/agent/base.py)** | Abstract base class | Defines agent interface and initialization |
| **[`AgentContext`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/agent_context.py)** | Context container | Manages skills, prompts, and metadata |
| **[`Condenser`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/)** | History compression | Reduces context when token limits approached |
| **[`SecurityAnalyzer`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/)** | Safety validation | Evaluates action risk before execution |
## Reasoning-Action Loop
The agent operates through a **single-step execution model** where each `step()` call processes one reasoning cycle:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 10, "rankSpacing": 10}} }%%
flowchart TB
Start["step() called"]
Pending{"Pending actions?"}
ExecutePending["Execute pending actions"]
HasCondenser{"Has condenser?"}
Condense["Call condenser.condense()"]
CondenseResult{"Result type?"}
EmitCondensation["Emit Condensation event"]
UseView["Use View events"]
UseRaw["Use raw events"]
Query["Query LLM with messages"]
ContextExceeded{"Context window exceeded?"}
EmitRequest["Emit CondensationRequest"]
Parse{"Response type?"}
CreateActions["Create ActionEvents"]
CreateMessage["Create MessageEvent"]
Confirmation{"Need confirmation?"}
SetWaiting["Set WAITING_FOR_CONFIRMATION"]
Execute["Execute actions"]
Observe["Create ObservationEvents"]
Return["Return"]
Start --> Pending
Pending -->|Yes| ExecutePending --> Return
Pending -->|No| HasCondenser
HasCondenser -->|Yes| Condense
HasCondenser -->|No| UseRaw
Condense --> CondenseResult
CondenseResult -->|Condensation| EmitCondensation --> Return
CondenseResult -->|View| UseView --> Query
UseRaw --> Query
Query --> ContextExceeded
ContextExceeded -->|Yes| EmitRequest --> Return
ContextExceeded -->|No| Parse
Parse -->|Tool calls| CreateActions
Parse -->|Message| CreateMessage --> Return
CreateActions --> Confirmation
Confirmation -->|Yes| SetWaiting --> Return
Confirmation -->|No| Execute
Execute --> Observe
Observe --> Return
style Query fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Condense fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Confirmation fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Step Execution Flow:**
1. **Pending Actions:** If actions awaiting confirmation exist, execute them and return
2. **Condensation:** If condenser exists:
- Call `condenser.condense()` with current event view
- If returns `View`: use condensed events for LLM query (continue in same step)
- If returns `Condensation`: emit event and return (will be processed next step)
3. **LLM Query:** Query LLM with messages from event history
- If context window exceeded: emit `CondensationRequest` and return
4. **Response Parsing:** Parse LLM response into events
- Tool calls → create `ActionEvent`(s)
- Text message → create `MessageEvent` and return
5. **Confirmation Check:** If actions need user approval:
- Set conversation status to `WAITING_FOR_CONFIRMATION` and return
6. **Action Execution:** Execute tools and create `ObservationEvent`(s)
**Key Characteristics:**
- **Stateless:** Agent holds no mutable state between steps
- **Event-Driven:** Reads from event history, writes new events
- **Interruptible:** Each step is atomic and can be paused/resumed
## Agent Context
The agent applies `AgentContext` which includes **skills** and **prompts** to shape LLM behavior:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Context["AgentContext"]
subgraph Skills["Skills"]
Repo["repo Always active"]
Knowledge["knowledge Trigger-based"]
end
SystemAug["System prompt prefix/suffix Per-conversation"]
System["Prompt template Per-conversation"]
subgraph Application["Applied to LLM"]
SysPrompt["System Prompt"]
UserMsg["User Messages"]
end
Context --> Skills
Context --> SystemAug
Repo --> SysPrompt
Knowledge -.->|When triggered| UserMsg
System --> SysPrompt
SystemAug --> SysPrompt
style Context fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Repo fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Knowledge fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
| Skill Type | Activation | Use Case |
|------------|------------|----------|
| **repo** | Always included | Project-specific context, conventions |
| **knowledge** | Trigger words/patterns | Domain knowledge, special behaviors |
Review [this guide](/sdk/guides/skill) for details on creating and applying agent context and skills.
## Tool Execution
Tools follow a **strict action-observation pattern**:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
LLM["LLM generates tool_call"]
Convert["Convert to ActionEvent"]
Decision{"Confirmation mode?"}
Defer["Store as pending"]
Execute["Execute tool"]
Success{"Success?"}
Obs["ObservationEvent with result"]
Error["ObservationEvent with error"]
LLM --> Convert
Convert --> Decision
Decision -->|Yes| Defer
Decision -->|No| Execute
Execute --> Success
Success -->|Yes| Obs
Success -->|No| Error
style Convert fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Execute fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Decision fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Execution Modes:**
| Mode | Behavior | Use Case |
|------|----------|----------|
| **Direct** | Execute immediately | Development, trusted environments |
| **Confirmation** | Store as pending, wait for user approval | High-risk actions, production |
**Security Integration:**
Before execution, the security analyzer evaluates each action:
- **Low Risk:** Execute immediately
- **Medium Risk:** Log warning, execute with monitoring
- **High Risk:** Block execution, request user confirmation
## Component Relationships
### How Agent Interacts
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Agent["Agent"]
Conv["Conversation"]
LLM["LLM"]
Tools["Tools"]
Context["AgentContext"]
Conv -->|.step calls| Agent
Agent -->|Reads events| Conv
Agent -->|Query| LLM
Agent -->|Execute| Tools
Context -.->|Skills and Context| Agent
Agent -.->|New events| Conv
style Agent fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Conv fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style LLM fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Conversation → Agent**: Orchestrates step execution, provides event history
- **Agent → LLM**: Queries for next actions, receives tool calls or messages
- **Agent → Tools**: Executes actions, receives observations
- **AgentContext → Agent**: Injects skills and prompts into LLM queries
## See Also
- **[Conversation Architecture](/sdk/arch/conversation)** - Agent orchestration and lifecycle
- **[Tool System](/sdk/arch/tool-system)** - Tool definition and execution patterns
- **[Events](/sdk/arch/events)** - Event types and structures
- **[Skills](/sdk/arch/skill)** - Prompt engineering and skill patterns
- **[LLM](/sdk/arch/llm)** - Language model abstraction
### Agent Server Package
Source: https://docs.openhands.dev/sdk/arch/agent-server.md
import InstallAgentServer from "/sdk/shared-snippets/install-agent-server.mdx";
The Agent Server package (`openhands-agent-server`) runs the OpenHands Software Agent SDK behind an HTTP and WebSocket API. Use it when another service, such as an Agent Canvas backend, needs to start conversations, stream events, and run file or command operations in a workspace without embedding the SDK directly in the same process.
## When to Use It
Use the Agent Server when you need:
- A backend process that clients can reach over HTTP/WebSocket.
- A long-running service for conversations and workspace files.
- A server API that can be protected with a session API key.
- A clean boundary between your application backend and the agent runtime.
For a single local script, the standalone SDK is usually simpler. For a backend service, web UI, automation system, or Agent Canvas-style deployment, run an Agent Server and connect to it from the client service.
## Install
Install the server package and its SDK dependencies into a Python environment:
If you are working from the `OpenHands/software-agent-sdk` repository, use the repository's normal `uv` setup instead:
```bash
git clone https://github.com/OpenHands/software-agent-sdk.git
cd software-agent-sdk
uv sync
```
## Start a Local Server
For local-only use, bind to `127.0.0.1`:
```bash
python -m openhands.agent_server --host 127.0.0.1 --port 8000
```
If you are working from the SDK repository, run the module through `uv` instead:
```bash
uv run python -m openhands.agent_server --host 127.0.0.1 --port 8000
```
Check that the server is alive:
```bash
curl http://127.0.0.1:8000/health
```
The interactive API docs are available at:
```text
http://127.0.0.1:8000/docs
```
If `SESSION_API_KEY` (legacy alias) or `OH_SESSION_API_KEYS_*` is already set in your shell, the server will require that key for `/api/*` requests. Unset those variables for unauthenticated local-only testing.
## Secure the Server
By default, the Agent Server starts without API authentication. Before exposing it to another process, container, host, or user, set at least one session API key.
```bash
export OH_SESSION_API_KEYS_0="$(openssl rand -hex 32)"
export OH_SECRET_KEY="$(openssl rand -hex 32)"
python -m openhands.agent_server --host 127.0.0.1 --port 8000
```
Clients must send the session key in the `X-Session-API-Key` header. This request returns the conversation count when the key is accepted:
```bash
curl \
-H "X-Session-API-Key: $OH_SESSION_API_KEYS_0" \
http://127.0.0.1:8000/api/conversations/count
```
Use additional indexed variables when you need key rotation:
```bash
export OH_SESSION_API_KEYS_0="current-key"
export OH_SESSION_API_KEYS_1="next-key"
```
`OH_SECRET_KEY` encrypts sensitive values stored with conversations, including LLM API keys and secrets. Keep it stable across restarts. If it changes, previously encrypted values cannot be restored.
## Connect From Python
Pass the server URL and API key to `Workspace`. The SDK sends the key as `X-Session-API-Key` and uses remote HTTP/WebSocket calls for workspace and conversation operations.
```python
import os
from pydantic import SecretStr
from openhands.sdk import Conversation, LLM, Workspace
from openhands.tools.preset.default import get_default_agent
llm = LLM(
model=os.environ.get("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=SecretStr(os.environ["LLM_API_KEY"]),
)
agent = get_default_agent(llm=llm, cli_mode=True) # disable browser-automation tools
workspace = Workspace(
host="http://127.0.0.1:8000",
api_key=os.environ["OH_SESSION_API_KEYS_0"],
working_dir="workspace/project",
)
conversation = Conversation(agent=agent, workspace=workspace)
conversation.send_message("Create a NOTES.md file with three facts about this project.")
conversation.run()
conversation.close()
```
If the server was started without `OH_SESSION_API_KEYS_0`, remove the `api_key=...` argument.
## Expose It Safely
If another service runs on the same machine, keep the server bound to `127.0.0.1` and let that service connect locally.
If another host must connect to the server:
1. Set `OH_SESSION_API_KEYS_0` and `OH_SECRET_KEY`.
2. Bind the server to a reachable interface, for example `--host 0.0.0.0`.
3. Put the server behind TLS, a private network, or a trusted reverse proxy.
4. Restrict firewall access to only the services that need it.
5. Configure CORS only for browser clients that must call the server directly.
```bash
export OH_SESSION_API_KEYS_0="$(openssl rand -hex 32)"
export OH_SECRET_KEY="$(openssl rand -hex 32)"
export OH_ALLOW_CORS_ORIGINS_0="https://your-frontend.example.com"
python -m openhands.agent_server --host 0.0.0.0 --port 8000
```
Do not expose an unauthenticated Agent Server on a public network. It can execute commands and read or write files in its configured workspace.
## Runtime Files
By default, the server stores conversation and workspace data under `workspace/` relative to the process working directory:
```text
workspace/
|-- bash_events/
|-- conversations/
`-- project/
```
Run the server from a directory with enough disk space and with permissions appropriate for the files the agent should access.
## Useful Endpoints
- `GET /health` - Basic health check.
- `GET /ready` - Readiness check after startup initialization.
- `GET /server_info` - Version, uptime, and available tool information.
- `GET /docs` - Interactive OpenAPI documentation.
- `/api/*` - Authenticated conversation, workspace, file, command, and settings APIs when session API keys are configured.
## Troubleshooting
- **401 responses**: Send `X-Session-API-Key` with one of the configured `OH_SESSION_API_KEYS_*` values.
- **Secrets disappear after restart**: Set a stable `OH_SECRET_KEY` before starting the server.
- **Port already in use**: Change the port with `--port`.
- **Browser CORS errors**: Add the browser origin with `OH_ALLOW_CORS_ORIGINS_0`.
- **Cannot reach the server from another host**: Check `--host`, firewall rules, reverse proxy routing, and TLS configuration.
## Next Steps
- [Local Agent Server](/sdk/guides/agent-server/local-server) - Run and connect to a local server.
- [Docker Sandboxed Server](/sdk/guides/agent-server/docker-sandbox) - Run the server in an isolated Docker workspace.
- [API Sandboxed Server](/sdk/guides/agent-server/api-sandbox) - Start agent servers through a hosted runtime API.
- [Agent Server API Reference](/sdk/guides/agent-server/api-reference/server-details/alive) - Browse the generated REST API docs.
### Condenser
Source: https://docs.openhands.dev/sdk/arch/condenser.md
The **Condenser** system manages conversation history compression to keep agent context within LLM token limits. It reduces long event histories into condensed summaries while preserving critical information for reasoning. For more details, read the [blog here](https://openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents).
**Source:** [`openhands-sdk/openhands/sdk/context/condenser/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/context/condenser)
## Core Responsibilities
The Condenser system has four primary responsibilities:
1. **History Compression** - Reduce event lists to fit within context windows
2. **Threshold Detection** - Determine when condensation should trigger
3. **Summary Generation** - Create meaningful summaries via LLM or heuristics
4. **View Management** - Transform event history into LLM-ready views
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 50}} }%%
flowchart TB
subgraph Interface["Abstract Interface"]
Base["CondenserBase Abstract base"]
end
subgraph Implementations["Concrete Implementations"]
NoOp["NoOpCondenser No compression"]
LLM["LLMSummarizingCondenser LLM-based"]
Pipeline["PipelineCondenser Multi-stage"]
end
subgraph Process["Condensation Process"]
View["View Event history"]
Check["should_condense()?"]
Condense["get_condensation()"]
Result["View | Condensation"]
end
subgraph Output["Condensation Output"]
CondEvent["Condensation Event Summary metadata"]
NewView["Condensed View Reduced tokens"]
end
Base --> NoOp
Base --> LLM
Base --> Pipeline
View --> Check
Check -->|Yes| Condense
Check -->|No| Result
Condense --> CondEvent
CondEvent --> NewView
NewView --> Result
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Base primary
class LLM,Pipeline secondary
class Check,Condense tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`CondenserBase`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/base.py)** | Abstract interface | Defines `condense()` contract |
| **[`RollingCondenser`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/base.py)** | Rolling window base | Implements threshold-based triggering |
| **[`LLMSummarizingCondenser`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py)** | LLM summarization | Uses LLM to generate summaries |
| **[`NoOpCondenser`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/no_op_condenser.py)** | No-op implementation | Returns view unchanged |
| **[`PipelineCondenser`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/pipeline_condenser.py)** | Multi-stage pipeline | Chains multiple condensers |
| **[`View`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/view.py)** | Event view | Represents history for LLM |
| **[`Condensation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/condenser.py)** | Condensation event | Metadata about compression |
## Condenser Types
### NoOpCondenser
Pass-through condenser that performs no compression:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
View["View"]
NoOp["NoOpCondenser"]
Same["Same View"]
View --> NoOp --> Same
style NoOp fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
```
### LLMSummarizingCondenser
Uses an LLM to generate summaries of conversation history:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart LR
View["Long View 120+ events"]
Check["Threshold exceeded?"]
Summarize["LLM Summarization"]
Summary["Summary Text"]
Metadata["Condensation Event"]
AddToHistory["Add to History"]
NextStep["Next Step: View.from_events()"]
NewView["Condensed View"]
View --> Check
Check -->|Yes| Summarize
Summarize --> Summary
Summary --> Metadata
Metadata --> AddToHistory
AddToHistory --> NextStep
NextStep --> NewView
style Check fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Summarize fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style NewView fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Process:**
1. **Check Threshold:** Compare view size to configured limit (e.g., event count > `max_size`)
2. **Select Events:** Identify events to keep (first N + last M) and events to summarize (middle)
3. **LLM Call:** Generate summary of middle events using dedicated LLM
4. **Create Event:** Wrap summary in `Condensation` event with `forgotten_event_ids`
5. **Add to History:** Agent adds `Condensation` to event log and returns early
6. **Next Step:** `View.from_events()` filters forgotten events and inserts summary
**Configuration:**
- **`max_size`:** Event count threshold before condensation triggers (default: 120)
- **`keep_first`:** Number of initial events to preserve verbatim (default: 4)
- **`llm`:** LLM instance for summarization (often cheaper model than reasoning LLM)
### PipelineCondenser
Chains multiple condensers in sequence:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
View["Original View"]
C1["Condenser 1"]
C2["Condenser 2"]
C3["Condenser 3"]
Final["Final View"]
View --> C1 --> C2 --> C3 --> Final
style C1 fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style C2 fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style C3 fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Use Case:** Multi-stage compression (e.g., remove old events, then summarize, then truncate)
## Condensation Flow
### Trigger Mechanisms
Condensers can be triggered in two ways:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
subgraph Automatic["Automatic Trigger"]
Agent1["Agent Step"]
Build1["View.from_events()"]
Check1["condenser.condense(view)"]
Trigger1["should_condense()?"]
end
Agent1 --> Build1 --> Check1 --> Trigger1
style Check1 fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
```
**Automatic Trigger:**
- **When:** Threshold exceeded (e.g., event count > `max_size`)
- **Who:** Agent calls `condenser.condense()` each step
- **Purpose:** Proactively keep context within limits
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
subgraph Manual["Manual Trigger"]
Error["LLM Context Error"]
Request["CondensationRequest Event"]
NextStep["Next Agent Step"]
Trigger2["condense() detects request"]
end
Error --> Request --> NextStep --> Trigger2
style Request fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Manual Trigger:**
- **When:** `CondensationRequest` event added to history (via `view.unhandled_condensation_request`)
- **Who:** Agent (on LLM context window error) or application code
- **Purpose:** Force compression when context limit exceeded
### Condensation Workflow
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Start["Agent calls condense(view)"]
Decision{"should_condense?"}
ReturnView["Return View Agent proceeds"]
Extract["Select Events to Keep/Forget"]
Generate["LLM Generates Summary"]
Create["Create Condensation Event"]
ReturnCond["Return Condensation"]
AddHistory["Agent adds to history"]
NextStep["Next Step: View.from_events()"]
FilterEvents["Filter forgotten events"]
InsertSummary["Insert summary at offset"]
NewView["New condensed view"]
Start --> Decision
Decision -->|No| ReturnView
Decision -->|Yes| Extract
Extract --> Generate
Generate --> Create
Create --> ReturnCond
ReturnCond --> AddHistory
AddHistory --> NextStep
NextStep --> FilterEvents
FilterEvents --> InsertSummary
InsertSummary --> NewView
style Decision fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Generate fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Create fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Key Steps:**
1. **Threshold Check:** `should_condense()` determines if condensation needed
2. **Event Selection:** Identify events to keep (head + tail) vs forget (middle)
3. **Summary Generation:** LLM creates compressed representation of forgotten events
4. **Condensation Creation:** Create `Condensation` event with `forgotten_event_ids` and summary
5. **Return to Agent:** Condenser returns `Condensation` (not `View`)
6. **History Update:** Agent adds `Condensation` to event log and exits step
7. **Next Step:** `View.from_events()` ([source](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/view.py)) processes Condensation to filter events and insert summary
## View and Condensation
### View Structure
A `View` represents the conversation history as it will be sent to the LLM:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Events["Full Event List + Condensation events"]
FromEvents["View.from_events()"]
Filter["Filter forgotten events"]
Insert["Insert summary"]
View["View LLMConvertibleEvents"]
Convert["events_to_messages()"]
LLM["LLM Input"]
Events --> FromEvents
FromEvents --> Filter
Filter --> Insert
Insert --> View
View --> Convert
Convert --> LLM
style View fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style FromEvents fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**View Components:**
- **`events`:** List of `LLMConvertibleEvent` objects (filtered by Condensation)
- **`unhandled_condensation_request`:** Flag for pending manual condensation
- **`condensations`:** List of all Condensation events processed
- **Methods:** `from_events()` creates view from raw events, handling Condensation semantics
### Condensation Event
When condensation occurs, a `Condensation` event is created:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Old["Middle Events ~60 events"]
Summary["Summary Text LLM-generated"]
Event["Condensation Event forgotten_event_ids"]
Applied["View.from_events()"]
New["New View ~60 events + summary"]
Old -.->|Summarized| Summary
Summary --> Event
Event --> Applied
Applied --> New
style Event fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Summary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Condensation Fields:**
- **`forgotten_event_ids`:** List of event IDs to filter out
- **`summary`:** Compressed text representation of forgotten events
- **`summary_offset`:** Index where summary event should be inserted
- Inherits from `Event`: `id`, `timestamp`, `source`
## Rolling Window Pattern
`RollingCondenser` implements a common pattern for threshold-based condensation:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
View["Current View 120+ events"]
Check["Count Events"]
Compare{"Count > max_size?"}
Keep["Keep All Events"]
Split["Split Events"]
Head["Head First 4 events"]
Middle["Middle ~56 events"]
Tail["Tail ~56 events"]
Summarize["LLM Summarizes Middle"]
Result["Head + Summary + Tail ~60 events total"]
View --> Check
Check --> Compare
Compare -->|Under| Keep
Compare -->|Over| Split
Split --> Head
Split --> Middle
Split --> Tail
Middle --> Summarize
Head --> Result
Summarize --> Result
Tail --> Result
style Compare fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Split fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Summarize fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Rolling Window Strategy:**
1. **Keep Head:** Preserve first `keep_first` events (default: 4) - usually system prompts
2. **Keep Tail:** Preserve last `target_size - keep_first - 1` events - recent context
3. **Summarize Middle:** Compress events between head and tail into summary
4. **Target Size:** After condensation, view has `max_size // 2` events (default: 60)
## Component Relationships
### How Condenser Integrates
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Agent["Agent"]
Condenser["Condenser"]
State["Conversation State"]
Events["Event Log"]
Agent -->|"View.from_events()"| State
State -->|View| Agent
Agent -->|"condense(view)"| Condenser
Condenser -->|"View | Condensation"| Agent
Agent -->|Adds Condensation| Events
style Condenser fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Events fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Agent → State**: Calls `View.from_events()` to get current view
- **Agent → Condenser**: Calls `condense(view)` each step if condenser registered
- **Condenser → Agent**: Returns `View` (proceed) or `Condensation` (defer)
- **Agent → Events**: Adds `Condensation` event to log when returned
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - How agents use condensers during reasoning
- **[Conversation Architecture](/sdk/arch/conversation)** - View generation and event management
- **[Events](/sdk/arch/events)** - Condensation event type and append-only log
- **[Context Condenser Guide](/sdk/guides/context-condenser)** - Configuring and using condensers
### Conversation
Source: https://docs.openhands.dev/sdk/arch/conversation.md
The **Conversation** component orchestrates agent execution through structured message flows and state management. It serves as the primary interface for interacting with agents, managing their lifecycle from initialization to completion.
**Source:** [`openhands-sdk/openhands/sdk/conversation/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/conversation)
## Core Responsibilities
The Conversation system has four primary responsibilities:
1. **Agent Lifecycle Management** - Initialize, run, pause, and terminate agents
2. **State Orchestration** - Maintain conversation history, events, and execution status
3. **Workspace Coordination** - Bridge agent operations with execution environments
4. **Runtime Services** - Provide persistence, monitoring, security, and visualization
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 35}} }%%
flowchart LR
User["User Code"]
subgraph Factory[" "]
Entry["Conversation()"]
end
subgraph Implementations[" "]
Local["LocalConversation Direct execution"]
Remote["RemoteConversation Via agent-server API"]
end
subgraph Core[" "]
State["ConversationState • agent workspace • stats • ..."]
EventLog["ConversationState.events Event storage"]
end
User --> Entry
Entry -.->|LocalWorkspace| Local
Entry -.->|RemoteWorkspace| Remote
Local --> State
Remote --> State
State --> EventLog
classDef factory fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef impl fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef core fill:#fff4df,stroke:#b7791f,stroke-width:2px
classDef service fill:#e9f9ef,stroke:#2f855a,stroke-width:1.5px
class Entry factory
class Local,Remote impl
class State,EventLog core
class Persist,Stuck,Viz,Secrets service
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`Conversation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/conversation.py)** | Unified entrypoint | Returns correct implementation based on workspace type |
| **[`LocalConversation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py)** | Local execution | Runs agent directly in process |
| **[`RemoteConversation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py)** | Remote execution | Delegates to agent-server via HTTP/WebSocket |
| **[`ConversationState`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/state.py)** | State container | Pydantic model with validation and serialization |
| **[`EventLog`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/event_store.py)** | Event storage | Immutable append-only store with efficient queries |
## Factory Pattern
The [`Conversation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/conversation.py) class automatically selects the correct implementation based on workspace type:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Input["Conversation(agent, workspace)"]
Check{Workspace Type?}
Local["LocalConversation Agent runs in-process"]
Remote["RemoteConversation Agent runs via API"]
Input --> Check
Check -->|str or LocalWorkspace| Local
Check -->|RemoteWorkspace| Remote
style Input fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Local fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Remote fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Dispatch Logic:**
- **Local:** String paths or `LocalWorkspace` → in-process execution
- **Remote:** `RemoteWorkspace` → agent-server via HTTP/WebSocket
This abstraction enables switching deployment modes without code changes—just swap the workspace type.
## State Management
State updates follow a **two-path pattern** depending on the type of change:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Start["State Update Request"]
Lock["Acquire FIFO Lock"]
Decision{New Event?}
StateOnly["Update State Fields stats, status, metadata"]
EventPath["Append to Event Log messages, actions, observations"]
Callback["Trigger Callbacks"]
Release["Release Lock"]
Start --> Lock
Lock --> Decision
Decision -->|No| StateOnly
Decision -->|Yes| EventPath
StateOnly --> Callback
EventPath --> Callback
Callback --> Release
style Decision fill:#fff4df,stroke:#b7791f,stroke-width:2px
style EventPath fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style StateOnly fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
```
**Two Update Patterns:**
1. **State-Only Updates** - Modify fields without appending events (e.g., status changes, stat increments)
2. **Event-Based Updates** - Append to event log when new messages, actions, or observations occur
**Thread Safety:**
- FIFO Lock ensures ordered, atomic updates
- Callbacks fire after successful commit
- Read operations never block writes
## Execution Models
The conversation system supports two execution models with identical APIs:
### Local vs Remote Execution
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
subgraph Local["LocalConversation"]
L1["User sends message"]
L2["Agent executes in-process"]
L3["Direct tool calls"]
L4["Events via callbacks"]
L1 --> L2 --> L3 --> L4
end
style Local fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
subgraph Remote["RemoteConversation"]
R1["User sends message"]
R2["HTTP → Agent Server"]
R3["Isolated container execution"]
R4["WebSocket event stream"]
R1 --> R2 --> R3 --> R4
end
style Remote fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
| Aspect | LocalConversation | RemoteConversation |
|--------|-------------------|-------------------|
| **Execution** | In-process | Remote container/server |
| **Communication** | Direct function calls | HTTP + WebSocket |
| **State Sync** | Immediate | Network serialized |
| **Use Case** | Development, CLI tools | Production, web apps |
| **Isolation** | Process-level | Container-level |
**Key Insight:** Same API surface means switching between local and remote requires only changing workspace type—no code changes.
## Auxiliary Services
The conversation system provides pluggable services that operate independently on the event stream:
| Service | Purpose | Architecture Pattern |
|---------|---------|---------------------|
| **[Event Log](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/event_store.py)** | Append-only immutable storage | Event sourcing with indexing |
| **[Persistence](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/state.py)** | Auto-save & resume | Debounced writes, incremental events |
| **[Stuck Detection](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/stuck_detector.py)** | Loop prevention | Sliding window pattern matching |
| **[Visualization](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/visualizer/)** | Execution diagrams | Event stream → visual representation |
| **[Secret Registry](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/secret_registry.py)** | Secure value storage | Memory-only with masked logging |
**Design Principle:** Services read from the event log but never mutate state directly. This enables:
- Services can be enabled/disabled independently
- Easy to add new services without changing core orchestration
- Event stream acts as the integration point
## Component Relationships
### How Conversation Interacts
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Conv["Conversation"]
Agent["Agent"]
WS["Workspace"]
Tools["Tools"]
LLM["LLM"]
Conv -->|Delegates to| Agent
Conv -->|Configures| WS
Agent -.->|Updates| Conv
Agent -->|Uses| Tools
Agent -->|Queries| LLM
style Conv fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style WS fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Conversation → Agent**: One-way orchestration, agent reports back via state updates
- **Conversation → Workspace**: Configuration only, workspace doesn't know about conversation
- **Agent → Conversation**: Indirect via state events
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - Agent reasoning loop design
- **[Workspace Architecture](/sdk/arch/workspace)** - Execution environment design
- **[Event System](/sdk/arch/events)** - Event types and flow
- **[Conversation Usage Guide](/sdk/guides/convo-persistence)** - Practical examples
### Design Principles
Source: https://docs.openhands.dev/sdk/arch/design.md
The **OpenHands Software Agent SDK** is part of the [OpenHands V1](https://openhands.dev/blog/the-path-to-openhands-v1) effort — a complete architectural rework based on lessons from **OpenHands V0**, one of the most widely adopted open-source coding agents.
[Over the last eighteen months](https://openhands.dev/blog/one-year-of-openhands-a-journey-of-open-source-ai-development), OpenHands V0 evolved from a scrappy prototype into a widely used open-source coding agent. The project grew to tens of thousands of GitHub stars, hundreds of contributors, and multiple production deployments. That growth exposed architectural tensions — tight coupling between research and production, mandatory sandboxing, mutable state, and configuration sprawl — which informed the design principles of agent-sdk in V1.
## Optional Isolation over Mandatory Sandboxing
**V0 Challenge:**
Every tool call in V0 executed in a sandboxed Docker container by default. While this guaranteed reproducibility and security, it also created friction — the agent and sandbox ran as separate processes, states diverged easily, and multi-tenant workloads could crash each other.
Moreover, with the rise of the Model Context Protocol (MCP), which assumes local execution and direct access to user environments, V0's rigid isolation model became incompatible.
**V1 Principle:**
**Sandboxing should be opt-in, not universal.**
V1 unifies agent and tool execution within a single process by default, aligning with MCP's local-execution model.
When isolation is needed, the same stack can be transparently containerized, maintaining flexibility without complexity.
## Stateless by Default, One Source of Truth for State
**V0 Challenge:**
V0 relied on mutable Python objects and dynamic typing, which led to silent inconsistencies — failed session restores, version drift, and non-deterministic behavior. Each subsystem tracked its own transient state, making debugging and recovery painful.
**V1 Principle:**
**Keep everything stateless, with exactly one mutable state.**
All components (agents, tools, LLMs, and configurations) are immutable Pydantic models validated at construction.
The only mutable entity is the [conversation state](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/conversation_state.py), a single source of truth that enables deterministic replay and robust persistence across sessions or distributed systems.
## Clear Boundaries between Agent and Applications
**V0 Challenge:**
The same codebase powered the CLI, web interface, and integrations (e.g., Github, Gitlab, etc). Over time, application-specific conditionals and prompts polluted the agent core, making it brittle.
Heavy research dependencies and benchmark integrations further bloated production builds.
**V1 Principle:**
**Maintain strict separation of concerns.**
V1 divides the system into stable, isolated layers: the [SDK (agent core)](/sdk/arch/overview#1-sdk-%E2%80%93-openhands-sdk), [tools (set of tools)](/sdk/arch/overview#2-tools-%E2%80%93-openhands-tools), [workspace (sandbox)](/sdk/arch/overview#3-workspace-%E2%80%93-openhands-workspace), and [agent server (server that runs inside sandbox)](/sdk/arch/overview#4-agent-server-%E2%80%93-openhands-agent-server).
Applications communicate with the agent via APIs rather than embedding it directly, ensuring research and production can evolve independently.
## Composable Components for Extensibility
**V0 Challenge:**
Because agent logic was hard-coded into the core application, extending behavior (e.g., adding new tools or entry points) required branching logic for different entrypoints. This rigidity limited experimentation and discouraged contributions.
**V1 Principle:**
**Everything should be composable and safe to extend.**
Agents are defined as graphs of interchangeable components—tools, prompts, LLMs, and contexts—each described declaratively with strong typing.
Developers can reconfigure capabilities (e.g., swap toolsets, override prompts, add delegation logic) without modifying core code, preserving stability while fostering rapid innovation.
### Events
Source: https://docs.openhands.dev/sdk/arch/events.md
The **Event System** provides an immutable, type-safe event framework that drives agent execution and state management. Events form an append-only log that serves as both the agent's memory and the integration point for auxiliary services.
**Source:** [`openhands-sdk/openhands/sdk/event/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/event)
## Core Responsibilities
The Event System has four primary responsibilities:
1. **Type Safety** - Enforce event schemas through Pydantic models
2. **LLM Integration** - Convert events to/from LLM message formats
3. **Append-Only Log** - Maintain immutable event history
4. **Service Integration** - Enable observers to react to event streams
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 80}} }%%
flowchart TB
Base["Event Base class"]
LLMBase["LLMConvertibleEvent Abstract base"]
subgraph LLMTypes["LLM-Convertible Events Visible to the LLM"]
Message["MessageEvent User/assistant text"]
Action["ActionEvent Tool calls"]
System["SystemPromptEvent Initial system prompt"]
CondSummary["CondensationSummaryEvent Condenser summary"]
ObsBase["ObservationBaseEvent Base for tool responses"]
Observation["ObservationEvent Tool results"]
UserReject["UserRejectObservation User rejected action"]
AgentError["AgentErrorEvent Agent error"]
end
subgraph Internals["Internal Events NOT visible to the LLM"]
ConvState["ConversationStateUpdateEvent State updates"]
CondReq["CondensationRequest Request compression"]
Cond["Condensation Compression result"]
Pause["PauseEvent User pause"]
end
Base --> LLMBase
Base --> Internals
LLMBase --> LLMTypes
ObsBase --> Observation
ObsBase --> UserReject
ObsBase --> AgentError
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Base,LLMBase,Message,Action,SystemPromptEvent primary
class ObsBase,Observation,UserReject,AgentError secondary
class ConvState,CondReq,Cond,Pause tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`Event`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/base.py)** | Base event class | Immutable Pydantic model with ID, timestamp, source |
| **[`LLMConvertibleEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/base.py)** | LLM-compatible events | Abstract class with `to_llm_message()` method |
| **[`MessageEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/message.py)** | Text messages | User or assistant conversational messages with skills |
| **[`ActionEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/action.py)** | Tool calls | Agent tool invocations with thought, reasoning, security risk |
| **[`ObservationBaseEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/observation.py)** | Tool response base | Base for all tool call responses |
| **[`ObservationEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/observation.py)** | Tool results | Successful tool execution outcomes |
| **[`UserRejectObservation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/observation.py)** | User rejection | User rejected action in confirmation mode |
| **[`AgentErrorEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/observation.py)** | Agent errors | Errors from agent/scaffold (not model output) |
| **[`SystemPromptEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/system.py)** | System context | System prompt with tool schemas |
| **[`CondensationSummaryEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/condenser.py)** | Condenser summary | LLM-convertible summary of forgotten events |
| **[`ConversationStateUpdateEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/conversation_state.py)** | State updates | Key-value conversation state changes |
| **[`Condensation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/condenser.py)** | Condensation result | Events being forgotten with optional summary |
| **[`CondensationRequest`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/condenser.py)** | Request compression | Trigger for conversation history compression |
| **[`PauseEvent`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/user_action.py)** | User pause | User requested pause of agent execution |
## Event Types
### LLM-Convertible Events
Events that participate in agent reasoning and can be converted to LLM messages:
| Event Type | Source | Content | LLM Role |
|------------|--------|---------|----------|
| **MessageEvent (user)** | user | Text, images | `user` |
| **MessageEvent (agent)** | agent | Text reasoning, skills | `assistant` |
| **ActionEvent** | agent | Tool call with thought, reasoning, security risk | `assistant` with `tool_calls` |
| **ObservationEvent** | environment | Tool execution result | `tool` |
| **UserRejectObservation** | environment | Rejection reason | `tool` |
| **AgentErrorEvent** | agent | Error details | `tool` |
| **SystemPromptEvent** | agent | System prompt with tool schemas | `system` |
| **CondensationSummaryEvent** | environment | Summary of forgotten events | `user` |
The event system bridges agent events to LLM messages:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Events["Event List"]
Filter["Filter LLMConvertibleEvent"]
Group["Group ActionEvents by llm_response_id"]
Convert["Convert to Messages"]
LLM["LLM Input"]
Events --> Filter
Filter --> Group
Group --> Convert
Convert --> LLM
style Filter fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Group fill:#fff4df,stroke:#b7791f,stroke-width:2px
style Convert fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Special Handling - Parallel Function Calling:**
When multiple `ActionEvent`s share the same `llm_response_id` (parallel function calling):
1. Group all ActionEvents by `llm_response_id`
2. Combine into single Message with multiple `tool_calls`
3. Only first event's `thought`, `reasoning_content`, and `thinking_blocks` are included
4. All subsequent events in the batch have empty thought fields
**Example:**
```
ActionEvent(llm_response_id="abc123", thought="Let me check...", tool_call=tool1)
ActionEvent(llm_response_id="abc123", thought=[], tool_call=tool2)
→ Combined into single Message(role="assistant", content="Let me check...", tool_calls=[tool1, tool2])
```
### Internal Events
Events for metadata, control flow, and user actions (not sent to LLM):
| Event Type | Source | Purpose | Key Fields |
|------------|--------|---------|------------|
| **ConversationStateUpdateEvent** | environment | State synchronization | `key` (field name), `value` (serialized data) |
| **CondensationRequest** | environment | Trigger history compression | Signal to condenser when context window exceeded |
| **Condensation** | environment | Compression result | `forgotten_event_ids`, `summary`, `summary_offset` |
| **PauseEvent** | user | User pause action | Indicates agent execution was paused by user |
**Source Types:**
- **user**: Event originated from user input
- **agent**: Event generated by agent logic
- **environment**: Event from system/framework/tools
## Component Relationships
### How Events Integrate
## `source` vs LLM `role`
Events often carry **two different concepts** that are easy to confuse:
- **`Event.source`**: where the event *originated* (`user`, `agent`, or `environment`). This is about attribution.
- **LLM `role`** (e.g. `Message.role` / `MessageEvent.llm_message.role`): how the event should be represented to the LLM (`system`, `user`, `assistant`, `tool`). This is about LLM formatting.
These fields are **intentionally independent**.
Common examples include:
- **Observations**: tool results are typically `source="environment"` and represented to the LLM with `role="tool"`.
- **Synthetic framework messages**: the SDK may inject feedback or control messages (e.g. from hooks) as `source="environment"` while still using an LLM `role="user"` so the agent reads it as a user-facing instruction.
**Do not infer event origin from LLM role.** If you need to distinguish real user input from synthetic/framework messages, rely on `Event.source` (and any explicit metadata fields on the event), not the LLM role.
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Events["Event System"]
Agent["Agent"]
Conversation["Conversation"]
Tools["Tools"]
Services["Auxiliary Services"]
Agent -->|Reads| Events
Agent -->|Writes| Events
Conversation -->|Manages| Events
Tools -->|Creates| Events
Events -.->|Stream| Services
style Events fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Conversation fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Agent → Events**: Reads history for context, writes actions/messages
- **Conversation → Events**: Owns and persists event log
- **Tools → Events**: Create ObservationEvents after execution
- **Services → Events**: Read-only observers for monitoring, visualization
## Error Events: Agent vs Conversation
Two distinct error events exist in the SDK, with different purpose and visibility:
- AgentErrorEvent
- Type: ObservationBaseEvent (LLM-convertible)
- Scope: Error for a specific tool call (has tool_name and tool_call_id)
- Source: "agent"
- LLM visibility: Sent as a tool message so the model can react/recover
- Effect: Conversation continues; not a terminal state
- Code: https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/observation.py
- ConversationErrorEvent
- Type: Event (not LLM-convertible)
- Scope: Conversation-level runtime failure (no tool_name/tool_call_id)
- Source: typically "environment"
- LLM visibility: Not sent to the model
- Effect: Run loop transitions to ERROR and run() raises ConversationRunError; surface top-level error to client applications
- Code: https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/conversation_error.py
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - How agents read and write events
- **[Conversation Architecture](/sdk/arch/conversation)** - Event log management
- **[Tool System](/sdk/arch/tool-system)** - ActionEvent and ObservationEvent generation
- **[Condenser](/sdk/arch/condenser)** - Event history compression
### LLM
Source: https://docs.openhands.dev/sdk/arch/llm.md
The **LLM** system provides a unified interface to language model providers through LiteLLM. It handles model configuration, request orchestration, retry logic, telemetry, and cost tracking across all providers.
**Source:** [`openhands-sdk/openhands/sdk/llm/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/llm)
## Core Responsibilities
The LLM system has five primary responsibilities:
1. **Provider Abstraction** - Uniform interface to OpenAI, Anthropic, Google, and 100+ providers
2. **Request Pipeline** - Dual API support: Chat Completions (`completion()`) and Responses API (`responses()`)
3. **Configuration Management** - Load from environment, JSON, or programmatic configuration
4. **Telemetry & Cost** - Track usage, latency, and costs across providers
5. **Enhanced Reasoning** - Support for OpenAI Responses API with encrypted thinking and reasoning summaries
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 70}} }%%
flowchart TB
subgraph Configuration["Configuration Sources"]
Env["Environment Variables LLM_MODEL, LLM_API_KEY"]
JSON["JSON Files config/llm.json"]
Code["Programmatic LLM(...)"]
end
subgraph Core["Core LLM"]
Model["LLM Model Pydantic configuration"]
Pipeline["Request Pipeline Retry, timeout, telemetry"]
end
subgraph Backend["LiteLLM Backend"]
Providers["100+ Providers OpenAI, Anthropic, etc."]
end
subgraph Output["Telemetry"]
Usage["Token Usage"]
Cost["Cost Tracking"]
Latency["Latency Metrics"]
end
Env --> Model
JSON --> Model
Code --> Model
Model --> Pipeline
Pipeline --> Providers
Pipeline --> Usage
Pipeline --> Cost
Pipeline --> Latency
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Model primary
class Pipeline secondary
class LiteLLM tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`LLM`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/llm.py)** | Configuration model | Pydantic model with provider settings |
| **[`completion()`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/llm.py)** | Chat Completions API | Handles retries, timeouts, streaming |
| **[`responses()`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/llm.py)** | Responses API | Enhanced reasoning with encrypted thinking |
| **[`LiteLLM`](https://github.com/BerriAI/litellm)** | Provider adapter | Unified API for 100+ providers |
| **Configuration Loaders** | Config hydration | `load_from_env()`, `load_from_json()` |
| **Telemetry** | Usage tracking | Token counts, costs, latency |
## Configuration
See [`LLM` source](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/llm.py) for complete list of supported fields.
### Programmatic Configuration
Create LLM instances directly in code:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Code["Python Code"]
LLM["LLM(model=...)"]
Agent["Agent"]
Code --> LLM
LLM --> Agent
style LLM fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
```
**Example:**
```python
from pydantic import SecretStr
from openhands.sdk import LLM
llm = LLM(
model="anthropic/claude-sonnet-4.1",
api_key=SecretStr("sk-ant-123"),
temperature=0.1,
timeout=120,
)
```
### Environment Variable Configuration
Load from environment using naming convention:
**Environment Variable Pattern:**
- **Prefix:** All variables start with `LLM_`
- **Mapping:** `LLM_FIELD` → `field` (lowercased)
- **Types:** Auto-cast to int, float, bool, JSON, or SecretStr
**Common Variables:**
```bash
export LLM_MODEL="anthropic/claude-sonnet-4.1"
export LLM_API_KEY="sk-ant-123"
export LLM_USAGE_ID="primary"
export LLM_TIMEOUT="120"
export LLM_NUM_RETRIES="5"
```
### JSON Configuration
Serialize and load from JSON files:
**Example:**
```python
# Save
llm.model_dump_json(exclude_none=True, indent=2)
# Load
llm = LLM.load_from_json("config/llm.json")
```
**Security:** Secrets are redacted in serialized JSON (combine with environment variables for sensitive data).
If you need to include secrets in JSON, use `llm.model_dump_json(exclude_none=True, context={"expose_secrets": True})`.
## Request Pipeline
### Completion Flow
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 20}} }%%
flowchart TB
Request["completion() or responses() call"]
Validate["Validate Config"]
Attempt["LiteLLM Request"]
Success{"Success?"}
Retry{"Retries remaining?"}
Wait["Exponential Backoff"]
Telemetry["Record Telemetry"]
Response["Return Response"]
Error["Raise Error"]
Request --> Validate
Validate --> Attempt
Attempt --> Success
Success -->|Yes| Telemetry
Success -->|No| Retry
Retry -->|Yes| Wait
Retry -->|No| Error
Wait --> Attempt
Telemetry --> Response
style Attempt fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Retry fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Telemetry fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Pipeline Stages:**
1. **Validation:** Check required fields (model, messages)
2. **Request:** Call LiteLLM with provider-specific formatting
3. **Retry Logic:** Exponential backoff on failures (configurable)
4. **Telemetry:** Record tokens, cost, latency
5. **Response:** Return completion or raise error
### Responses API Support
In addition to the standard chat completion API, the LLM system supports [OpenAI's Responses API](https://platform.openai.com/docs/api-reference/responses) as an alternative invocation path for models that benefit from this newer interface (e.g., GPT-5-Codex only supports Responses API). The Responses API provides enhanced reasoning capabilities with encrypted thinking and detailed reasoning summaries.
#### Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Check{"Model supports Responses API?"}
subgraph Standard["Standard Path"]
ChatFormat["Format as Chat Messages"]
ChatCall["litellm.completion()"]
end
subgraph ResponsesPath["Responses Path"]
RespFormat["Format as instructions + input[]"]
RespCall["litellm.responses()"]
end
ChatResponse["ModelResponse"]
RespResponse["ResponsesAPIResponse"]
Parse["Parse to Message"]
Return["LLMResponse"]
Check -->|No| ChatFormat
Check -->|Yes| RespFormat
ChatFormat --> ChatCall
RespFormat --> RespCall
ChatCall --> ChatResponse
RespCall --> RespResponse
ChatResponse --> Parse
RespResponse --> Parse
Parse --> Return
style RespFormat fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style RespCall fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
#### Supported Models
Models that automatically use the Responses API path:
| Pattern | Examples | Documentation |
|---------|----------|---------------|
| **gpt-5*** | `gpt-5`, `gpt-5-mini`, `gpt-5-codex` | OpenAI GPT-5 family |
**Detection:** The SDK automatically detects if a model supports the Responses API using pattern matching in [`model_features.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/utils/model_features.py).
## Provider Integration
### LiteLLM Abstraction
Software Agent SDK uses LiteLLM for provider abstraction:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart TB
SDK["Software Agent SDK"]
LiteLLM["LiteLLM"]
subgraph Providers["100+ Providers"]
OpenAI["OpenAI"]
Anthropic["Anthropic"]
Google["Google"]
Azure["Azure"]
Others["..."]
end
SDK --> LiteLLM
LiteLLM --> OpenAI
LiteLLM --> Anthropic
LiteLLM --> Google
LiteLLM --> Azure
LiteLLM --> Others
style LiteLLM fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style SDK fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Benefits:**
- **100+ Providers:** OpenAI, Anthropic, Google, Azure, AWS Bedrock, local models, etc.
- **Unified API:** Same interface regardless of provider
- **Format Translation:** Provider-specific request/response formatting
- **Error Handling:** Normalized error codes and messages
### LLM Providers
Provider integrations remain shared between the Software Agent SDK and the OpenHands Application.
The pages linked below live under the OpenHands app section but apply
verbatim to SDK applications because both layers wrap the same
`openhands.sdk.llm.LLM` interface.
| Provider / scenario | Documentation |
| --- | --- |
| OpenHands hosted models | [/openhands/usage/llms/openhands-llms](/openhands/usage/llms/openhands-llms) |
| OpenAI | [/openhands/usage/llms/openai-llms](/openhands/usage/llms/openai-llms) |
| Azure OpenAI | [/openhands/usage/llms/azure-llms](/openhands/usage/llms/azure-llms) |
| Google Gemini / Vertex | [/openhands/usage/llms/google-llms](/openhands/usage/llms/google-llms) |
| Groq | [/openhands/usage/llms/groq](/openhands/usage/llms/groq) |
| OpenRouter | [/openhands/usage/llms/openrouter](/openhands/usage/llms/openrouter) |
| Moonshot | [/openhands/usage/llms/moonshot](/openhands/usage/llms/moonshot) |
| LiteLLM proxy | [/openhands/usage/llms/litellm-proxy](/openhands/usage/llms/litellm-proxy) |
| Local LLMs (Ollama, SGLang, vLLM, LM Studio) | [/openhands/usage/llms/local-llms](/openhands/usage/llms/local-llms) |
| Custom LLM configurations | [/openhands/usage/llms/custom-llm-configs](/openhands/usage/llms/custom-llm-configs) |
When you follow any of those guides while building with the SDK, create an
`LLM` object using the documented parameters (for example, API keys, base URLs,
or custom headers) and pass it into your agent or registry. The OpenHands UI
surfacing is simply a convenience layer on top of the same configuration model.
## Telemetry and Cost Tracking
### Telemetry Collection
LLM requests automatically collect metrics:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Request["LLM Request"]
subgraph Metrics
Tokens["Token Counts Input/Output"]
Cost["Cost USD"]
Latency["Latency ms"]
end
Events["Event Log"]
Request --> Tokens
Request --> Cost
Request --> Latency
Tokens --> Events
Cost --> Events
Latency --> Events
style Metrics fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Events fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Tracked Metrics:**
- **Token Usage:** Input tokens, output tokens, total
- **Cost:** Per-request cost using configured rates
- **Latency:** Request duration in milliseconds
- **Errors:** Failure types and retry counts
### Cost Configuration
Configure per-token costs for custom models:
```python
llm = LLM(
model="custom/my-model",
input_cost_per_token=0.00001, # $0.01 per 1K tokens
output_cost_per_token=0.00003, # $0.03 per 1K tokens
)
```
**Built-in Costs:** LiteLLM includes costs for major providers (updated regularly, [link](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json))
**Custom Costs:** Override for:
- Internal models
- Custom pricing agreements
- Cost estimation for budgeting
## Component Relationships
### How LLM Integrates
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
LLM["LLM"]
Agent["Agent"]
Conversation["Conversation"]
Events["Events"]
Security["Security Analyzer"]
Condenser["Context Condenser"]
Agent -->|Uses| LLM
LLM -->|Records| Events
Security -.->|Optional| LLM
Condenser -.->|Optional| LLM
Conversation -->|Provides context| Agent
style LLM fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Events fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Agent → LLM**: Agent uses LLM for reasoning and tool calls
- **LLM → Events**: LLM requests/responses recorded as events
- **Security → LLM**: Optional security analyzer can use separate LLM
- **Condenser → LLM**: Optional context condenser can use separate LLM
- **Configuration**: LLM configured independently, passed to agent
- **Telemetry**: LLM metrics flow through event system to UI/logging
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - How agents use LLMs for reasoning and perform actions
- **[Events](/sdk/arch/events)** - LLM request/response event types
- **[Security](/sdk/arch/security)** - Optional LLM-based security analysis
- **[Provider Setup Guides](/openhands/usage/llms/openai-llms)** - Provider-specific configuration
### MCP Integration
Source: https://docs.openhands.dev/sdk/arch/mcp.md
The **MCP Integration** system enables agents to use external tools via the Model Context Protocol (MCP). It provides a bridge between MCP servers and the Software Agent SDK's tool system, supporting both synchronous and asynchronous execution.
**Source:** [`openhands/sdk/mcp/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/mcp)
## Core Responsibilities
The MCP Integration system has four primary responsibilities:
1. **MCP Client Management** - Connect to and communicate with MCP servers
2. **Tool Discovery** - Enumerate available tools from MCP servers
3. **Schema Adaptation** - Convert MCP tool schemas to SDK tool definitions
4. **Execution Bridge** - Execute MCP tool calls from agent actions
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 35}} }%%
flowchart TB
subgraph Client["MCP Client"]
Sync["MCPClient Sync/Async bridge"]
Async["AsyncMCPClient FastMCP base"]
end
subgraph Bridge["Tool Bridge"]
Def["MCPToolDefinition Schema conversion"]
Exec["MCPToolExecutor Execution handler"]
end
subgraph Integration["Agent Integration"]
Action["MCPToolAction Dynamic model"]
Obs["MCPToolObservation Result wrapper"]
end
subgraph External["External"]
Server["MCP Server stdio/HTTP"]
Tools["External Tools"]
end
Sync --> Async
Async --> Server
Server --> Def
Def --> Exec
Exec --> Action
Action --> Server
Server --> Obs
Server -.->|Spawns| Tools
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Sync,Async primary
class Def,Exec secondary
class Action,Obs tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`MCPClient`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/client.py)** | Client wrapper | Extends FastMCP with sync/async bridge |
| **[`MCPToolDefinition`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/definition.py)** | Tool metadata | Converts MCP schemas to SDK format |
| **[`MCPToolExecutor`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/tool.py)** | Execution handler | Bridges agent actions to MCP calls |
| **[`MCPToolAction`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/definition.py)** | Dynamic action model | Runtime-generated Pydantic model |
| **[`MCPToolObservation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/definition.py)** | Result wrapper | Wraps MCP tool results |
## MCP Client
### Sync/Async Bridge
The SDK's `MCPClient` extends FastMCP's async client with synchronous wrappers:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Sync["Sync Code Agent execution"]
Bridge["call_async_from_sync()"]
Executor["AsyncExecutor Background loop"]
Async["Async MCP Call"]
Server["MCP Server"]
Result["Result"]
Sync --> Bridge
Bridge --> Executor
Executor --> Async
Async --> Server
Server --> Result
Result --> Sync
style Bridge fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Executor fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Async fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Bridge Pattern:**
- **Problem:** MCP protocol is async, but agent tools run synchronously
- **Solution:** Background event loop that executes async code from sync contexts
- **Benefit:** Agents use MCP tools without async/await in tool definitions
**Client Features:**
- **Lifecycle Management:** `__enter__`/`__exit__` for context manager
- **Timeout Support:** Configurable timeouts for MCP operations
- **Error Handling:** Wraps MCP errors in observations
- **Connection Pooling:** Reuses connections across tool calls
### MCP Server Configuration
MCP servers are configured using the FastMCP format:
```python
mcp_config = {
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
}
}
}
```
**Configuration Fields:**
- **command:** Executable to spawn (e.g., `uvx`, `npx`, `node`)
- **args:** Arguments to pass to command
- **env:** Environment variables (optional)
## Tool Discovery and Conversion
### Discovery Flow
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Config["MCP Config"]
Spawn["Spawn Server"]
List["List Tools"]
subgraph Convert["Convert Each Tool"]
Schema["MCP Schema"]
Action["Generate Action Model"]
Def["Create ToolDefinition"]
end
Register["Register in ToolRegistry"]
Config --> Spawn
Spawn --> List
List --> Schema
Schema --> Action
Action --> Def
Def --> Register
style Spawn fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Action fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Register fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Discovery Steps:**
1. **Spawn Server:** Launch MCP server via stdio
2. **List Tools:** Call `tools/list` MCP endpoint
3. **Parse Schemas:** Extract tool names, descriptions, parameters
4. **Generate Models:** Dynamically create Pydantic models for actions
5. **Create Definitions:** Wrap in `ToolDefinition` objects
6. **Register:** Add to agent's tool registry
### Schema Conversion
MCP tool schemas are converted to SDK tool definitions:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
MCP["MCP Tool Schema JSON Schema"]
Parse["Parse Parameters"]
Model["Dynamic Pydantic Model MCPToolAction"]
Def["ToolDefinition SDK format"]
MCP --> Parse
Parse --> Model
Model --> Def
style Parse fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Model fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Conversion Rules:**
| MCP Schema | SDK Action Model |
|------------|------------------|
| **name** | Class name (camelCase) |
| **description** | Docstring |
| **inputSchema** | Pydantic fields |
| **required** | Field(required=True) |
| **type** | Python type hints |
**Example:**
```python
# MCP Schema
{
"name": "fetch_url",
"description": "Fetch content from URL",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string"},
"timeout": {"type": "number"}
},
"required": ["url"]
}
}
# Generated Action Model
class FetchUrl(MCPToolAction):
"""Fetch content from URL"""
url: str
timeout: float | None = None
```
## Tool Execution
### Execution Flow
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Agent["Agent generates action"]
Action["MCPToolAction"]
Executor["MCPToolExecutor"]
Convert["Convert to MCP format"]
Call["MCP call_tool"]
Server["MCP Server"]
Result["MCP Result"]
Obs["MCPToolObservation"]
Return["Return to Agent"]
Agent --> Action
Action --> Executor
Executor --> Convert
Convert --> Call
Call --> Server
Server --> Result
Result --> Obs
Obs --> Return
style Executor fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Call fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Obs fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Execution Steps:**
1. **Action Creation:** LLM generates tool call, parsed into `MCPToolAction`
2. **Executor Lookup:** Find `MCPToolExecutor` for tool name
3. **Format Conversion:** Convert action fields to MCP arguments
4. **MCP Call:** Execute `call_tool` via MCP client
5. **Result Parsing:** Parse MCP result (text, images, resources)
6. **Observation Creation:** Wrap in `MCPToolObservation`
7. **Error Handling:** Catch exceptions, return error observations
### MCPToolExecutor
Executors bridge SDK actions to MCP calls:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Executor["MCPToolExecutor"]
Client["MCP Client"]
Name["tool_name"]
Executor -->|Uses| Client
Executor -->|Knows| Name
style Executor fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Client fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Executor Responsibilities:**
- **Client Management:** Hold reference to MCP client
- **Tool Identification:** Know which MCP tool to call
- **Argument Conversion:** Transform action fields to MCP format
- **Result Handling:** Parse MCP responses
- **Error Recovery:** Handle connection errors, timeouts, server failures
## MCP Tool Lifecycle
### From Configuration to Execution
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Load["Load MCP Config"]
Start["Start Conversation"]
Spawn["Spawn MCP Servers"]
Discover["Discover Tools"]
Register["Register Tools"]
Ready["Agent Ready"]
Step["Agent Step"]
LLM["LLM Tool Call"]
Execute["Execute MCP Tool"]
Result["Return Observation"]
End["End Conversation"]
Cleanup["Close MCP Clients"]
Load --> Start
Start --> Spawn
Spawn --> Discover
Discover --> Register
Register --> Ready
Ready --> Step
Step --> LLM
LLM --> Execute
Execute --> Result
Result --> Step
Step --> End
End --> Cleanup
style Spawn fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Execute fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Cleanup fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Lifecycle Phases:**
| Phase | Operations | Components |
|-------|-----------|------------|
| **Initialization** | Spawn servers, discover tools | MCPClient, ToolRegistry |
| **Registration** | Create definitions, executors | MCPToolDefinition, MCPToolExecutor |
| **Execution** | Handle tool calls | Agent, MCPToolAction |
| **Cleanup** | Close connections, shutdown servers | MCPClient.sync_close() |
## MCP Annotations
MCP tools can include metadata hints for agents:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Tool["MCP Tool"]
subgraph Annotations
ReadOnly["readOnlyHint"]
Destructive["destructiveHint"]
Progress["progressEnabled"]
end
Security["Security Analysis"]
Tool --> ReadOnly
Tool --> Destructive
Tool --> Progress
ReadOnly --> Security
Destructive --> Security
style Destructive fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Security fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Annotation Types:**
| Annotation | Meaning | Use Case |
|------------|---------|----------|
| **readOnlyHint** | Tool doesn't modify state | Lower security risk |
| **destructiveHint** | Tool modifies/deletes data | Require confirmation |
| **progressEnabled** | Tool reports progress | Show progress UI |
These annotations feed into the security analyzer for risk assessment.
## Component Relationships
### How MCP Integrates
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
MCP["MCP System"]
Skills["Skills"]
Tools["Tool Registry"]
Agent["Agent"]
Security["Security"]
Skills -->|Configures| MCP
MCP -->|Registers| Tools
Agent -->|Uses| Tools
MCP -->|Provides hints| Security
style MCP fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Skills fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Agent fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Skills → MCP**: Repository skills can embed MCP configurations
- **MCP → Tools**: MCP tools registered alongside native tools
- **Agent → Tools**: Agents use MCP tools like any other tool
- **MCP → Security**: Annotations inform security risk assessment
- **Transparent Integration**: Agent doesn't distinguish MCP from native tools
## Design Rationale
**Async Bridge Pattern:** MCP protocol requires async, but synchronous tool execution simplifies agent implementation. Background event loop bridges the gap without exposing async complexity to tool users.
**Dynamic Model Generation:** Creating Pydantic models at runtime from MCP schemas enables type-safe tool calls without manual model definitions. This supports arbitrary MCP servers without SDK code changes.
**Unified Tool Interface:** Wrapping MCP tools in `ToolDefinition` makes them indistinguishable from native tools. Agents use the same interface regardless of tool source.
**FastMCP Foundation:** Building on FastMCP (MCP SDK for Python) provides battle-tested client implementation, protocol compliance, and ongoing updates as MCP evolves.
**Annotation Support:** Exposing MCP hints (readOnly, destructive) enables intelligent security analysis and user confirmation flows based on tool characteristics.
**Lifecycle Management:** Automatic spawn/cleanup of MCP servers in conversation lifecycle ensures resources are properly managed without manual bookkeeping.
## See Also
- **[Tool System](/sdk/arch/tool-system)** - How MCP tools integrate with tool framework
- **[Skill Architecture](/sdk/arch/skill)** - Embedding MCP configs in repository skills
- **[Security](/sdk/arch/security)** - How MCP annotations inform risk assessment
- **[MCP Guide](/sdk/guides/mcp)** - Using MCP tools in applications
- **[FastMCP Documentation](https://gofastmcp.com/)** - Underlying MCP client library
### Overview
Source: https://docs.openhands.dev/sdk/arch/overview.md
The **OpenHands Software Agent SDK** provides a unified, type-safe framework for building and deploying AI agents—from local experiments to full production systems, focused on **statelessness**, **composability**, and **clear boundaries** between research and deployment.
Check [this document](/sdk/arch/design) for the core design principles that guided its architecture.
## Relationship With OpenHands Applications
The Software Agent SDK is the source of truth for agents in OpenHands. Its repository also contains Agent Server, which exposes SDK conversations and workspaces to remote clients through REST and WebSocket APIs. OpenHands applications live in separate repositories and consume these SDK interfaces.
- **The SDK defines agent behavior.** It provides agents, LLMs, conversations, tools, workspaces, events, and security policies.
- **Agent Server exposes remote execution.** Clients use its APIs to run conversations and tools in the selected workspace or sandbox.
- **Applications remain separate.** [Agent Canvas](https://github.com/OpenHands/OpenHands), the [OpenHands CLI](https://github.com/OpenHands/OpenHands-CLI), and custom clients integrate with the SDK or Agent Server without sharing one application repository.
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 50}} }%%
graph TB
subgraph Clients["Clients"]
Canvas[Agent Canvas Browser client]
CLI[OpenHands CLI Command-line client]
Custom[Custom Client Applications and workflows]
end
Server[Agent Server REST and WebSocket API]
SDK[Software Agent SDK Agents, tools, and workspaces]
subgraph External["External Services"]
LLM[LLM Providers]
Workspace[Workspace or Sandbox]
end
Canvas --> Server
CLI --> SDK
Custom --> Server
Custom --> SDK
Server --> SDK
SDK --> LLM
SDK --> Workspace
classDef interface fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef sdk fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef external fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Canvas,CLI,Custom interface
class Server,SDK sdk
class LLM,Workspace external
```
## Four-Package Architecture
The agent-sdk is organized into four distinct Python packages:
| Package | What It Does | When You Need It |
|---------|-------------|------------------|
| **openhands.sdk** | Core agent framework + base workspace classes | Always (required) |
| **openhands.tools** | Pre-built tools (bash, file editing, etc.) | Optional - provides common tools |
| **openhands.workspace** | Extended workspace implementations (Docker, remote) | Optional - extends SDK's base classes |
| **openhands.agent_server** | Multi-user API server | Optional - used by workspace implementations |
### Two Deployment Modes
The SDK supports two deployment architectures depending on your needs:
#### Mode 1: Local Development
**Installation:** Just install `openhands-sdk` + `openhands-tools`
```bash
pip install openhands-sdk openhands-tools
```
**Architecture:**
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart LR
SDK["openhands.sdk Agent · LLM · Conversation + LocalWorkspace"]:::sdk
Tools["openhands.tools BashTool · FileEditor · GrepTool · …"]:::tools
SDK -->|uses| Tools
classDef sdk fill:#e8f3ff,stroke:#2b6cb0,color:#0f2a45,stroke-width:2px,rx:8,ry:8
classDef tools fill:#e9f9ef,stroke:#2f855a,color:#14532d,stroke-width:2px,rx:8,ry:8
```
- `LocalWorkspace` included in SDK (no extra install)
- Everything runs in one process
- Perfect for prototyping and simple use cases
- Quick setup, no Docker required
#### Mode 2: Production / Sandboxed
**Installation:** Install all 4 packages
```bash
pip install openhands-sdk openhands-tools openhands-workspace openhands-agent-server
```
**Architecture:**
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 20, "rankSpacing": 30}} }%%
flowchart LR
WSBase["openhands.sdk Base Classes: Workspace · Local · Remote"]:::sdk
subgraph WS[" "]
direction LR
Docker["openhands.workspace DockerWorkspace extends RemoteWorkspace"]:::ws
Remote["openhands.workspace RemoteAPIWorkspace extends RemoteWorkspace"]:::ws
end
Server["openhands.agent_server FastAPI + WebSocket"]:::server
Agent["openhands.sdk Agent · LLM · Conversation"]:::sdk
Tools["openhands.tools BashTool · FileEditor · …"]:::tools
WSBase -.->|extended by| Docker
WSBase -.->|extended by| Remote
Docker -->|spawns container with| Server
Remote -->|connects via HTTP to| Server
Server -->|runs| Agent
Agent -->|uses| Tools
classDef sdk fill:#e8f3ff,stroke:#2b6cb0,color:#0f2a45,stroke-width:1.1px,rx:8,ry:8
classDef ws fill:#fff4df,stroke:#b7791f,color:#5b3410,stroke-width:1.1px,rx:8,ry:8
classDef server fill:#f3e8ff,stroke:#7c3aed,color:#3b2370,stroke-width:1.1px,rx:8,ry:8
classDef tools fill:#e9f9ef,stroke:#2f855a,color:#14532d,stroke-width:1.1px,rx:8,ry:8
style WS stroke:#b7791f,stroke-width:1.5px,stroke-dasharray: 4 3,rx:8,ry:8,fill:none
```
- `RemoteWorkspace` auto-spawns agent-server in containers
- Sandboxed execution for security
- Multi-user deployments
- Distributed systems (e.g., Kubernetes) support
**Key Point:** Same agent code works in both modes—just swap the workspace type (`LocalWorkspace` → `DockerWorkspace` → `RemoteAPIWorkspace`).
### SDK Package (`openhands.sdk`)
**Purpose:** Core components and base classes for OpenHands agent.
**Key Components:**
- **[Agent](/sdk/arch/agent):** Implements the reasoning-action loop
- **[Conversation](/sdk/arch/conversation):** Manages conversation state and lifecycle
- **[LLM](/sdk/arch/llm):** Provider-agnostic language model interface with retry and telemetry
- **[Tool System](/sdk/arch/tool-system):** Typed base class definitions for action, observation, tool, and executor; includes MCP integration
- **[Events](/sdk/arch/events):** Typed event framework (e.g., action, observation, user messages, state update, etc.)
- **[Workspace](/sdk/arch/workspace):** Base classes (`Workspace`, `LocalWorkspace`, `RemoteWorkspace`)
- **[Skill](/sdk/arch/skill):** Reusable user-defined prompts with trigger-based activation
- **[Condenser](/sdk/arch/condenser):** Conversation history compression for token management
- **[Security](/sdk/arch/security):** Action risk assessment and validation before execution
**Design:** Stateless, immutable components with type-safe Pydantic models.
**Self-Contained:** Build and run agents with just `openhands-sdk` using `LocalWorkspace`.
**Source:** [`openhands-sdk/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk)
### Tools Package (`openhands.tools`)
**Tool Independence:** Tools run alongside the agent in whatever environment workspace configures (local/container/remote). They don't run "through" workspace APIs.
**Purpose:** Pre-built tools following consistent patterns.
**Design:** All tools follow Action/Observation/Executor pattern with built-in validation, error handling, and security.
For full list of tools, see the [source code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools) as the source of truth.
### Workspace Package (`openhands.workspace`)
**Purpose:** Workspace implementations extending SDK base classes.
**Key Components:** Docker Workspace, Remote API Workspace, and more.
**Design:** All workspace implementations extend `RemoteWorkspace` from SDK, adding container lifecycle or API client functionality.
**Use Cases:** Sandboxed execution, multi-user deployments, production environments.
For full list of implemented workspaces, see the [source code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-workspace).
### Agent Server Package (`openhands.agent_server`)
**Purpose:** FastAPI-based HTTP/WebSocket server for remote agent execution.
**Features:**
- REST API & WebSocket endpoints for conversations, bash, files, events, desktop, and VSCode
- [OpenAI-compatible `/v1/chat/completions` endpoint](/sdk/guides/agent-server/openai-gateway) for clients that expect an OpenAI-style backend
- Service management with isolated per-user sessions
- API key authentication and health checking
**Deployment:** Runs inside containers (via `DockerWorkspace`) or as standalone process (connected via `RemoteWorkspace`).
**Use Cases:** Multi-user web apps, SaaS products, distributed systems.
For implementation details, see the [source code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server).
## How Components Work Together
### Basic Execution Flow (Local)
When you send a message to an agent, here's what happens:
```mermaid
sequenceDiagram
participant You
participant Conversation
participant Agent
participant LLM
participant Tool
You->>Conversation: "Create hello.txt"
Conversation->>Agent: Process message
Agent->>LLM: What should I do?
LLM-->>Agent: Use BashTool("touch hello.txt")
Agent->>Tool: Execute action
Note over Tool: Runs in same environment as Agent (local/container/remote)
Tool-->>Agent: Observation
Agent->>LLM: Got result, continue?
LLM-->>Agent: Done
Agent-->>Conversation: Update state
Conversation-->>You: "File created!"
```
**Key takeaway:** The agent orchestrates the reasoning-action loop—calling the LLM for decisions and executing tools to perform actions.
### Deployment Flexibility
The same agent code runs in different environments by swapping workspace configuration:
```mermaid
graph TB
subgraph "Your Code (Unchanged)"
Code["Agent + Tools + LLM"]
end
subgraph "Deployment Options"
Local["Local Direct execution"]
Docker["Docker Containerized"]
Remote["Remote Multi-user server"]
end
Code -->|LocalWorkspace| Local
Code -->|DockerWorkspace| Docker
Code -->|RemoteAPIWorkspace| Remote
style Code fill:#e1f5fe
style Local fill:#e8f5e8
style Docker fill:#e8f5e8
style Remote fill:#e8f5e8
```
## Next Steps
### Get Started
- [Getting Started](/sdk/getting-started) – Build your first agent
- [Hello World](/sdk/guides/hello-world) – Minimal example
### Explore Components
**SDK Package:**
- [Agent](/sdk/arch/agent) – Core reasoning-action loop
- [Conversation](/sdk/arch/conversation) – State management and lifecycle
- [LLM](/sdk/arch/llm) – Language model integration
- [Tool System](/sdk/arch/tool-system) – Action/Observation/Executor pattern
- [Events](/sdk/arch/events) – Typed event framework
- [Workspace](/sdk/arch/workspace) – Base workspace architecture
**Tools Package:**
- See [`openhands-tools/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools) source code for implementation details
**Workspace Package:**
- See [`openhands-workspace/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-workspace) source code for implementation details
**Agent Server:**
- See [`openhands-agent-server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server) source code for implementation details
### Deploy
- [Remote Server](/sdk/guides/agent-server/overview) – Deploy remotely
- [Docker Sandboxed Server](/sdk/guides/agent-server/docker-sandbox) – Container setup
- [API Sandboxed Server](/sdk/guides/agent-server/api-sandbox) – Hosted runtime service
- [Local Agent Server](/sdk/guides/agent-server/local-server) – In-process server
### Source Code
- [`openhands/sdk/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk) – Core framework
- [`openhands/tools/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools) – Pre-built tools
- [`openhands/workspace/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-workspace/openhands/workspace) – Workspaces
- [`openhands/agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server/openhands/agent_server) – HTTP server
- [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples) – Working examples
### SDK Package
Source: https://docs.openhands.dev/sdk/arch/sdk.md
The SDK package (`openhands.sdk`) is the heart of the OpenHands Software Agent SDK. It provides the core framework for building agents locally or embedding them in applications.
**Source**: [`sdk/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk)
## Purpose
The SDK package handles:
- **Agent reasoning loop**: How agents process messages and make decisions
- **State management**: Conversation lifecycle and persistence
- **LLM integration**: Provider-agnostic language model access
- **Tool system**: Typed actions and observations
- **Workspace abstraction**: Where code executes
- **Extensibility**: Skills, condensers, MCP, security
## Core Components
```mermaid
graph TB
Conv[Conversation Lifecycle Manager] --> Agent[Agent Reasoning Loop]
Agent --> LLM[LLM Language Model]
Agent --> Tools[Tool System Capabilities]
Agent --> Micro[Skills Behavior Modules]
Agent --> Cond[Condenser Memory Manager]
Tools --> Workspace[Workspace Execution]
Conv --> Events[Events Communication]
Tools --> MCP[MCP External Tools]
Workspace --> Security[Security Validation]
style Conv fill:#e1f5fe
style Agent fill:#f3e5f5
style LLM fill:#e8f5e8
style Tools fill:#fff3e0
style Workspace fill:#fce4ec
```
### 1. Conversation - State & Lifecycle
**What it does**: Manages the entire conversation lifecycle and state.
**Key responsibilities**:
- Maintains conversation state (immutable)
- Handles message flow between user and agent
- Manages turn-taking and async execution
- Persists and restores conversation state
- Emits events for monitoring
**Design decisions**:
- **Immutable state**: Each operation returns a new Conversation instance
- **Serializable**: Can be saved to disk or database and restored
- **Async-first**: Built for streaming and concurrent execution
**When to use directly**: When you need fine-grained control over conversation state, want to implement custom persistence, or need to pause/resume conversations.
**Example use cases**:
- Saving conversation to database after each turn
- Implementing undo/redo functionality
- Building multi-session chatbots
- Time-travel debugging
**Learn more**:
- Guide: [Conversation Persistence](/sdk/guides/convo-persistence)
- Guide: [Pause and Resume](/sdk/guides/convo-pause-and-resume)
- Source: [`conversation/`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation)
---
### 2. Agent - The Reasoning Loop
**What it does**: The core reasoning engine that processes messages and decides what to do.
**Key responsibilities**:
- Receives messages and current state
- Consults LLM to reason about next action
- Validates and executes tool calls
- Processes observations and loops until completion
- Integrates with skills for specialized behavior
**Design decisions**:
- **Stateless**: Agent doesn't hold state, operates on Conversation
- **Extensible**: Behavior can be modified via skills
- **Provider-agnostic**: Works with any LLM through unified interface
**The reasoning loop**:
1. Receive message from Conversation
2. Add message to context
3. Consult LLM with full conversation history
4. If LLM returns tool call → validate and execute tool
5. If tool returns observation → add to context, go to step 3
6. If LLM returns response → done, return to user
**When to customize**: When you need specialized reasoning strategies, want to implement custom agent behaviors, or need to control the execution flow.
**Example use cases**:
- Planning agents that break tasks into steps
- Code review agents with specific checks
- Agents with domain-specific reasoning patterns
**Learn more**:
- Guide: [Custom Agents](/sdk/guides/agent-custom)
- Guide: [Agent Stuck Detector](/sdk/guides/agent-stuck-detector)
- Source: [`agent/`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/agent)
---
### 3. LLM - Language Model Integration
**What it does**: Provides a provider-agnostic interface to language models.
**Key responsibilities**:
- Abstracts different LLM providers (OpenAI, Anthropic, etc.)
- Handles message formatting and conversion
- Manages streaming responses
- Supports tool calling and reasoning modes
- Handles retries and error recovery
**Design decisions**:
- **Provider-agnostic**: Same API works with any provider
- **Streaming-first**: Built for real-time responses
- **Type-safe**: Pydantic models for all messages
- **Extensible**: Easy to add new providers
**Why provider-agnostic?** You can switch between OpenAI, Anthropic, local models, etc. without changing your agent code. This is crucial for:
- Cost optimization (switch to cheaper models)
- Testing with different models
- Avoiding vendor lock-in
- Supporting customer choice
**When to customize**: When you need to add a new LLM provider, implement custom retries, or modify message formatting.
**Example use cases**:
- Routing requests to different models based on complexity
- Implementing custom caching strategies
- Adding observability hooks
**Learn more**:
- Guide: [LLM Registry](/sdk/guides/llm-registry)
- Guide: [LLM Routing](/sdk/guides/llm-routing)
- Guide: [Reasoning and Tool Use](/sdk/guides/llm-reasoning)
- Source: [`llm/`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm)
---
### 4. Tool System - Typed Capabilities
**What it does**: Defines what agents can do through a typed action/observation pattern.
**Key responsibilities**:
- Defines tool schemas (inputs and outputs)
- Validates actions before execution
- Executes tools and returns typed observations
- Generates JSON schemas for LLM tool calling
- Registers tools with the agent
**Design decisions**:
- **Action/Observation pattern**: Tools are defined as type-safe input/output pairs
- **Schema generation**: Pydantic models auto-generate JSON schemas
- **Executor pattern**: Separation of tool definition and execution
- **Composable**: Tools can call other tools
**The three components**:
1. **Action**: Input schema (what the tool accepts)
2. **Observation**: Output schema (what the tool returns)
3. **ToolExecutor**: Logic that transforms Action → Observation
**Why this pattern?**
- Type safety catches errors early
- LLMs get accurate schemas for tool calling
- Tools are testable in isolation
- Easy to compose tools
**When to customize**: When you need domain-specific capabilities not covered by built-in tools.
**Example use cases**:
- Database query tools
- API integration tools
- Custom file format parsers
- Domain-specific calculators
**Learn more**:
- Guide: [Custom Tools](/sdk/guides/custom-tools)
- Source: [`tools/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools)
---
### 5. Workspace - Execution Abstraction
**What it does**: Abstracts *where* code executes (local, Docker, remote).
**Key responsibilities**:
- Provides unified interface for code execution
- Handles file operations across environments
- Manages working directories
- Supports different isolation levels
**Design decisions**:
- **Abstract interface**: LocalWorkspace in SDK, advanced types in workspace package
- **Environment-agnostic**: Code works the same locally or remotely
- **Lazy initialization**: Workspace setup happens on first use
**Why abstract?** You can develop locally with LocalWorkspace, then deploy with DockerWorkspace or RemoteAPIWorkspace without changing agent code.
**When to use directly**: Rarely - usually configured when creating an agent. Use advanced workspaces for production.
**Learn more**:
- Architecture: [Workspace Architecture](/sdk/arch/workspace)
- Guides: [Remote Agent Server](/sdk/guides/agent-server/overview)
- Source: [`workspace/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/workspace)
---
### 6. Events - Component Communication
**What it does**: Enables observability and debugging through event emissions.
**Key responsibilities**:
- Defines event types (messages, actions, observations, errors)
- Emitted by Conversation, Agent, Tools
- Enables logging, debugging, and monitoring
- Supports custom event handlers
**Design decisions**:
- **Immutable**: Events are snapshots, not mutable objects
- **Serializable**: Can be logged, stored, replayed
- **Type-safe**: Pydantic models for all events
**Why events?** They provide a timeline of what happened during agent execution. Essential for:
- Debugging agent behavior
- Understanding decision-making
- Building observability dashboards
- Implementing custom logging
**When to use**: When building monitoring systems, debugging tools, or need to track agent behavior.
**Learn more**:
- Guide: [Metrics and Observability](/sdk/guides/metrics)
- Source: [`event/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/event)
---
### 7. Condenser - Memory Management
**What it does**: Compresses conversation history when it gets too long.
**Key responsibilities**:
- Monitors conversation length
- Summarizes older messages
- Preserves important context
- Keeps conversation within token limits
**Design decisions**:
- **Pluggable**: Different condensing strategies
- **Automatic**: Triggered when context gets large
- **Preserves semantics**: Important information retained
**Why needed?** LLMs have token limits. Long conversations would eventually exceed context windows. Condensers keep conversations running indefinitely while staying within limits.
**When to customize**: When you need domain-specific summarization strategies or want to control what gets preserved.
**Example strategies**:
- Summarize old messages
- Keep only last N turns
- Preserve task-related messages
**Learn more**:
- Guide: [Context Condenser](/sdk/guides/context-condenser)
- Source: [`condenser/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/context/condenser)
---
### 8. MCP - Model Context Protocol
**What it does**: Integrates external tool servers via Model Context Protocol.
**Key responsibilities**:
- Connects to MCP-compatible tool servers
- Translates MCP tools to SDK tool format
- Manages server lifecycle
- Handles server communication
**Design decisions**:
- **Standard protocol**: Uses MCP specification
- **Transparent integration**: MCP tools look like regular tools to agents
- **Process management**: Handles server startup/shutdown
**Why MCP?** It lets you use external tools without writing custom SDK integrations. Many tools (databases, APIs, services) provide MCP servers.
**When to use**: When you need tools that:
- Already have MCP servers (fetch, filesystem, etc.)
- Are too complex to rewrite as SDK tools
- Need to run in separate processes
- Are provided by third parties
**Learn more**:
- Guide: [MCP Integration](/sdk/guides/mcp)
- Spec: [Model Context Protocol](https://modelcontextprotocol.io/)
- Source: [`mcp/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/mcp)
---
### 9. Skills (formerly Microagents) - Behavior Modules
**What it does**: Specialized modules that modify agent behavior for specific tasks.
**Key responsibilities**:
- Provide domain-specific instructions
- Modify system prompts
- Guide agent decision-making
- Compose to create specialized agents
**Design decisions**:
- **Composable**: Multiple skills can work together
- **Declarative**: Defined as configuration, not code
- **Reusable**: Share skills across agents
**Why skills?** Instead of hard-coding behaviors, skills let you compose agent personalities and capabilities. Like "plugins" for agent behavior.
**Example skills**:
- GitHub operations (issue creation, PRs)
- Code review guidelines
- Documentation style enforcement
- Project-specific conventions
**When to use**: When you need agents with specialized knowledge or behavior patterns that apply to specific domains or tasks.
**Learn more**:
- Guide: [Agent Skills & Context](/sdk/guides/skill)
- Source: [`skills/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/context/skills)
---
### 10. Security - Validation & Sandboxing
**What it does**: Validates inputs and enforces security constraints.
**Key responsibilities**:
- Input validation
- Command sanitization
- Path traversal prevention
- Resource limits
**Design decisions**:
- **Defense in depth**: Multiple validation layers
- **Fail-safe**: Rejects suspicious inputs by default
- **Configurable**: Adjust security levels as needed
**Why needed?** Agents execute arbitrary code and file operations. Security prevents:
- Malicious prompts escaping sandboxes
- Path traversal attacks
- Resource exhaustion
- Unintended system access
**When to customize**: When you need domain-specific validation rules or want to adjust security policies.
**Learn more**:
- Guide: [Security and Secrets](/sdk/guides/security)
- Source: [`security/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/security)
---
## How Components Work Together
### Example: User asks agent to create a file
```
1. User → Conversation: "Create a file called hello.txt with 'Hello World'"
2. Conversation → Agent: New message event
3. Agent → LLM: Full conversation history + available tools
4. LLM → Agent: Tool call for FileEditorTool.create()
5. Agent → Tool System: Validate FileEditorAction
6. Tool System → Tool Executor: Execute action
7. Tool Executor → Workspace: Create file (local/docker/remote)
8. Workspace → Tool Executor: Success
9. Tool Executor → Tool System: FileEditorObservation (success=true)
10. Tool System → Agent: Observation
11. Agent → LLM: Updated history with observation
12. LLM → Agent: "File created successfully"
13. Agent → Conversation: Done, final response
14. Conversation → User: "File created successfully"
```
Throughout this flow:
- **Events** are emitted for observability
- **Condenser** may trigger if history gets long
- **Skills** influence LLM's decision-making
- **Security** validates file paths and operations
- **MCP** could provide additional tools if configured
## Design Patterns
### Immutability
All core objects are immutable. Operations return new instances:
```python
conversation = Conversation(...)
new_conversation = conversation.add_message(message)
# conversation is unchanged, new_conversation has the message
```
**Why?** Makes debugging easier, enables time-travel, ensures serializability.
### Composition Over Inheritance
Agents are composed from:
- LLM provider
- Tool list
- Skill list
- Condenser strategy
- Security policy
You don't subclass Agent - you configure it.
**Why?** More flexible, easier to test, enables runtime configuration.
### Type Safety
Everything uses Pydantic models:
- Messages, actions, observations are typed
- Validation happens automatically
- Schemas generate from types
**Why?** Catches errors early, provides IDE support, self-documenting.
## Next Steps
### For Usage Examples
- [Getting Started](/sdk/getting-started) - Build your first agent
- [Custom Tools](/sdk/guides/custom-tools) - Extend capabilities
- [LLM Configuration](/sdk/guides/llm-registry) - Configure providers
- [Conversation Management](/sdk/guides/convo-persistence) - State handling
### For Related Architecture
- [Tool System](/sdk/arch/tool-system) - Built-in tool implementations
- [Workspace Architecture](/sdk/arch/workspace) - Execution environments
- [Agent Server Architecture](/sdk/arch/agent-server) - Remote execution
### For Implementation Details
- [`openhands-sdk/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk) - SDK source code
- [`openhands-tools/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools) - Tools source code
- [`openhands-workspace/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-workspace) - Workspace source code
- [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples) - Working examples
### Security
Source: https://docs.openhands.dev/sdk/arch/security.md
The **Security** system evaluates agent actions for potential risks before execution. It provides pluggable security analyzers that assess action risk levels and enforce confirmation policies based on security characteristics.
**Source:** [`openhands-sdk/penhands/sdk/security/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/security)
## Core Responsibilities
The Security system has four primary responsibilities:
1. **Risk Assessment** - Capture and validate LLM-provided risk levels for actions
2. **Confirmation Policy** - Determine when user approval is required based on risk
3. **Action Validation** - Enforce security policies before execution
4. **Audit Trail** - Record security decisions in event history
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 50}} }%%
flowchart TB
subgraph Interface["Abstract Interface"]
Base["SecurityAnalyzerBase Abstract analyzer"]
end
subgraph Implementations["Concrete Analyzers"]
LLM["LLMSecurityAnalyzer Inline risk prediction"]
NoOp["NoOpSecurityAnalyzer No analysis"]
end
subgraph Risk["Risk Levels"]
Low["LOW Safe operations"]
Medium["MEDIUM Moderate risk"]
High["HIGH Dangerous ops"]
Unknown["UNKNOWN Unanalyzed"]
end
subgraph Policy["Confirmation Policy"]
Check["should_require_confirmation()"]
Mode["Confirmation Mode"]
Decision["Require / Allow"]
end
Base --> LLM
Base --> NoOp
Implementations --> Low
Implementations --> Medium
Implementations --> High
Implementations --> Unknown
Low --> Check
Medium --> Check
High --> Check
Unknown --> Check
Check --> Mode
Mode --> Decision
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
classDef danger fill:#ffe8e8,stroke:#dc2626,stroke-width:2px
class Base primary
class LLM secondary
class High danger
class Check tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`SecurityAnalyzerBase`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/analyzer.py)** | Abstract interface | Defines `security_risk()` contract |
| **[`LLMSecurityAnalyzer`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/llm_analyzer.py)** | Inline risk assessment | Returns LLM-provided risk from action arguments |
| **[`NoOpSecurityAnalyzer`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/analyzer.py)** | Passthrough analyzer | Always returns UNKNOWN |
| **[`SecurityRisk`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/risk.py)** | Risk enum | LOW, MEDIUM, HIGH, UNKNOWN |
| **[`ConfirmationPolicy`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/confirmation_policy.py)** | Decision logic | Maps risk levels to confirmation requirements |
## Risk Levels
Security analyzers return one of four risk levels:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart TB
Action["ActionEvent"]
Analyze["Security Analyzer"]
subgraph Levels["Risk Levels"]
Low["LOW Read-only, safe"]
Medium["MEDIUM Modify files"]
High["HIGH Delete, execute"]
Unknown["UNKNOWN Not analyzed"]
end
Action --> Analyze
Analyze --> Low
Analyze --> Medium
Analyze --> High
Analyze --> Unknown
style Low fill:#d1fae5,stroke:#10b981,stroke-width:2px
style Medium fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style High fill:#ffe8e8,stroke:#dc2626,stroke-width:2px
style Unknown fill:#f3f4f6,stroke:#6b7280,stroke-width:2px
```
### Risk Level Definitions
| Level | Characteristics | Examples |
|-------|----------------|----------|
| **LOW** | Read-only, no state changes | File reading, directory listing, search |
| **MEDIUM** | Modifies user data | File editing, creating files, API calls |
| **HIGH** | Dangerous operations | File deletion, system commands, privilege escalation |
| **UNKNOWN** | Not analyzed or indeterminate | Complex commands, ambiguous operations |
## Security Analyzers
### LLMSecurityAnalyzer
Leverages the LLM's inline risk assessment during action generation:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Schema["Tool Schema + security_risk param"]
LLM["LLM generates action with security_risk"]
ToolCall["Tool Call Arguments {command: 'rm -rf', security_risk: 'HIGH'}"]
Extract["Extract security_risk from arguments"]
ActionEvent["ActionEvent with security_risk set"]
Analyzer["LLMSecurityAnalyzer returns security_risk"]
Schema --> LLM
LLM --> ToolCall
ToolCall --> Extract
Extract --> ActionEvent
ActionEvent --> Analyzer
style Schema fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Extract fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Analyzer fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Analysis Process:**
1. **Schema Enhancement:** A required `security_risk` parameter is added to each tool's schema
2. **LLM Generation:** The LLM generates tool calls with `security_risk` as part of the arguments
3. **Risk Extraction:** The agent extracts the `security_risk` value from the tool call arguments
4. **ActionEvent Creation:** The security risk is stored on the `ActionEvent`
5. **Analyzer Query:** `LLMSecurityAnalyzer.security_risk()` returns the pre-assigned risk level
6. **No Additional LLM Calls:** Risk assessment happens inline—no separate analysis step
**Example Tool Call:**
```json
{
"name": "execute_bash",
"arguments": {
"command": "rm -rf /tmp/cache",
"security_risk": "HIGH"
}
}
```
The LLM reasons about risk in context when generating the action, eliminating the need for a separate security analysis call.
**Configuration:**
- **Enabled When:** A `LLMSecurityAnalyzer` is configured for the agent
- **Schema Modification:** Automatically adds `security_risk` field to non-read-only tools
- **Zero Overhead:** No additional LLM calls or latency beyond normal action generation
### NoOpSecurityAnalyzer
Passthrough analyzer that skips analysis:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Action["ActionEvent"]
NoOp["NoOpSecurityAnalyzer"]
Unknown["SecurityRisk.UNKNOWN"]
Action --> NoOp --> Unknown
style NoOp fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
```
**Use Case:** Development, trusted environments, or when confirmation mode handles all actions
## Confirmation Policy
The confirmation policy determines when user approval is required. There are three policy implementations:
**Source:** [`confirmation_policy.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/confirmation_policy.py)
### Policy Types
| Policy | Behavior | Use Case |
|--------|----------|----------|
| **[`AlwaysConfirm`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/confirmation_policy.py#L27-L32)** | Requires confirmation for **all** actions | Maximum safety, interactive workflows |
| **[`NeverConfirm`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/confirmation_policy.py#L35-L40)** | Never requires confirmation | Fully autonomous agents, trusted environments |
| **[`ConfirmRisky`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/confirmation_policy.py#L43-L62)** | Configurable risk-based policy | Balanced approach, production use |
### ConfirmRisky (Default Policy)
The most flexible policy with configurable thresholds:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Risk["SecurityRisk"]
CheckUnknown{"Risk == UNKNOWN?"}
UseConfirmUnknown{"confirm_unknown setting?"}
CheckThreshold{"risk.is_riskier (threshold)?"}
Confirm["Require Confirmation"]
Allow["Allow Execution"]
Risk --> CheckUnknown
CheckUnknown -->|Yes| UseConfirmUnknown
CheckUnknown -->|No| CheckThreshold
UseConfirmUnknown -->|True| Confirm
UseConfirmUnknown -->|False| Allow
CheckThreshold -->|Yes| Confirm
CheckThreshold -->|No| Allow
style CheckUnknown fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Confirm fill:#ffe8e8,stroke:#dc2626,stroke-width:2px
style Allow fill:#d1fae5,stroke:#10b981,stroke-width:2px
```
**Configuration:**
- **`threshold`** (default: `HIGH`) - Risk level at or above which confirmation is required
- Cannot be set to `UNKNOWN`
- Uses reflexive comparison: `risk.is_riskier(threshold)` returns `True` if `risk >= threshold`
- **`confirm_unknown`** (default: `True`) - Whether `UNKNOWN` risk requires confirmation
### Confirmation Rules by Policy
#### ConfirmRisky with threshold=HIGH (Default)
| Risk Level | `confirm_unknown=True` (default) | `confirm_unknown=False` |
|------------|----------------------------------|-------------------------|
| **LOW** | ✅ Allow | ✅ Allow |
| **MEDIUM** | ✅ Allow | ✅ Allow |
| **HIGH** | 🔒 Require confirmation | 🔒 Require confirmation |
| **UNKNOWN** | 🔒 Require confirmation | ✅ Allow |
#### ConfirmRisky with threshold=MEDIUM
| Risk Level | `confirm_unknown=True` | `confirm_unknown=False` |
|------------|------------------------|-------------------------|
| **LOW** | ✅ Allow | ✅ Allow |
| **MEDIUM** | 🔒 Require confirmation | 🔒 Require confirmation |
| **HIGH** | 🔒 Require confirmation | 🔒 Require confirmation |
| **UNKNOWN** | 🔒 Require confirmation | ✅ Allow |
#### ConfirmRisky with threshold=LOW
| Risk Level | `confirm_unknown=True` | `confirm_unknown=False` |
|------------|------------------------|-------------------------|
| **LOW** | 🔒 Require confirmation | 🔒 Require confirmation |
| **MEDIUM** | 🔒 Require confirmation | 🔒 Require confirmation |
| **HIGH** | 🔒 Require confirmation | 🔒 Require confirmation |
| **UNKNOWN** | 🔒 Require confirmation | ✅ Allow |
**Key Rules:**
- **Risk comparison** is **reflexive**: `HIGH.is_riskier(HIGH)` returns `True`
- **UNKNOWN handling** is configurable via `confirm_unknown` flag
- **Threshold cannot be UNKNOWN** - validated at policy creation time
## Component Relationships
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Security["Security Analyzer"]
Agent["Agent"]
Conversation["Conversation"]
Tools["Tools"]
MCP["MCP Tools"]
Agent -->|Validates actions| Security
Security -->|Checks| Tools
Security -->|Uses hints| MCP
Conversation -->|Pauses for confirmation| Agent
style Security fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Conversation fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Agent → Security**: Validates actions before execution
- **Security → Tools**: Examines tool characteristics (annotations)
- **Security → MCP**: Uses MCP hints for risk assessment
- **Conversation → Agent**: Pauses for user confirmation when required
- **Optional Component**: Security analyzer can be disabled for trusted environments
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - How agents use security analyzers
- **[Tool System](/sdk/arch/tool-system)** - Tool annotations and metadata; includes MCP tool hints
- **[Security Guide](/sdk/guides/security)** - Configuring security policies
### Skill
Source: https://docs.openhands.dev/sdk/arch/skill.md
The **Skill** system provides a mechanism for injecting reusable, specialized knowledge into agent context. Skills use trigger-based activation to determine when they should be included in the agent's prompt.
**Source:** [`openhands/sdk/context/skills/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/context/skills)
## Core Responsibilities
The Skill system has five primary responsibilities:
1. **Context Injection** - Add specialized prompts to agent context based on triggers
2. **Trigger Evaluation** - Determine when skills should activate (always, keyword, task, path)
3. **Dynamic Content Rendering** - Execute inline shell commands for dynamic context injection
4. **MCP Integration** - Load MCP tools associated with repository skills
5. **Third-Party Support** - Parse `.cursorrules`, `agents.md`, and other skill formats
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 35}} }%%
flowchart TB
subgraph Types["Skill Types"]
Repo["Repository Skill trigger: None"]
Knowledge["Knowledge Skill trigger: KeywordTrigger"]
Task["Task Skill trigger: TaskTrigger"]
Rule["Path Rule trigger: PathTrigger"]
end
subgraph Triggers["Trigger Evaluation"]
Always["Always Active Repository guidelines"]
Keyword["Keyword Match String matching on user messages"]
TaskMatch["Keyword Match + Inputs Same as KeywordTrigger + user inputs"]
PathMatch["File-Touch Match Glob match on touched file path"]
end
subgraph Content["Skill Content"]
Markdown["Markdown with Frontmatter"]
Dynamic["Dynamic Commands !`command` execution"]
MCPTools["MCP Tools Config Repo skills only"]
Inputs["Input Metadata Task skills only"]
end
subgraph Integration["Agent Integration"]
Context["Agent Context"]
Prompt["System Prompt"]
ToolResult["Tool Result Rules injected on file-touch"]
end
Repo --> Always
Knowledge --> Keyword
Task --> TaskMatch
Rule --> PathMatch
Always --> Markdown
Keyword --> Markdown
TaskMatch --> Markdown
PathMatch --> ToolResult
Markdown -.->|Optional| Dynamic
Repo -.->|Optional| MCPTools
Task -.->|Requires| Inputs
Markdown --> Context
Dynamic --> Context
MCPTools --> Context
Context --> Prompt
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
classDef dynamic fill:#e9f9ef,stroke:#2f855a,stroke-width:2px
class Repo,Knowledge,Task,Rule primary
class Always,Keyword,TaskMatch,PathMatch secondary
class Context,ToolResult tertiary
class Dynamic dynamic
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`Skill`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/skills/skill.py)** | Core skill model | Pydantic model with name, content, trigger |
| **[`KeywordTrigger`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/skills/trigger.py)** | Keyword-based activation | String matching on user messages |
| **[`TaskTrigger`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/skills/trigger.py)** | Task-based activation | Special type of KeywordTrigger for skills with user inputs |
| **[`PathTrigger`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/skills/trigger.py)** | Path-based activation ("rules") | Glob match on a touched file path; injected into the tool result, not model-invocable |
| **[`InputMetadata`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/skills/types.py)** | Task input parameters | Defines user inputs for task skills |
| **[`render_content_with_commands`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/skills/execute.py)** | Dynamic content | Executes inline `!`command`` patterns |
| **Skill Loader** | File parsing | Reads markdown with frontmatter, validates schema |
## Skill Types
### Repository Skills
Always-active, repository-specific guidelines.
**Recommended:** put these permanent instructions in `AGENTS.md` (and optionally `GEMINI.md` / `CLAUDE.md`) at the repo root.
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart LR
File["AGENTS.md"]
Parse["Parse Frontmatter"]
Skill["Skill(trigger=None)"]
Context["Always in Context"]
File --> Parse
Parse --> Skill
Skill --> Context
style Skill fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Context fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Characteristics:**
- **Trigger:** `None` (always active)
- **Purpose:** Project conventions, coding standards, architecture rules
- **MCP Tools:** Can include MCP tool configuration
- **Location:** `AGENTS.md` (recommended) and/or `.agents/skills/*.md` (supported)
**Example Files (permanent context):**
- `AGENTS.md` - General agent instructions
- `GEMINI.md` - Gemini-specific instructions
- `CLAUDE.md` - Claude-specific instructions
**Other supported formats:**
- `.cursorrules` - Cursor IDE guidelines
- `agents.md` / `agent.md` - General agent instructions
### Knowledge Skills
Keyword-triggered skills for specialized domains:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
User["User Message"]
Check["Check Keywords"]
Match{"Match?"}
Activate["Activate Skill"]
Skip["Skip Skill"]
Context["Add to Context"]
User --> Check
Check --> Match
Match -->|Yes| Activate
Match -->|No| Skip
Activate --> Context
style Check fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Activate fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Characteristics:**
- **Trigger:** `KeywordTrigger` with regex patterns
- **Purpose:** Domain-specific knowledge (e.g., "kubernetes", "machine learning")
- **Activation:** Keywords detected in user messages
- **Location:** System or user-defined knowledge base
**Trigger Example:**
```yaml
---
name: kubernetes
trigger:
type: keyword
keywords: ["kubernetes", "k8s", "kubectl"]
---
```
### Task Skills
Keyword-triggered skills with structured inputs for guided workflows:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
User["User Message"]
Match{"Keyword Match?"}
Inputs["Collect User Inputs"]
Template["Apply Template"]
Context["Add to Context"]
Skip["Skip Skill"]
User --> Match
Match -->|Yes| Inputs
Match -->|No| Skip
Inputs --> Template
Template --> Context
style Match fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Template fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Characteristics:**
- **Trigger:** `TaskTrigger` (a special type of KeywordTrigger for skills with user inputs)
- **Activation:** Keywords/triggers detected in user messages (same matching logic as KeywordTrigger)
- **Purpose:** Guided workflows (e.g., bug fixing, feature implementation)
- **Inputs:** User-provided parameters (e.g., bug description, acceptance criteria)
- **Location:** System-defined or custom task templates
**Trigger Example:**
```yaml
---
name: bug_fix
triggers: ["/bug_fix", "fix bug", "bug report"]
inputs:
- name: bug_description
description: "Describe the bug"
required: true
---
```
**Note:** TaskTrigger uses the same keyword matching mechanism as KeywordTrigger. The distinction is semantic - TaskTrigger is used for skills that require structured user inputs, while KeywordTrigger is for knowledge-based skills.
### Path Skills (Rules)
Skills that are injected **deterministically** when the agent touches a matching file, modeled on Claude Code "rules":
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Touch["Agent Reads/Edits/Creates File"]
Match{"Path Matches Glob?"}
Inject["Inject into Tool Result"]
Skip["Skip Rule"]
Dedup["Dedup: once per conversation"]
Touch --> Match
Match -->|Yes| Dedup
Match -->|No| Skip
Dedup --> Inject
style Match fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Inject fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Characteristics:**
- **Trigger:** `PathTrigger` with gitignore-style `paths` globs (matched against the workspace-relative POSIX path)
- **Activation:** The agent reads, edits, or creates a file whose path matches a glob (fires on `create` too)
- **Injection point:** Folded into the `ObservationEvent` tool result (`extended_content`) as an `` block — **not** the user message
- **Baseline cost:** Zero — excluded from `` and ``; `disable_model_invocation` is forced, so rules are never model-invocable
- **Dedup:** Each rule is injected only once per conversation (tracked via `ConversationState.activated_path_rules`)
- **Location:** Any skills directory (e.g. `.agents/skills/*.md`) — a rule is just a skill with `paths:` frontmatter
**Trigger Example:**
```yaml
---
paths:
- "src/api/**/*.ts"
- "**/*.route.ts"
---
```
**Note:** A skill is either path-triggered or model-invocable, not both — if a file declares both `paths:` and `triggers:`, `paths:` wins. Path-rule injection applies to local conversations; ACP-backed conversations do not inject rules because the ACP server owns tool execution.
## Trigger Evaluation
Skills are evaluated at different points in the agent lifecycle:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Start["Agent Step Start"]
Repo["Check Repository Skills trigger: None"]
AddRepo["Always Add to Context"]
Message["Check User Message"]
Keyword["Match Keyword Triggers"]
AddKeyword["Add Matched Skills"]
TaskType["Check Task Type"]
TaskMatch["Match Task Triggers"]
AddTask["Add Task Skill"]
Build["Build Agent Context"]
Start --> Repo
Repo --> AddRepo
Start --> Message
Message --> Keyword
Keyword --> AddKeyword
Start --> TaskType
TaskType --> TaskMatch
TaskMatch --> AddTask
AddRepo --> Build
AddKeyword --> Build
AddTask --> Build
style Repo fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Keyword fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style TaskMatch fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Evaluation Rules:**
| Trigger Type | Evaluation Point | Activation Condition |
|--------------|------------------|----------------------|
| **None** | Every step | Always active |
| **KeywordTrigger** | On user message | Keyword/string match in message |
| **TaskTrigger** | On user message | Keyword/string match in message (same as KeywordTrigger) |
| **PathTrigger** | On tool observation | Glob match on the touched file's path (read/edit/create) |
**Note:** Both KeywordTrigger and TaskTrigger use identical string matching logic. TaskTrigger is simply a semantic variant used for skills that include user input parameters.
## MCP Tool Integration
Repository skills can include MCP tool configurations:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Skill["Repository Skill"]
MCPConfig["mcp_tools Config"]
Client["MCP Client"]
Tools["Tool Registry"]
Skill -->|Contains| MCPConfig
MCPConfig -->|Spawns| Client
Client -->|Registers| Tools
style Skill fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style MCPConfig fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Tools fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**MCP Configuration Format:**
Skills can embed MCP server configuration following the [FastMCP format](https://gofastmcp.com/clients/client#configuration-format):
```yaml
---
name: repo_skill
mcp_tools:
mcpServers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
---
```
**Workflow:**
1. **Load Skill:** Parse markdown file with frontmatter
2. **Extract MCP Config:** Read `mcp_tools` field
3. **Spawn MCP Servers:** Create MCP clients for each server
4. **Register Tools:** Add MCP tools to agent's tool registry
5. **Inject Context:** Add skill content to agent prompt
## Dynamic Content Rendering
Skills support inline command execution for injecting dynamic context at render time:
1. Parse content for `` !`cmd` `` patterns outside code blocks
2. Execute each command via subprocess
3. Replace pattern with stdout (or error marker)
4. Return rendered content
**Syntax:**
- `` !`command` `` - Executes command and replaces with stdout
- `` \!`command` `` - Escapes to literal `` !`command` `` text
- Fenced (```) and inline (`) code blocks are never executed
**Safety:**
- Unclosed fenced blocks (odd ``` count) extend to EOF, protecting trailing content
- Failed commands return `[Error: ...]` markers
- Output truncated at 50KB per command
See [Dynamic Command Execution](/sdk/guides/skill#dynamic-command-execution) for usage details.
## Skill File Format
Skills are defined in markdown files with YAML frontmatter:
```markdown
---
name: skill_name
trigger:
type: keyword
keywords: ["pattern1", "pattern2"]
---
# Skill Content
This is the instruction text that will be added to the agent's context.
Dynamic values: !`git branch --show-current`
```
**Frontmatter Fields:**
| Field | Required | Description |
|-------|----------|-------------|
| **name** | Yes | Unique skill identifier |
| **trigger** | Yes* | Activation trigger (`null` for always active) |
| **paths** | No | Glob patterns that make the skill a path-triggered rule (`PathTrigger`); takes precedence over `triggers` |
| **mcp_tools** | No | MCP server configuration (repo skills only) |
| **inputs** | No | User input metadata (task skills only) |
*Repository skills use `trigger: null` (or omit trigger field)
## Component Relationships
### How Skills Integrate
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Skills["Skill System"]
Context["Agent Context"]
Agent["Agent"]
MCP["MCP Client"]
Skills -->|Injects content| Context
Skills -.->|Spawns tools| MCP
Context -->|System prompt| Agent
MCP -->|Tool| Agent
style Skills fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Context fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Agent fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Skills → Agent Context**: Active skills contribute their content to system prompt
- **Skills → MCP**: Repository skills can spawn MCP servers and register tools
- **Context → Agent**: Combined skill content becomes part of agent's instructions
- **Skills Lifecycle**: Loaded at conversation start, evaluated each step
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - How agents use skills for context
- **[Tool System](/sdk/arch/tool-system#mcp-integration)** - MCP tool spawning and client management
- **[Context Management Guide](/sdk/guides/skill)** - Using skills in applications
### Tool System & MCP
Source: https://docs.openhands.dev/sdk/arch/tool-system.md
The **Tool System** provides a type-safe, extensible framework for defining agent capabilities. It standardizes how agents interact with external systems through a structured Action-Observation pattern with automatic validation and schema generation.
**Source:** [`openhands-sdk/openhands/sdk/tool/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/tool)
## Core Responsibilities
The Tool System has four primary responsibilities:
1. **Type Safety** - Enforce action/observation schemas via Pydantic models
2. **Schema Generation** - Auto-generate LLM-compatible tool descriptions from Pydantic schemas
3. **Execution Lifecycle** - Validate inputs, execute logic, wrap outputs
4. **Tool Registry** - Discover and resolve tools by name or pattern
## Tool System
### Architecture Overview
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 50}} }%%
flowchart TB
subgraph Definition["Tool Definition"]
Action["Action Input schema"]
Observation["Observation Output schema"]
Executor["Executor Business logic"]
end
subgraph Framework["Tool Framework"]
Base["ToolBase Abstract base"]
Impl["Tool Implementation Concrete tool"]
Registry["Tool Registry Spec → Tool"]
end
Agent["Agent"]
LLM["LLM"]
ToolSpec["Tool Spec name + params"]
Base -.->|Extends| Impl
ToolSpec -->|resolve_tool| Registry
Registry -->|Create instances| Impl
Impl -->|Available in| Agent
Impl -->|Generate schema| LLM
LLM -->|Generate tool call| Agent
Agent -->|Parse & validate| Action
Agent -->|Execute via Tool.\_\_call\_\_| Executor
Executor -->|Return| Observation
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Base primary
class Action,Observation,Executor secondary
class Registry tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`ToolBase`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/tool.py)** | Abstract base class | Generic over Action and Observation types, defines abstract `create()` |
| **[`ToolDefinition`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/tool.py)** | Concrete tool class | Can be instantiated directly or subclassed for factory pattern |
| **[`Action`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/schema.py)** | Input model | Pydantic model with `visualize` property |
| **[`Observation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/schema.py)** | Output model | Pydantic model with `to_llm_content` property |
| **[`ToolExecutor`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/tool.py)** | Execution interface | ABC with `__call__()` method, optional `close()` |
| **[`ToolAnnotations`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/tool.py)** | Behavioral hints | MCP-spec hints (readOnly, destructive, idempotent, openWorld) |
| **[`Tool` (spec)](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/spec.py)** | Tool specification | Configuration object with name and params |
| **[`ToolRegistry`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/registry.py)** | Tool discovery | Resolves Tool specs to ToolDefinition instances |
### Action-Observation Pattern
The tool system follows a **strict input-output contract**: `Action → Observation`. The Agent layer wraps these in events for conversation management.
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
subgraph Agent["Agent Layer"]
ToolCall["MessageToolCall from LLM"]
ParseJSON["Parse JSON arguments"]
CreateAction["tool.action_from_arguments() Pydantic validation"]
WrapAction["ActionEvent wraps Action"]
WrapObs["ObservationEvent wraps Observation"]
Error["AgentErrorEvent"]
end
subgraph ToolSystem["Tool System"]
ActionType["Action Pydantic model"]
ToolCall2["tool.\_\_call\_\_(action) type-safe execution"]
Execute["ToolExecutor business logic"]
ObsType["Observation Pydantic model"]
end
ToolCall --> ParseJSON
ParseJSON -->|Valid JSON| CreateAction
ParseJSON -->|Invalid JSON| Error
CreateAction -->|Valid| ActionType
CreateAction -->|Invalid| Error
ActionType --> WrapAction
ActionType --> ToolCall2
ToolCall2 --> Execute
Execute --> ObsType
ObsType --> WrapObs
style ToolSystem fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style ActionType fill:#ddd6fe,stroke:#7c3aed,stroke-width:2px
style ObsType fill:#ddd6fe,stroke:#7c3aed,stroke-width:2px
```
**Tool System Boundary:**
- **Input**: `dict[str, Any]` (JSON arguments) → validated `Action` instance
- **Output**: `Observation` instance with structured result
- **No knowledge of**: Events, LLM messages, conversation state
### Tool Definition
Tools are defined using two patterns depending on complexity:
#### Pattern 1: Direct Instantiation (Simple Tools)
For stateless tools that don't need runtime configuration (e.g., `finish`, `think`):
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 20}} }%%
flowchart LR
Action["Define Action with visualize"]
Obs["Define Observation with to_llm_content"]
Exec["Define Executor stateless logic"]
Tool["ToolDefinition(..., executor=Executor())"]
Action --> Tool
Obs --> Tool
Exec --> Tool
style Tool fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
```
**Components:**
1. **Action** - Pydantic model with `visualize` property for display
2. **Observation** - Pydantic model with `to_llm_content` property for LLM
3. **ToolExecutor** - Stateless executor with `__call__(action) → observation`
4. **ToolDefinition** - Direct instantiation with executor instance
#### Pattern 2: Subclass with Factory (Stateful Tools)
For tools requiring runtime configuration or persistent state (e.g., `execute_bash`, `file_editor`, `glob`):
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 20}} }%%
flowchart LR
Action["Define Action with visualize"]
Obs["Define Observation with to_llm_content"]
Exec["Define Executor with \_\_init\_\_ and state"]
Subclass["class MyTool(ToolDefinition) with create() method"]
Instance["Return [MyTool(..., executor=instance)]"]
Action --> Subclass
Obs --> Subclass
Exec --> Subclass
Subclass --> Instance
style Instance fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Components:**
1. **Action/Observation** - Same as Pattern 1
2. **ToolExecutor** - Stateful executor with `__init__()` for configuration and optional `close()` for cleanup
3. **MyTool(ToolDefinition)** - Subclass with `@classmethod create(conv_state, ...)` factory method
4. **Factory Method** - Returns sequence of configured tool instances
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart TB
subgraph Pattern1["Pattern 1: Direct Instantiation"]
P1A["Define Action/Observation with visualize/to_llm_content"]
P1E["Define ToolExecutor with \_\_call\_\_()"]
P1T["ToolDefinition(..., executor=Executor())"]
end
subgraph Pattern2["Pattern 2: Subclass with Factory"]
P2A["Define Action/Observation with visualize/to_llm_content"]
P2E["Define Stateful ToolExecutor with \_\_init\_\_() and \_\_call\_\_()"]
P2C["class MyTool(ToolDefinition) @classmethod create()"]
P2I["Return [MyTool(..., executor=instance)]"]
end
P1A --> P1E
P1E --> P1T
P2A --> P2E
P2E --> P2C
P2C --> P2I
style P1T fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style P2I fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Key Design Elements:**
| Component | Purpose | Requirements |
|-----------|---------|--------------|
| **Action** | Defines LLM-provided parameters | Extends `Action`, includes `visualize` property returning Rich Text |
| **Observation** | Defines structured output | Extends `Observation`, includes `to_llm_content` property returning content list |
| **ToolExecutor** | Implements business logic | Extends `ToolExecutor[ActionT, ObservationT]`, implements `__call__()` method |
| **ToolDefinition** | Ties everything together | Either instantiate directly (Pattern 1) or subclass with `create()` method (Pattern 2) |
**When to Use Each Pattern:**
| Pattern | Use Case | Examples |
|---------|----------|----------|
| **Direct Instantiation** | Stateless tools with no configuration needs | `finish`, `think`, simple utilities |
| **Subclass with Factory** | Tools requiring runtime state or configuration | `execute_bash`, `file_editor`, `glob`, `grep` |
### Tool Annotations
Tools include optional `ToolAnnotations` based on the [Model Context Protocol (MCP) spec](https://github.com/modelcontextprotocol/modelcontextprotocol) that provide behavioral hints to LLMs:
| Field | Meaning | Examples |
|-------|---------|----------|
| `readOnlyHint` | Tool doesn't modify state | `glob` (True), `execute_bash` (False) |
| `destructiveHint` | May delete/overwrite data | `file_editor` (True), `task_tracker` (False) |
| `idempotentHint` | Repeated calls are safe | `glob` (True), `execute_bash` (False) |
| `openWorldHint` | Interacts beyond closed domain | `execute_bash` (True), `task_tracker` (False) |
**Key Behaviors:**
- [LLM-based Security risk prediction](/sdk/guides/security) automatically added for tools with `readOnlyHint=False`
- Annotations help LLMs reason about tool safety and side effects
### Tool Registry
The registry enables **dynamic tool discovery** and instantiation from tool specifications:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
ToolSpec["Tool Spec name + params"]
subgraph Registry["Tool Registry"]
Resolver["Resolver name → factory"]
Factory["Factory create(params)"]
end
Instance["Tool Instance with executor"]
Agent["Agent"]
ToolSpec -->|"resolve_tool(spec)"| Resolver
Resolver -->|Lookup factory| Factory
Factory -->|"create(**params)"| Instance
Instance -->|Used by| Agent
style Registry fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Factory fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Resolution Workflow:**
1. **[Tool (Spec)](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/spec.py)** - Configuration object with `name` (e.g., "BashTool") and `params` (e.g., `{"working_dir": "/workspace"}`)
2. **Resolver Lookup** - Registry finds the registered resolver for the tool name
3. **Factory Invocation** - Resolver calls the tool's `.create()` method with params and conversation state
4. **Instance Creation** - Tool instance(s) are created with configured executors
5. **Agent Usage** - Instances are added to the agent's tools_map for execution
**Registration Types:**
| Type | Registration | Resolver Behavior |
|------|-------------|-------------------|
| **Tool Instance** | `register_tool(name, instance)` | Returns the fixed instance (params not allowed) |
| **Tool Subclass** | `register_tool(name, ToolClass)` | Calls `ToolClass.create(**params, conv_state=state)` |
| **Factory Function** | `register_tool(name, factory)` | Calls `factory(**params, conv_state=state)` |
### File Organization
Tools follow a consistent file structure for maintainability:
```
openhands-tools/openhands/tools/my_tool/
├── __init__.py # Export MyTool
├── definition.py # Action, Observation, MyTool(ToolDefinition)
├── impl.py # MyExecutor(ToolExecutor)
└── [other modules] # Tool-specific utilities
```
**File Responsibilities:**
| File | Contains | Purpose |
|------|----------|---------|
| `definition.py` | Action, Observation, ToolDefinition subclass | Public API, schema definitions, factory method |
| `impl.py` | ToolExecutor implementation | Business logic, state management, execution |
| `__init__.py` | Tool exports | Package interface |
**Benefits:**
- **Separation of Concerns** - Public API separate from implementation
- **Avoid Circular Imports** - Import `impl` only inside `create()` method
- **Consistency** - All tools follow same structure for discoverability
**Example Reference:** See [`terminal/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools/terminal) for complete implementation
## MCP Integration
The tool system supports external tools via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). MCP tools are **configured separately from the tool registry** via the `mcp_config` field in `Agent` class and are automatically discovered from MCP servers during agent initialization.
**Source:** [`openhands-sdk/openhands/sdk/mcp/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/mcp)
### Architecture Overview
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 50}} }%%
flowchart TB
subgraph External["External MCP Server"]
Server["MCP Server stdio/HTTP"]
ExtTools["External Tools"]
end
subgraph Bridge["MCP Integration Layer"]
MCPClient["MCPClient Sync/Async bridge"]
Convert["Schema Conversion MCP → MCPToolDefinition"]
MCPExec["MCPToolExecutor Bridges to MCP calls"]
end
subgraph Agent["Agent System"]
ToolsMap["tools_map str -> ToolDefinition"]
AgentLogic["Agent Execution"]
end
Server -.->|Spawns| ExtTools
MCPClient --> Server
Server --> Convert
Convert -->|create_mcp_tools| MCPExec
MCPExec -->|Added during agent.initialize| ToolsMap
ToolsMap --> AgentLogic
AgentLogic -->|Tool call| MCPExec
MCPExec --> MCPClient
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef external fill:#fff4df,stroke:#b7791f,stroke-width:2px
class MCPClient primary
class Convert,MCPExec secondary
class Server,ExtTools external
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`MCPClient`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/client.py)** | MCP server connection | Extends FastMCP with sync/async bridge |
| **[`MCPToolDefinition`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/tool.py)** | Tool wrapper | Wraps MCP tools as SDK `ToolDefinition` with dynamic validation |
| **[`MCPToolExecutor`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/tool.py)** | Execution handler | Bridges agent actions to MCP tool calls via MCPClient |
| **[`MCPToolAction`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/definition.py)** | Generic action wrapper | Simple `dict[str, Any]` wrapper for MCP tool arguments |
| **[`MCPToolObservation`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/definition.py)** | Result wrapper | Wraps MCP tool results as observations with content blocks |
| **[`_create_mcp_action_type()`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/tool.py)** | Dynamic schema | Runtime Pydantic model generated from MCP `inputSchema` for validation |
### Sync/Async Bridge
MCP protocol is asynchronous, but SDK tools execute synchronously. The bridge pattern in [client.py](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/client.py) solves this:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Sync["Sync Tool Execution"]
Bridge["call_async_from_sync()"]
Loop["Background Event Loop"]
Async["Async MCP Call"]
Result["Return Result"]
Sync --> Bridge
Bridge --> Loop
Loop --> Async
Async --> Result
Result --> Sync
style Bridge fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Loop fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Bridge Features:**
- **Background Event Loop** - Executes async code from sync contexts
- **Timeout Support** - Configurable timeouts for MCP operations
- **Error Handling** - Wraps MCP errors in observations
- **Connection Pooling** - Reuses connections across tool calls
### Tool Discovery Flow
**Source:** [`create_mcp_tools()`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/mcp/utils.py) | [`agent._initialize()`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/agent/base.py)
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart TB
Config["MCP Server Config command + args"]
Spawn["Spawn Server Process MCPClient"]
List["List Available Tools client.list_tools()"]
subgraph Convert["For Each MCP Tool"]
Store["Store MCP metadata name, description, inputSchema"]
CreateExec["Create MCPToolExecutor bound to tool + client"]
Def["Create MCPToolDefinition generic MCPToolAction type"]
end
Register["Add to Agent's tools_map bypasses ToolRegistry"]
Ready["Tools Available Dynamic models created on-demand"]
Config --> Spawn
Spawn --> List
List --> Store
Store --> CreateExec
CreateExec --> Def
Def --> Register
Register --> Ready
style Spawn fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Def fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Register fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Discovery Steps:**
1. **Spawn Server** - Launch MCP server via stdio protocol (using `MCPClient`)
2. **List Tools** - Call MCP `tools/list` endpoint to retrieve available tools
3. **Parse Schemas** - Extract tool names, descriptions, and `inputSchema` from MCP response
4. **Create Definitions** - For each tool, call `MCPToolDefinition.create()` which:
- Creates an `MCPToolExecutor` instance bound to the tool name and client
- Wraps the MCP tool metadata in `MCPToolDefinition`
- Uses generic `MCPToolAction` as the action type (NOT dynamic models yet)
5. **Add to Agent** - All `MCPToolDefinition` instances are added to agent's `tools_map` during `initialize()` (bypasses ToolRegistry)
6. **Lazy Validation** - Dynamic Pydantic models are generated lazily when:
- `action_from_arguments()` is called (argument validation)
- `to_openai_tool()` is called (schema export to LLM)
**Schema Handling:**
| MCP Schema | SDK Integration | When Used |
|------------|----------------|-----------|
| `name` | Tool name (stored in `MCPToolDefinition`) | Discovery, execution |
| `description` | Tool description for LLM | Discovery, LLM prompt |
| `inputSchema` | Stored in `mcp_tool.inputSchema` | Lazy model generation |
| `inputSchema` fields | Converted to Pydantic fields via `Schema.from_mcp_schema()` | Validation, schema export |
| `annotations` | Mapped to `ToolAnnotations` | Security analysis, LLM hints |
### MCP Server Configuration
MCP servers are configured via the `mcp_config` field on the `Agent` class. Configuration follows [FastMCP config format](https://gofastmcp.com/clients/client#configuration-format):
```python
from openhands.sdk import Agent
agent = Agent(
mcp_config={
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
}
}
}
)
```
## Component Relationships
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart TB
subgraph Sources["Tool Sources"]
Native["Native Tools"]
MCP["MCP Tools"]
end
Registry["Tool Registry resolve_tool"]
ToolsMap["Agent.tools_map Merged tool dict"]
subgraph AgentSystem["Agent System"]
Agent["Agent Logic"]
LLM["LLM"]
end
Security["Security Analyzer"]
Conversation["Conversation State"]
Native -->|register_tool| Registry
Registry --> ToolsMap
MCP -->|create_mcp_tools| ToolsMap
ToolsMap -->|Provide schemas| LLM
Agent -->|Execute tools| ToolsMap
ToolsMap -.->|Action risk| Security
ToolsMap -.->|Read state| Conversation
style ToolsMap fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Agent fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style Security fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Relationship Characteristics:**
- **Native → Registry → tools_map**: Native tools resolved via `ToolRegistry`
- **MCP → tools_map**: MCP tools bypass registry, added directly during `initialize()`
- **tools_map → LLM**: Generate schemas describing all available capabilities
- **Agent → tools_map**: Execute actions, receive observations
- **tools_map → Conversation**: Read state for context-aware execution
- **tools_map → Security**: Tool annotations inform risk assessment
## See Also
- **[Agent Architecture](/sdk/arch/agent)** - How agents select and execute tools
- **[Events](/sdk/arch/events)** - ActionEvent and ObservationEvent structures
- **[Security Analyzer](/sdk/arch/security)** - Action risk assessment
- **[Skill Architecture](/sdk/arch/skill)** - Embedding MCP configs in repository skills
- **[Custom Tools Guide](/sdk/guides/custom-tools)** - Building your own tools
- **[FastMCP Documentation](https://gofastmcp.com/)** - Underlying MCP client library
### Workspace
Source: https://docs.openhands.dev/sdk/arch/workspace.md
The **Workspace** component abstracts execution environments for agent operations. It provides a unified interface for command execution and file operations across local processes, containers, and remote servers.
**Source:** [`openhands/sdk/workspace/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/workspace)
## Core Responsibilities
The Workspace system has four primary responsibilities:
1. **Execution Abstraction** - Unified interface for command execution across environments
2. **File Operations** - Upload, download, and manipulate files in workspace
3. **Resource Management** - Context manager protocol for setup/teardown
4. **Environment Isolation** - Separate agent execution from host system
## Architecture
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 25, "rankSpacing": 60}} }%%
flowchart TB
subgraph Interface["Abstract Interface"]
Base["BaseWorkspace Abstract base class"]
end
subgraph Implementations["Concrete Implementations"]
Local["LocalWorkspace Direct subprocess"]
Remote["RemoteWorkspace HTTP API calls"]
end
subgraph Operations["Core Operations"]
Command["execute_command()"]
Upload["file_upload()"]
Download["file_download()"]
Context["__enter__ / __exit__"]
end
subgraph Targets["Execution Targets"]
Process["Local Process"]
Container["Docker Container"]
Server["Remote Server"]
end
Base --> Local
Base --> Remote
Base -.->|Defines| Operations
Local --> Process
Remote --> Container
Remote --> Server
classDef primary fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
classDef secondary fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
classDef tertiary fill:#fff4df,stroke:#b7791f,stroke-width:2px
class Base primary
class Local,Remote secondary
class Command,Upload tertiary
```
### Key Components
| Component | Purpose | Design |
|-----------|---------|--------|
| **[`BaseWorkspace`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/base.py)** | Abstract interface | Defines execution and file operation contracts |
| **[`LocalWorkspace`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/local.py)** | Local execution | Subprocess-based command execution |
| **[`RemoteWorkspace`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/remote/base.py)** | Remote execution | HTTP API-based execution via agent-server |
| **[`CommandResult`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/models.py)** | Execution output | Structured result with stdout, stderr, exit_code |
| **[`FileOperationResult`](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/models.py)** | File op outcome | Success status and metadata |
## Workspace Types
### Local vs Remote Execution
| Aspect | LocalWorkspace | RemoteWorkspace |
|--------|----------------|-----------------|
| **Execution** | Direct subprocess | HTTP → agent-server |
| **Isolation** | Process-level | Container/VM-level |
| **Performance** | Fast (no network) | Network overhead |
| **Security** | Host system access | Sandboxed |
| **Use Case** | Development, CLI | Production, web apps |
## Core Operations
### Command Execution
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}} }%%
flowchart LR
Tool["Tool invokes execute_command()"]
Decision{"Workspace type?"}
LocalExec["subprocess.run() Direct execution"]
RemoteExec["POST /command HTTP API"]
Result["CommandResult stdout, stderr, exit_code"]
Tool --> Decision
Decision -->|Local| LocalExec
Decision -->|Remote| RemoteExec
LocalExec --> Result
RemoteExec --> Result
style Decision fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style LocalExec fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style RemoteExec fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Command Result Structure:**
| Field | Type | Description |
|-------|------|-------------|
| **stdout** | str | Standard output stream |
| **stderr** | str | Standard error stream |
| **exit_code** | int | Process exit code (0 = success) |
| **timeout** | bool | Whether command timed out |
| **duration** | float | Execution time in seconds |
### File Operations
| Operation | Local Implementation | Remote Implementation |
|-----------|---------------------|----------------------|
| **Upload** | `shutil.copy()` | `POST /file/upload` with multipart |
| **Download** | `shutil.copy()` | `GET /file/download` stream |
| **Result** | `FileOperationResult` | `FileOperationResult` |
## Resource Management
Workspaces use **context manager** for safe resource handling:
**Lifecycle Hooks:**
| Phase | LocalWorkspace | RemoteWorkspace |
|-------|----------------|-----------------|
| **Enter** | Create working directory | Connect to agent-server, verify |
| **Use** | Execute commands | Proxy commands via HTTP |
| **Exit** | No cleanup (persistent) | Disconnect, optionally stop container |
## Remote Workspace Extensions
The SDK provides remote workspace implementations in `openhands-workspace` package:
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 50}} }%%
flowchart TB
Base["RemoteWorkspace SDK base class"]
Docker["DockerWorkspace Auto-spawn containers"]
API["RemoteAPIWorkspace Connect to existing server"]
Base -.->|Extended by| Docker
Base -.->|Extended by| API
Docker -->|Creates| Container["Docker Container with agent-server"]
API -->|Connects| Server["Remote Agent Server"]
style Base fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Docker fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
style API fill:#fff4df,stroke:#b7791f,stroke-width:2px
```
**Implementation Comparison:**
| Type | Setup | Isolation | Use Case |
|------|-------|-----------|----------|
| **LocalWorkspace** | Immediate | Process | Development, trusted code |
| **DockerWorkspace** | Spawn container | Container | Multi-user, untrusted code |
| **RemoteAPIWorkspace** | Connect to URL | Remote server | Distributed systems, cloud |
**Source:**
- **DockerWorkspace**: [`openhands-workspace/openhands/workspace/docker`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-workspace/openhands/workspace/docker)
- **RemoteAPIWorkspace**: [`openhands-workspace/openhands/workspace/remote_api`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-workspace/openhands/workspace/remote_api)
## Component Relationships
### How Workspace Integrates
```mermaid
%%{init: {"theme": "default", "flowchart": {"nodeSpacing": 30}} }%%
flowchart LR
Workspace["Workspace"]
Conversation["Conversation"]
AgentServer["Agent Server"]
Conversation -->|Configures| Workspace
Workspace -.->|Remote type| AgentServer
style Workspace fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
style Conversation fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px
```
**Relationship Characteristics:**
- **Conversation → Workspace**: Conversation factory uses workspace type to select LocalConversation or RemoteConversation
- **Workspace → Agent Server**: RemoteWorkspace delegates operations to agent-server API
- **Tools Independence**: Tools run in the same environment as workspace
## See Also
- **[Conversation Architecture](/sdk/arch/conversation)** - How workspace type determines conversation implementation
- **[Agent Server](/sdk/arch/agent-server)** - Remote execution API
- **[Tool System](/sdk/arch/tool-system)** - Tools that use workspace for execution
### FAQ
Source: https://docs.openhands.dev/sdk/faq.md
## How do I use AWS Bedrock with the SDK?
**Yes, the OpenHands SDK supports AWS Bedrock through LiteLLM.**
Since LiteLLM requires `boto3` for Bedrock requests, you need to install it alongside the SDK.
### Step 1: Install boto3
Install the SDK with boto3:
```bash
# Using pip
pip install openhands-sdk boto3
# Using uv
uv pip install openhands-sdk boto3
# Or when installing as a CLI tool
uv tool install openhands --with boto3
```
### Step 2: Configure Authentication
You have two authentication options:
**Option A: API Key Authentication (Recommended)**
Use the `AWS_BEARER_TOKEN_BEDROCK` environment variable:
```bash
export AWS_BEARER_TOKEN_BEDROCK="your-bedrock-api-key"
```
**Option B: AWS Credentials**
Use traditional AWS credentials:
```bash
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_REGION_NAME="us-west-2"
```
### Step 3: Configure the Model
Use the `bedrock/` prefix for your model name:
```python
from openhands.sdk import LLM, Agent
llm = LLM(
model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
# api_key is read from AWS_BEARER_TOKEN_BEDROCK automatically
)
```
For cross-region inference profiles, include the region prefix:
```python
llm = LLM(
model="bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0", # US region
# or
model="bedrock/apac.anthropic.claude-sonnet-4-20250514-v1:0", # APAC region
)
```
For more details on Bedrock configuration options, see the [LiteLLM Bedrock documentation](https://docs.litellm.ai/docs/providers/bedrock).
## Does the agent SDK support parallel tool calling?
**Yes, the OpenHands SDK supports parallel tool calling by default.**
The SDK automatically handles parallel tool calls when the underlying LLM (like Claude or GPT-4) returns multiple tool calls in a single response. This allows agents to execute multiple independent actions before the next LLM call.
When the LLM generates multiple tool calls in parallel, the SDK groups them using a shared `llm_response_id`:
```python
ActionEvent(llm_response_id="abc123", thought="Let me check...", tool_call=tool1)
ActionEvent(llm_response_id="abc123", thought=[], tool_call=tool2)
# Combined into: Message(role="assistant", content="Let me check...", tool_calls=[tool1, tool2])
```
Multiple `ActionEvent`s with the same `llm_response_id` are grouped together and combined into a single LLM message with multiple `tool_calls`. Only the first event's thought/reasoning is included. The parallel tool calling implementation can be found in the [Events Architecture](/sdk/arch/events#event-types) for detailed explanation of how parallel function calling works, the [`prepare_llm_messages` in utils.py](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/agent/utils.py) which groups ActionEvents by `llm_response_id` when converting events to LLM messages, the [agent step method](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/agent/agent.py#L200-L300) where actions are created with shared `llm_response_id`, and the [`ActionEvent` class](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/event/llm_convertible/action.py) which includes the `llm_response_id` field. For more details, see the **[Events Architecture](/sdk/arch/events)** for a deep dive into the event system and parallel function calling, the **[Tool System](/sdk/arch/tool-system)** for understanding how tools work with the agent, and the **[Agent Architecture](/sdk/arch/agent)** for how agents process and execute actions.
## Does the agent SDK support image content?
**Yes, the OpenHands SDK fully supports image content for vision-capable LLMs.**
The SDK supports both HTTP/HTTPS URLs and base64-encoded images through the `ImageContent` class.
### Check Vision Support
Before sending images, verify your LLM supports vision:
```python
from openhands.sdk import LLM
from pydantic import SecretStr
llm = LLM(
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr("your-api-key"),
usage_id="my-agent"
)
# Check if vision is active
assert llm.vision_is_active(), "Model does not support vision"
```
### Using HTTP URLs
```python
from openhands.sdk import ImageContent, Message, TextContent
message = Message(
role="user",
content=[
TextContent(text="What do you see in this image?"),
ImageContent(image_urls=["https://example.com/image.png"]),
],
)
```
### Using Base64 Images
Base64 images are supported using data URLs:
```python
import base64
from openhands.sdk import ImageContent, Message, TextContent
# Read and encode an image file
with open("my_image.png", "rb") as f:
image_base64 = base64.b64encode(f.read()).decode("utf-8")
# Create message with base64 image
message = Message(
role="user",
content=[
TextContent(text="Describe this image"),
ImageContent(image_urls=[f"data:image/png;base64,{image_base64}"]),
],
)
```
### Supported Image Formats
The data URL format is: `data:;base64,`
Supported MIME types:
- `image/png`
- `image/jpeg`
- `image/gif`
- `image/webp`
- `image/bmp`
### Built-in Image Support
Several SDK tools automatically handle images:
- **FileEditorTool**: When viewing image files (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`), they're automatically converted to base64 and sent to the LLM
- **BrowserUseTool**: Screenshots are captured and sent as base64 images
- **MCP Tools**: Image content from MCP tool results is automatically converted to base64 data URLs
### Disabling Vision
To disable vision for cost reduction (even on vision-capable models):
```python
llm = LLM(
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr("your-api-key"),
usage_id="my-agent",
disable_vision=True, # Images will be filtered out
)
```
For a complete example, see the [image input example](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/17_image_input.py) in the SDK repository.
## How do I handle MessageEvent in one-off tasks?
**The SDK provides utilities to automatically respond to agent messages when running tasks end-to-end.**
When running one-off tasks, some models may send a `MessageEvent` (proposing an action or asking for confirmation) instead of directly using tools. This causes `conversation.run()` to return, even though the agent hasn't finished the task.
When an agent sends a message (via `MessageEvent`) instead of using the `finish` tool, the conversation ends because it's waiting for user input. In automated pipelines, there's no human to respond, so the task appears incomplete.
**Key event types:**
- `ActionEvent`: Agent uses a tool (terminal, file editor, etc.)
- `MessageEvent`: Agent sends a text message (waiting for user response)
- `FinishAction`: Agent explicitly signals task completion
The solution is to automatically send a "fake user response" when the agent sends a message, prompting it to continue.
The [`run_conversation_with_fake_user_response`](https://github.com/OpenHands/benchmarks/blob/main/benchmarks/utils/fake_user_response.py) function wraps your conversation and automatically handles agent messages:
```python
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.event import ActionEvent, MessageEvent
from openhands.sdk.tool.builtins.finish import FinishAction
def run_conversation_with_fake_user_response(conversation, max_responses: int = 10):
"""Run conversation, auto-responding to agent messages until finish or limit."""
for _ in range(max_responses):
conversation.run()
if conversation.state.execution_status != ConversationExecutionStatus.FINISHED:
break
events = list(conversation.state.events)
# Check if agent used finish tool
if any(isinstance(e, ActionEvent) and isinstance(e.action, FinishAction) for e in reversed(events)):
break
# Check if agent sent a message (needs response)
if not any(isinstance(e, MessageEvent) and e.source == "agent" for e in reversed(events)):
break
# Send continuation prompt
conversation.send_message(
"Please continue. Use the finish tool when done. DO NOT ask for human help."
)
```
```python
from openhands.sdk import Agent, Conversation, LLM
from openhands.workspace import DockerWorkspace
from openhands.tools.preset.default import get_default_tools
llm = LLM(model="anthropic/claude-sonnet-4-20250514", api_key="...")
agent = Agent(llm=llm, tools=get_default_tools())
workspace = DockerWorkspace()
conversation = Conversation(agent=agent, workspace=workspace, max_iteration_per_run=100)
conversation.send_message("Fix the bug in src/utils.py")
run_conversation_with_fake_user_response(conversation, max_responses=10)
# Results available in conversation.state.events
```
**Pro tip:** Add a hint to your task prompt:
> "If you're 100% done with the task, use the finish action. Otherwise, keep going until you're finished."
This encourages the agent to use the finish tool rather than asking for confirmation.
For the full implementation used in OpenHands benchmarks, see the [fake_user_response.py](https://github.com/OpenHands/benchmarks/blob/main/benchmarks/utils/fake_user_response.py) module.
## More questions?
If you have additional questions:
- **[Join our Slack Community](https://openhands.dev/joinslack)** - Ask questions and get help from the community
- **[GitHub Issues](https://github.com/OpenHands/software-agent-sdk/issues)** - Report bugs, request features, or start a discussion
### Getting Started
Source: https://docs.openhands.dev/sdk/getting-started.md
The OpenHands SDK is a modular framework for building AI agents that interact with code, files, and system commands. Agents can execute bash commands, edit files, browse the web, and more.
## Prerequisites
Install the **[uv package manager](https://docs.astral.sh/uv/)** (version 0.8.13+):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
## Installation
### Step 1: Acquire an LLM API Key
The SDK requires an LLM API key from any [LiteLLM-supported provider](https://docs.litellm.ai/docs/providers). See our [recommended models](/openhands/usage/llms/llms) for best results.
Bring your own API key from providers like:
- [Anthropic](https://console.anthropic.com/)
- [OpenAI](https://platform.openai.com/)
- [Other LiteLLM-supported providers](https://docs.litellm.ai/docs/providers)
Example:
```bash
export LLM_API_KEY="your-api-key"
uv run python examples/01_standalone_sdk/01_hello_world.py
```
Sign up for [OpenHands Cloud](https://app.all-hands.dev), add credits to your account, and get your OpenHands LLM API key from the [API keys page](https://app.all-hands.dev/settings/api-keys). This gives you access to models verified to work well with OpenHands, with no markup.
Example:
```bash
export LLM_MODEL="openhands/claude-sonnet-4-5-20250929"
uv run python examples/01_standalone_sdk/01_hello_world.py
```
[Learn more →](/openhands/usage/llms/openhands-llms)
If you have a ChatGPT Plus or Pro subscription, you can use `LLM.subscription_login()` to authenticate with your ChatGPT account and access Codex models without consuming API credits.
```python
from openhands.sdk import LLM
llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
```
[Learn more →](/sdk/guides/llm-subscriptions)
> Tip: Model name prefixes depend on your provider
>
> - If you bring your own provider key (Anthropic/OpenAI/etc.), use that provider's model name, e.g. `anthropic/claude-sonnet-4-5-20250929`
OpenHands supports [dozens of models](https://docs.openhands.dev/sdk/arch/llm#llm-providers), you can choose the model you want to try.
> - If you use OpenHands Cloud, use `openhands/`-prefixed models, e.g. `openhands/claude-sonnet-4-5-20250929`
>
> Many examples in the docs read the model from the `LLM_MODEL` environment variable. You can set it like:
>
> ```bash
> export LLM_MODEL="openhands/claude-sonnet-4-5-20250929" # for OpenHands Provider
> ```
**Set Your API Key:**
```bash
export LLM_API_KEY=your-api-key-here
```
### Step 2: Install the SDK
```bash
# Core SDK + built-in tools — install together so their versions stay aligned
pip install -U openhands-sdk openhands-tools
# Optional: sandboxed workspaces in Docker or remote servers.
# List every package in one command so they all resolve to the same version.
pip install -U openhands-sdk openhands-tools openhands-workspace openhands-agent-server
```
`openhands-sdk` and `openhands-tools` are a matched set: they are built, tested, and released together at the same version number, and `openhands-tools` imports `openhands-sdk` internals directly. Always install and upgrade them in a **single** `pip` command so their versions match. Installing them separately can leave a newer `openhands-tools` against an older `openhands-sdk` (for example, when a previously installed copy is not upgraded), which fails at import with errors like `ModuleNotFoundError: No module named 'openhands.sdk.utils.path'`. To pin a specific release, use the same version for both, e.g. `pip install "openhands-sdk==1.22.1" "openhands-tools==1.22.1"`.
```bash
# Clone the repository
git clone https://github.com/OpenHands/software-agent-sdk.git
cd software-agent-sdk
# Install dependencies and setup development environment
make build
```
### Step 3: Run Your First Agent
Here's a complete example that creates an agent and asks it to perform a simple task:
```python icon="python" expandable examples/01_standalone_sdk/01_hello_world.py
import os
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL", None),
)
agent = Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
],
)
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message("Write 3 facts about the current project into FACTS.txt.")
conversation.run()
print("All done!")
```
Run the example:
```bash
# Using a direct provider key (Anthropic/OpenAI/etc.)
uv run python examples/01_standalone_sdk/01_hello_world.py
```
```bash
# Using OpenHands Cloud
export LLM_MODEL="openhands/claude-sonnet-4-5-20250929"
uv run python examples/01_standalone_sdk/01_hello_world.py
```
You should see the agent understand your request, explore the project, and create a file with facts about it.
## Core Concepts
**Agent**: An AI-powered entity that can reason, plan, and execute actions using tools.
**Tools**: Capabilities like executing bash commands, editing files, or browsing the web.
**Workspace**: The execution environment where agents operate (local, Docker, or remote).
**Conversation**: Manages the interaction lifecycle between you and the agent.
## Basic Workflow
1. **Configure LLM**: Choose model and provide API key
2. **Create Agent**: Use preset or custom configuration
3. **Add Tools**: Enable capabilities (bash, file editing, etc.)
4. **Start Conversation**: Create conversation context
5. **Send Message**: Provide task description
6. **Run Agent**: Agent executes until task completes or stops
7. **Get Result**: Review agent's output and actions
## Try More Examples
The repository includes 24+ examples demonstrating various capabilities:
```bash
# Simple hello world
uv run python examples/01_standalone_sdk/01_hello_world.py
# Custom tools
uv run python examples/01_standalone_sdk/02_custom_tools.py
# With skills
uv run python examples/01_standalone_sdk/03_activate_microagent.py
# See all examples
ls examples/01_standalone_sdk/
```
## Next Steps
### Explore Documentation
- **[SDK Architecture](/sdk/arch/sdk)** - Deep dive into components
- **[Tool System](/sdk/arch/tool-system)** - Available tools
- **[Workspace Architecture](/sdk/arch/workspace)** - Execution environments
- **[LLM Configuration](/sdk/arch/llm)** - Deep dive into language model configuration
### Build Custom Solutions
- **[Custom Tools](/sdk/guides/custom-tools)** - Create custom tools to expand agent capabilities
- **[MCP Integration](/sdk/guides/mcp)** - Connect to external tools via Model Context Protocol
- **[Docker Workspaces](/sdk/guides/agent-server/docker-sandbox)** - Sandbox agent execution in containers
### Get Help
- **[Slack Community](https://openhands.dev/joinslack)** - Ask questions and share projects
- **[GitHub Issues](https://github.com/OpenHands/software-agent-sdk/issues)** - Report bugs or request features
- **[Example Directory](https://github.com/OpenHands/software-agent-sdk/tree/main/examples)** - Browse working code samples
### ACP Agent
Source: https://docs.openhands.dev/sdk/guides/agent-acp.md
> A ready-to-run example is available [here](#ready-to-run-example)!
`ACPAgent` lets you use any [Agent Client Protocol](https://agentclientprotocol.com/protocol/overview) server as the backend for an OpenHands conversation. Instead of calling an LLM directly, the agent spawns an ACP server subprocess and communicates with it over JSON-RPC. The server manages its own LLM, tools, and execution — your code just sends messages and collects responses.
## Basic Usage
```python icon="python" highlight={5,7-9}
from openhands.sdk.agent import ACPAgent
from openhands.sdk.conversation import Conversation
# Point at any ACP-compatible server
agent = ACPAgent(acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"])
conversation = Conversation(agent=agent, workspace="./my-project")
conversation.send_message("Explain the architecture of this project.")
conversation.run()
agent.close()
```
The `acp_command` is the shell command used to spawn the server process. The SDK communicates with it over stdin/stdout JSON-RPC.
**Key difference from standard agents:** With `ACPAgent`, you don't need an `LLM_API_KEY` in your code. The ACP server handles its own LLM authentication and API calls. This is *delegation* — your code sends messages to the ACP server, which manages all LLM interactions internally.
### Prompt Context (AgentContext)
`ACPAgent` supports `agent_context` for **prompt-only extensions** — skills, repository context, current datetime, and system/user message suffixes are appended to the user message before it reaches the ACP server. This lets you inject the same skill catalog and repo-specific guidance that the built-in Agent receives, without interfering with the server's own tools or execution model.
```python icon="python" highlight={4-12,16}
from openhands.sdk.agent import ACPAgent
from openhands.sdk import AgentContext
from openhands.sdk.context import Skill
context = AgentContext(
skills=[
Skill(
name="code-style",
content="Always use type hints in Python.",
trigger=None, # always active
),
],
system_message_suffix="You are reviewing a Python project.",
)
agent = ACPAgent(
acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
agent_context=context,
)
```
The prompt assembly works as follows:
1. The conversation layer builds the user `MessageEvent`, including any per-turn `extended_content` (e.g. triggered-skill injections).
2. `ACPAgent._build_acp_prompt()` collects all text blocks from the message and appends the rendered `AgentContext` prompt (datetime, repo context, available skills, system suffix) via `to_acp_prompt_context()`.
3. The combined text is sent as a single user message to the ACP server.
`user_message_suffix` is an ACP-compatible field, but it is **not** duplicated in `to_acp_prompt_context()` because the conversation layer already applies it through `MessageEvent.to_llm_message()`.
#### Compatible AgentContext Fields
Each `AgentContext` field is tagged as ACP-compatible or not. At initialization, `validate_acp_compatibility()` rejects any context that uses unsupported fields.
| Field | ACP Compatible | Notes |
|-------|:-:|-------|
| `skills` | ✅ | Skill catalog and trigger-based injections |
| `system_message_suffix` | ✅ | Appended to the prompt context |
| `user_message_suffix` | ✅ | Applied by the conversation layer |
| `current_datetime` | ✅ | Included in the rendered prompt |
| `load_user_skills` | ✅ | Load skills from `~/.openhands/skills/` |
| `load_public_skills` | ✅ | Load skills from the public extensions repo |
| `marketplace_path` | ✅ | Filter public skills via marketplace JSON |
| `secrets` | ✅ | Injected into the ACP subprocess environment (and masked if the server echoes them back) |
Any `AgentContext` field marked `acp_compatible: False` raises `NotImplementedError` at initialization.
### What ACPAgent Does Not Support
Because the ACP server manages its own tools, context window, and execution, these `AgentBase` features are not available on `ACPAgent`:
- `tools` / `include_default_tools` — the server has its own tools
- `mcp_config` — configure MCP on the server side
- `condenser` — the server manages its own context window
- `critic` — the server manages its own evaluation
Passing any of these raises `NotImplementedError` at initialization.
## ACPAgent with RemoteConversation
`ACPAgent` also works with remote agent-server deployments such as `APIRemoteWorkspace`, `DockerWorkspace`, and other `RemoteWorkspace`-backed setups.
When `RemoteConversation` detects an `ACPAgent`, it automatically uses the ACP-capable conversation routes for:
- conversation creation
- conversation info reads
- conversation counting
The rest of the lifecycle, including events, runs, pauses, and secrets, continues to use the standard agent-server routes. This keeps the existing remote execution flow intact while isolating the schema-sensitive ACP contract under `/api/acp/conversations`.
If you attach to an existing conversation by `conversation_id`, use `ACPAgent` for ACP-backed conversations. Attaching with a regular `Agent` to an ACP conversation ID is rejected explicitly to avoid mixing the standard and ACP conversation contracts.
## How It Works
- **Subprocess delegation**: `ACPAgent` spawns the ACP server and communicates via JSON-RPC over stdin/stdout
- **Server-managed execution**: The ACP server handles its own LLM calls, tools, and context — your code just sends messages
- **Auto-approval**: Permission requests from the server are automatically granted, so ensure you trust the ACP server you're running
- **Metrics collection**: Token usage and costs from the server are captured into the agent's `LLM.metrics`
## Configuration
### Server Command and Arguments
```python icon="python"
agent = ACPAgent(
acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
acp_args=["--profile", "my-profile"], # extra CLI args
)
```
| Parameter | Description |
|-----------|-------------|
| `acp_command` | Command to start the ACP server (required) |
| `acp_args` | Additional arguments appended to the command |
| `acp_env` | **Deprecated** (removed in 1.29.0). Route env/credentials through the conversation's `secrets` or `agent_context.secrets` instead — see below. |
### Environment Variables and Credentials
Pass the environment variables and credentials the ACP server needs through the conversation's `secrets` (or `agent_context.secrets`). They flow into the conversation's secret registry, are injected into the ACP subprocess environment, and are masked if the server echoes them back into its output:
```python icon="python"
conversation = Conversation(
agent=agent,
workspace="./my-project",
secrets={"ANTHROPIC_API_KEY": "sk-..."},
)
```
`acp_env` still works but is deprecated and will be removed in 1.29.0; prefer the secret-registry channels above for environment variables and credentials.
### Authentication
When the ACP server advertises authentication methods, `ACPAgent` automatically selects a credential source:
1. **ChatGPT subscription login** — If the server supports a `chatgpt` auth method and `~/.codex/auth.json` exists (created by `LLM.subscription_login()`), this is selected first. This enables ACP-backed workflows to use device-code login credentials without an explicit API key.
2. **API key environment variables** — Falls back to checking for `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` depending on which auth methods the server supports.
If no supported credential source is found, the server may proceed without authentication (some servers don't require it).
## Metrics
Token usage and cost data are automatically captured from the ACP server's responses. You can inspect them through the standard `LLM.metrics` interface:
```python icon="python"
metrics = agent.llm.metrics
print(f"Total cost: ${metrics.accumulated_cost:.6f}")
for usage in metrics.token_usages:
print(f" prompt={usage.prompt_tokens} completion={usage.completion_tokens}")
```
Usage data comes from two ACP protocol sources:
- **`PromptResponse.usage`** — per-turn token counts (input, output, cached, reasoning tokens)
- **`UsageUpdate` notifications** — cumulative session cost and context window size
## Cleanup
Always call `agent.close()` when you are done to terminate the ACP server subprocess. A `try/finally` block is recommended:
```python icon="python"
agent = ACPAgent(acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"])
try:
conversation = Conversation(agent=agent, workspace=".")
conversation.send_message("Hello!")
conversation.run()
finally:
agent.close()
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/40_acp_agent_example.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/40_acp_agent_example.py)
```python icon="python" expandable examples/01_standalone_sdk/40_acp_agent_example.py
"""Example: Using ACPAgent with Claude Code ACP server.
This example shows how to use an ACP-compatible server (claude-agent-acp)
as the agent backend instead of direct LLM calls. It also demonstrates
``ask_agent()`` — a stateless side-question that forks the ACP session
and leaves the main conversation untouched.
Prerequisites:
- Node.js / npx available
- ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY set (can point to LiteLLM proxy)
Usage:
uv run python examples/01_standalone_sdk/40_acp_agent_example.py
"""
import os
from openhands.sdk.agent import ACPAgent
from openhands.sdk.conversation import Conversation
agent = ACPAgent(acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"])
try:
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)
# --- Main conversation turn ---
conversation.send_message(
"List the Python source files under openhands-sdk/openhands/sdk/agent/, "
"then read the __init__.py and summarize what agent classes are exported."
)
conversation.run()
# --- ask_agent: stateless side-question via fork_session ---
print("\n--- ask_agent ---")
response = conversation.ask_agent(
"Based on what you just saw, which agent class is the newest addition?"
)
print(f"ask_agent response: {response}")
# Report cost (ACP server reports usage via session_update notifications)
cost = agent.llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost:.4f}")
finally:
# Clean up the ACP server subprocess
agent.close()
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nEXAMPLE_COST: {cost}")
print("Done!")
```
This example uses ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY environment variables to configure the Claude Code ACP server.
```bash Running the Example
# Set up environment variables (can point to LiteLLM proxy)
export ANTHROPIC_BASE_URL="https://your-proxy.example.com"
export ANTHROPIC_API_KEY="your-api-key"
cd software-agent-sdk
uv run python examples/01_standalone_sdk/40_acp_agent_example.py
```
## Remote Runtime Example
This example is available on GitHub: [examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.py)
This example shows how to run an ACPAgent in a remote sandboxed environment via the Runtime API, using `APIRemoteWorkspace`:
```python icon="python" expandable examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.py
"""Example: ACPAgent with Remote Runtime via API.
This example demonstrates running an ACPAgent (Claude Code via ACP protocol)
in a remote sandboxed environment via Runtime API. It follows the same pattern
as 04_convo_with_api_sandboxed_server.py but uses ACPAgent instead of the
default LLM-based Agent.
Usage:
uv run examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.py
Requirements:
- LLM_BASE_URL: LiteLLM proxy URL (routes Claude Code requests)
- LLM_API_KEY: LiteLLM virtual API key
- RUNTIME_API_KEY: API key for runtime API access
"""
import os
import time
from openhands.sdk import (
Conversation,
RemoteConversation,
get_logger,
)
from openhands.sdk.agent import ACPAgent
from openhands.workspace import APIRemoteWorkspace
logger = get_logger(__name__)
# ACP agents (Claude Code) route through LiteLLM proxy
llm_base_url = os.getenv("LLM_BASE_URL")
llm_api_key = os.getenv("LLM_API_KEY")
assert llm_base_url and llm_api_key, "LLM_BASE_URL and LLM_API_KEY required"
# Set ANTHROPIC_* vars so Claude Code routes through LiteLLM
os.environ["ANTHROPIC_BASE_URL"] = llm_base_url
os.environ["ANTHROPIC_API_KEY"] = llm_api_key
runtime_api_key = os.getenv("RUNTIME_API_KEY")
assert runtime_api_key, "RUNTIME_API_KEY required"
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, use the latest image from main
server_image_sha = os.getenv("GITHUB_SHA") or "main"
server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64"
logger.info(f"Using server image: {server_image}")
with APIRemoteWorkspace(
runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"),
runtime_api_key=runtime_api_key,
server_image=server_image,
image_pull_policy="Always",
target_type="binary", # CI builds binary target images
forward_env=["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"],
) as workspace:
agent = ACPAgent(
acp_command=["claude-agent-acp"], # Pre-installed in Docker image
)
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
received_events.append(event)
last_event_time["ts"] = time.time()
conversation = Conversation(
agent=agent, workspace=workspace, callbacks=[event_callback]
)
assert isinstance(conversation, RemoteConversation)
try:
conversation.send_message(
"List the files in /workspace and describe what you see."
)
conversation.run()
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost:.4f}")
finally:
conversation.close()
```
```bash Running the Example
export LLM_BASE_URL="https://your-litellm-proxy.example.com"
export LLM_API_KEY="your-litellm-api-key"
export RUNTIME_API_KEY="your-runtime-api-key"
export RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
cd software-agent-sdk
uv run python examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.py
```
On the agent-server side, the ACP-capable REST surface lives under `/api/acp/conversations`, including `POST`, `GET`, `search`, `batch get`, and `count`.
## Next Steps
- **[Creating Custom Agents](/sdk/guides/agent-custom)** — Build specialized agents with custom tool sets and system prompts
- **[TaskToolSet](/sdk/guides/task-tool-set)** — Compose multiple agents for complex workflows
- **[LLM Metrics](/sdk/guides/metrics)** — Track token usage and costs across models
### Browser Use
Source: https://docs.openhands.dev/sdk/guides/agent-browser-use.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The BrowserToolSet integration enables your agent to interact with web pages through automated browser control. Built
on top of [browser-use](https://github.com/browser-use/browser-use), it provides capabilities for navigating websites, clicking elements, filling forms,
and extracting content - all through natural language instructions.
## How It Works
The [ready-to-run example](#ready-to-run-example) demonstrates combining multiple tools to create a capable web research agent:
1. **BrowserToolSet**: Provides automated browser control for web interaction
2. **FileEditorTool**: Allows the agent to read and write files if needed
3. **BashTool**: Enables command-line operations for additional functionality
The agent uses these tools to:
- Navigate to specified URLs
- Interact with web page elements (clicking, scrolling, etc.)
- Extract and analyze content from web pages
- Summarize information from multiple sources
In this example, the agent visits the openhands.dev blog, finds the latest blog post, and provides a summary of its main points.
## Customization
For advanced use cases requiring only a subset of browser tools or custom configurations, you can manually
register individual browser tools. Refer to the [BrowserToolSet definition](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-tools/openhands/tools/browser_use/definition.py) to see the available individual
tools and create a `BrowserToolExecutor` with customized tool configurations before constructing the Agent.
This gives you fine-grained control over which browser capabilities are exposed to the agent.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/15_browser_use.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/15_browser_use.py)
```python icon="python" expandable examples/01_standalone_sdk/15_browser_use.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.browser_use import BrowserToolSet
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
Tool(name=BrowserToolSet.name),
]
# If you need fine-grained browser control, you can manually register individual browser
# tools by creating a BrowserToolExecutor and providing factories that return customized
# Tool instances before constructing the Agent.
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
conversation.send_message(
"Could you go to https://openhands.dev/ blog page and summarize main "
"points of the latest blog?"
)
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
```
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Create specialized tools
- **[MCP Integration](/sdk/guides/mcp)** - Connect external services
### Creating Custom Agent
Source: https://docs.openhands.dev/sdk/guides/agent-custom.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
This guide demonstrates how to create custom agents tailored for specific use cases. Using the planning agent as a concrete example, you'll learn how to design specialized agents with custom tool sets, system prompts, and configurations that optimize performance for particular workflows.
This example is available on GitHub: [examples/01_standalone_sdk/24_planning_agent_workflow.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/24_planning_agent_workflow.py)
The example showcases a two-phase workflow where a custom planning agent (with read-only tools) analyzes tasks and creates structured plans, followed by an execution agent that implements those plans with full editing capabilities.
```python icon="python" expandable examples/01_standalone_sdk/24_planning_agent_workflow.py
#!/usr/bin/env python3
"""
Planning Agent Workflow Example
This example demonstrates a two-stage workflow:
1. Planning Agent: Analyzes the task and creates a detailed implementation plan
2. Execution Agent: Implements the plan with full editing capabilities
The task: Create a Python web scraper that extracts article titles and URLs
from a news website, handles rate limiting, and saves results to JSON.
"""
import os
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation
from openhands.sdk.llm import content_to_str
from openhands.tools.preset.default import get_default_agent
from openhands.tools.preset.planning import get_planning_agent
def get_event_content(event):
"""Extract content from an event."""
if hasattr(event, "llm_message"):
return "".join(content_to_str(event.llm_message.content))
return str(event)
"""Run the planning agent workflow example."""
# Create a temporary workspace
workspace_dir = Path(tempfile.mkdtemp())
print(f"Working in: {workspace_dir}")
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
usage_id="agent",
)
# Task description
task = """
Create a Python web scraper with the following requirements:
- Scrape article titles and URLs from a news website
- Handle HTTP errors gracefully with retry logic
- Save results to a JSON file with timestamp
- Use requests and BeautifulSoup for scraping
Do NOT ask for any clarifying questions. Directly create your implementation plan.
"""
print("=" * 80)
print("PHASE 1: PLANNING")
print("=" * 80)
# Create Planning Agent with read-only tools
planning_agent = get_planning_agent(llm=llm)
# Create conversation for planning
planning_conversation = Conversation(
agent=planning_agent,
workspace=str(workspace_dir),
)
# Run planning phase
print("Planning Agent is analyzing the task and creating implementation plan...")
planning_conversation.send_message(
f"Please analyze this web scraping task and create a detailed "
f"implementation plan:\n\n{task}"
)
planning_conversation.run()
print("\n" + "=" * 80)
print("PLANNING COMPLETE")
print("=" * 80)
print(f"Implementation plan saved to: {workspace_dir}/PLAN.md")
print("\n" + "=" * 80)
print("PHASE 2: EXECUTION")
print("=" * 80)
# Create Execution Agent with full editing capabilities
execution_agent = get_default_agent(llm=llm, cli_mode=True)
# Create conversation for execution
execution_conversation = Conversation(
agent=execution_agent,
workspace=str(workspace_dir),
)
# Prepare execution prompt with reference to the plan file
execution_prompt = f"""
Please implement the web scraping project according to the implementation plan.
The detailed implementation plan has been created and saved at: {workspace_dir}/PLAN.md
Please read the plan from PLAN.md and implement all components according to it.
Create all necessary files, implement the functionality, and ensure everything
works together properly.
"""
print("Execution Agent is implementing the plan...")
execution_conversation.send_message(execution_prompt)
execution_conversation.run()
# Get the last message from the conversation
execution_result = execution_conversation.state.events[-1]
print("\n" + "=" * 80)
print("EXECUTION RESULT:")
print("=" * 80)
print(get_event_content(execution_result))
print("\n" + "=" * 80)
print("WORKFLOW COMPLETE")
print("=" * 80)
print(f"Project files created in: {workspace_dir}")
# List created files
print("\nCreated files:")
for file_path in workspace_dir.rglob("*"):
if file_path.is_file():
print(f" - {file_path.relative_to(workspace_dir)}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Anatomy of a Custom Agent
The planning agent demonstrates the two key components for creating specialized agent:
### 1. Custom Tool Selection
Choose tools that match your agent's specific role. Here's how the planning agent defines its tools:
```python icon="python"
def register_planning_tools() -> None:
"""Register the planning agent tools."""
from openhands.tools.glob import GlobTool
from openhands.tools.grep import GrepTool
from openhands.tools.planning_file_editor import PlanningFileEditorTool
register_tool("GlobTool", GlobTool)
logger.debug("Tool: GlobTool registered.")
register_tool("GrepTool", GrepTool)
logger.debug("Tool: GrepTool registered.")
register_tool("PlanningFileEditorTool", PlanningFileEditorTool)
logger.debug("Tool: PlanningFileEditorTool registered.")
def get_planning_tools() -> list[Tool]:
"""Get the planning agent tool specifications.
Returns:
List of tools optimized for planning and analysis tasks, including
file viewing and PLAN.md editing capabilities for advanced
code discovery and navigation.
"""
register_planning_tools()
return [
Tool(name="GlobTool"),
Tool(name="GrepTool"),
Tool(name="PlanningFileEditorTool"),
]
```
The planning agent uses:
- **GlobTool**: For discovering files and directories matching patterns
- **GrepTool**: For searching specific content across files
- **PlanningFileEditorTool**: For writing structured plans to `PLAN.md` only
This read-only approach (except for `PLAN.md`) keeps the agent focused on analysis without implementation distractions.
### 2. System Prompt Customization
Custom agents can use specialized system prompts to guide behavior. The planning agent uses `system_prompt_planning.j2` with injected plan structure that enforces:
1. **Objective**: Clear goal statement
2. **Context Summary**: Relevant system components and constraints
3. **Approach Overview**: High-level strategy and rationale
4. **Implementation Steps**: Detailed step-by-step execution plan
5. **Testing and Validation**: Verification methods and success criteria
### Complete Implementation Reference
For a complete implementation example showing all these components working together, refer to the [planning agent preset source code](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-tools/openhands/tools/preset/planning.py).
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Create specialized tools for your use case
- **[Context Condenser](/sdk/guides/context-condenser)** - Optimize context management
- **[MCP Integration](/sdk/guides/mcp)** - Add MCP
### File-Based Agents
Source: https://docs.openhands.dev/sdk/guides/agent-file-based.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
File-based agents let you define specialized sub-agents using Markdown files. Each file declares the agent's name, description, tools, and system prompt — the same things you'd pass to `register_agent()` in code, but without writing any Python.
This is the fastest way to create reusable, domain-specific agents that can be invoked via [delegation](/sdk/guides/task-tool-set).
## Agent File Format
An agent is a single `.md` file with YAML frontmatter and a Markdown body:
```markdown icon="markdown"
---
name: code-reviewer
description: >
Reviews code for quality, bugs, and best practices.
Review this pull request for issuesCheck this code for bugs
tools:
- file_editor
- terminal
model: inherit
---
# Code Reviewer
You are a meticulous code reviewer. When reviewing code:
1. **Correctness** - Look for bugs, off-by-one errors, and race conditions.
2. **Style** - Check for consistent naming and idiomatic usage.
3. **Performance** - Identify unnecessary allocations or algorithmic issues.
4. **Security** - Flag injection vulnerabilities or hardcoded secrets.
Keep feedback concise and actionable. For each issue, suggest a fix.
```
The YAML frontmatter configures the agent. The Markdown body becomes the agent's system prompt.
### Frontmatter Fields
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `name` | Yes | - | Agent identifier (e.g., `code-reviewer`) |
| `description` | No | `""` | What this agent does. Shown to the orchestrator |
| `tools` | No | `[]` | List of tools the agent can use |
| `model` | No | `"inherit"` | LLM model profile to load and use for the subagent (`"inherit"` uses the parent agent's model) |
| `skills` | No | `[]` | List of skill names for this agent (see [Skill Loading Precedence](/overview/skills#skill-loading-precedence) for resolution order). |
| `max_iteration_per_run` | No | `None`| Maximum iterations per run. Must be strictly positive, or `None` for the default value. |
| `color` | No | `None` | [Rich color name](https://rich.readthedocs.io/en/stable/appendix/colors.html) (e.g., `"blue"`, `"green"`) used by visualizers to style this agent's output in terminal panels |
| `mcp_servers` | No | `None` | MCP server configurations for this agent (see [MCP Servers](#mcp-servers)) |
| `hooks` | No | `None` | Hook configuration for lifecycle events (see [Hooks](#hooks)) |
| `permission_mode` | No | `None` | Controls how the subagent handles action confirmations (see [Permission Mode](#permission-mode)) |
| `profile_store_dir` | No | `None` | Custom directory path for LLM profiles when using a named `model` |
### `` Tags
Add `` tags inside the description to help the orchestrating agent know **when** to delegate to this agent:
```markdown icon="markdown"
description: >
Writes and improves technical documentation.
Write docs for this moduleImprove the README
```
These examples are extracted and stored as `when_to_use_examples` on the `AgentDefinition` object. They can be used by routing logic (or prompt-building) to help decide when to delegate to the right sub-agent.
## Directory Conventions
Place agent files in these directories, scanned in **priority order** (first match wins):
| Priority | Location | Scope |
|----------|----------|-------|
| 1 | `{project}/.agents/agents/*.md` | Project-level (primary) |
| 2 | `{project}/.openhands/agents/*.md` | Project-level (secondary) |
| 3 | `~/.agents/agents/*.md` | User-level (primary) |
| 4 | `~/.openhands/agents/*.md` | User-level (secondary) |
**Rules:**
- Only top-level `.md` files are loaded (subdirectories are skipped)
- `README.md` files are automatically skipped
- Project-level agents take priority over user-level agents with the same name
Put agents shared across all your projects in `~/.agents/agents/`. Put project-specific agents in `{project}/.agents/agents/`.
## Built-in Agents
The `openhands-tools` package ships with built-in sub-agents as Markdown files in `openhands/tools/preset/subagents/`.
They can be registered via `register_builtins_agents()` and become available for delegation tasks.
By default, all agents include `finish` tool and the `think` tool.
### Available Built-in Sub-Agents
| Agent | Tools | Description |
|--------|-------|-------|
| **general-purpose** | `terminal`, `file_editor`, `task_tracker` | General-purpose agent for tasks requiring a combination of capabilities. Used as the fallback when no agent name is specified. |
| **code-explorer** | `terminal` | Read-only codebase exploration agent. Finds files, searches code, reads source — never creates or modifies anything. |
| **bash-runner** | `terminal` | Command execution specialist. Runs shell commands, builds, tests, linters, and git operations. Returns concise reports instead of raw output. |
| **web-researcher** | `browser_tool_set` + MCP (`fetch`, `tavily`) | Web research specialist. Searches the web, navigates documentation, and extracts information from URLs. |
When `enable_browser=False`, browser-dependent agents like `web-researcher` are not registered.
**Deprecated names:** The following legacy names are deprecated (since v1.12.0) and will be removed in version 2.0.0:
- `default` → use `general-purpose`
- `default cli mode` → use `general-purpose`
- `explore` → use `code-explorer`
- `bash` → use `bash-runner`
### Registering Built-in Sub-Agents
Call `register_builtins_agents()` to register all built-in sub-agents. This is typically done once before creating a conversation:
```python icon="python" focus={3-4, 6-7}
from openhands.tools.preset.default import register_builtins_agents
# Register all built-in sub-agents (including web-researcher)
register_builtins_agents()
# Or without browser-dependent agents (excludes web-researcher)
register_builtins_agents(enable_browser=False)
```
Registration order is critical when programmatically registering agents that share a name with a built-in agent. The system is designed to skip registration if a name is already taken. Therefore, if you register your custom agents before the built-in agents are loaded, your custom versions will take precedence.
Conversely, if the built-in agents are loaded first, they will take precedence, and any subsequent registration of a custom agent with the same name will be ignored.
## Overall Priority
When the same agent name is defined in multiple places, the highest-priority source wins. Registration is first-come first-win.
| Priority | Source | Description |
|----------|--------|-------------|
| 1 (highest) | **Programmatic** `register_agent()` | Registered first, never overwritten |
| 2 | **Plugin agents** (`Plugin.agents`) | Loaded from plugin `agents/` directories |
| 3 | **Project-level** file-based agents | `.agents/agents/*.md` or `.openhands/agents/*.md` |
| 4 (lowest) | **User-level** file-based agents | `~/.agents/agents/*.md` or `~/.openhands/agents/*.md` |
## Auto-Registration
The simplest way to use file-based agents is auto-registration. Call `register_file_agents()` with your project directory, and all discovered agents are registered into the delegation system:
```python icon="python" focus={3}
from openhands.sdk.subagent import register_file_agents
agent_names = register_file_agents("/path/to/project")
print(f"Registered {len(agent_names)} agents: {agent_names}")
```
This scans both project-level and user-level directories, deduplicates by name, and registers each agent as a delegate that can be spawned by the orchestrator.
## Manual Loading
For more control, load and register agents explicitly:
```python icon="python" focus={3-6, 8-14}
from pathlib import Path
from openhands.sdk import load_agents_from_dir, register_agent, agent_definition_to_factory
# Load from a specific directory
agents_dir = Path("agents")
agent_definitions = load_agents_from_dir(agents_dir)
# Register each agent
for agent_def in agent_definitions:
register_agent(
name=agent_def.name,
factory_func=agent_definition_to_factory(agent_def),
description=agent_def.description,
)
```
### Key Functions
#### `load_agents_from_dir()`
Scans a directory for `.md` files and returns a list of `AgentDefinition` objects:
```python icon="python" focus={3-4}
from pathlib import Path
from openhands.sdk import load_agents_from_dir
definitions = load_agents_from_dir(Path(".agents/agents"))
for d in definitions:
print(f"{d.name}: {d.tools}, model={d.model}")
```
#### `agent_definition_to_factory()`
Converts an `AgentDefinition` into a factory function `(LLM) -> Agent`:
```python icon="python"
from openhands.sdk import agent_definition_to_factory
factory = agent_definition_to_factory(agent_def)
# The factory is called by the delegation system with the parent's LLM
```
The factory:
- Maps tool names from the frontmatter to `Tool` objects
- Appends the Markdown body to the parent system message via `AgentContext(system_message_suffix=...)`
- Respects the `model` field (`"inherit"` keeps the parent LLM; an explicit model name creates a copy)
#### `load_project_agents()` / `load_user_agents()`
Load agents from project-level or user-level directories respectively:
```python icon="python" focus={3, 4}
from openhands.sdk.subagent import load_project_agents, load_user_agents
project_agents = load_project_agents("/path/to/project")
user_agents = load_user_agents() # scans ~/.agents/agents/ and ~/.openhands/agents/
```
## Using with Delegation
File-based agents are designed to work with the [`TaskToolSet`](/sdk/guides/task-tool-set). Once registered, the orchestrating agent can delegate tasks to them by name through the task tool's `subagent_type` parameter:
```python icon="python" focus={6, 9-12, 14-18}
from openhands.sdk import Agent, Conversation, Tool
from openhands.sdk.subagent import register_file_agents
from openhands.tools.delegate import DelegationVisualizer
from openhands.tools.task import TaskToolSet
register_file_agents("/path/to/project") # Register .agents/agents/*.md
# Set up the orchestrator with the task tool
main_agent = Agent(
llm=llm,
tools=[Tool(name=TaskToolSet.name)],
)
conversation = Conversation(
agent=main_agent,
workspace="/path/to/project",
visualizer=DelegationVisualizer(name="Orchestrator"),
)
```
To learn more about agent delegation, follow our [comprehensive guide](/sdk/guides/task-tool-set).
## Example Agent Files
### Code Reviewer
```markdown icon="markdown"
---
name: code-reviewer
description: >
Reviews code for quality, bugs, and best practices.
Review this pull request for issuesCheck this code for bugs
tools:
- file_editor
- terminal
---
# Code Reviewer
You are a meticulous code reviewer. When reviewing code:
1. **Correctness** - Look for bugs, off-by-one errors, null pointer issues, and race conditions.
2. **Style** - Check for consistent naming, formatting, and idiomatic usage.
3. **Performance** - Identify unnecessary allocations, N+1 queries, or algorithmic inefficiencies.
4. **Security** - Flag potential injection vulnerabilities, hardcoded secrets, or unsafe deserialization.
Keep feedback concise and actionable. For each issue found, suggest a concrete fix.
```
### Technical Writer
```markdown icon="markdown"
---
name: tech-writer
description: >
Writes and improves technical documentation.
Write docs for this moduleImprove the README
tools:
- file_editor
---
# Technical Writer
You are a skilled technical writer. When creating or improving documentation:
1. **Audience** - Write for developers who are new to the project.
2. **Structure** - Use clear headings, code examples, and step-by-step instructions.
3. **Accuracy** - Read the source code before documenting behavior. Never guess.
4. **Brevity** - Prefer short, concrete sentences over long explanations.
Always include a usage example with expected output when documenting functions or APIs.
```
## Advanced Features
### MCP Servers
File-based agents can define [MCP server configurations](/sdk/guides/mcp) inline, giving them access to external tools without any Python code:
```markdown icon="markdown"
---
name: web-researcher
description: Researches topics using web fetching capabilities.
tools:
- file_editor
mcp_servers:
fetch:
command: uvx
args:
- mcp-server-fetch
filesystem:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
---
You are a web researcher with access to fetch and filesystem tools.
Use the fetch tool to retrieve web content and save findings to files.
```
The `mcp_servers` field uses the same format as the [MCP configuration](/sdk/guides/mcp) — each key is a server name, and the value contains `command` and `args` for launching the server.
#### Environment Variable Resolution
All string values in MCP server configurations support `${VAR}` (and `$VAR`) environment variable references, which are resolved from `os.environ` at load time. This lets you forward secrets and dynamic paths without hard-coding them in Markdown:
```markdown icon="markdown"
---
name: api-agent
description: Agent with MCP server using environment-based secrets.
mcp_servers:
my-server:
command: ${PLUGIN_ROOT}/bin/server
args:
- --config
- ${PLUGIN_ROOT}/config.json
env:
API_KEY: ${MY_API_KEY}
remote:
type: http
url: ${API_BASE}/mcp
headers:
Authorization: Bearer ${AUTH_TOKEN}
---
An agent that connects to MCP servers configured via environment variables.
```
Environment variable resolution applies recursively to all string fields — `command`, `args`, `url`, `headers`, `env`, and any other string values in the server config. If a referenced variable is not set, the placeholder is left unchanged (e.g., `${NONEXISTENT_VAR}` stays as-is).
### Hooks
File-based agents can define [lifecycle hooks](/sdk/guides/hooks) that run at specific points during execution:
```markdown icon="markdown"
---
name: audited-agent
description: An agent with audit logging hooks.
tools:
- terminal
- file_editor
hooks:
pre_tool_use:
- matcher: "terminal"
hooks:
- command: "./scripts/validate_command.sh"
timeout: 10
post_tool_use:
- matcher: "*"
hooks:
- command: "./scripts/log_tool_usage.sh"
timeout: 5
---
You are an audited agent. All your actions are logged for compliance.
```
**Hook event types:**
- `pre_tool_use` — Runs before tool execution (can block with exit code 2)
- `post_tool_use` — Runs after tool execution
- `user_prompt_submit` — Runs before processing user messages
- `session_start` / `session_end` — Run when conversation starts/ends
- `stop` — Runs when agent tries to finish (can block)
Each hook matcher supports:
- `"*"` — Matches all tools
- Exact name — e.g., `"terminal"` matches only that tool
- Regex patterns — e.g., `"/file_.*/"` matches tools starting with `file_`
For more details on hooks, see the [Hooks guide](/sdk/guides/hooks).
### Permission Mode
Control how a file-based agent handles action confirmations with the `permission_mode` field:
```markdown icon="markdown"
---
name: autonomous-agent
description: Runs without requiring user confirmation.
tools:
- terminal
- file_editor
permission_mode: never_confirm
---
You are an autonomous agent that executes tasks without manual approval.
```
**Available modes:**
| Mode | Behavior |
|------|----------|
| `always_confirm` | Requires user approval for **all** actions |
| `never_confirm` | Executes all actions without approval |
| `confirm_risky` | Only requires approval for actions above a risk threshold (requires a [security analyzer](/sdk/guides/security)) |
When `permission_mode` is omitted (or set to `None`), the subagent inherits the confirmation policy from its parent conversation.
Permission mode is particularly useful for specialized sub-agents. For example, a "read-only explorer" agent might use `never_confirm` since it only reads files, while a "deploy" agent might use `always_confirm` for safety.
For more details on security and confirmation policies, see the [Security guide](/sdk/guides/security).
## Agents in Plugins
> Plugins bundle agents, tools, skills, and MCP servers into reusable packages.
Learn more about plugins [here](/sdk/guides/plugins).
File-based agents can also be bundled inside plugins. Place them in the `agents/` directory of your plugin:
Plugin agents use the same `.md` format and are registered automatically when the plugin is loaded. They have higher priority than file-based agents but lower than programmatic `register_agent()` calls.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/42_file_based_subagents.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/42_file_based_subagents.py)
This example uses `AgentDefinition` directly. File-based agents are loaded into the same `AgentDefinition` objects (from Markdown) and registered the same way.
```python icon="python" expandable examples/01_standalone_sdk/42_file_based_subagents.py
"""Example: Defining a sub-agent inline with AgentDefinition.
Defines a grammar-checker sub-agent using AgentDefinition, registers it,
and delegates work to it from an orchestrator agent. The orchestrator then
asks the builtin default agent to judge the results.
"""
import os
from pathlib import Path
from openhands.sdk import (
LLM,
Agent,
Conversation,
Tool,
agent_definition_to_factory,
register_agent,
)
from openhands.sdk.subagent import AgentDefinition
from openhands.tools.delegate import DelegationVisualizer
from openhands.tools.task import TaskToolSet
# 1. Define a sub-agent using AgentDefinition
grammar_checker = AgentDefinition(
name="grammar-checker",
description="Checks documents for grammatical errors.",
tools=["file_editor"],
system_prompt="You are a grammar expert. Find and list grammatical errors.",
)
# 2. Register it in the delegate registry
register_agent(
name=grammar_checker.name,
factory_func=agent_definition_to_factory(grammar_checker),
description=grammar_checker.description,
)
# 3. Set up the orchestrator agent with the task tool
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
usage_id="file-agents-demo",
)
main_agent = Agent(
llm=llm,
tools=[Tool(name=TaskToolSet.name)],
)
conversation = Conversation(
agent=main_agent,
workspace=Path.cwd(),
visualizer=DelegationVisualizer(name="Orchestrator"),
)
# 4. Ask the orchestrator to delegate to our agent
task = (
"Please delegate to the grammar-checker agent and ask it to review "
"the README.md file in search of grammatical errors.\n"
"Then ask the default agent to judge the errors."
)
conversation.send_message(task)
conversation.run()
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nTotal cost: ${cost:.4f}")
print(f"EXAMPLE_COST: {cost:.4f}")
```
## Next Steps
- **[TaskToolSet](/sdk/guides/task-tool-set)** - Delegate work to specialized sub-agents
- **[Skills](/sdk/guides/skill)** - Add specialized knowledge and triggers to agents
- **[Plugins](/sdk/guides/plugins)** - Bundle agents, skills, hooks, and MCP servers together
- **[Custom Agent](/sdk/guides/agent-custom)** - Create agents programmatically for more control
### Interactive Terminal
Source: https://docs.openhands.dev/sdk/guides/agent-interactive-terminal.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The `BashTool` provides agents with the ability to interact with terminal applications that require back-and-forth communication, such as Python's interactive mode, ipython, database CLIs, and other REPL environments. This enables agents to execute commands within these interactive sessions, receive output, and send follow-up commands based on the results.
## How It Works
```python icon="python" focus={4-7}
cwd = os.getcwd()
register_tool("BashTool", BashTool)
tools = [
Tool(
name="BashTool",
params={"no_change_timeout_seconds": 3},
)
]
```
The `BashTool` is configured with a `no_change_timeout_seconds` parameter that determines how long to wait for terminal updates before sending the output back to the agent.
In the example above, the agent should:
1. Enters Python's interactive mode by running `python3`
2. Executes Python code to get the current time
3. Exits the Python interpreter
The `BashTool` maintains the session state throughout these interactions, allowing the agent to send multiple commands within the same terminal session. Review the [BashTool](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-tools/openhands/tools/terminal/definition.py) and [terminal source code](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-tools/openhands/tools/terminal/terminal/terminal_session.py) to better understand how the interactive session is configured and managed.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/06_interactive_terminal_w_reasoning.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/06_interactive_terminal_w_reasoning.py)
```python icon="python" expandable examples/01_standalone_sdk/06_interactive_terminal_w_reasoning.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
params={"no_change_timeout_seconds": 3},
)
]
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
conversation.send_message(
"Enter python interactive mode by directly running `python3`, then tell me "
"the current time, and exit python interactive mode."
)
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
```
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Create your own tools for specific use cases
### API-based Sandbox
Source: https://docs.openhands.dev/sdk/guides/agent-server/api-sandbox.md
> A ready-to-run example is available [here](#ready-to-run-example)!
The [Runtime API](https://runtime.all-hands.dev/) (`runtime.all-hands.dev`) is designed primarily for **[benchmark evaluation at scale](https://github.com/OpenHands/benchmarks)**, not for building production applications. If you are building a production application with the SDK, use the **[OpenHands Cloud Workspace](/sdk/guides/agent-server/cloud-workspace)** instead, which provides fully managed sandbox environments with SaaS credential support.
The API-sandboxed agent server demonstrates how to use `APIRemoteWorkspace` to connect to a [OpenHands runtime API service](https://runtime.all-hands.dev/). This eliminates the need to manage your own infrastructure, providing automatic scaling, monitoring, and secure sandboxed execution.
## Key Concepts
### APIRemoteWorkspace
The `APIRemoteWorkspace` connects to a hosted runtime API service:
```python icon="python"
with APIRemoteWorkspace(
runtime_api_url="https://runtime.eval.all-hands.dev",
runtime_api_key=runtime_api_key,
server_image="ghcr.io/openhands/agent-server:main-python",
) as workspace:
```
This workspace type:
- Connects to a remote runtime API service
- Automatically provisions sandboxed environments
- Manages container lifecycle through the API
- Handles all infrastructure concerns
### Runtime API Authentication
The example requires a runtime API key for authentication:
```python icon="python"
runtime_api_key = os.getenv("RUNTIME_API_KEY")
if not runtime_api_key:
logger.error("RUNTIME_API_KEY required")
exit(1)
```
This key authenticates your requests to the hosted runtime service.
### Pre-built Image Selection
You can specify which pre-built agent server image to use:
```python icon="python" focus={4}
APIRemoteWorkspace(
runtime_api_url="https://runtime.eval.all-hands.dev",
runtime_api_key=runtime_api_key,
server_image="ghcr.io/openhands/agent-server:main-python",
)
```
The runtime API will pull and run the specified image in a sandboxed environment.
### Workspace Testing
Just like with `DockerWorkspace`, you can test the workspace before running the agent:
```python icon="python" focus={1-3}
result = workspace.execute_command(
"echo 'Hello from sandboxed environment!' && pwd"
)
logger.info(f"Command completed: {result.exit_code}, {result.stdout}")
```
This verifies connectivity to the remote runtime and ensures the environment is ready.
### Automatic RemoteConversation
The conversation uses WebSocket communication with the remote server:
```python icon="python" focus={1, 7}
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
visualize=True
)
assert isinstance(conversation, RemoteConversation)
```
All agent execution happens on the remote runtime infrastructure.
The same runtime flow also supports `ACPAgent`. For an end-to-end example, see the [ACP Agent guide](/sdk/guides/agent-acp#remote-runtime-example).
ACP-backed remote conversations use the ACP-capable conversation endpoints under `/api/acp/conversations` for creation, reads, and counts. If you reconnect to an existing ACP conversation by `conversation_id`, use `ACPAgent` rather than a standard `Agent`.
## Ready-to-run Example
This example is available on GitHub: [examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py)
This example shows how to connect to a hosted runtime API for fully managed agent execution:
```python icon="python" expandable examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py
"""Example: APIRemoteWorkspace with Dynamic Build.
This example demonstrates building an agent-server image on-the-fly from the SDK
codebase and launching it in a remote sandboxed environment via Runtime API.
Usage:
uv run examples/24_remote_convo_with_api_sandboxed_server.py
Requirements:
- LLM_API_KEY: API key for LLM access
- RUNTIME_API_KEY: API key for runtime API access
"""
import os
import time
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Conversation,
RemoteConversation,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import APIRemoteWorkspace
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY")
assert api_key, "LLM_API_KEY required"
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
runtime_api_key = os.getenv("RUNTIME_API_KEY")
if not runtime_api_key:
logger.error("RUNTIME_API_KEY required")
exit(1)
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, use the latest image from main
server_image_sha = os.getenv("GITHUB_SHA") or "main"
server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64"
logger.info(f"Using server image: {server_image}")
with APIRemoteWorkspace(
runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"),
runtime_api_key=runtime_api_key,
server_image=server_image,
image_pull_policy="Always",
) as workspace:
agent = get_default_agent(llm=llm, cli_mode=True)
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
received_events.append(event)
last_event_time["ts"] = time.time()
result = workspace.execute_command(
"echo 'Hello from sandboxed environment!' && pwd"
)
logger.info(f"Command completed: {result.exit_code}, {result.stdout}")
conversation = Conversation(
agent=agent, workspace=workspace, callbacks=[event_callback]
)
assert isinstance(conversation, RemoteConversation)
try:
conversation.send_message(
"Read the current repo and write 3 facts about the project into FACTS.txt."
)
conversation.run()
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
conversation.send_message("Great! Now delete that file.")
conversation.run()
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
conversation.close()
```
You can run the example code as-is.
```bash Running the Example
export LLM_API_KEY="your-api-key"
# If using the OpenHands LLM proxy, set its base URL:
export LLM_BASE_URL="https://llm-proxy.eval.all-hands.dev"
export RUNTIME_API_KEY="your-runtime-api-key"
# Set the runtime API URL for the remote sandbox
export RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
cd agent-sdk
uv run python examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py
```
## Next Steps
- **[Docker Sandboxed Server](/sdk/guides/agent-server/docker-sandbox)**
- **[Local Agent Server](/sdk/guides/agent-server/local-server)**
- **[Agent Server Overview](/sdk/guides/agent-server/overview)** - Architecture and implementation details
- **[Agent Server Package Architecture](/sdk/arch/agent-server)** - Remote execution architecture
### Apptainer Sandbox
Source: https://docs.openhands.dev/sdk/guides/agent-server/apptainer-sandbox.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#basic-apptainer-sandbox-example)!
The Apptainer sandboxed agent server demonstrates how to run agents in isolated Apptainer containers using ApptainerWorkspace.
Apptainer (formerly Singularity) is a container runtime designed for HPC environments that doesn't require root access, making it ideal for shared computing environments, university clusters, and systems where Docker is not available.
## When to Use Apptainer
Use Apptainer instead of Docker when:
- Running on HPC clusters or shared computing environments
- Root access is not available
- Docker daemon cannot be installed
- Working in academic or research computing environments
- Security policies restrict Docker usage
## Prerequisites
Before running this example, ensure you have:
- Apptainer installed ([Installation Guide](https://apptainer.org/docs/user/main/quick_start.html))
- LLM API key set in environment
## Basic Apptainer Sandbox Example
This example is available on GitHub: [examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py)
This example shows how to create an `ApptainerWorkspace` that automatically manages Apptainer containers for agent execution:
```python icon="python" expandable examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py
import os
import platform
import time
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Conversation,
RemoteConversation,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import ApptainerWorkspace
logger = get_logger(__name__)
# 1) Ensure we have LLM API key
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
def detect_platform():
"""Detects the correct platform string."""
machine = platform.machine().lower()
if "arm" in machine or "aarch64" in machine:
return "linux/arm64"
return "linux/amd64"
def get_server_image():
"""Get the server image tag, using PR-specific image in CI."""
platform_str = detect_platform()
arch = "arm64" if "arm64" in platform_str else "amd64"
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, use the latest image from main
github_sha = os.getenv("GITHUB_SHA")
if github_sha:
return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}"
return "ghcr.io/openhands/agent-server:latest-python"
# 2) Create an Apptainer-based remote workspace that will set up and manage
# the Apptainer container automatically. Use `ApptainerWorkspace` with a
# pre-built agent server image.
# Apptainer (formerly Singularity) doesn't require root access, making it
# ideal for HPC and shared computing environments.
server_image = get_server_image()
logger.info(f"Using server image: {server_image}")
with ApptainerWorkspace(
# use pre-built image for faster startup
server_image=server_image,
host_port=8010,
platform=detect_platform(),
) as workspace:
# 3) Create agent
agent = get_default_agent(
llm=llm,
cli_mode=True,
)
# 4) Set up callback collection
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
event_type = type(event).__name__
logger.info(f"🔔 Callback received event: {event_type}\n{event}")
received_events.append(event)
last_event_time["ts"] = time.time()
# 5) Test the workspace with a simple command
result = workspace.execute_command(
"echo 'Hello from sandboxed environment!' && pwd"
)
logger.info(
f"Command '{result.command}' completed with exit code {result.exit_code}"
)
logger.info(f"Output: {result.stdout}")
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
)
assert isinstance(conversation, RemoteConversation)
try:
logger.info(f"\n📋 Conversation ID: {conversation.state.id}")
logger.info("📝 Sending first message...")
conversation.send_message(
"Read the current repo and write 3 facts about the project into FACTS.txt."
)
logger.info("🚀 Running conversation...")
conversation.run()
logger.info("✅ First task completed!")
logger.info(f"Agent status: {conversation.state.execution_status}")
# Wait for events to settle (no events for 2 seconds)
logger.info("⏳ Waiting for events to stop...")
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
logger.info("✅ Events have stopped")
logger.info("🚀 Running conversation again...")
conversation.send_message("Great! Now delete that file.")
conversation.run()
logger.info("✅ Second task completed!")
# Report cost (must be before conversation.close())
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
print("\n🧹 Cleaning up conversation...")
conversation.close()
```
## Configuration Options
The `ApptainerWorkspace` supports several configuration options:
### Option 1: Pre-built Image (Recommended)
Use a pre-built agent server image for fastest startup:
```python icon="python" focus={2}
with ApptainerWorkspace(
server_image="ghcr.io/openhands/agent-server:main-python",
host_port=8010,
) as workspace:
# Your code here
```
### Option 2: Build from Base Image
Build from a base image when you need custom dependencies:
```python icon="python" focus={2}
with ApptainerWorkspace(
base_image="nikolaik/python-nodejs:python3.12-nodejs22",
host_port=8010,
) as workspace:
# Your code here
```
Building from a base image requires internet access and may take several minutes on first run. The built image is cached for subsequent runs.
### Option 3: Use Existing SIF File
If you have a pre-built Apptainer SIF file:
```python icon="python" focus={2}
with ApptainerWorkspace(
sif_file="/path/to/your/agent-server.sif",
host_port=8010,
) as workspace:
# Your code here
```
## Key Features
### Rootless Container Execution
Apptainer runs completely without root privileges:
- No daemon process required
- User namespace isolation
- Compatible with most HPC security policies
### Image Caching
Apptainer automatically caches container images:
- First run builds/pulls the image
- Subsequent runs reuse cached SIF files
- Cache location: `~/.cache/apptainer/`
### Port Mapping
The workspace exposes ports for agent services:
```python icon="python" focus={1, 3}
with ApptainerWorkspace(
server_image="ghcr.io/openhands/agent-server:main-python",
host_port=8010, # Maps to container port 8010
) as workspace:
# Access agent server at http://localhost:8010
```
## Differences from Docker
While the API is similar to DockerWorkspace, there are some differences:
| Feature | Docker | Apptainer |
|---------|--------|-----------|
| Root access required | Yes (daemon) | No |
| Installation | Requires Docker Engine | Single binary |
| Image format | OCI/Docker | SIF |
| Build speed | Fast (layers) | Slower (monolithic) |
| HPC compatibility | Limited | Excellent |
| Networking | Bridge/overlay | Host networking |
## Troubleshooting
### Apptainer Not Found
If you see `apptainer: command not found`:
1. Install Apptainer following the [official guide](https://apptainer.org/docs/user/main/quick_start.html)
2. Ensure it's in your PATH: `which apptainer`
### Permission Errors
Apptainer should work without root. If you see permission errors:
- Check that your user has access to `/tmp`
- Verify Apptainer is properly installed: `apptainer version`
- Ensure the cache directory is writable: `ls -la ~/.cache/apptainer/`
## Next Steps
- **[Docker Sandbox](/sdk/guides/agent-server/docker-sandbox)** - Alternative container runtime
- **[API Sandbox](/sdk/guides/agent-server/api-sandbox)** - Remote API-based sandboxing
- **[Local Server](/sdk/guides/agent-server/local-server)** - Non-sandboxed local execution
### OpenHands Cloud Workspace
Source: https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace.md
> A ready-to-run example is available [here](#ready-to-run-example)!
The `OpenHandsCloudWorkspace` demonstrates how to use the [OpenHands Cloud](https://app.all-hands.dev) to provision and manage sandboxed environments for agent execution. This provides a seamless experience with automatic sandbox provisioning, monitoring, and secure execution without managing your own infrastructure.
## Key Concepts
### OpenHandsCloudWorkspace
The `OpenHandsCloudWorkspace` connects to OpenHands Cloud to provision sandboxes:
```python icon="python" focus={1-2}
with OpenHandsCloudWorkspace(
cloud_api_url="https://app.all-hands.dev",
cloud_api_key=cloud_api_key,
) as workspace:
```
This workspace type:
- Connects to OpenHands Cloud API
- Automatically provisions sandboxed environments
- Manages sandbox lifecycle (create, poll status, delete)
- Handles all infrastructure concerns
### Getting Your API Key
To use OpenHands Cloud, you need an API key:
1. Go to [app.all-hands.dev](https://app.all-hands.dev)
2. Sign in to your account
3. Navigate to Settings → API Keys
4. Create a new API key
Store this key securely and use it as the `OPENHANDS_CLOUD_API_KEY` environment variable.
### Configuration Options
The `OpenHandsCloudWorkspace` supports several configuration options:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `cloud_api_url` | `str` | Required | OpenHands Cloud API URL |
| `cloud_api_key` | `str` | Required | API key for authentication |
| `sandbox_spec_id` | `str \| None` | `None` | Custom sandbox specification ID |
| `init_timeout` | `float` | `300.0` | Timeout for sandbox initialization (seconds) |
| `api_timeout` | `float` | `60.0` | Timeout for API requests (seconds) |
| `keep_alive` | `bool` | `False` | Keep sandbox running after cleanup |
### Keep Alive Mode
By default, the sandbox is deleted when the workspace is closed. To keep it running:
```python icon="python" focus={4}
workspace = OpenHandsCloudWorkspace(
cloud_api_url="https://app.all-hands.dev",
cloud_api_key=cloud_api_key,
keep_alive=True,
)
```
This is useful for debugging or when you want to inspect the sandbox state after execution.
### Workspace Testing
You can test the workspace before running the agent:
```python icon="python" focus={1-3}
result = workspace.execute_command(
"echo 'Hello from OpenHands Cloud sandbox!' && pwd"
)
logger.info(f"Command completed: {result.exit_code}, {result.stdout}")
```
This verifies connectivity to the cloud sandbox and ensures the environment is ready.
### Inheriting SaaS Credentials
Instead of providing your own `LLM_API_KEY`, you can inherit the LLM configuration and secrets from your OpenHands Cloud account. This means you only need `OPENHANDS_CLOUD_API_KEY` — no separate LLM key required.
#### `get_llm()`
Fetches your account's LLM settings (model, API key, base URL) and returns a ready-to-use `LLM` instance:
```python icon="python" focus={2-3}
with OpenHandsCloudWorkspace(...) as workspace:
llm = workspace.get_llm()
agent = Agent(llm=llm, tools=get_default_tools())
```
You can override any parameter:
```python icon="python"
llm = workspace.get_llm(model="gpt-4o", temperature=0.5)
```
Under the hood, `get_llm()` calls `GET /api/v1/users/me?expose_secrets=true`, sending your Cloud API key in the `Authorization` header plus the sandbox's `X-Session-API-Key`. That session key is issued by OpenHands Cloud for the running sandbox, so it scopes the request to that sandbox rather than acting like a separately provisioned second credential.
#### `get_secrets()`
Builds `LookupSecret` references for your SaaS-configured secrets. Raw values **never transit through the SDK client** — they are resolved lazily by the agent-server inside the sandbox:
```python icon="python" focus={2-3}
with OpenHandsCloudWorkspace(...) as workspace:
secrets = workspace.get_secrets()
conversation.update_secrets(secrets)
```
You can also filter to specific secrets:
```python icon="python"
gh_secrets = workspace.get_secrets(names=["GITHUB_TOKEN"])
```
See the [SaaS Credentials example](#saas-credentials-example) below for a complete working example.
## Comparison with Other Workspace Types
| Feature | OpenHandsCloudWorkspace | APIRemoteWorkspace | DockerWorkspace |
|---------|------------------------|-------------------|-----------------|
| Infrastructure | OpenHands Cloud | Runtime API | Local Docker |
| Authentication | API Key | API Key | None |
| Setup Required | None | Runtime API access | Docker installed |
| Custom Images | Via sandbox specs | Direct image specification | Direct image specification |
| Best For | Production use | Custom runtime environments | Local development |
## Ready-to-run Example
This example is available on GitHub: [examples/02_remote_agent_server/07_convo_with_cloud_workspace.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/07_convo_with_cloud_workspace.py)
This example shows how to connect to OpenHands Cloud for fully managed agent execution:
```python icon="python" expandable examples/02_remote_agent_server/07_convo_with_cloud_workspace.py
"""Example: OpenHandsCloudWorkspace for OpenHands Cloud API.
This example demonstrates using OpenHandsCloudWorkspace to provision a sandbox
via OpenHands Cloud (app.all-hands.dev) and run an agent conversation.
Usage:
uv run examples/02_remote_agent_server/06_convo_with_cloud_workspace.py
Requirements:
- LLM_API_KEY: API key for direct LLM provider access (e.g., Anthropic API key)
- OPENHANDS_CLOUD_API_KEY: API key for OpenHands Cloud access
Note:
The LLM configuration is sent to the cloud sandbox, so you need an API key
that works directly with the LLM provider (not a local proxy). If using
Anthropic, set LLM_API_KEY to your Anthropic API key.
"""
import os
import time
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Conversation,
RemoteConversation,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import OpenHandsCloudWorkspace
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY")
assert api_key, "LLM_API_KEY required"
# Note: Don't use a local proxy URL here - the cloud sandbox needs direct access
# to the LLM provider. Use None for base_url to let LiteLLM use the default
# provider endpoint, or specify the provider's direct URL.
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL") or None,
api_key=SecretStr(api_key),
)
cloud_api_key = os.getenv("OPENHANDS_CLOUD_API_KEY")
if not cloud_api_key:
logger.error("OPENHANDS_CLOUD_API_KEY required")
exit(1)
cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev")
logger.info(f"Using OpenHands Cloud API: {cloud_api_url}")
with OpenHandsCloudWorkspace(
cloud_api_url=cloud_api_url,
cloud_api_key=cloud_api_key,
) as workspace:
agent = get_default_agent(llm=llm, cli_mode=True)
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
received_events.append(event)
last_event_time["ts"] = time.time()
result = workspace.execute_command(
"echo 'Hello from OpenHands Cloud sandbox!' && pwd"
)
logger.info(f"Command completed: {result.exit_code}, {result.stdout}")
conversation = Conversation(
agent=agent, workspace=workspace, callbacks=[event_callback]
)
assert isinstance(conversation, RemoteConversation)
try:
conversation.send_message(
"Read the current repo and write 3 facts about the project into FACTS.txt."
)
conversation.run()
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
conversation.send_message("Great! Now delete that file.")
conversation.run()
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
conversation.close()
logger.info("✅ Conversation completed successfully.")
logger.info(f"Total {len(received_events)} events received during conversation.")
```
```bash Running the Example
export LLM_API_KEY="your-llm-api-key"
export OPENHANDS_CLOUD_API_KEY="your-cloud-api-key"
# Optional: specify a custom sandbox spec
# export OPENHANDS_SANDBOX_SPEC_ID="your-sandbox-spec-id"
cd agent-sdk
uv run python examples/02_remote_agent_server/07_convo_with_cloud_workspace.py
```
## SaaS Credentials Example
This example is available on GitHub: [examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py)
This example demonstrates the simplified flow where your OpenHands Cloud account's LLM configuration and secrets are inherited automatically — no need to provide `LLM_API_KEY` separately:
```python icon="python" expandable examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py
"""Example: Inherit SaaS credentials via OpenHandsCloudWorkspace.
This example shows the simplified flow where your OpenHands Cloud account's
LLM configuration and secrets are inherited automatically — no need to
provide LLM_API_KEY separately.
Compared to 07_convo_with_cloud_workspace.py (which requires a separate
LLM_API_KEY), this approach uses:
- workspace.get_llm() → fetches LLM config from your SaaS account
- workspace.get_secrets() → builds lazy LookupSecret references for your secrets
Raw secret values never transit through the SDK client. The agent-server
inside the sandbox resolves them on demand.
Usage:
uv run examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py
Requirements:
- OPENHANDS_CLOUD_API_KEY: API key for OpenHands Cloud (the only credential needed)
Optional:
- OPENHANDS_CLOUD_API_URL: Override the Cloud API URL (default: https://app.all-hands.dev)
- LLM_MODEL: Override the model from your SaaS settings
"""
import os
import time
from openhands.sdk import (
Conversation,
RemoteConversation,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import OpenHandsCloudWorkspace
logger = get_logger(__name__)
cloud_api_key = os.getenv("OPENHANDS_CLOUD_API_KEY")
if not cloud_api_key:
logger.error("OPENHANDS_CLOUD_API_KEY required")
exit(1)
cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev")
logger.info(f"Using OpenHands Cloud API: {cloud_api_url}")
with OpenHandsCloudWorkspace(
cloud_api_url=cloud_api_url,
cloud_api_key=cloud_api_key,
) as workspace:
# --- LLM from SaaS account settings ---
# get_llm() calls GET /users/me?expose_secrets=true,
# sending your Cloud API key plus the sandbox session
# key that OpenHands Cloud issued for this workspace.
# It returns a fully configured LLM instance.
# Override any parameter: workspace.get_llm(model="gpt-4o")
llm = workspace.get_llm()
logger.info(f"LLM configured: model={llm.model}")
# --- Secrets from SaaS account ---
# get_secrets() fetches secret *names* (not values) and builds LookupSecret
# references. Values are resolved lazily inside the sandbox.
secrets = workspace.get_secrets()
logger.info(f"Available secrets: {list(secrets.keys())}")
# Build agent and conversation
agent = get_default_agent(llm=llm, cli_mode=True)
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
received_events.append(event)
last_event_time["ts"] = time.time()
conversation = Conversation(
agent=agent, workspace=workspace, callbacks=[event_callback]
)
assert isinstance(conversation, RemoteConversation)
# Inject SaaS secrets into the conversation
if secrets:
conversation.update_secrets(secrets)
logger.info(f"Injected {len(secrets)} secrets into conversation")
# Build a prompt that exercises the injected secrets by asking the agent to
# print the last 50% of each token — proves values resolved without leaking
# full secrets in logs.
secret_names = list(secrets.keys()) if secrets else []
if secret_names:
names_str = ", ".join(f"${name}" for name in secret_names)
prompt = (
f"For each of these environment variables: {names_str} — "
"print the variable name and the LAST 50% of its value "
"(i.e. the second half of the string). "
"Then write a short summary into SECRETS_CHECK.txt."
)
else:
# No secret was configured on OpenHands Cloud
prompt = "Tell me, is there any secret configured for you?"
try:
conversation.send_message(prompt)
conversation.run()
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
conversation.close()
logger.info("✅ Conversation completed successfully.")
logger.info(f"Total {len(received_events)} events received during conversation.")
```
```bash Running the SaaS Credentials Example
export OPENHANDS_CLOUD_API_KEY="your-cloud-api-key"
# Optional: override LLM model from your SaaS settings
# export LLM_MODEL="gpt-4o"
cd agent-sdk
uv run python examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py
```
## Settings and Secrets API Examples
The remote agent-server examples also include end-to-end scripts for settings-backed secrets and authenticated LLM configuration:
- [examples/02_remote_agent_server/12_settings_and_secrets_api.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/12_settings_and_secrets_api.py) demonstrates storing secrets through the Settings and Secrets API, referencing them with `LookupSecret`, and cleaning them up after use.
- [examples/02_remote_agent_server/13_workspace_get_llm.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/13_workspace_get_llm.py) demonstrates configuring LLM settings on an authenticated agent-server and retrieving them through `RemoteWorkspace.get_llm()`.
## Next Steps
- **[API-based Sandbox](/sdk/guides/agent-server/api-sandbox)** - Connect to Runtime API service
- **[Docker Sandboxed Server](/sdk/guides/agent-server/docker-sandbox)** - Run locally with Docker
- **[Local Agent Server](/sdk/guides/agent-server/local-server)** - Development without containers
- **[Agent Server Overview](/sdk/guides/agent-server/overview)** - Architecture and implementation details
### Conversation Goals
Source: https://docs.openhands.dev/sdk/guides/agent-server/conversation-goals.md
A goal is an optional strategy on a normal remote conversation. It does **not** create a special conversation type, a fork, or a separate history. The agent-server keeps using the same conversation events and adds a background driver that audits progress toward the objective.
Use this for UI flows such as a `/goal` command: the user sets an objective, the agent keeps working toward it, and the UI can show progress with stop/resume controls.
## Behavior
When a client starts a goal, the agent-server:
1. Finds the live conversation `EventService`.
2. Rejects the request if the conversation or another goal is already running.
3. Creates a `GoalController` from the objective and `max_iterations`.
4. Starts a background task and returns immediately.
5. Emits `ConversationStateUpdateEvent` with `key="goal"` and `status="running"`.
6. Sends the objective as a normal user message into the same conversation history.
7. Runs the agent, judges the resulting events, and either emits `complete` / `capped` or sends a follow-up prompt and loops.
A normal user message interrupts the active goal before that new user input is appended. This lets the user take control without losing the goal state.
## Endpoints
All endpoints are under the agent-server API prefix.
| Endpoint | Purpose |
| --- | --- |
| `POST /api/conversations/{conversation_id}/goal` | Set a goal and start the background driver. |
| `POST /api/conversations/{conversation_id}/goal/stop` | Stop the active goal and record it as resumable. |
| `POST /api/conversations/{conversation_id}/goal/resume` | Resume the most recent interrupted goal. |
Start requests include the objective and an optional iteration cap:
```json
{
"objective": "Refactor the authentication flow and verify tests pass",
"max_iterations": 10
}
```
`max_iterations` defaults to `10` and must be at least `1`.
## Status Events
Clients should render goal progress from streamed `ConversationStateUpdateEvent` events where `key == "goal"`. The `value` includes:
| Field | Meaning |
| --- | --- |
| `active` | Whether the goal driver is still active. |
| `status` | `running`, `complete`, `capped`, or `interrupted`. |
| `iteration` | Current audit round. |
| `max_iterations` | Iteration cap for this goal. |
| `objective` | Original objective. |
| `verdict` | Optional judge feedback for the latest round. |
These status events are persisted with the conversation events, so resume can work after a server restart.
## Stop and Resume
`POST /goal/stop` cancels the background goal driver if one is running. The cancel path records a `status="interrupted"` goal event, so the UI can stop showing the goal as active and the goal can be resumed later. It does not delete conversation history.
`POST /goal/resume` reads the last persisted goal status. It only resumes statuses that are not terminal; a goal with `status="complete"` or `status="capped"` is not resumable. Resume rebuilds the controller with the same objective and stored iteration, then continues with a resume prompt.
Stopping is graceful. If a model call is already in flight, it may finish before the conversation becomes idle.
## Error Handling
| Case | Result |
| --- | --- |
| Conversation not found | `404` |
| Conversation already running | `409` |
| Another goal already running | `409` |
| No resumable goal | `400` |
| Invalid objective or iteration cap | `400` / validation error |
There is no dedicated `GET /goal` endpoint. To restore UI state on load, read the conversation events and use the latest `ConversationStateUpdateEvent` with `key="goal"`.
### Custom Tools with Remote Agent Server
Source: https://docs.openhands.dev/sdk/guides/agent-server/custom-tools.md
> A ready-to-run example is available [here](#ready-to-run-example)!
When using a [remote agent server](/sdk/guides/agent-server/overview), custom tools must be available in the server's Python environment. This guide shows how to build a custom base image with your tools and use `DockerDevWorkspace` to automatically build the agent server on top of it.
For standalone custom tools (without remote agent server), see the [Custom Tools guide](/sdk/guides/custom-tools).
## How It Works
1. **Define custom tool** with `register_tool()` at module level
2. **Create Dockerfile** that copies tools and sets `PYTHONPATH`
3. **Build custom base image** with your tools
4. **Use `DockerDevWorkspace`** with `base_image` parameter - it builds the agent server on top
5. **Import tool module** in client before creating conversation
6. **Server imports modules** dynamically, triggering registration
## Key Files
### Custom Tool (`custom_tools/log_data.py`)
```python icon="python" expandable examples/02_remote_agent_server/06_custom_tool/custom_tools/log_data.py
"""Log Data Tool - Example custom tool for logging structured data to JSON.
This tool demonstrates how to create a custom tool that logs structured data
to a local JSON file during agent execution. The data can be retrieved and
verified after the agent completes.
"""
import json
from collections.abc import Sequence
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import Any
from pydantic import Field
from openhands.sdk import (
Action,
ImageContent,
Observation,
TextContent,
ToolDefinition,
)
from openhands.sdk.tool import ToolExecutor, register_tool
# --- Enums and Models ---
class LogLevel(str, Enum):
"""Log level for entries."""
DEBUG = "debug"
INFO = "info"
WARNING = "warning"
ERROR = "error"
class LogDataAction(Action):
"""Action to log structured data to a JSON file."""
message: str = Field(description="The log message")
level: LogLevel = Field(
default=LogLevel.INFO,
description="Log level (debug, info, warning, error)",
)
data: dict[str, Any] = Field(
default_factory=dict,
description="Additional structured data to include in the log entry",
)
class LogDataObservation(Observation):
"""Observation returned after logging data."""
success: bool = Field(description="Whether the data was successfully logged")
log_file: str = Field(description="Path to the log file")
entry_count: int = Field(description="Total number of entries in the log file")
@property
def to_llm_content(self) -> Sequence[TextContent | ImageContent]:
"""Convert observation to LLM content."""
if self.success:
return [
TextContent(
text=(
f"✅ Data logged successfully to {self.log_file}\n"
f"Total entries: {self.entry_count}"
)
)
]
return [TextContent(text="❌ Failed to log data")]
# --- Executor ---
# Default log file path
DEFAULT_LOG_FILE = "/tmp/agent_data.json"
class LogDataExecutor(ToolExecutor[LogDataAction, LogDataObservation]):
"""Executor that logs structured data to a JSON file."""
def __init__(self, log_file: str = DEFAULT_LOG_FILE):
"""Initialize the log data executor.
Args:
log_file: Path to the JSON log file
"""
self.log_file = Path(log_file)
def __call__(
self,
action: LogDataAction,
conversation=None, # noqa: ARG002
) -> LogDataObservation:
"""Execute the log data action.
Args:
action: The log data action
conversation: Optional conversation context (not used)
Returns:
LogDataObservation with the result
"""
# Load existing entries or start fresh
entries: list[dict[str, Any]] = []
if self.log_file.exists():
try:
with open(self.log_file) as f:
entries = json.load(f)
except (json.JSONDecodeError, OSError):
entries = []
# Create new entry with timestamp
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"level": action.level.value,
"message": action.message,
"data": action.data,
}
entries.append(entry)
# Write back to file
self.log_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.log_file, "w") as f:
json.dump(entries, f, indent=2)
return LogDataObservation(
success=True,
log_file=str(self.log_file),
entry_count=len(entries),
)
# --- Tool Definition ---
_LOG_DATA_DESCRIPTION = """Log structured data to a JSON file.
Use this tool to record information, findings, or events during your work.
Each log entry includes a timestamp and can contain arbitrary structured data.
Parameters:
* message: A descriptive message for the log entry
* level: Log level - one of 'debug', 'info', 'warning', 'error' (default: info)
* data: Optional dictionary of additional structured data to include
Example usage:
- Log a finding: message="Found potential issue", level="warning", data={"file": "app.py", "line": 42}
- Log progress: message="Completed analysis", level="info", data={"files_checked": 10}
""" # noqa: E501
class LogDataTool(ToolDefinition[LogDataAction, LogDataObservation]):
"""Tool for logging structured data to a JSON file."""
@classmethod
def create(cls, conv_state, **params) -> Sequence[ToolDefinition]: # noqa: ARG003
"""Create LogDataTool instance.
Args:
conv_state: Conversation state (not used in this example)
**params: Additional parameters:
- log_file: Path to the JSON log file (default: /tmp/agent_data.json)
Returns:
A sequence containing a single LogDataTool instance
"""
log_file = params.get("log_file", DEFAULT_LOG_FILE)
executor = LogDataExecutor(log_file=log_file)
return [
cls(
description=_LOG_DATA_DESCRIPTION,
action_type=LogDataAction,
observation_type=LogDataObservation,
executor=executor,
)
]
# Auto-register the tool when this module is imported
# This is what enables dynamic tool registration in the remote agent server
register_tool("LogDataTool", LogDataTool)
```
### Dockerfile
```dockerfile icon="docker"
FROM nikolaik/python-nodejs:python3.12-nodejs22
COPY custom_tools /app/custom_tools
ENV PYTHONPATH="/app:${PYTHONPATH}"
```
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Tool not found | Ensure `register_tool()` is called at module level, import tool before creating conversation |
| Import errors on server | Check `PYTHONPATH` in Dockerfile, verify all dependencies installed |
| Build failures | Verify file paths in `COPY` commands, ensure Python 3.12+ |
**Binary Mode Limitation**: Custom tools only work with **source mode** deployments. When using `DockerDevWorkspace`, set `target="source"` (the default). See [GitHub issue #1531](https://github.com/OpenHands/software-agent-sdk/issues/1531) for details.
## Ready-to-run Example
This example is available on GitHub: [examples/02_remote_agent_server/06_custom_tool/](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server/06_custom_tool)
```python icon="python" expandable examples/02_remote_agent_server/06_custom_tool/main.py
"""Example: Using custom tools with remote agent server.
This example demonstrates how to use custom tools with a remote agent server
by building a custom base image that includes the tool implementation.
Prerequisites:
1. Build the custom base image first:
cd examples/02_remote_agent_server/05_custom_tool
./build_custom_image.sh
2. Set LLM_API_KEY environment variable
The workflow is:
1. Define a custom tool (LogDataTool for logging structured data to JSON)
2. Create a simple Dockerfile that copies the tool into the base image
3. Build the custom base image
4. Use DockerDevWorkspace with base_image pointing to the custom image
5. DockerDevWorkspace builds the agent server on top of the custom base image
6. The server dynamically registers tools when the client creates a conversation
7. The agent can use the custom tool during execution
8. Verify the logged data by reading the JSON file from the workspace
This pattern is useful for:
- Collecting structured data during agent runs (logs, metrics, events)
- Implementing custom integrations with external systems
- Adding domain-specific operations to the agent
"""
import os
import platform
import subprocess
import sys
import time
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Conversation,
RemoteConversation,
Tool,
get_logger,
)
from openhands.workspace import DockerDevWorkspace
logger = get_logger(__name__)
# 1) Ensure we have LLM API key
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
def detect_platform():
"""Detects the correct Docker platform string."""
machine = platform.machine().lower()
if "arm" in machine or "aarch64" in machine:
return "linux/arm64"
return "linux/amd64"
# Get the directory containing this script
example_dir = Path(__file__).parent.absolute()
# Custom base image tag (contains custom tools, agent server built on top)
CUSTOM_BASE_IMAGE_TAG = "custom-base-image:latest"
# 2) Check if custom base image exists, build if not
logger.info(f"🔍 Checking for custom base image: {CUSTOM_BASE_IMAGE_TAG}")
result = subprocess.run(
["docker", "images", "-q", CUSTOM_BASE_IMAGE_TAG],
capture_output=True,
text=True,
check=False,
)
if not result.stdout.strip():
logger.info("⚠️ Custom base image not found. Building...")
logger.info("📦 Building custom base image with custom tools...")
build_script = example_dir / "build_custom_image.sh"
try:
subprocess.run(
[str(build_script), CUSTOM_BASE_IMAGE_TAG],
cwd=str(example_dir),
check=True,
)
logger.info("✅ Custom base image built successfully!")
except subprocess.CalledProcessError as e:
logger.error(f"❌ Failed to build custom base image: {e}")
logger.error("Please run ./build_custom_image.sh manually and fix any errors.")
sys.exit(1)
else:
logger.info(f"✅ Custom base image found: {CUSTOM_BASE_IMAGE_TAG}")
# 3) Create a DockerDevWorkspace with the custom base image
# DockerDevWorkspace will build the agent server on top of this base image
logger.info("🚀 Building and starting agent server with custom tools...")
logger.info("📦 This may take a few minutes on first run...")
with DockerDevWorkspace(
base_image=CUSTOM_BASE_IMAGE_TAG,
host_port=8011,
platform=detect_platform(),
target="source", # NOTE: "binary" target does not work with custom tools
) as workspace:
logger.info("✅ Custom agent server started!")
# 4) Import custom tools to register them in the client's registry
# This allows the client to send the module qualname to the server
# The server will then import the same module and execute the tool
import custom_tools.log_data # noqa: F401
# 5) Create agent with custom tools
# Note: We specify the tool here, but it's actually executed on the server
# Get default tools and add our custom tool
from openhands.sdk import Agent
from openhands.tools.preset.default import get_default_condenser, get_default_tools
tools = get_default_tools(enable_browser=False)
# Add our custom tool!
tools.append(Tool(name="LogDataTool"))
agent = Agent(
llm=llm,
tools=tools,
system_prompt_kwargs={"cli_mode": True},
condenser=get_default_condenser(
llm=llm.model_copy(update={"usage_id": "condenser"})
),
)
# 6) Set up callback collection
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
event_type = type(event).__name__
logger.info(f"🔔 Callback received event: {event_type}\n{event}")
received_events.append(event)
last_event_time["ts"] = time.time()
# 7) Test the workspace with a simple command
result = workspace.execute_command(
"echo 'Custom agent server ready!' && python --version"
)
logger.info(
f"Command '{result.command}' completed with exit code {result.exit_code}"
)
logger.info(f"Output: {result.stdout}")
# 8) Create conversation with the custom agent
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
)
assert isinstance(conversation, RemoteConversation)
try:
logger.info(f"\n📋 Conversation ID: {conversation.state.id}")
logger.info("📝 Sending task to analyze files and log findings...")
conversation.send_message(
"Please analyze the Python files in the current directory. "
"Use the LogDataTool to log your findings as you work. "
"For example:\n"
"- Log when you start analyzing a file (level: info)\n"
"- Log any interesting patterns you find (level: info)\n"
"- Log any potential issues (level: warning)\n"
"- Include relevant data like file names, line numbers, etc.\n\n"
"Make at least 3 log entries using the LogDataTool."
)
logger.info("🚀 Running conversation...")
conversation.run()
logger.info("✅ Task completed!")
logger.info(f"Agent status: {conversation.state.execution_status}")
# Wait for events to settle (no events for 2 seconds)
logger.info("⏳ Waiting for events to stop...")
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
logger.info("✅ Events have stopped")
# 9) Read the logged data from the JSON file using file_download API
logger.info("\n📊 Logged Data Summary:")
logger.info("=" * 80)
# Download the log file from the workspace using the file download API
import json
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp_file:
local_path = tmp_file.name
download_result = workspace.file_download(
source_path="/tmp/agent_data.json",
destination_path=local_path,
)
if download_result.success:
try:
with open(local_path) as f:
log_entries = json.load(f)
logger.info(f"Found {len(log_entries)} log entries:\n")
for i, entry in enumerate(log_entries, 1):
logger.info(f"Entry {i}:")
logger.info(f" Timestamp: {entry.get('timestamp', 'N/A')}")
logger.info(f" Level: {entry.get('level', 'N/A')}")
logger.info(f" Message: {entry.get('message', 'N/A')}")
if entry.get("data"):
logger.info(f" Data: {json.dumps(entry['data'], indent=4)}")
logger.info("")
except json.JSONDecodeError:
logger.info("Log file exists but couldn't parse JSON")
with open(local_path) as f:
logger.info(f"Raw content: {f.read()}")
finally:
# Clean up the temporary file
Path(local_path).unlink(missing_ok=True)
else:
logger.info("No log file found (agent may not have used the tool)")
if download_result.error:
logger.debug(f"Download error: {download_result.error}")
logger.info("=" * 80)
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nEXAMPLE_COST: {cost}")
finally:
logger.info("\n🧹 Cleaning up conversation...")
conversation.close()
logger.info("\n✅ Example completed successfully!")
logger.info("\nThis example demonstrated how to:")
logger.info("1. Create a custom tool that logs structured data to JSON")
logger.info("2. Build a simple base image with the custom tool")
logger.info("3. Use DockerDevWorkspace with base_image to build agent server on top")
logger.info("4. Enable dynamic tool registration on the server")
logger.info("5. Use the custom tool during agent execution")
logger.info("6. Read the logged data back from the workspace")
```
```bash Running the Example
# Build the custom base image first
cd examples/02_remote_agent_server/06_custom_tool
./build_custom_image.sh
# Run the example
export LLM_API_KEY="your-api-key"
uv run python custom_tool_example.py
```
## Next Steps
- **[Custom Tools (Standalone)](/sdk/guides/custom-tools)** - For local execution without remote server
- **[Agent Server Overview](/sdk/guides/agent-server/overview)** - Understanding remote agent servers
### Deferred Init (Warm-Pool)
Source: https://docs.openhands.dev/sdk/guides/agent-server/deferred-init.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
In **warm-pool deployments** server pods are booted before a user is matched to
one. The pod starts in a *dormant* state — stateless services (tool preload,
VSCode, etc.) come up normally, but all `/api/*` routes return `503` until
`POST /api/init` delivers the per-user runtime configuration (credentials,
workspace paths, session keys).
This pattern reduces cold-start latency for users while keeping per-user data
out of the image.
## State Machine
```
dormant ──(POST /api/init)──▶ initializing ──▶ ready
▲ │
└───────────(on error)──────────┘
```
| State | `/health`, `/ready` | `GET /api/init` | `POST /api/init` | `/api/*` |
|---|---|---|---|---|
| `dormant` | `200` | `200` `state: dormant` | `200` → starts init | `503` |
| `initializing` | `200` | `200` `state: initializing` | `400` (already running) | `503` |
| `ready` | `200` | `200` `state: ready` | `400` (already done) | live |
When `deferred_init` is `false` (the default), the `/api/init` endpoints return
`404` and all `/api/*` routes are live immediately.
## Enabling Dormant Mode
Set the `OH_DEFERRED_INIT` environment variable when starting the server:
```bash
OH_DEFERRED_INIT=true OH_SECRET_KEY= python -m openhands.agent_server
```
The `OH_SECRET_KEY` value is used to authenticate `POST /api/init` via the
`X-Init-API-Key` request header. The orchestrator already holds this key for
encryption purposes, so no additional secret distribution is required.
## Checking the Init State
`GET /api/init` is unauthenticated and returns the current state at any time:
```bash
curl http://localhost:8000/api/init
# {"state":"dormant","error":null}
```
## Activating the Server
Send `POST /api/init` with the `X-Init-API-Key` header set to the bootstrap
secret. The body is an `InitRequest` and all fields are optional — only the
values you provide override the dormant configuration:
```python icon="python"
import httpx
client = httpx.Client(base_url="http://localhost:8000")
resp = client.post(
"/api/init",
json={
# Credentials that should not be baked into the warm image arrive here.
"env": {"LLM_API_KEY": user_api_key},
# Point at the user's mounted workspace.
"conversations_path": "/mnt/user-workspace/conversations",
# Lock down the API to this user's session key.
"session_api_keys": [user_session_key],
},
headers={"X-Init-API-Key": BOOTSTRAP_SECRET_KEY},
)
assert resp.json()["state"] == "ready"
```
`InitRequest` fields:
| Field | Type | Description |
|---|---|---|
| `session_api_keys` | `list[str]` | Per-user API keys for subsequent `/api/*` calls |
| `secret_key` | `str` | Encryption secret (defaults to first `session_api_key`) |
| `conversations_path` | `path` | Where conversations are persisted |
| `bash_events_dir` | `path` | Where bash events are persisted |
| `env` | `dict[str, str]` | Process env vars set before services start (e.g. credentials) |
| `webhooks` | `list` | Per-user webhooks for event streaming |
| `web_url` | `str` | External server URL for root-path calculation |
| `allow_cors_origins` | `list[str]` | CORS origins added to the localhost allowlist |
| `max_concurrent_runs` | `int` | Override conversation-step concurrency limit |
## Error Handling
If initialization fails, the state rolls back to `dormant` and the error is
stored in `GET /api/init` response's `error` field. The orchestrator can then
retry `POST /api/init`:
```bash
curl http://localhost:8000/api/init
# {"state":"dormant","error":"ConversationService failed to start: ..."}
```
## Ready-to-run Example
This example is available on GitHub: [examples/02_remote_agent_server/16_deferred_init.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/16_deferred_init.py)
This example walks through the full warm-pool lifecycle: starting a dormant
server, verifying the `503` gate, activating it via `POST /api/init`, and
running a conversation on the ready server.
```python icon="python" expandable examples/02_remote_agent_server/16_deferred_init.py
```
## Next Steps
- **[Local Agent Server](/sdk/guides/agent-server/local-server)** — Run a server in the same process
- **[Docker Sandbox](/sdk/guides/agent-server/docker-sandbox)** — Isolated Docker-based deployment
- **[Settings & Secrets API](/sdk/guides/secrets)** — Manage per-user secrets securely
- **[Agent Server Overview](/sdk/guides/agent-server/overview)** — Architecture and deployment options
### Docker Sandbox
Source: https://docs.openhands.dev/sdk/guides/agent-server/docker-sandbox.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
The docker sandboxed agent server demonstrates how to run agents in isolated Docker containers using `DockerWorkspace`.
This provides complete isolation from the host system, making it ideal for production deployments, testing, and executing untrusted code safely.
Use `DockerWorkspace` with a pre-built agent server image for the fastest startup. When you need to build your own image from a base image, switch to `DockerDevWorkspace`.
the Docker sandbox image ships with features configured in the [Dockerfile](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-agent-server/openhands/agent_server/docker/Dockerfile) (e.g., secure defaults and services like VSCode and VNC exposed behind well-defined ports), which are not available in the local (non-Docker) agent server.
## 1) Basic Docker Sandbox
> A ready-to-run example is available [here](#ready-to-run-example-docker-sandbox)!
### Key Concepts
#### DockerWorkspace Context Manager
The `DockerWorkspace` uses a context manager to automatically handle container lifecycle:
```python icon="python"
with DockerWorkspace(
# use pre-built image for faster startup (recommended)
server_image="ghcr.io/openhands/agent-server:latest-python",
host_port=8010,
platform=detect_platform(),
) as workspace:
# Container is running here
# Work with the workspace
pass
# Container is automatically stopped and cleaned up here
```
The workspace automatically:
- Pulls or builds the Docker image
- Starts the container with an agent server
- Waits for the server to be ready
- Cleans up the container when done
#### Platform Detection
The example includes platform detection to ensure the correct Docker image is built and used:
```python icon="python"
def detect_platform():
"""Detects the correct Docker platform string."""
machine = platform.machine().lower()
if "arm" in machine or "aarch64" in machine:
return "linux/arm64"
return "linux/amd64"
```
This ensures compatibility across different CPU architectures (Intel/AMD vs ARM/Apple Silicon).
#### Testing the Workspace
Before creating a conversation, the example tests the workspace connection:
```python icon="python"
result = workspace.execute_command(
"echo 'Hello from sandboxed environment!' && pwd"
)
logger.info(
f"Command '{result.command}' completed"
f"with exit code {result.exit_code}"
)
logger.info(f"Output: {result.stdout}")
```
This verifies the workspace is properly initialized and can execute commands.
#### Automatic RemoteConversation
When you use a DockerWorkspace, the Conversation automatically becomes a RemoteConversation:
```python icon="python" focus={1, 3, 7}
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
visualize=True,
)
assert isinstance(conversation, RemoteConversation)
```
The SDK detects the remote workspace and uses WebSocket communication for real-time event streaming.
#### DockerWorkspace vs DockerDevWorkspace
Use `DockerWorkspace` when you can rely on the official pre-built images for the agent server. Switch to `DockerDevWorkspace` when you need to build or customize the image on-demand (slower startup, requires the SDK source tree and Docker build support).
```python icon="python"
# ✅ Fast: Use pre-built image (recommended)
DockerWorkspace(
server_image="ghcr.io/openhands/agent-server:latest-python",
host_port=8010,
)
# 🛠️ Custom: Build on the fly (requires SDK tooling)
DockerDevWorkspace(
base_image="nikolaik/python-nodejs:python3.12-nodejs22",
host_port=8010,
target="source",
)
```
### Ready-tu-run Example Docker Sandbox
This example is available on GitHub: [examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py)
This example shows how to create a DockerWorkspace that automatically manages Docker containers for agent execution:
```python icon="python" expandable examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py
import os
import platform
import time
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Conversation,
RemoteConversation,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import DockerWorkspace
logger = get_logger(__name__)
# 1) Ensure we have LLM API key
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
def detect_platform():
"""Detects the correct Docker platform string."""
machine = platform.machine().lower()
if "arm" in machine or "aarch64" in machine:
return "linux/arm64"
return "linux/amd64"
def get_server_image():
"""Get the server image tag, using PR-specific image in CI."""
platform_str = detect_platform()
arch = "arm64" if "arm64" in platform_str else "amd64"
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, use the latest image from main
github_sha = os.getenv("GITHUB_SHA")
if github_sha:
return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}"
return "ghcr.io/openhands/agent-server:latest-python"
# 2) Create a Docker-based remote workspace that will set up and manage
# the Docker container automatically. Use `DockerWorkspace` with a pre-built
# image or `DockerDevWorkspace` to automatically build the image on-demand.
# with DockerDevWorkspace(
# # dynamically build agent-server image
# base_image="nikolaik/python-nodejs:python3.13-nodejs22",
# host_port=8010,
# platform=detect_platform(),
# ) as workspace:
server_image = get_server_image()
logger.info(f"Using server image: {server_image}")
with DockerWorkspace(
# use pre-built image for faster startup
server_image=server_image,
host_port=8010,
platform=detect_platform(),
) as workspace:
# 3) Create agent
agent = get_default_agent(
llm=llm,
cli_mode=True,
)
# 4) Set up callback collection
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
event_type = type(event).__name__
logger.info(f"🔔 Callback received event: {event_type}\n{event}")
received_events.append(event)
last_event_time["ts"] = time.time()
# 5) Test the workspace with a simple command
result = workspace.execute_command(
"echo 'Hello from sandboxed environment!' && pwd"
)
logger.info(
f"Command '{result.command}' completed with exit code {result.exit_code}"
)
logger.info(f"Output: {result.stdout}")
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
)
assert isinstance(conversation, RemoteConversation)
try:
logger.info(f"\n📋 Conversation ID: {conversation.state.id}")
logger.info("📝 Sending first message...")
conversation.send_message(
"Read the current repo and write 3 facts about the project into FACTS.txt."
)
logger.info("🚀 Running conversation...")
conversation.run()
logger.info("✅ First task completed!")
logger.info(f"Agent status: {conversation.state.execution_status}")
# Wait for events to settle (no events for 2 seconds)
logger.info("⏳ Waiting for events to stop...")
while time.time() - last_event_time["ts"] < 2.0:
time.sleep(0.1)
logger.info("✅ Events have stopped")
logger.info("🚀 Running conversation again...")
conversation.send_message("Great! Now delete that file.")
conversation.run()
logger.info("✅ Second task completed!")
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
print("\n🧹 Cleaning up conversation...")
conversation.close()
```
---
## 2) VS Code in Docker Sandbox
> A ready-to-run example is available [here](#ready-to-run-example-vs-code)!
VS Code with Docker demonstrates how to enable VS Code Web integration in a Docker-sandboxed environment. This allows you to access a full VS Code editor running in the container, making it easy to inspect, edit, and manage files that the agent is working with.
### Key Concepts
#### VS Code-Enabled DockerWorkspace
The workspace is configured with extra ports for VS Code access:
```python icon="python" focus={1, 5}
with DockerWorkspace(
server_image="ghcr.io/openhands/agent-server:latest-python",
host_port=18010,
platform="linux/arm64", # or "linux/amd64" depending on your architecture
extra_ports=True, # Expose extra ports for VSCode and VNC
) as workspace:
"""Extra ports allows you to access VSCode at localhost:18011"""
```
The `extra_ports=True` setting exposes:
- Port `host_port+1`: VS Code Web interface (host_port + 1)
- Port `host_port+2`: VNC viewer for visual access
If you need to customize the agent-server image, swap in `DockerDevWorkspace` with the same parameters and provide `base_image`/`target` to build on demand.
#### VS Code URL Generation
The example retrieves the VS Code URL with authentication token:
```python icon="python"
# Get VSCode URL with token
vscode_port = (workspace.host_port or 8010) + 1
try:
response = httpx.get(
f"{workspace.host}/api/vscode/url",
params={"workspace_dir": workspace.working_dir},
)
vscode_data = response.json()
vscode_url = vscode_data.get("url", "").replace(
"localhost:8001", f"localhost:{vscode_port}"
)
except Exception:
# Fallback if server route not available
folder = (
f"/{workspace.working_dir}"
if not str(workspace.working_dir).startswith("/")
else str(workspace.working_dir)
)
vscode_url = f"http://localhost:{vscode_port}/?folder={folder}"
```
This generates a properly authenticated URL with the workspace directory pre-opened.
#### VS Code URL Format
```text
http://localhost:{vscode_port}/?tkn={token}&folder={workspace_dir}
```
where:
- `vscode_port`: Usually host_port + 1 (e.g., 8011)
- `token`: Authentication token for security
- `workspace_dir`: Workspace directory to open
### Ready-to-run Example VS Code
This example is available on GitHub: [examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py)
```python icon="python" expandable examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py
import os
import platform
import time
import httpx
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation, get_logger
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import DockerWorkspace
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
# Create a Docker-based remote workspace with extra ports for VSCode access
def detect_platform():
"""Detects the correct Docker platform string."""
machine = platform.machine().lower()
if "arm" in machine or "aarch64" in machine:
return "linux/arm64"
return "linux/amd64"
def get_server_image():
"""Get the server image tag, using PR-specific image in CI."""
platform_str = detect_platform()
arch = "arm64" if "arm64" in platform_str else "amd64"
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, use the latest image from main
github_sha = os.getenv("GITHUB_SHA")
if github_sha:
return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}"
return "ghcr.io/openhands/agent-server:latest-python"
server_image = get_server_image()
logger.info(f"Using server image: {server_image}")
with DockerWorkspace(
server_image=server_image,
host_port=18010,
platform=detect_platform(),
extra_ports=True, # Expose extra ports for VSCode and VNC
) as workspace:
"""Extra ports allows you to access VSCode at localhost:18011"""
# Create agent
agent = get_default_agent(
llm=llm,
cli_mode=True,
)
# Set up callback collection
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
event_type = type(event).__name__
logger.info(f"🔔 Callback received event: {event_type}\n{event}")
received_events.append(event)
last_event_time["ts"] = time.time()
# Create RemoteConversation using the workspace
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
)
assert isinstance(conversation, RemoteConversation)
logger.info(f"\n📋 Conversation ID: {conversation.state.id}")
logger.info("📝 Sending first message...")
conversation.send_message("Create a simple Python script that prints Hello World")
conversation.run()
# Get VSCode URL with token
vscode_port = (workspace.host_port or 8010) + 1
try:
response = httpx.get(
f"{workspace.host}/api/vscode/url",
params={"workspace_dir": workspace.working_dir},
)
vscode_data = response.json()
vscode_url = vscode_data.get("url", "").replace(
"localhost:8001", f"localhost:{vscode_port}"
)
except Exception:
# Fallback if server route not available
folder = (
f"/{workspace.working_dir}"
if not str(workspace.working_dir).startswith("/")
else str(workspace.working_dir)
)
vscode_url = f"http://localhost:{vscode_port}/?folder={folder}"
# Wait for user to explore VSCode
y = None
while y != "y":
y = input(
"\n"
"Because you've enabled extra_ports=True in DockerDevWorkspace, "
"you can open VSCode Web to see the workspace.\n\n"
f"VSCode URL: {vscode_url}\n\n"
"The VSCode should have the OpenHands settings extension installed:\n"
" - Dark theme enabled\n"
" - Auto-save enabled\n"
" - Telemetry disabled\n"
" - Auto-updates disabled\n\n"
"Press 'y' and Enter to exit and terminate the workspace.\n"
">> "
)
```
---
## 3) Browser in Docker Sandbox
> A ready-to-run example is available [here](#ready-to-run-example-browser)!
Browser with Docker demonstrates how to enable browser automation capabilities in a Docker-sandboxed environment. This allows agents to browse websites, interact with web content, and perform web automation tasks while maintaining complete isolation from your host system.
### Key Concepts
#### Browser-Enabled DockerWorkspace
The workspace is configured with extra ports for browser access:
```python icon="python" focus={1-5}
with DockerWorkspace(
server_image="ghcr.io/openhands/agent-server:latest-python",
host_port=8010,
platform=detect_platform(),
extra_ports=True, # Expose extra ports for VSCode and VNC
) as workspace:
"""Extra ports allows you to check localhost:8012 for VNC"""
```
The `extra_ports=True` setting exposes additional ports for:
- Port `host_port+1`: VS Code Web interface
- Port `host_port+2`: VNC viewer for browser visualization
If you need to pre-build a custom browser image, replace `DockerWorkspace` with `DockerDevWorkspace` and provide `base_image`/`target` to build before launch.
#### Enabling Browser Tools
Browser tools are enabled by setting `cli_mode=False`:
```python icon="python" focus={2, 4}
# Create agent with browser tools enabled
agent = get_default_agent(
llm=llm,
cli_mode=False, # CLI mode = False will enable browser tools
)
```
When `cli_mode=False`, the agent gains access to browser automation tools for web interaction.
When VNC is available and `extra_ports=True`, the browser will be opened in the VNC desktop to visualize agent's work. You can watch the browser in real-time via VNC. Demo video:
#### VNC Access
The VNC interface provides real-time visual access to the browser:
```text
http://localhost:8012/vnc.html?autoconnect=1&resize=remote
```
- `autoconnect=1`: Automatically connect to VNC server
- `resize=remote`: Automatically adjust resolution
---
### Ready-to-run Example Browser
This example is available on GitHub: [examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py)
This example shows how to configure `DockerWorkspace` with browser capabilities and VNC access:
```python icon="python" expandable examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py
import os
import platform
import time
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation, get_logger
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import DockerWorkspace
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
def detect_platform():
"""Detects the correct Docker platform string."""
machine = platform.machine().lower()
if "arm" in machine or "aarch64" in machine:
return "linux/arm64"
return "linux/amd64"
def get_server_image():
"""Get the server image tag, using PR-specific image in CI."""
platform_str = detect_platform()
arch = "arm64" if "arm64" in platform_str else "amd64"
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, use the latest image from main
github_sha = os.getenv("GITHUB_SHA")
if github_sha:
return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}"
return "ghcr.io/openhands/agent-server:latest-python"
# Create a Docker-based remote workspace with extra ports for browser access.
# Use `DockerWorkspace` with a pre-built image or `DockerDevWorkspace` to
# automatically build the image on-demand.
# with DockerDevWorkspace(
# # dynamically build agent-server image
# base_image="nikolaik/python-nodejs:python3.13-nodejs22",
# host_port=8010,
# platform=detect_platform(),
# ) as workspace:
server_image = get_server_image()
logger.info(f"Using server image: {server_image}")
with DockerWorkspace(
server_image=server_image,
host_port=8011,
platform=detect_platform(),
extra_ports=True, # Expose extra ports for VSCode and VNC
) as workspace:
"""Extra ports allows you to check localhost:8012 for VNC"""
# Create agent with browser tools enabled
agent = get_default_agent(
llm=llm,
cli_mode=False, # CLI mode = False will enable browser tools
)
# Set up callback collection
received_events: list = []
last_event_time = {"ts": time.time()}
def event_callback(event) -> None:
event_type = type(event).__name__
logger.info(f"🔔 Callback received event: {event_type}\n{event}")
received_events.append(event)
last_event_time["ts"] = time.time()
# Create RemoteConversation using the workspace
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[event_callback],
)
assert isinstance(conversation, RemoteConversation)
logger.info(f"\n📋 Conversation ID: {conversation.state.id}")
logger.info("📝 Sending first message...")
conversation.send_message(
"Could you go to https://openhands.dev/ blog page and summarize main "
"points of the latest blog?"
)
conversation.run()
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
if os.getenv("CI"):
logger.info(
"CI environment detected; skipping interactive prompt and closing workspace." # noqa: E501
)
else:
# Wait for user confirm to exit when running locally
y = None
while y != "y":
y = input(
"Because you've enabled extra_ports=True in DockerDevWorkspace, "
"you can open a browser tab to see the *actual* browser OpenHands "
"is interacting with via VNC.\n\n"
"Link: http://localhost:8012/vnc.html?autoconnect=1&resize=remote\n\n"
"Press 'y' and Enter to exit and terminate the workspace.\n"
">> "
)
```
## Next Steps
- **[Local Agent Server](/sdk/guides/agent-server/local-server)**
- **[Agent Server Overview](/sdk/guides/agent-server/overview)** - Architecture and implementation details
- **[API Sandboxed Server](/sdk/guides/agent-server/api-sandbox)** - Connect to hosted API service
- **[Agent Server Package Architecture](/sdk/arch/agent-server)** - Remote execution architecture
### Local Agent Server
Source: https://docs.openhands.dev/sdk/guides/agent-server/local-server.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
import InstallAgentServer from "/sdk/shared-snippets/install-agent-server.mdx";
Run a local Agent Server when you want a backend process to host OpenHands conversations over HTTP and WebSocket. This is the simplest setup for testing Agent Canvas-style backends, local integrations, and client-server SDK applications.
## Install
Create a Python environment and install the server package and its SDK dependencies:
If you are working from the `OpenHands/software-agent-sdk` repository, see [Agent Server Package § Install](/sdk/arch/agent-server#install) for the `uv`-based setup.
## Start Without Authentication
For local development on your own machine, start the server on loopback:
```bash
python -m openhands.agent_server --host 127.0.0.1 --port 8000
```
Verify that it is running:
```bash
curl http://127.0.0.1:8000/health
```
Open the API docs at `http://127.0.0.1:8000/docs`.
If `SESSION_API_KEY` (legacy alias) or `OH_SESSION_API_KEYS_*` is already set in your shell, the server will require that key for `/api/*` requests. Unset those variables for unauthenticated local-only testing.
This unauthenticated mode is only appropriate for local development. Do not bind an unauthenticated server to a public or shared network interface.
## Start With an API Key
Set a session API key before starting the server:
```bash
export OH_SESSION_API_KEYS_0="$(openssl rand -hex 32)"
export OH_SECRET_KEY="$(openssl rand -hex 32)"
python -m openhands.agent_server --host 127.0.0.1 --port 8000
```
Requests to `/api/*` must include the session key. This request returns the conversation count when the key is accepted:
```bash
curl \
-H "X-Session-API-Key: $OH_SESSION_API_KEYS_0" \
http://127.0.0.1:8000/api/conversations/count
```
`OH_SECRET_KEY` encrypts sensitive values stored with conversations, including LLM API keys and secrets. Keep it stable across restarts. If it changes, previously encrypted values cannot be restored.
## Connect From the SDK
Use `Workspace(host=..., api_key=...)` to connect SDK code to the server:
```python
import os
from pydantic import SecretStr
from openhands.sdk import Conversation, LLM, Workspace
from openhands.tools.preset.default import get_default_agent
llm = LLM(
model=os.environ.get("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=SecretStr(os.environ["LLM_API_KEY"]),
)
agent = get_default_agent(llm=llm, cli_mode=True) # disable browser-automation tools
workspace = Workspace(
host="http://127.0.0.1:8000",
api_key=os.environ["OH_SESSION_API_KEYS_0"],
working_dir="workspace/project",
)
conversation = Conversation(agent=agent, workspace=workspace)
conversation.send_message("Create a NOTES.md file with three facts about this project.")
conversation.run()
conversation.close()
```
If the server was started without `OH_SESSION_API_KEYS_0`, remove the `api_key=...` argument.
The `working_dir` value is relative to the server's process working directory. See [Runtime Files](/sdk/arch/agent-server#runtime-files) for the default directory layout.
## Connect From Another Service
For a non-SDK backend service, pass the session API key as `X-Session-API-Key`:
```bash
curl \
-H "X-Session-API-Key: $OH_SESSION_API_KEYS_0" \
-H "Content-Type: application/json" \
http://127.0.0.1:8000/api/conversations/count
```
Keep the Agent Server bound to `127.0.0.1` when the backend runs on the same machine. If the backend runs on another host, use a private network or reverse proxy, enable TLS, and restrict network access to trusted callers.
For CORS configuration and running directly from a checkout of `OpenHands/software-agent-sdk`, see the [Agent Server Package](/sdk/arch/agent-server) page.
## Ready-to-Run Example
This example is available on GitHub: [examples/02_remote_agent_server/01_convo_with_local_agent_server.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/01_convo_with_local_agent_server.py).
The example starts a local Agent Server subprocess, waits for it to become healthy, connects with `Workspace(host=...)`, and runs a `RemoteConversation`.
## Troubleshooting
- **401 Unauthorized**: Check that the client sends `X-Session-API-Key` and that it matches `OH_SESSION_API_KEYS_0`.
- **Secrets are missing after restart**: Set a stable `OH_SECRET_KEY` before starting the server.
- **The server is reachable locally but not from another machine**: Use `--host 0.0.0.0` only behind trusted network controls, then check firewall and proxy rules.
- **CORS errors in a browser**: Set `OH_ALLOW_CORS_ORIGINS_0` to the browser app origin.
- **Port conflict**: Start with another port, for example `--port 8001`.
## Next Steps
- [Agent Server Package](/sdk/arch/agent-server) - Installation, security, and operational guidance.
- [Docker Sandboxed Server](/sdk/guides/agent-server/docker-sandbox) - Run the server in an isolated Docker workspace.
- [API Sandboxed Server](/sdk/guides/agent-server/api-sandbox) - Start hosted runtime workspaces.
- [Agent Server API Reference](/sdk/guides/agent-server/api-reference/server-details/alive) - Browse generated endpoint docs.
### OpenAI-Compatible Endpoint
Source: https://docs.openhands.dev/sdk/guides/agent-server/openai-gateway.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
The agent-server exposes an OpenAI-compatible `/v1/chat/completions` endpoint so clients that already speak the OpenAI protocol can call an OpenHands agent.
Use this when you want an existing chat UI, IDE integration, evaluation harness, voice platform, or another agent to treat OpenHands as an OpenAI-style backend while still getting the full agent runtime behind the request.
## What to Configure
Most OpenAI-compatible clients ask for the same three fields:
| Client Field | Value |
| --- | --- |
| Base URL | `https://YOUR_AGENT_SERVER/v1` |
| API key | Your agent-server session API key |
| Model | `openhands_` |
For example, a saved LLM profile named `gateway_demo` appears as the OpenAI model `openhands_gateway_demo`.
The gateway accepts the same session key in either OpenHands or OpenAI-compatible form:
- `X-Session-API-Key: `
- `Authorization: Bearer `
## Prepare a Profile
OpenAI-compatible traffic is backed by an agent-server LLM profile. Create one with the native profile API first:
```bash
export AGENT_SERVER_URL="http://localhost:8000"
export SESSION_API_KEY="your-session-api-key"
export PROFILE_NAME="gateway_demo"
export OPENHANDS_MODEL="openhands_${PROFILE_NAME}"
curl -X POST "$AGENT_SERVER_URL/api/profiles/$PROFILE_NAME" \
-H "X-Session-API-Key: $SESSION_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"llm": {
"model": "gpt-5-nano",
"api_key": "YOUR_LLM_API_KEY"
},
"include_secrets": true
}'
```
Then confirm the profile is visible to OpenAI clients:
```bash
curl "$AGENT_SERVER_URL/v1/models" \
-H "Authorization: Bearer $SESSION_API_KEY"
```
## Client Recipes
```bash
curl -i "$AGENT_SERVER_URL/v1/chat/completions" \
-H "Authorization: Bearer $SESSION_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$OPENHANDS_MODEL\",
\"messages\": [
{
\"role\": \"system\",
\"content\": \"Answer directly unless you need to inspect files.\"
},
{
\"role\": \"user\",
\"content\": \"Explain what this OpenHands endpoint does in one sentence.\"
}
]
}"
```
The response includes `X-OpenHands-ServerConversation-ID`. Save that header if you want a later request to continue the same agent conversation.
```python
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["SESSION_API_KEY"],
base_url=f"{os.environ['AGENT_SERVER_URL']}/v1",
)
response = client.chat.completions.with_raw_response.create(
model=os.environ["OPENHANDS_MODEL"],
messages=[
{"role": "user", "content": "Summarize this repository."},
],
)
completion = response.parse()
conversation_id = response.headers["X-OpenHands-ServerConversation-ID"]
print(completion.choices[0].message.content)
follow_up = client.chat.completions.create(
model=os.environ["OPENHANDS_MODEL"],
messages=[{"role": "user", "content": "Now list the main packages."}],
extra_headers={"X-OpenHands-ServerConversation-ID": conversation_id},
)
print(follow_up.choices[0].message.content)
```
```javascript
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.SESSION_API_KEY,
baseURL: `${process.env.AGENT_SERVER_URL}/v1`,
});
const first = await client.chat.completions
.create({
model: process.env.OPENHANDS_MODEL,
messages: [
{ role: "user", content: "Summarize this repository." },
],
})
.withResponse();
const conversationId = first.response.headers.get(
"x-openhands-serverconversation-id",
);
console.log(first.data.choices[0].message.content);
const followUp = await client.chat.completions.create(
{
model: process.env.OPENHANDS_MODEL,
messages: [{ role: "user", content: "Now list the main packages." }],
},
{
headers: { "X-OpenHands-ServerConversation-ID": conversationId },
},
);
console.log(followUp.choices[0].message.content);
```
For Open WebUI, LibreChat, Chatbot UI, and similar OpenAI-compatible frontends, configure a custom OpenAI provider with:
- **Base URL**: `https://YOUR_AGENT_SERVER/v1`
- **API key**: your agent-server session API key
- **Model**: `openhands_`
- **Streaming**: disabled for now
If the UI can store a response header and send a custom request header, persist `X-OpenHands-ServerConversation-ID` per chat thread and send it on follow-up turns. If it cannot, each request starts a new OpenHands conversation and works best for one-shot tasks.
Voice platforms and webhook integrations usually have their own session or call ID. Store a mapping from that external ID to the OpenHands conversation ID:
```python
import os
# Initialize this once at app startup, or replace it with durable session storage.
conversation_ids: dict[str, str] = {}
conversation_id = conversation_ids.get(platform_session_id)
headers = {}
if conversation_id:
headers["X-OpenHands-ServerConversation-ID"] = conversation_id
response = client.chat.completions.with_raw_response.create(
model=os.environ.get("OPENHANDS_MODEL", "openhands_gateway_demo"),
messages=[{"role": "user", "content": transcript_text}],
extra_headers=headers,
)
conversation_ids[platform_session_id] = response.headers[
"X-OpenHands-ServerConversation-ID"
]
reply_text = response.parse().choices[0].message.content
```
Return `reply_text` to the voice or webhook platform. Keep the mapping for as long as that external session should continue.
## Conversation State
The OpenAI Chat Completions protocol usually sends full message history on every request. The OpenHands gateway does not reconstruct agent history from prior assistant messages. Instead:
- Omit `X-OpenHands-ServerConversation-ID` to start a new OpenHands conversation.
- Read `X-OpenHands-ServerConversation-ID` from the response.
- Send that header on follow-up requests to continue the same OpenHands conversation.
When reusing a conversation, send the newest user turn in `messages`. The server-side OpenHands conversation owns the previous agent state, tool activity, and workspace context.
## Current Limitations
- Only non-streaming Chat Completions requests are supported. Requests with `stream: true` return `400` until streaming support is added.
- The response contains the final assistant text only. Internal OpenHands tool activity is not exposed as OpenAI tool calls.
- OpenAI request fields that are not needed by the gateway are ignored or rejected intentionally by the server implementation.
## Ready-to-run example
This example is available on GitHub: [examples/02_remote_agent_server/15_openai_compatible_gateway.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/15_openai_compatible_gateway.py)
```python icon="python" expandable examples/02_remote_agent_server/15_openai_compatible_gateway.py
"""Use the agent-server through an OpenAI-compatible Chat Completions client.
This example starts a local agent-server, stores an LLM profile, lists it through
``GET /v1/models``, then calls ``POST /v1/chat/completions`` with the OpenAI
Python SDK. The returned ``X-OpenHands-ServerConversation-ID`` header is passed
back on a second call to continue the same OpenHands conversation.
"""
import os
from uuid import UUID
import httpx
from openai import OpenAI
from scripts.utils import ManagedAPIServer
# The gateway runs a full OpenHands agent, but OpenAI clients still need a
# normal model-like name. We create an LLM profile below and expose it as
# `openhands_` through `/v1/models`.
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
assert api_key is not None, "Set LLM_API_KEY or OPENAI_API_KEY."
llm_model = os.getenv("LLM_MODEL", "gpt-5-nano")
llm_base_url = os.getenv("LLM_BASE_URL")
profile_name = "gateway_demo"
gateway_model = f"openhands_{profile_name}"
# Start a local agent-server for the demo. `use_session_api_key=True` turns on
# authentication; the same key works as both `X-Session-API-Key` for native
# agent-server routes and `Authorization: Bearer ...` for OpenAI SDK calls.
with ManagedAPIServer(
port=8770,
use_session_api_key=True,
extra_env={
"OH_ENABLE_VNC": "0",
"OH_ENABLE_VSCODE": "0",
"OH_PRELOAD_TOOLS": "0",
"OH_SECRET_KEY": "example-secret-key-for-demo-only-32b",
"OH_WEBHOOKS": "[]",
},
health_request_timeout=2.0,
) as server:
session_api_key = (
os.getenv("SESSION_API_KEY")
or os.getenv("OH_SESSION_API_KEYS_0")
or server.session_api_key
)
assert session_api_key is not None
# Use the native REST API once to create the profile that backs the gateway
# model. After that, normal OpenAI SDK calls are enough for chat traffic.
api_client = httpx.Client(
base_url=server.base_url,
headers={"X-Session-API-Key": session_api_key},
timeout=120.0,
)
openai_client = OpenAI(
api_key=session_api_key,
base_url=f"{server.base_url}/v1",
timeout=120.0,
)
llm_config = {"model": llm_model, "api_key": api_key}
if llm_base_url:
llm_config["base_url"] = llm_base_url
# `gateway_demo` becomes visible to OpenAI clients as `openhands_gateway_demo`.
profile_response = api_client.post(
f"/api/profiles/{profile_name}",
json={"llm": llm_config, "include_secrets": True},
)
assert profile_response.status_code == 201, profile_response.text
models = openai_client.models.list()
model_ids = [model.id for model in models.data]
assert gateway_model in model_ids
print(f"Gateway models include: {gateway_model}")
# Ask through the OpenAI SDK. `with_raw_response` lets us read the custom
# response header that identifies the OpenHands conversation created behind
# this otherwise OpenAI-shaped request.
first_response = openai_client.chat.completions.with_raw_response.create(
model=gateway_model,
messages=[
{
"role": "system",
"content": "Answer directly and do not use tools.",
},
{
"role": "user",
"content": (
"In one sentence, explain what an OpenAI-compatible "
"agent-server gateway does."
),
},
],
)
first_completion = first_response.parse()
conversation_id = first_response.headers.get("X-OpenHands-ServerConversation-ID")
assert conversation_id is not None
UUID(conversation_id)
first_answer = first_completion.choices[0].message.content
print(f"First answer: {first_answer}")
print(f"OpenHands conversation ID: {conversation_id}")
persisted_response = api_client.get(f"/api/conversations/{conversation_id}")
assert persisted_response.status_code == 200, persisted_response.text
# The gateway keeps conversations by default. Passing the header back lets
# another OpenAI-compatible request continue the same server-side agent
# conversation instead of starting over.
second_completion = openai_client.chat.completions.create(
model=gateway_model,
messages=[
{
"role": "user",
"content": "Now answer in five words or fewer: what did I ask about?",
}
],
extra_headers={"X-OpenHands-ServerConversation-ID": conversation_id},
)
second_answer = second_completion.choices[0].message.content
print(f"Second answer using same conversation: {second_answer}")
conversation_response = api_client.get(f"/api/conversations/{conversation_id}")
assert conversation_response.status_code == 200, conversation_response.text
stats = conversation_response.json().get("stats") or {}
usage_to_metrics = stats.get("usage_to_metrics") or {}
accumulated_cost = sum(
metrics.get("accumulated_cost", 0.0) for metrics in usage_to_metrics.values()
)
# Clean up the demo resources. Real applications can keep the conversation
# ID and inspect it later through the native agent-server API.
api_client.delete(f"/api/conversations/{conversation_id}")
api_client.delete(f"/api/profiles/{profile_name}")
api_client.close()
print(f"EXAMPLE_COST: {accumulated_cost}")
```
### Overview
Source: https://docs.openhands.dev/sdk/guides/agent-server/overview.md
Remote Agent Servers package the Software Agent SDK into containers you can deploy anywhere (Kubernetes, VMs, on‑prem, any cloud) with strong isolation. The remote path uses the exact same SDK API as local—switching is just changing the workspace argument; your Conversation code stays the same.
For example, switching from a local workspace to a Docker‑based remote agent server:
```python icon="python" lines
# Local → Docker
conversation = Conversation(agent=agent, workspace=os.getcwd()) # [!code --]
from openhands.workspace import DockerWorkspace # [!code ++]
with DockerWorkspace( # [!code ++]
server_image="ghcr.io/openhands/agent-server:latest-python", # [!code ++]
) as workspace: # [!code ++]
conversation = Conversation(agent=agent, workspace=workspace) # [!code ++]
```
Use `DockerWorkspace` with the pre-built agent server image for the fastest startup. When you need to build from a custom base image, switch to [`DockerDevWorkspace`](/sdk/guides/agent-server/docker-sandbox).
Or switching to an API‑based remote workspace (via [OpenHands Runtime API](https://runtime.all-hands.dev/)):
```python icon="python" lines
# Local → Remote API
conversation = Conversation(agent=agent, workspace=os.getcwd()) # [!code --]
from openhands.workspace import APIRemoteWorkspace # [!code ++]
with APIRemoteWorkspace( # [!code ++]
runtime_api_url="https://runtime.eval.all-hands.dev", # [!code ++]
runtime_api_key="YOUR_API_KEY", # [!code ++]
server_image="ghcr.io/openhands/agent-server:latest-python", # [!code ++]
) as workspace: # [!code ++]
conversation = Conversation(agent=agent, workspace=workspace) # [!code ++]
```
## What is a Remote Agent Server?
A Remote Agent Server is an HTTP/WebSocket server that:
- **Package the Software Agent SDK into containers** and deploy on your own infrastructure (Kubernetes, VMs, on-prem, or cloud)
- **Runs agents** on dedicated infrastructure
- **Manages workspaces** (Docker containers or remote sandboxes)
- **Streams events** to clients via WebSocket
- **Handles command and file operations** (execute command, upload, download), check [base class](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/base.py) for more details
- **Accepts OpenAI-compatible Chat Completions requests** through the [OpenAI-compatible endpoint](/sdk/guides/agent-server/openai-gateway)
- **Provides isolation** between different agent executions
Think of it as the "backend" for your agent, while your Python code acts as the "frontend" client.
{/*
Same interfaces as local:
[BaseConversation](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/base.py),
[ConversationStateProtocol](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/base.py),
[EventsListBase](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/events_list_base.py). Server-backed impl:
[RemoteConversation](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py).
*/}
## Architecture Overview
Remote Agent Servers follow a simple three-part architecture:
```mermaid
graph TD
Client[Client Code] -->|HTTP / WebSocket| Server[Agent Server]
Server --> Workspace[Workspace]
subgraph Workspace Types
Workspace --> Local[Local Folder]
Workspace --> Docker[Docker Container]
Workspace --> API[Remote Sandbox via API]
end
Local --> Files[File System]
Docker --> Container[Isolated Runtime]
API --> Cloud[Cloud Infrastructure]
style Client fill:#e1f5fe
style Server fill:#fff3e0
style Workspace fill:#e8f5e8
```
1. **Client (Python SDK)** — Your application creates and controls conversations using the SDK.
2. **Agent Server** — A lightweight HTTP/WebSocket service that runs the agent and manages workspace execution.
3. **Workspace** — An isolated environment (local, Docker, or remote VM) where the agent code runs.
The same SDK API works across all three workspace types—you just switch which workspace the conversation connects to.
## How Remote Conversations Work
Each step in the diagram maps directly to how the SDK and server interact:
### 1. Workspace Connection → *(Client → Server)*
When you create a conversation with a remote workspace (e.g., `DockerWorkspace` or `APIRemoteWorkspace`), the SDK automatically starts or connects to an agent server inside that workspace:
```python icon="python"
with DockerWorkspace(
server_image="ghcr.io/openhands/agent-server:latest"
) as workspace:
conversation = Conversation(agent=agent, workspace=workspace)
```
This turns the local `Conversation` into a **[RemoteConversation](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py)** that speaks to the agent server over HTTP/WebSocket.
### 2. Server Initialization → *(Server → Workspace)*
Once the workspace starts:
- It launches the agent server process.
- Waits for it to be ready.
- Shares the server URL with the SDK client.
You don’t need to manage this manually—the workspace context handles startup and teardown automatically.
### 3. Event Streaming → *(Bidirectional WebSocket)*
The client and agent server maintain a live WebSocket connection for streaming events:
```python icon="python"
def on_event(event):
print(f"Received: {type(event).__name__}")
conversation = Conversation(
agent=agent,
workspace=workspace,
callbacks=[on_event],
)
```
This allows you to see real-time updates from the running agent as it executes tasks inside the workspace.
### 4. Workspace Supports File and Command Operations → *(Server ↔ Workspace)*
Workspace supports file and command operations via the agent server API ([base class](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/workspace/base.py)), ensuring isolation and consistent behavior:
```python icon="python"
workspace.file_upload(local_path, remote_path)
workspace.file_download(remote_path, local_path)
result = workspace.execute_command("ls -la")
print(result.stdout)
```
These commands are proxied through the agent server, whether it’s a Docker container or a remote VM, keeping your client code environment-agnostic.
### Summary
The architecture makes remote execution seamless:
- Your **client code** stays the same.
- The **agent server** manages execution and streaming.
- The **workspace** provides secure, isolated runtime environments.
Switching from local to remote is just a matter of swapping the workspace class—no code rewrites needed.
## Next Steps
Explore different deployment options:
- **[Local Agent Server](/sdk/guides/agent-server/local-server)** - Run agent server in the same process
- **[Docker Sandboxed Server](/sdk/guides/agent-server/docker-sandbox)** - Run agent server in isolated Docker containers
- **[API Sandboxed Server](/sdk/guides/agent-server/api-sandbox)** - Connect to hosted agent server via API
- **[OpenAI-Compatible Endpoint](/sdk/guides/agent-server/openai-gateway)** - Access an OpenHands agent from OpenAI-compatible clients
For architectural details:
- **[Agent Server Package Architecture](/sdk/arch/agent-server)** - Remote execution architecture and deployment
### Agent Settings
Source: https://docs.openhands.dev/sdk/guides/agent-settings.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
`OpenHandsAgentSettings` gives you a structured, serializable way to define an agent's model, tools, and optional subsystems like the condenser. Use it when you want to store agent configuration in JSON, send it over an API, or rebuild agents from validated settings later.
## Why Use Agent Settings
- Keep agent configuration as data instead of wiring everything together imperatively.
- Validate settings with Pydantic before creating an agent.
- Serialize and deserialize settings for storage, transport, or UI-driven configuration.
- Create different agent variants by changing only the settings payload.
## Build Settings
Create an `OpenHandsAgentSettings` object with the same ingredients you would normally pass to an `Agent`.
```python icon="python" focus={8, 11, 12, 13}
from pydantic import SecretStr
from openhands.sdk import LLM, Tool
from openhands.sdk.settings import CondenserSettings, OpenHandsAgentSettings
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
settings = OpenHandsAgentSettings(
llm=LLM(
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr("your-api-key"),
),
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
condenser=CondenserSettings(enabled=True, max_size=50),
)
```
## Serialize and Restore Settings
Because `OpenHandsAgentSettings` is a Pydantic model, you can dump it to JSON-compatible data and restore it later.
```python icon="python" focus={1, 2}
payload = settings.model_dump(mode="json")
restored = OpenHandsAgentSettings.model_validate(payload)
```
This is useful when:
- Saving agent configuration in a database
- Sending settings through an API
- Letting users edit agent configuration in a form-based UI
- Rehydrating the same agent setup in another process
## Create an Agent from Settings
Once validated, create a working agent directly from the settings object.
```python icon="python" focus={1}
agent = settings.create_agent()
```
You can then pass that agent into a `Conversation`, or derive another agent by changing the settings payload. For example, the full example below also shows how removing `FileEditorTool` and disabling the condenser produces a different agent configuration without rewriting the rest of the setup.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/46_agent_settings.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/46_agent_settings.py)
```python icon="python" expandable examples/01_standalone_sdk/46_agent_settings.py
"""Create, serialize, and deserialize OpenHandsAgentSettings, then build an agent.
Demonstrates:
1. Configuring an agent entirely through OpenHandsAgentSettings (LLM, tools, condenser).
2. Serializing settings to JSON and restoring them.
3. Building an Agent from settings via ``create_agent()``.
4. Running a short conversation to prove the settings take effect.
5. Changing the tool list and showing the agent's capabilities change.
"""
import json
import os
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation, OpenHandsAgentSettings, Tool
from openhands.sdk.settings import CondenserSettings
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# ── 1. Build settings ────────────────────────────────────────────────────
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
settings = OpenHandsAgentSettings(
llm=LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL"),
),
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
condenser=CondenserSettings(enabled=True, max_size=50),
)
# ── 2. Serialize → JSON → deserialize ────────────────────────────────────
payload = settings.model_dump(mode="json")
print("Serialized settings (JSON):")
print(json.dumps(payload, indent=2, default=str)[:800], "…")
print()
restored = OpenHandsAgentSettings.model_validate(payload)
assert restored.condenser.enabled is True
assert restored.condenser.max_size == 50
assert len(restored.tools) == 2
print("✓ Roundtrip deserialization successful — all fields preserved")
print()
# ── 3. Create agent from settings and run a task ─────────────────────────
agent = settings.create_agent()
print(f"Agent created: llm.model={agent.llm.model}")
print(f" tools={[t.name for t in agent.tools]}")
print(f" condenser={type(agent.condenser).__name__}")
print()
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message(
"Create a file called hello_settings.txt containing "
"'Agent settings work!' then confirm the file exists with ls."
)
conversation.run()
# Verify the agent actually wrote the file
assert os.path.exists(os.path.join(cwd, "hello_settings.txt")), (
"Agent should have created hello_settings.txt"
)
print("✓ Agent created hello_settings.txt — settings drove real behavior")
print()
# ── 4. Different settings → different behavior ───────────────────────────
# Now create settings with ONLY the terminal tool and condenser disabled.
terminal_only_settings = OpenHandsAgentSettings(
llm=settings.llm,
tools=[Tool(name=TerminalTool.name)],
condenser=CondenserSettings(enabled=False),
)
terminal_agent = terminal_only_settings.create_agent()
print(f"Terminal-only agent tools: {[t.name for t in terminal_agent.tools]}")
assert len(terminal_agent.tools) == 1
assert terminal_agent.condenser is None # condenser disabled in these settings
print("✓ Different settings produce different agent configuration")
print()
# ── Cleanup ──────────────────────────────────────────────────────────────
os.remove(os.path.join(cwd, "hello_settings.txt"))
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nEXAMPLE_COST: {cost}")
```
## Next Steps
- **[Getting Started](/sdk/getting-started)** - Start from a minimal agent and conversation setup
- **[Context Condenser](/sdk/guides/context-condenser)** - Control conversation compaction behavior
- **[TaskToolSet](/sdk/guides/task-tool-set)** - Compose specialized sub-agents for larger tasks
### Stuck Detector
Source: https://docs.openhands.dev/sdk/guides/agent-stuck-detector.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The Stuck Detector automatically identifies when an agent enters unproductive patterns such as repeating the same actions, encountering repeated errors, or engaging in monologues. By analyzing the conversation history after the last user message, it detects five types of stuck patterns:
1. **Repeating Action-Observation Cycles**: The same action produces the same observation repeatedly (4+ times)
2. **Repeating Action-Error Cycles**: The same action repeatedly results in errors (3+ times)
3. **Agent Monologue**: The agent sends multiple consecutive messages without user input or meaningful progress (3+ messages)
4. **Alternating Patterns**: Two different action-observation pairs alternate in a ping-pong pattern (6+ cycles)
5. **Context Window Errors**: Repeated context window errors that indicate memory management issues
When enabled (which is the default), the stuck detector monitors the conversation in real-time and can automatically halt execution when stuck patterns are detected, preventing infinite loops and wasted resources.
For more information about the detection algorithms and how pattern matching works, refer to the [StuckDetector source code](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/stuck_detector.py).
## How It Works
In the [ready-to-run example](#ready-to-run-example), the agent is deliberately given a task designed to trigger stuck detection - executing the same `ls`
command 5 times in a row. The stuck detector analyzes the event history and identifies the repetitive pattern:
1. The conversation proceeds normally until the agent starts repeating actions
2. After detecting the pattern (4 identical action-observation pairs), the stuck detector flags the conversation as stuck
3. The conversation can then handle this gracefully, either by stopping execution or taking corrective action
The example demonstrates that stuck detection is enabled by default (`stuck_detection=True`), and you can check the
stuck status at any point using `conversation.stuck_detector.is_stuck()`.
## Pattern Detection
The stuck detector compares events based on their semantic content rather than object identity. For example:
- **Actions** are compared by their tool name, action content, and thought (ignoring IDs and metrics)
- **Observations** are compared by their observation content and tool name
- **Errors** are compared by their error messages
- **Messages** are compared by their content and source
This allows the detector to identify truly repetitive behavior while ignoring superficial differences like timestamps or event IDs.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/20_stuck_detector.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/20_stuck_detector.py)
```python icon="python" expandable examples/01_standalone_sdk/20_stuck_detector.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
agent = get_default_agent(llm=llm)
llm_messages = []
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
# Create conversation with built-in stuck detection
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=os.getcwd(),
# This is by default True, shown here for clarity of the example
stuck_detection=True,
)
# Send a task that will be caught by stuck detection
conversation.send_message(
"Please execute 'ls' command 5 times, each in its own "
"action without any thought and then exit at the 6th step."
)
# Run the conversation - stuck detection happens automatically
conversation.run()
assert conversation.stuck_detector is not None
final_stuck_check = conversation.stuck_detector.is_stuck()
print(f"Final stuck status: {final_stuck_check}")
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Conversation Pause and Resume](/sdk/guides/convo-pause-and-resume)** - Manual execution control
- **[Hello World](/sdk/guides/hello-world)** - Learn the basics of the SDK
### Theory of Mind (TOM) Agent
Source: https://docs.openhands.dev/sdk/guides/agent-tom-agent.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
## Overview
Tom (Theory of Mind) Agent provides advanced user understanding capabilities that help your agent interpret vague instructions and adapt to user preferences over time. Built on research in user mental modeling, Tom agents can:
- Understand unclear or ambiguous user requests
- Provide personalized guidance based on user modeling
- Build long-term user preference profiles
- Adapt responses based on conversation history
This is particularly useful when:
- User instructions are vague or incomplete
- You need to infer user intent from minimal context
- Building personalized experiences across multiple conversations
- Understanding user preferences and working patterns
## Research Foundation
Tom agent is based on the TOM-SWE research paper on user mental modeling for software engineering agents:
```bibtex Citation
@misc{zhou2025tomsweusermentalmodeling,
title={TOM-SWE: User Mental Modeling For Software Engineering Agents},
author={Xuhui Zhou and Valerie Chen and Zora Zhiruo Wang and Graham Neubig and Maarten Sap and Xingyao Wang},
year={2025},
eprint={2510.21903},
archivePrefix={arXiv},
primaryClass={cs.SE},
url={https://arxiv.org/abs/2510.21903},
}
```
Paper: [TOM-SWE on arXiv](https://arxiv.org/abs/2510.21903)
## Quick Start
This example is available on GitHub: [examples/01_standalone_sdk/30_tom_agent.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/30_tom_agent.py)
```python icon="python" expandable examples/01_standalone_sdk/30_tom_agent.py
"""Example demonstrating Tom agent with Theory of Mind capabilities.
This example shows how to set up an agent with Tom tools for getting
personalized guidance based on user modeling. Tom tools include:
- TomConsultTool: Get guidance for vague or unclear tasks
- SleeptimeComputeTool: Index conversations for user modeling
"""
import os
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, Conversation
from openhands.sdk.tool import Tool
from openhands.tools.preset.default import get_default_tools
from openhands.tools.tom_consult import (
SleeptimeComputeAction,
SleeptimeComputeObservation,
SleeptimeComputeTool,
TomConsultTool,
)
# Configure LLM
api_key: str | None = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm: LLM = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL", None),
usage_id="agent",
drop_params=True,
)
# Build tools list with Tom tools
# Note: Tom tools are automatically registered on import (PR #862)
tools = get_default_tools(enable_browser=False)
# Configure Tom tools with parameters
tom_params: dict[str, bool | str] = {
"enable_rag": True, # Enable RAG in Tom agent
}
# Add LLM configuration for Tom tools (uses same LLM as main agent)
tom_params["llm_model"] = llm.model
if llm.api_key:
if isinstance(llm.api_key, SecretStr):
tom_params["api_key"] = llm.api_key.get_secret_value()
else:
tom_params["api_key"] = llm.api_key
if llm.base_url:
tom_params["api_base"] = llm.base_url
# Add both Tom tools to the agent
tools.append(Tool(name=TomConsultTool.name, params=tom_params))
tools.append(Tool(name=SleeptimeComputeTool.name, params=tom_params))
# Create agent with Tom capabilities
# This agent can consult Tom for personalized guidance
# Note: Tom's user modeling data will be stored in ~/.openhands/
agent: Agent = Agent(llm=llm, tools=tools)
# Start conversation
cwd: str = os.getcwd()
PERSISTENCE_DIR = os.path.expanduser("~/.openhands")
CONVERSATIONS_DIR = os.path.join(PERSISTENCE_DIR, "conversations")
conversation = Conversation(
agent=agent, workspace=cwd, persistence_dir=CONVERSATIONS_DIR
)
# Optionally run sleeptime compute to index existing conversations
# This builds user preferences and patterns from conversation history
# Using execute_tool allows running tools before conversation.run()
print("\nRunning sleeptime compute to index conversations...")
try:
sleeptime_result = conversation.execute_tool(
"sleeptime_compute", SleeptimeComputeAction()
)
# Cast to the expected observation type for type-safe access
if isinstance(sleeptime_result, SleeptimeComputeObservation):
print(f"Result: {sleeptime_result.message}")
print(f"Sessions processed: {sleeptime_result.sessions_processed}")
else:
print(f"Result: {sleeptime_result.text}")
except KeyError as e:
print(f"Tool not available: {e}")
# Send a potentially vague message where Tom consultation might help
conversation.send_message(
"I need to debug some code but I'm not sure where to start. "
+ "Can you help me figure out the best approach?"
)
conversation.run()
print("\n" + "=" * 80)
print("Tom agent consultation example completed!")
print("=" * 80)
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
# Optional: Index this conversation for Tom's user modeling
# This builds user preferences and patterns from conversation history
# Uncomment the lines below to index the conversation:
#
# conversation.send_message("Please index this conversation using sleeptime_compute")
# conversation.run()
# print("\nConversation indexed for user modeling!")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Tom Tools
### TomConsultTool
The consultation tool provides personalized guidance when the agent encounters vague or unclear user requests:
```python icon="python"
# The agent can automatically call this tool when needed
# Example: User says "I need to debug something"
# Tom analyzes the vague request and provides specific guidance
```
Key features:
- Analyzes conversation history for context
- Provides personalized suggestions based on user modeling
- Helps disambiguate vague instructions
- Adapts to user communication patterns
### SleeptimeComputeTool
The indexing tool processes conversation history to build user preference profiles:
```python icon="python"
# Index conversations for future personalization
sleeptime_compute_tool = conversation.agent.tools_map.get("sleeptime_compute")
if sleeptime_compute_tool:
result = sleeptime_compute_tool.executor(
SleeptimeComputeAction(), conversation
)
```
Key features:
- Processes conversation history into user models
- Stores preferences in `~/.openhands/` directory
- Builds understanding of user patterns over time
- Enables long-term personalization across sessions
## Configuration
### RAG Support
Enable retrieval-augmented generation for enhanced context awareness:
```python icon="python"
tom_params = {
"enable_rag": True, # Enable RAG for better context retrieval
}
```
### Custom LLM for Tom
You can optionally use a different LLM for Tom's internal reasoning:
```python icon="python"
# Use the same LLM as main agent
tom_params["llm_model"] = llm.model
tom_params["api_key"] = llm.api_key.get_secret_value()
# Or configure a separate LLM for Tom
tom_llm = LLM(model="gpt-4", api_key=SecretStr("different-key"))
tom_params["llm_model"] = tom_llm.model
tom_params["api_key"] = tom_llm.api_key.get_secret_value()
```
## Data Storage
Tom stores user modeling data persistently in `~/.openhands/`:
where
- `user_models/` stores user preference profiles, with each user having their own subdirectory containing `user_model.json` (the current user model).
- `conversations/` contains indexed conversation data
This persistent storage enables Tom to:
- Remember user preferences across sessions
- Track which conversations have been indexed
- Build long-term understanding of user patterns
## Use Cases
### 1. Handling Vague Requests
When a user provides minimal information:
```python icon="python"
conversation.send_message("Help me with that bug")
# Tom analyzes history to determine which bug and suggest approach
```
### 2. Personalized Recommendations
Tom adapts suggestions based on past interactions:
```python icon="python"
# After multiple conversations, Tom learns:
# - User prefers minimal explanations
# - User typically works with Python
# - User values efficiency over verbosity
```
### 3. Intent Inference
Understanding what the user really wants:
```python icon="python"
conversation.send_message("Make it better")
# Tom infers from context what "it" is and how to improve it
```
## Best Practices
1. **Enable RAG**: For better context awareness, always enable RAG:
```python icon="python"
tom_params = {"enable_rag": True}
```
2. **Index Regularly**: Run sleeptime compute after important conversations to build better user models
3. **Provide Context**: Even with Tom, providing more context leads to better results
4. **Monitor Data**: Check `~/.openhands/` periodically to understand what's being learned
5. **Privacy Considerations**: Be aware that conversation data is stored locally for user modeling
## Next Steps
- **[TaskToolSet](/sdk/guides/task-tool-set)** - Combine Tom with sub-agents for complex workflows
- **[Context Condenser](/sdk/guides/context-condenser)** - Manage long conversation histories effectively
- **[Custom Tools](/sdk/guides/custom-tools)** - Create tools that work with Tom's insights
### Browser Session Recording
Source: https://docs.openhands.dev/sdk/guides/browser-session-recording.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The browser session recording feature allows you to capture your agent's browser interactions and replay them later using [rrweb](https://github.com/rrweb-io/rrweb). This is useful for debugging, auditing, and understanding how your agent interacts with web pages.
## How It Works
The recording feature uses rrweb to capture DOM mutations, mouse movements, scrolling, and other browser events. The recordings are saved as JSON files that can be replayed using rrweb-player or the online viewer.
The [ready-to-run example](#ready-to-run-example) demonstrates:
1. **Starting a recording**: Use `browser_start_recording` to begin capturing browser events
2. **Browsing and interacting**: Navigate to websites and perform actions while recording
3. **Stopping the recording**: Use `browser_stop_recording` to stop and save the recording
The recording files are automatically saved to the persistence directory when the recording is stopped.
## Replaying Recordings
After recording a session, you can replay it using:
- **rrweb-player**: A standalone player component - [GitHub](https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player)
- **Online viewer**: Upload your recording at [rrweb.io/demo](https://www.rrweb.io/)
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/38_browser_session_recording.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/38_browser_session_recording.py)
```python icon="python" expandable examples/01_standalone_sdk/38_browser_session_recording.py
"""Browser Session Recording Example
This example demonstrates how to use the browser session recording feature
to capture and save a recording of the agent's browser interactions using rrweb.
The recording can be replayed later using rrweb-player to visualize the agent's
browsing session.
The recording will be automatically saved to the persistence directory when
browser_stop_recording is called. You can replay it with:
- rrweb-player: https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player
- Online viewer: https://www.rrweb.io/demo/
"""
import json
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.browser_use import BrowserToolSet
from openhands.tools.browser_use.definition import BROWSER_RECORDING_OUTPUT_DIR
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools - including browser tools with recording capability
cwd = os.getcwd()
tools = [
Tool(name=BrowserToolSet.name),
]
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
# Create conversation with persistence_dir set to save browser recordings
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
persistence_dir="./.conversations",
)
# The prompt instructs the agent to:
# 1. Start recording the browser session
# 2. Browse to a website and perform some actions
# 3. Stop recording (auto-saves to file)
PROMPT = """
Please complete the following task to demonstrate browser session recording:
1. First, use `browser_start_recording` to begin recording the browser session.
2. Then navigate to https://docs.openhands.dev/ and:
- Get the page content
- Scroll down the page
- Get the browser state to see interactive elements
3. Next, navigate to https://docs.openhands.dev/openhands/usage/cli/installation and:
- Get the page content
- Scroll down to see more content
4. Finally, use `browser_stop_recording` to stop the recording.
Events are automatically saved.
"""
print("=" * 80)
print("Browser Session Recording Example")
print("=" * 80)
print("\nTask: Record an agent's browser session and save it for replay")
print("\nStarting conversation with agent...\n")
conversation.send_message(PROMPT)
conversation.run()
print("\n" + "=" * 80)
print("Conversation finished!")
print("=" * 80)
# Check if the recording files were created
# Recordings are saved in BROWSER_RECORDING_OUTPUT_DIR/recording-{timestamp}/
if os.path.exists(BROWSER_RECORDING_OUTPUT_DIR):
# Find recording subdirectories (they start with "recording-")
recording_dirs = sorted(
[
d
for d in os.listdir(BROWSER_RECORDING_OUTPUT_DIR)
if d.startswith("recording-")
and os.path.isdir(os.path.join(BROWSER_RECORDING_OUTPUT_DIR, d))
]
)
if recording_dirs:
# Process the most recent recording directory
latest_recording = recording_dirs[-1]
recording_path = os.path.join(BROWSER_RECORDING_OUTPUT_DIR, latest_recording)
json_files = sorted(
[f for f in os.listdir(recording_path) if f.endswith(".json")]
)
print(f"\n✓ Recording saved to: {recording_path}")
print(f"✓ Number of files: {len(json_files)}")
# Count total events across all files
total_events = 0
all_event_types: dict[int | str, int] = {}
total_size = 0
for json_file in json_files:
filepath = os.path.join(recording_path, json_file)
file_size = os.path.getsize(filepath)
total_size += file_size
with open(filepath) as f:
events = json.load(f)
# Events are stored as a list in each file
if isinstance(events, list):
total_events += len(events)
for event in events:
event_type = event.get("type", "unknown")
all_event_types[event_type] = all_event_types.get(event_type, 0) + 1
print(f" - {json_file}: {len(events)} events, {file_size} bytes")
print(f"✓ Total events: {total_events}")
print(f"✓ Total size: {total_size} bytes")
if all_event_types:
print(f"✓ Event types: {all_event_types}")
print("\nTo replay this recording, you can use:")
print(
" - rrweb-player: "
"https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player"
)
else:
print(f"\n✗ No recording directories found in: {BROWSER_RECORDING_OUTPUT_DIR}")
print(" The agent may not have completed the recording task.")
else:
print(f"\n✗ Observations directory not found: {BROWSER_RECORDING_OUTPUT_DIR}")
print(" The agent may not have completed the recording task.")
print("\n" + "=" * 100)
print("Conversation finished.")
print(f"Total LLM messages: {len(llm_messages)}")
print("=" * 100)
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"Conversation ID: {conversation.id}")
print(f"EXAMPLE_COST: {cost}")
```
### Context Condenser
Source: https://docs.openhands.dev/sdk/guides/context-condenser.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## What is a Context Condenser?
A **context condenser** is a crucial component that addresses one of the most persistent challenges in AI agent development: managing growing conversation context efficiently. As conversations with AI agents grow longer, the cumulative history leads to:
- **💰 Increased API Costs**: More tokens in the context means higher costs per API call
- **⏱️ Slower Response Times**: Larger contexts take longer to process
- **📉 Reduced Effectiveness**: LLMs become less effective when dealing with excessive irrelevant information
The context condenser solves this by intelligently summarizing older parts of the conversation while preserving essential information needed for the agent to continue working effectively.
## Default Implementation: `LLMSummarizingCondenser`
OpenHands SDK provides `LLMSummarizingCondenser` as the default condenser implementation. This condenser uses an LLM to generate summaries of conversation history when it exceeds the configured size limit.
### How It Works
When conversation history exceeds a defined threshold, the LLM-based condenser:
1. **Keeps recent messages intact** - The most recent exchanges remain unchanged for immediate context
2. **Preserves key information** - Important details like user goals, technical specifications, and critical files are retained
3. **Summarizes older content** - Earlier parts of the conversation are condensed into concise summaries using LLM-generated summaries
4. **Maintains continuity** - The agent retains awareness of past progress without processing every historical interaction
{/* Auto-switching light/dark mode image. */}
This approach achieves remarkable efficiency gains:
- Up to **2x reduction** in per-turn API costs
- **Consistent response times** even in long sessions
- **Equivalent or better performance** on software engineering tasks
Learn more about the implementation and benchmarks in our [blog post on context condensation](https://openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents).
### Extensibility
The `LLMSummarizingCondenser` extends the `RollingCondenser` base class, which provides a framework for condensers that work with rolling conversation history. You can create custom condensers by extending base classes ([source code](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/context/condenser/base.py)):
- **`RollingCondenser`** - For condensers that apply condensation to rolling history
- **`CondenserBase`** - For more specialized condensation strategies
This architecture allows you to implement custom condensation logic tailored to your specific needs while leveraging the SDK's conversation management infrastructure.
### Setting Up Condensing
Create a `LLMSummarizingCondenser` to manage the context.
The condenser will automatically truncate conversation history when it exceeds max_size, and replaces the dropped events with an LLM-generated summary.
This condenser triggers when there are more than `max_context_length` events in
the conversation history, and always keeps the first `keep_first` events (system prompts,
initial user messages) to preserve important context.
```python focus={3-4} icon="python"
from openhands.sdk.context import LLMSummarizingCondenser
condenser = LLMSummarizingCondenser(
llm=llm.model_copy(update={"usage_id": "condenser"}), max_size=10, keep_first=2
)
# Agent with condenser
agent = Agent(llm=llm, tools=tools, condenser=condenser)
```
### Ready-to-run example
This example is available on GitHub: [examples/01_standalone_sdk/14_context_condenser.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/14_context_condenser.py)
Automatically condense conversation history when context length exceeds limits, reducing token usage while preserving important information:
```python icon="python" expandable examples/01_standalone_sdk/14_context_condenser.py
"""
To manage context in long-running conversations, the agent can use a context condenser
that keeps the conversation history within a specified size limit. This example
demonstrates using the `LLMSummarizingCondenser`, which automatically summarizes
older parts of the conversation when the history exceeds a defined threshold.
"""
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.context.condenser import LLMSummarizingCondenser
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
]
# Create a condenser to manage the context. The condenser will automatically truncate
# conversation history when it exceeds max_size, and replaces the dropped events with an
# LLM-generated summary. This condenser triggers when there are more than ten events in
# the conversation history, and always keeps the first two events (system prompts,
# initial user messages) to preserve important context.
condenser = LLMSummarizingCondenser(
llm=llm.model_copy(update={"usage_id": "condenser"}), max_size=10, keep_first=2
)
# Agent with condenser
agent = Agent(llm=llm, tools=tools, condenser=condenser)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
persistence_dir="./.conversations",
workspace=".",
)
# Send multiple messages to demonstrate condensation
print("Sending multiple messages to demonstrate LLM Summarizing Condenser...")
conversation.send_message(
"Hello! Can you create a Python file named math_utils.py with functions for "
"basic arithmetic operations (add, subtract, multiply, divide)?"
)
conversation.run()
conversation.send_message(
"Great! Now add a function to calculate the factorial of a number."
)
conversation.run()
conversation.send_message("Add a function to check if a number is prime.")
conversation.run()
conversation.send_message(
"Add a function to calculate the greatest common divisor (GCD) of two numbers."
)
conversation.run()
conversation.send_message(
"Now create a test file to verify all these functions work correctly."
)
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Conversation persistence
print("Serializing conversation...")
del conversation
# Deserialize the conversation
print("Deserializing conversation...")
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
persistence_dir="./.conversations",
workspace=".",
)
print("Sending message to deserialized conversation...")
conversation.send_message("Finally, clean up by deleting both files.")
conversation.run()
print("=" * 100)
print("Conversation finished with LLM Summarizing Condenser.")
print(f"Total LLM messages collected: {len(llm_messages)}")
print("\nThe condenser automatically summarized older conversation history")
print("when the conversation exceeded the configured max_size threshold.")
print("This helps manage context length while preserving important information.")
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[LLM Metrics](/sdk/guides/metrics)** - Track token usage reduction and analyze cost savings
### Ask Agent Questions
Source: https://docs.openhands.dev/sdk/guides/convo-ask-agent.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
Use `ask_agent()` to get quick responses from the agent about the current conversation state without
interrupting the main execution flow.
## Key Features
The `ask_agent()` method provides several important capabilities:
#### Context-Aware Responses
The agent has access to the full conversation history when answering questions:
```python focus={2-3} icon="python" wrap
# Agent can reference what it has done so far
response = conversation.ask_agent(
"Summarize the activity so far in 1 sentence."
)
print(f"Response: {response}")
```
#### Non-Intrusive Operation
Questions don't interrupt the main conversation flow - they're processed separately:
```python focus={4-6} icon="python" wrap
# Start main conversation
thread = threading.Thread(target=conversation.run)
thread.start()
# Ask questions without affecting main execution
response = conversation.ask_agent("How's the progress?")
```
#### Works During and After Execution
You can ask questions while the agent is running or after it has completed:
```python focus={3,7} icon="python" wrap
# During execution
time.sleep(2) # Let agent start working
response1 = conversation.ask_agent("Have you finished running?")
# After completion
thread.join()
response2 = conversation.ask_agent("What did you accomplish?")
```
### Use Cases
- **Progress Monitoring**: Check on long-running tasks
- **Status Updates**: Get real-time information about agent activities
- **User Interfaces**: Provide sidebar information in chat applications
## Ready-to-run Example
This example is available on GitHub:
[examples/01_standalone_sdk/28_ask_agent_example.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/28_ask_agent_example.py)
Example demonstrating the ask_agent functionality for getting sidebar replies
from the agent for a running conversation.
This example shows how to use `ask_agent()` to get quick responses from the agent
about the current conversation state without interrupting the main execution flow.
```python icon="python" expandable examples/01_standalone_sdk/28_ask_agent_example.py
"""
Example demonstrating the ask_agent functionality for getting sidebar replies
from the agent for a running conversation.
This example shows how to use ask_agent() to get quick responses from the agent
about the current conversation state without interrupting the main execution flow.
"""
import os
import threading
import time
from datetime import datetime
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
)
from openhands.sdk.conversation import ConversationVisualizerBase
from openhands.sdk.event import Event
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
]
class MinimalVisualizer(ConversationVisualizerBase):
"""A minimal visualizer that print the raw events as they occur."""
count = 0
def on_event(self, event: Event) -> None:
"""Handle events for minimal progress visualization."""
print(f"\n\n[EVENT {self.count}] {type(event).__name__}")
self.count += 1
# Agent
agent = Agent(llm=llm, tools=tools)
conversation = Conversation(
agent=agent, workspace=cwd, visualizer=MinimalVisualizer, max_iteration_per_run=5
)
def timestamp() -> str:
return datetime.now().strftime("%H:%M:%S")
print("=== Ask Agent Example ===")
print("This example demonstrates asking questions during conversation execution")
# Step 1: Build conversation context
print(f"\n[{timestamp()}] Building conversation context...")
conversation.send_message("Explore the current directory and describe the architecture")
# Step 2: Start conversation in background thread
print(f"[{timestamp()}] Starting conversation in background thread...")
thread = threading.Thread(target=conversation.run)
thread.start()
# Give the agent time to start processing
time.sleep(2)
# Step 3: Use ask_agent while conversation is running
print(f"\n[{timestamp()}] Using ask_agent while conversation is processing...")
# Ask context-aware questions
questions_and_responses = []
question_1 = "Summarize the activity so far in 1 sentence."
print(f"\n[{timestamp()}] Asking: {question_1}")
response1 = conversation.ask_agent(question_1)
questions_and_responses.append((question_1, response1))
print(f"Response: {response1}")
time.sleep(1)
question_2 = "How's the progress?"
print(f"\n[{timestamp()}] Asking: {question_2}")
response2 = conversation.ask_agent(question_2)
questions_and_responses.append((question_2, response2))
print(f"Response: {response2}")
time.sleep(1)
question_3 = "Have you finished running?"
print(f"\n[{timestamp()}] {question_3}")
response3 = conversation.ask_agent(question_3)
questions_and_responses.append((question_3, response3))
print(f"Response: {response3}")
# Step 4: Wait for conversation to complete
print(f"\n[{timestamp()}] Waiting for conversation to complete...")
thread.join()
# Step 5: Verify conversation state wasn't affected
final_event_count = len(conversation.state.events)
# Step 6: Ask a final question after conversation completion
print(f"\n[{timestamp()}] Asking final question after completion...")
final_response = conversation.ask_agent(
"Can you summarize what you accomplished in this conversation?"
)
print(f"Final response: {final_response}")
# Step 7: Summary
print("\n" + "=" * 60)
print("SUMMARY OF ASK_AGENT DEMONSTRATION")
print("=" * 60)
print("\nQuestions and Responses:")
for i, (question, response) in enumerate(questions_and_responses, 1):
print(f"\n{i}. Q: {question}")
print(f" A: {response[:100]}{'...' if len(response) > 100 else ''}")
final_truncated = final_response[:100] + ("..." if len(final_response) > 100 else "")
print(f"\nFinal Question Response: {final_truncated}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost:.4f}")
```
## Next Steps
- **[Send Messages While Running](/sdk/guides/convo-send-message-while-running)** - Interrupt and redirect agent execution
- **[Pause and Resume](/sdk/guides/convo-pause-and-resume)** - Control execution flow
- **[Custom Visualizers](/sdk/guides/convo-custom-visualizer)** - Monitor conversation progress
### Conversation with Async
Source: https://docs.openhands.dev/sdk/guides/convo-async.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
### Concurrent Agents
Run multiple agent tasks in parallel using `asyncio.gather()`:
```python icon="python" wrap
async def main():
loop = asyncio.get_running_loop()
callback = AsyncCallbackWrapper(callback_coro, loop)
# Create multiple conversation tasks running in parallel
tasks = [
loop.run_in_executor(None, run_conversation, callback),
loop.run_in_executor(None, run_conversation, callback),
loop.run_in_executor(None, run_conversation, callback)
]
results = await asyncio.gather(*tasks)
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/11_async.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/11_async.py)
This example demonstrates usage of a Conversation in an async context
(e.g.: From a fastapi server). The conversation is run in a background
thread and a callback with results is executed in the main runloop
```python icon="python" expandable examples/01_standalone_sdk/11_async.py
"""
This example demonstrates usage of a Conversation in an async context
(e.g.: From a fastapi server). The conversation is run in a background
thread and a callback with results is executed in the main runloop
"""
import asyncio
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.conversation.types import ConversationCallbackType
from openhands.sdk.tool import Tool
from openhands.sdk.utils.async_utils import AsyncCallbackWrapper
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
]
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
# Callback coroutine
async def callback_coro(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
# Synchronous run conversation
def run_conversation(callback: ConversationCallbackType):
conversation = Conversation(agent=agent, callbacks=[callback])
conversation.send_message(
"Hello! Can you create a new Python file named hello.py that prints "
"'Hello, World!'? Use task tracker to plan your steps."
)
conversation.run()
conversation.send_message("Great! Now delete that file.")
conversation.run()
async def main():
loop = asyncio.get_running_loop()
# Create the callback
callback = AsyncCallbackWrapper(callback_coro, loop)
# Run the conversation in a background thread and wait for it to finish...
await loop.run_in_executor(None, run_conversation, callback)
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
if __name__ == "__main__":
asyncio.run(main())
```
## Next Steps
- **[Persistence](/sdk/guides/convo-persistence)** - Save and restore conversation state
- **[Send Message While Processing](/sdk/guides/convo-send-message-while-running)** - Interrupt running agents
### Custom Visualizer
Source: https://docs.openhands.dev/sdk/guides/convo-custom-visualizer.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The SDK provides flexible visualization options. You can use the default rich-formatted visualizer, customize it with highlighting patterns, or build completely custom visualizers by subclassing `ConversationVisualizerBase`.
## Visualizer Configuration Options
The `visualizer` parameter in `Conversation` controls how events are displayed:
```python icon="python" focus={4-5, 7-8, 10-11, 13, 18, 20, 25}
from openhands.sdk import Conversation
from openhands.sdk.conversation import DefaultConversationVisualizer, ConversationVisualizerBase
# Option 1: Use default visualizer (enabled by default)
conversation = Conversation(agent=agent, workspace=workspace)
# Option 2: Disable visualization
conversation = Conversation(agent=agent, workspace=workspace, visualizer=None)
# Option 3: Pass a visualizer class (will be instantiated automatically)
conversation = Conversation(agent=agent, workspace=workspace, visualizer=DefaultConversationVisualizer)
# Option 4: Pass a configured visualizer instance
custom_viz = DefaultConversationVisualizer(
name="MyAgent",
highlight_regex={r"^Reasoning:": "bold cyan"}
)
conversation = Conversation(agent=agent, workspace=workspace, visualizer=custom_viz)
# Option 5: Use custom visualizer class
class MyVisualizer(ConversationVisualizerBase):
def on_event(self, event):
print(f"Event: {event}")
conversation = Conversation(agent=agent, workspace=workspace, visualizer=MyVisualizer())
```
## Customizing the Default Visualizer
`DefaultConversationVisualizer` uses Rich panels and supports customization through configuration:
```python icon="python" focus={3-14, 19}
from openhands.sdk.conversation import DefaultConversationVisualizer
# Configure highlighting patterns using regex
custom_visualizer = DefaultConversationVisualizer(
name="MyAgent", # Prefix panel titles with agent name
highlight_regex={
r"^Reasoning:": "bold cyan", # Lines starting with "Reasoning:"
r"^Thought:": "bold green", # Lines starting with "Thought:"
r"^Action:": "bold yellow", # Lines starting with "Action:"
r"\[ERROR\]": "bold red", # Error markers anywhere
r"\*\*(.*?)\*\*": "bold", # Markdown bold **text**
},
skip_user_messages=False, # Show user messages
)
conversation = Conversation(
agent=agent,
workspace=workspace,
visualizer=custom_visualizer
)
```
**When to use**: Perfect for customizing colors and highlighting without changing the panel-based layout.
## Creating Custom Visualizers
For complete control over visualization, subclass `ConversationVisualizerBase`:
```python icon="python" focus={4, 11, 28}
from openhands.sdk.conversation import ConversationVisualizerBase
from openhands.sdk.event import ActionEvent, ObservationEvent, AgentErrorEvent, Event
class MinimalVisualizer(ConversationVisualizerBase):
"""A minimal visualizer that prints raw event information."""
def __init__(self, name: str | None = None):
super().__init__(name=name)
self.step_count = 0
def on_event(self, event: Event) -> None:
"""Handle each event."""
if isinstance(event, ActionEvent):
self.step_count += 1
tool_name = event.tool_name or "unknown"
print(f"Step {self.step_count}: {tool_name}")
elif isinstance(event, ObservationEvent):
print(f" → Result received")
elif isinstance(event, AgentErrorEvent):
print(f"❌ Error: {event.error}")
# Use your custom visualizer
conversation = Conversation(
agent=agent,
workspace=workspace,
visualizer=MinimalVisualizer(name="Agent")
)
```
### Key Methods
**`__init__(self, name: str | None = None)`**
- Initialize your visualizer with optional configuration
- `name` parameter is available from the base class for agent identification
- Call `super().__init__(name=name)` to initialize the base class
**`initialize(self, state: ConversationStateProtocol)`**
- Called automatically by `Conversation` after state is created
- Provides access to conversation state and statistics via `self._state`
- Override if you need custom initialization, but call `super().initialize(state)`
**`on_event(self, event: Event)`** *(required)*
- Called for each conversation event
- Implement your visualization logic here
- Access conversation stats via `self.conversation_stats` property
**When to use**: When you need a completely different output format, custom state tracking, or integration with external systems.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/26_custom_visualizer.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/26_custom_visualizer.py)
```python icon="python" expandable examples/01_standalone_sdk/26_custom_visualizer.py
"""Custom Visualizer Example
This example demonstrates how to create and use a custom visualizer by subclassing
ConversationVisualizer. This approach provides:
- Clean, testable code with class-based state management
- Direct configuration (just pass the visualizer instance to visualizer parameter)
- Reusable visualizer that can be shared across conversations
This demonstrates how you can pass a ConversationVisualizer instance directly
to the visualizer parameter for clean, reusable visualization logic.
"""
import logging
import os
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation
from openhands.sdk.conversation.visualizer import ConversationVisualizerBase
from openhands.sdk.event import (
Event,
)
from openhands.tools.preset.default import get_default_agent
class MinimalVisualizer(ConversationVisualizerBase):
"""A minimal visualizer that print the raw events as they occur."""
def on_event(self, event: Event) -> None:
"""Handle events for minimal progress visualization."""
print(f"\n\n[EVENT] {type(event).__name__}: {event.model_dump_json()[:200]}...")
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
model=model,
api_key=SecretStr(api_key),
base_url=base_url,
usage_id="agent",
)
agent = get_default_agent(llm=llm, cli_mode=True)
# ============================================================================
# Configure Visualization
# ============================================================================
# Set logging level to reduce verbosity
logging.getLogger().setLevel(logging.WARNING)
# Start a conversation with custom visualizer
cwd = os.getcwd()
conversation = Conversation(
agent=agent,
workspace=cwd,
visualizer=MinimalVisualizer(),
)
# Send a message and let the agent run
print("Sending task to agent...")
conversation.send_message("Write 3 facts about the current project into FACTS.txt.")
conversation.run()
print("Task completed!")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost:.4f}")
```
## Next Steps
Now that you understand custom visualizers, explore these related topics:
- **[Events](/sdk/arch/events)** - Learn more about different event types
- **[Conversation Metrics](/sdk/guides/metrics)** - Track LLM usage, costs, and performance data
- **[Send Messages While Running](/sdk/guides/convo-send-message-while-running)** - Interactive conversations with real-time updates
- **[Pause and Resume](/sdk/guides/convo-pause-and-resume)** - Control agent execution flow with custom logic
### Fork a Conversation
Source: https://docs.openhands.dev/sdk/guides/convo-fork.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## Overview
`Conversation.fork()` deep-copies a conversation — events, agent config, workspace metadata — into a new conversation with its own ID. The fork starts in `idle` status and retains the full event memory of the source, so calling `run()` picks up right where the original left off.
**Use cases:**
- **CI debugging** — an agent produced a wrong patch; fork to debug without losing the original run's audit trail
- **A/B testing** — fork at a given turn, change one variable, compare downstream outcomes
- **Tool-change** — fork and swap in a different agent with new tools mid-conversation
## Basic Usage
### Create a fork
```python icon="python" focus={6} wrap
source = Conversation(agent=agent, workspace=workspace)
source.send_message("Analyse the sales report.")
source.run()
# Fork the conversation with a title
fork = source.fork(title="Follow-up exploration")
# The fork has the same events — agent remembers the full history
fork.send_message("Now focus on the EMEA region.")
fork.run() # Continues from the source's state
```
### Source stays immutable
Forking deep-copies events and state. Anything you do on the fork never touches the source:
```python icon="python" wrap
source_events_before = len(source.state.events)
fork = source.fork()
fork.send_message("Extra question")
assert len(source.state.events) == source_events_before # unchanged
```
### Fork with a different agent
Swap the agent on fork — useful for A/B testing models or adding/removing tools:
```python icon="python" focus={4-8} wrap
alt_llm = LLM(model="openai/gpt-4o", api_key=api_key, usage_id="alt")
alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])
fork = source.fork(
agent=alt_agent,
title="GPT-4o experiment",
tags={"variant": "B"},
)
fork.run() # Same history, different model
```
### Tags and metadata
Forks support `title` and arbitrary `tags` for organization:
```python icon="python" wrap
fork = source.fork(
title="Debug investigation",
tags={"purpose": "debugging", "triggered_by": "ci-pipeline"},
)
print(fork.state.tags)
# {'title': 'Debug investigation', 'purpose': 'debugging', 'triggered_by': 'ci-pipeline'}
```
### Metrics reset
By default, cost/token stats start fresh on the fork. Pass `reset_metrics=False` to preserve them:
```python icon="python" wrap
# Cost starts at 0 on the fork (default)
fork_fresh = source.fork()
# Cost carries over from source
fork_with_history = source.fork(reset_metrics=False)
```
## API Reference
```python icon="python" wrap
def fork(
self,
*,
conversation_id: ConversationID | None = None, # auto-generated if None
agent: AgentBase | None = None, # deep-copy of source agent if None
title: str | None = None, # sets tags["title"]
tags: dict[str, str] | None = None, # arbitrary metadata
reset_metrics: bool = True, # cost/tokens start fresh
) -> Conversation:
```
| Parameter | Default | Description |
|-----------|---------|-------------|
| `conversation_id` | auto-generated UUID | ID for the forked conversation |
| `agent` | deep-copy of source | Agent for the fork (swap model, tools, etc.) |
| `title` | `None` | Sets `tags["title"]` on the fork |
| `tags` | `None` | Arbitrary key-value metadata |
| `reset_metrics` | `True` | Whether cost/token stats start at zero |
**Returns:** A new `Conversation` with the same event history but independent state.
## What Gets Copied
| Component | Behavior |
|-----------|----------|
| **Events** | Deep-copied; source is never modified |
| **Agent** | Deep-copied by default, or replaced via the `agent` kwarg |
| **Workspace** | Shared (same working directory) |
| **Agent state** | Deep-copied (custom runtime data accumulated during the conversation) |
| **Activated knowledge skills** | Copied (list of skill names activated in the source) |
| **Stats / Metrics** | Reset by default (`reset_metrics=True`); pass `False` to carry over |
| **Tags** | Fresh from kwargs; source tags are **not** inherited |
| **Execution status** | Always `idle` on the fork |
| **Conversation ID** | New UUID (or explicit via `conversation_id`) |
## Agent-Server REST Endpoint
When using the [agent-server](/sdk/guides/agent-server/overview), forks are available via REST:
```bash icon="terminal"
POST /api/conversations/{id}/fork
```
**Request body** (all fields optional):
```json
{
"id": "custom-uuid-or-null",
"title": "Debug investigation",
"tags": {"purpose": "debugging"},
"reset_metrics": true
}
```
**Response:** Standard `ConversationInfo` for the newly created fork.
When you call `fork()` on a `RemoteConversation`, the SDK sends this request for
you and returns a new `RemoteConversation` pointing at the server-side copy.
Remote forks always reuse the server-managed agent configuration, so
`RemoteConversation.fork(agent=...)` is intentionally unsupported.
## Agent-Server Example
This example is available on GitHub: [examples/02_remote_agent_server/11_conversation_fork.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/11_conversation_fork.py)
```python icon="python" expandable examples/02_remote_agent_server/11_conversation_fork.py
"""Fork a conversation through the agent server REST API.
Demonstrates ``RemoteConversation.fork()`` which delegates to the server's
``POST /api/conversations/{id}/fork`` endpoint. The fork deep-copies
events and state on the server side, then returns a new
``RemoteConversation`` pointing at the copy.
Scenarios covered:
1. Run a source conversation on the server
2. Fork it — verify independent event histories
3. Fork with a title and custom tags
"""
import os
import subprocess
import sys
import tempfile
import threading
import time
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, Conversation, RemoteConversation, Tool, Workspace
from openhands.tools.terminal import TerminalTool
# -----------------------------------------------------------------
# Managed server helper (reused from example 01)
# -----------------------------------------------------------------
def _stream_output(stream, prefix, target_stream):
try:
for line in iter(stream.readline, ""):
if line:
target_stream.write(f"[{prefix}] {line}")
target_stream.flush()
except Exception as e:
print(f"Error streaming {prefix}: {e}", file=sys.stderr)
finally:
stream.close()
class ManagedAPIServer:
"""Context manager that starts and stops a local agent-server."""
def __init__(self, port: int = 8000, host: str = "127.0.0.1"):
self.port = port
self.host = host
self.process: subprocess.Popen[str] | None = None
self.base_url = f"http://{host}:{port}"
def __enter__(self):
print(f"Starting agent-server on {self.base_url} ...")
self.process = subprocess.Popen(
[
"python",
"-m",
"openhands.agent_server",
"--port",
str(self.port),
"--host",
self.host,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={"LOG_JSON": "true", **os.environ},
)
assert self.process.stdout is not None
assert self.process.stderr is not None
threading.Thread(
target=_stream_output,
args=(self.process.stdout, "SERVER", sys.stdout),
daemon=True,
).start()
threading.Thread(
target=_stream_output,
args=(self.process.stderr, "SERVER", sys.stderr),
daemon=True,
).start()
import httpx
for _ in range(30):
try:
if httpx.get(f"{self.base_url}/health", timeout=1.0).status_code == 200:
print(f"Agent-server ready at {self.base_url}")
return self
except Exception:
pass
assert self.process.poll() is None, "Server exited unexpectedly"
time.sleep(1)
raise RuntimeError("Server failed to start in 30 s")
def __exit__(self, *args):
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
time.sleep(0.5)
print("Agent-server stopped.")
# -----------------------------------------------------------------
# Config
# -----------------------------------------------------------------
api_key = os.getenv("LLM_API_KEY")
assert api_key, "LLM_API_KEY must be set"
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL"),
)
agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])
# -----------------------------------------------------------------
# Run
# -----------------------------------------------------------------
with ManagedAPIServer(port=8002) as server:
workspace_dir = tempfile.mkdtemp(prefix="fork_demo_")
workspace = Workspace(host=server.base_url, working_dir=workspace_dir)
# =============================================================
# 1. Source conversation
# =============================================================
source = Conversation(agent=agent, workspace=workspace)
assert isinstance(source, RemoteConversation)
source.send_message("Run `echo hello-from-source` in the terminal.")
source.run()
print("=" * 64)
print(" RemoteConversation.fork() — Agent-Server Example")
print("=" * 64)
print(f"\nSource conversation ID : {source.id}")
source_event_count = len(source.state.events)
print(f"Source events count : {source_event_count}")
# =============================================================
# 2. Fork and continue independently
# =============================================================
fork = source.fork(title="Follow-up fork")
assert isinstance(fork, RemoteConversation)
print("\n--- Fork created ---")
print(f"Fork ID : {fork.id}")
fork_event_count = len(fork.state.events)
print(f"Fork events (copied) : {fork_event_count}")
assert fork.id != source.id
# The fork copies all persisted events from the server-side EventLog.
# The source's client-side list may additionally contain transient
# WebSocket-only events (e.g. full-state snapshots) that are never
# persisted, so we only assert the fork has a non-trivial number of
# events rather than exact parity.
assert fork_event_count > 0
fork.send_message("Now run `echo hello-from-fork` in the terminal.")
fork.run()
print("\n--- After running fork ---")
print(f"Source events : {len(source.state.events)}")
print(f"Fork events (grew) : {len(fork.state.events)}")
assert len(fork.state.events) > fork_event_count
# =============================================================
# 3. Fork with tags
# =============================================================
fork_tagged = source.fork(
title="Tagged experiment",
tags={"purpose": "a/b-test"},
)
assert isinstance(fork_tagged, RemoteConversation)
print("\n--- Fork with tags ---")
print(f"Fork ID : {fork_tagged.id}")
fork_tagged.send_message(
"What command did you run earlier? Just tell me, no tools."
)
fork_tagged.run()
print(f"Fork events : {len(fork_tagged.state.events)}")
# =============================================================
# Summary
# =============================================================
print(f"\n{'=' * 64}")
print("All done — RemoteConversation.fork() works end-to-end.")
print("=" * 64)
# Cleanup
fork.close()
fork_tagged.close()
source.close()
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/48_conversation_fork.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/48_conversation_fork.py)
```python icon="python" expandable examples/01_standalone_sdk/48_conversation_fork.py
"""Fork a conversation to branch off for follow-up exploration.
``Conversation.fork()`` deep-copies a conversation — events, agent config,
workspace metadata — into a new conversation with its own ID. The fork
starts in ``idle`` status and retains full event memory of the source, so
calling ``run()`` picks up right where the original left off.
Use cases:
- CI agents that produced a wrong patch — engineer forks to debug
without losing the original run's audit trail
- A/B-testing prompts — fork at a given turn, change one variable,
compare downstream
- Swapping tools mid-conversation (fork-on-tool-change)
"""
import os
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.tools.terminal import TerminalTool
# -----------------------------------------------------------------
# Setup
# -----------------------------------------------------------------
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL", None),
)
agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])
cwd = os.getcwd()
# =================================================================
# 1. Run the source conversation
# =================================================================
source = Conversation(agent=agent, workspace=cwd)
source.send_message("Run `echo hello-from-source` in the terminal.")
source.run()
print("=" * 64)
print(" Conversation.fork() — SDK Example")
print("=" * 64)
print(f"\nSource conversation ID : {source.id}")
print(f"Source events count : {len(source.state.events)}")
# =================================================================
# 2. Fork and continue independently
# =================================================================
fork = source.fork(title="Follow-up fork")
source_event_count = len(source.state.events)
print("\n--- Fork created ---")
print(f"Fork ID : {fork.id}")
print(f"Fork events (copied) : {len(fork.state.events)}")
print(f"Fork title : {fork.state.tags.get('title')}")
assert fork.id != source.id
assert len(fork.state.events) == source_event_count
fork.send_message("Now run `echo hello-from-fork` in the terminal.")
fork.run()
# Source is untouched
assert len(source.state.events) == source_event_count
print("\n--- After running fork ---")
print(f"Source events (unchanged): {source_event_count}")
print(f"Fork events (grew) : {len(fork.state.events)}")
# =================================================================
# 3. Fork with a different agent (tool-change / A/B testing)
# =================================================================
alt_llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL", None),
usage_id="alt",
)
alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])
fork_alt = source.fork(
agent=alt_agent,
title="Tool-change experiment",
tags={"purpose": "a/b-test"},
)
print("\n--- Fork with alternate agent ---")
print(f"Fork ID : {fork_alt.id}")
print(f"Fork tags : {dict(fork_alt.state.tags)}")
fork_alt.send_message("What command did you run earlier? Just tell me, no tools.")
fork_alt.run()
print(f"Fork events : {len(fork_alt.state.events)}")
# =================================================================
# Summary
# =================================================================
print(f"\n{'=' * 64}")
print("All done — fork() works end-to-end.")
print("=" * 64)
# Report cost
cost = llm.metrics.accumulated_cost + alt_llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Persistence](/sdk/guides/convo-persistence)** — Save and restore conversation state
- **[Pause and Resume](/sdk/guides/convo-pause-and-resume)** — Control execution flow
- **[Agent Server](/sdk/guides/agent-server/overview)** — Deploy agents with the REST API
### Goal Completion Loop
Source: https://docs.openhands.dev/sdk/guides/convo-goal.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## Overview
A plain `conversation.run()` stops as soon as the agent *thinks* it is done. The `/goal` command is stricter: after each run it asks a second **judge LLM** to audit the transcript for authoritative evidence — file contents, command output, test results — that the objective is *provably* complete. If something is still missing, the loop re-prompts the agent with the judge's feedback and runs again, until the goal is genuinely done or a hard iteration cap is reached.
That makes it a good fit for **verifiable objectives** like "make the tests pass", "produce a working CLI", or "publish a passing migration": the agent cannot finish just by claiming success — the judge has to see the green output first.
**Use cases:**
- **Test-driven objectives** — finish only when `pytest` (or any command) actually passes
- **Multi-step deliverables** — keep the agent going until every requirement is verified
- **Long-running tasks** — combine with a critic and stop hooks for full control over termination
Like the [Critic](/sdk/guides/critic), `/goal` is an **extension applied to a conversation**: it composes with whatever agent, tools, or critic you already have. The critic governs each inner `run()`; the `/goal` loop governs the overall objective.
## How It Works
```
1. send objective → agent runs, calls FinishAction
2. judge LLM audits the transcript → produces { score, complete, missing }
3. if complete → stop, return GoalOutcome(status="complete")
else if max_iterations reached → stop, return GoalOutcome(status="capped")
else → send a follow-up with `missing`, run again
```
Because `run_goal` drives the conversation you pass in (it does not fork or spin up a sidecar), every turn — objective, agent work, judge-driven follow-ups — lands in the same `conversation.state.events` history.
## Quick Start
```python icon="python" focus={2,7-8,22}
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.sdk.conversation.goal import run_goal
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Two LLMs: one does the work, one independently judges completion.
agent_llm = LLM(usage_id="agent", model="gpt-5.5", api_key=api_key)
judge_llm = LLM(usage_id="goal-judge", model="gpt-5.5", api_key=api_key)
agent = Agent(
llm=agent_llm,
tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
)
conversation = Conversation(agent=agent, workspace=workspace)
objective = (
"Create mathx.py with an add(a, b) function and test_mathx.py with a "
"pytest test for it. The goal is complete only when "
"`python -m pytest -q` passes."
)
outcome = run_goal(conversation, objective, judge_llm, max_iterations=3)
print(f"Goal {outcome.status} after {outcome.iterations} audit round(s).")
print(f"Judge score: {outcome.verdict.score:.2f}")
```
Use a **separate `LLM` instance** (distinct `usage_id`) for the judge, even if you reuse the same model. Keeping the judge isolated from the agent's LLM lets you account for its cost separately and avoids accidentally sharing streaming or callback state.
## Understanding the Result
`run_goal` returns a `GoalOutcome` that reports whether the loop ended cleanly or was capped, plus the judge's final verdict.
| Field | Type | Description |
|---|---|---|
| `status` | `"complete"` \| `"capped"` | Whether the judge confirmed completion, or the loop hit `max_iterations`. |
| `iterations` | `int` | Number of audit rounds performed (≥ 1). |
| `verdict` | `GoalVerdict` | The judge's last verdict. |
The `GoalVerdict` is what the judge LLM produces every round:
| Field | Type | Description |
|---|---|---|
| `score` | `float` (0.0–1.0) | Probability that the full objective is **provably** done. |
| `complete` | `bool` | Whether the judge considers the objective complete. |
| `missing` | `str` | Concise description of what remains, or empty if complete. |
The `missing` field is what the loop feeds back to the agent in the next follow-up turn, so the agent knows exactly which requirements still need verifiable evidence.
## Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `conversation` | `BaseConversation` | — | The conversation to drive. Any agent/tools/critic config is supported. |
| `objective` | `str` | — | The goal to pursue and audit against. Must be non-empty. |
| `judge_llm` | `LLM` | — | The second LLM that grades completion. Should be independent from the agent's LLM. |
| `max_iterations` | `int` | `10` | Hard cap on audit rounds before the loop returns `status="capped"`. |
## Composing With a Critic
`/goal` and a [Critic](/sdk/guides/critic) operate at different layers:
- A **critic** governs each inner `run()` — it can refine the agent's work mid-run via iterative refinement.
- The **`/goal` loop** governs the overall objective — it decides whether to re-prompt the agent at all.
They compose without changes: attach a critic to the agent as usual, then drive the conversation with `run_goal`. Every inner `run()` still consults the critic; the outer loop still re-runs until the judge is satisfied.
```python icon="python" focus={1,5-7,11}
from openhands.sdk.critic import APIBasedCritic
from openhands.sdk.conversation.goal import run_goal
agent = Agent(
llm=agent_llm,
tools=[...],
critic=APIBasedCritic(...), # governs each run()
)
conversation = Conversation(agent=agent, workspace=workspace)
outcome = run_goal(conversation, objective, judge_llm, max_iterations=5)
```
## Lower-Level Building Blocks
`run_goal` is a thin synchronous driver over a transport-agnostic controller. If you need to integrate the loop into a custom driver (async, agent-server, UI progress reporting), reach for the building blocks directly.
### `GoalController`
`GoalController` owns the continue-vs-stop decision logic and the iteration cap. It does **no conversation transport I/O** — the driver owns sending messages and running the agent — but it *does* own the judge call: `on_run_finished()` synchronously invokes the judge LLM, so treat that call as blocking.
```python icon="python"
from openhands.sdk.conversation.goal import GoalController, GoalDone
controller = GoalController(objective, judge_llm, max_iterations=10)
conversation.send_message(controller.start())
while True:
conversation.run()
step = controller.on_run_finished(conversation.state.events)
if isinstance(step, GoalDone):
outcome = step.outcome
break
# step is GoalContinue — feed the follow-up back to the agent
conversation.send_message(step.followup)
```
That split lets a synchronous driver and an asynchronous agent-server task share the **exact same decision logic** — only the I/O loop differs.
### `judge_goal`
`judge_goal` is the reusable kernel: a synchronous, LLM-backed evaluator with signature `judge_goal(judge_llm, objective, events) → GoalVerdict` and no dependency on the loop. It calls the judge LLM each time, so it is not a pure function. Use it directly to build a `/status` command, a stop hook, or a server endpoint:
```python icon="python"
from openhands.sdk.conversation.goal import judge_goal
verdict = judge_goal(judge_llm, objective, conversation.state.events)
if verdict.complete:
print("Done!")
else:
print(f"Still missing: {verdict.missing}")
```
The judge renders the conversation as a plain `role: text` transcript and asks the LLM for a strict-JSON verdict. The agent's system prompt is intentionally excluded from the transcript to keep judge token cost low — it carries no goal-specific evidence.
## Notes
- **Goal vs. Critic.** A critic scores each `run()` and triggers refinement turns inside one run. The `/goal` loop drives the *overall* objective from the outside. The two compose: the critic improves each turn; the goal loop ensures the right number of turns happen.
- **No fork.** `run_goal` drives the conversation you pass in — it does **not** create a sidecar conversation. All goal-related events land in the same `conversation.state.events` history.
- **Conservative parsing.** If the judge response cannot be parsed as JSON, the verdict falls back to `score=0.0, complete=False` so the loop keeps working rather than falsely finishing.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/54_goal_completion_loop.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/54_goal_completion_loop.py)
```python icon="python" expandable examples/01_standalone_sdk/54_goal_completion_loop.py
"""The /goal command: pursue an objective until a judge LLM confirms it is done.
A plain ``conversation.run()`` stops as soon as the agent *thinks* it is
finished. The ``/goal`` loop is stricter: after each run it asks a second
"judge" LLM to audit the transcript for authoritative evidence -- file
contents, command output, test results -- that the objective is *provably*
complete. If something is still missing, it re-prompts the agent with the
judge's feedback and runs again, until the goal is genuinely done or a hard
iteration cap is reached.
That makes it a good fit for verifiable objectives like "make the tests pass":
the agent cannot finish just by claiming success; the judge has to see green
output first.
Key concepts demonstrated:
1. ``run_goal(conversation, objective, judge_llm, max_iterations=...)`` drives
the conversation from the outside, re-prompting until the judge is satisfied.
2. A second, independent "judge" LLM grades completion -- separate from the
agent that does the work.
3. The returned ``GoalOutcome`` reports whether the goal ``"complete"``-d or was
``"capped"``, how many audit rounds it took, and the judge's final verdict.
Because ``run_goal`` drives the conversation you pass in (it does not fork or
spin up a sidecar), every turn -- objective, agent work, judge-driven followups
-- lands in the same ``conversation.state.events`` history. It therefore
composes with whatever agent, tools, or critic you already have.
"""
import os
import tempfile
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.sdk.conversation.goal import run_goal
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# The agent LLM does the work; the judge LLM independently grades completion.
# Two separate instances (same model, distinct usage_id) keep their costs apart.
model = os.getenv("LLM_MODEL", "gpt-5.5")
api_key = os.getenv("LLM_API_KEY")
base_url = os.getenv("LLM_BASE_URL")
agent_llm = LLM(usage_id="agent", model=model, api_key=api_key, base_url=base_url)
judge_llm = LLM(usage_id="goal-judge", model=model, api_key=api_key, base_url=base_url)
agent = Agent(
llm=agent_llm,
tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
)
workspace = tempfile.mkdtemp(prefix="goal_demo_")
conversation = Conversation(agent=agent, workspace=workspace)
# A verifiable objective: the judge can only call it done once it has seen
# pytest actually pass -- not merely the agent asserting that it did.
objective = (
"Create mathx.py with an add(a, b) function and test_mathx.py with a pytest "
"test for it. The goal is complete only when `python -m pytest -q` passes."
)
# Drive the conversation toward the objective, re-judging after each run.
outcome = run_goal(conversation, objective, judge_llm, max_iterations=3)
print("\n" + "=" * 70)
print(f"Goal {outcome.status} after {outcome.iterations} audit round(s).")
print(f"Judge score: {outcome.verdict.score:.2f}")
if outcome.verdict.missing:
print(f"Still missing: {outcome.verdict.missing}")
print(f"Workspace: {workspace}")
print("=" * 70)
# Report cost (agent work + judge audits).
cost = agent_llm.metrics.accumulated_cost + judge_llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Critic](/sdk/guides/critic)** — Score and refine individual agent runs in real time
- **[Iterative Refinement](/sdk/guides/iterative-refinement)** — Multi-agent feedback loop for quality-bound tasks
- **[Hooks](/sdk/guides/hooks)** — Customize start/stop semantics on every run
- **[Persistence](/sdk/guides/convo-persistence)** — Save and restore conversation state across goal runs
### Pause and Resume
Source: https://docs.openhands.dev/sdk/guides/convo-pause-and-resume.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
### Pausing Execution
Pause the agent from another thread or after a delay using `conversation.pause()`, and
Resume the paused conversation after performing operations by calling `conversation.run()` again.
```python icon="python" focus={9, 15} wrap
import time
thread = threading.Thread(target=conversation.run)
thread.start()
print("Letting agent work for 5 seconds...")
time.sleep(5)
print("Pausing the agent...")
conversation.pause()
print("Waiting for 5 seconds...")
time.sleep(5)
print("Resuming the execution...")
conversation.run()
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/09_pause_example.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/09_pause_example.py)
Pause agent execution mid-task by calling `conversation.pause()`:
```python icon="python" expandable examples/01_standalone_sdk/09_pause_example.py
import os
import threading
import time
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
]
# Agent
agent = Agent(llm=llm, tools=tools)
conversation = Conversation(agent, workspace=os.getcwd())
print("=" * 60)
print("Pause and Continue Example")
print("=" * 60)
print()
# Phase 1: Start a long-running task
print("Phase 1: Starting agent with a task...")
conversation.send_message(
"Create a file called countdown.txt and write numbers from 100 down to 1, "
"one number per line. After you finish, summarize what you did."
)
print(f"Initial status: {conversation.state.execution_status}")
print()
# Start the agent in a background thread
thread = threading.Thread(target=conversation.run)
thread.start()
# Let the agent work for a few seconds
print("Letting agent work for 2 seconds...")
time.sleep(2)
# Phase 2: Pause the agent
print()
print("Phase 2: Pausing the agent...")
conversation.pause()
# Wait for the thread to finish (it will stop when paused)
thread.join()
print(f"Agent status after pause: {conversation.state.execution_status}")
print()
# Phase 3: Send a new message while paused
print("Phase 3: Sending a new message while agent is paused...")
conversation.send_message(
"Actually, stop working on countdown.txt. Instead, create a file called "
"hello.txt with just the text 'Hello, World!' in it."
)
print()
# Phase 4: Resume the agent with .run()
print("Phase 4: Resuming agent with .run()...")
print(f"Status before resume: {conversation.state.execution_status}")
# Resume execution
conversation.run()
print(f"Final status: {conversation.state.execution_status}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Persistence](/sdk/guides/convo-persistence)** - Save and restore conversation state
- **[Send Message While Processing](/sdk/guides/convo-send-message-while-running)** - Interrupt running agents
### Persistence
Source: https://docs.openhands.dev/sdk/guides/convo-persistence.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## How to use Persistence
Save conversation state to disk and restore it later for long-running or multi-session workflows.
### Saving State
Create a conversation with a unique ID to enable persistence:
```python focus={3-4,10-11} icon="python" wrap
import uuid
conversation_id = uuid.uuid4()
persistence_dir = "./.conversations"
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
persistence_dir=persistence_dir,
conversation_id=conversation_id,
)
conversation.send_message("Start long task")
conversation.run() # State automatically saved
```
### Restoring State
Restore a conversation using the same ID and persistence directory:
```python focus={9-10} icon="python"
# Later, in a different session
del conversation
# Deserialize the conversation
print("Deserializing conversation...")
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
persistence_dir=persistence_dir,
conversation_id=conversation_id,
)
conversation.send_message("Continue task")
conversation.run() # Continues from saved state
```
## What Gets Persisted
The conversation state includes information that allows seamless restoration:
- **Message History**: Complete event log including user messages, agent responses, and system events
- **Agent Configuration**: LLM settings, tools, MCP servers, and agent parameters
- **Execution State**: Current agent status (idle, running, paused, etc.), iteration count, and stuck detection settings
- **Tool Outputs**: Results from bash commands, file operations, and other tool executions
- **Statistics**: LLM usage metrics like token counts and API calls
- **Workspace Context**: Working directory and file system state
- **Activated Skills**: [Skills](/sdk/guides/skill) that have been enabled during the conversation
- **Secrets**: Managed credentials and API keys
- **Agent State**: Custom runtime state stored by agents (see [Agent State](#agent-state) below)
For the complete implementation details, see the [ConversationState class](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/conversation/state.py) in the source code.
## Persistence Directory Structure
When you set a `persistence_dir`, your conversation will be persisted to a directory structure where each
conversation has its own subdirectory. By default, the persistence directory is `workspace/conversations/`
(unless you specify a custom path).
**Directory structure:**
Each conversation directory contains:
- **`base_state.json`**: The core conversation state including agent configuration, execution status, statistics, and metadata
- **`events/`**: A subdirectory containing individual event files, each named with a sequential index and event ID (e.g., `event-00000-abc123.json`)
The collection of event files in the `events/` directory represents the same trajectory data you would find in the `trajectory.json` file from OpenHands V0, but split into individual files for better performance and granular access.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/10_persistence.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/10_persistence.py)
```python icon="python" expandable examples/01_standalone_sdk/10_persistence.py
import os
import uuid
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
]
# Add MCP Tools
mcp_config = {
"mcpServers": {
"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]},
}
}
# Agent
agent = Agent(llm=llm, tools=tools, mcp_config=mcp_config)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation_id = uuid.uuid4()
persistence_dir = "./.conversations"
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
persistence_dir=persistence_dir,
conversation_id=conversation_id,
)
conversation.send_message(
"Read https://github.com/OpenHands/OpenHands. Then write 3 facts "
"about the project into FACTS.txt."
)
conversation.run()
conversation.send_message("Great! Now delete that file.")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Conversation persistence
print("Serializing conversation...")
del conversation
# Deserialize the conversation
print("Deserializing conversation...")
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
persistence_dir=persistence_dir,
conversation_id=conversation_id,
)
print("Sending message to deserialized conversation...")
conversation.send_message("Hey what did you create? Return an agent finish action")
conversation.run()
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Reading serialized events
Convert persisted events into LLM-ready messages for reuse or analysis.
This example is available on GitHub: [examples/01_standalone_sdk/36_event_json_to_openai_messages.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/36_event_json_to_openai_messages.py)
```python icon="python" expandable examples/01_standalone_sdk/36_event_json_to_openai_messages.py
"""Load persisted events and convert them into LLM-ready messages."""
import json
import os
import uuid
from pathlib import Path
from pydantic import SecretStr
conversation_id = uuid.uuid4()
persistence_root = Path(".conversations")
log_dir = (
persistence_root / "logs" / "event-json-to-openai-messages" / conversation_id.hex
)
os.environ.setdefault("LOG_JSON", "true")
os.environ.setdefault("LOG_TO_FILE", "true")
os.environ.setdefault("LOG_DIR", str(log_dir))
os.environ.setdefault("LOG_LEVEL", "INFO")
from openhands.sdk import ( # noqa: E402
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
Tool,
)
from openhands.sdk.logger import get_logger, setup_logging # noqa: E402
from openhands.tools.terminal import TerminalTool # noqa: E402
setup_logging(log_to_file=True, log_dir=str(log_dir))
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY")
if not api_key:
raise RuntimeError("LLM_API_KEY environment variable is not set.")
llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
agent = Agent(
llm=llm,
tools=[Tool(name=TerminalTool.name)],
)
######
# Create a conversation that persists its events
######
conversation = Conversation(
agent=agent,
workspace=os.getcwd(),
persistence_dir=str(persistence_root),
conversation_id=conversation_id,
)
conversation.send_message(
"Use the terminal tool to run `pwd` and write the output to tool_output.txt. "
"Reply with a short confirmation once done."
)
conversation.run()
conversation.send_message(
"Without using any tools, summarize in one sentence what you did."
)
conversation.run()
assert conversation.state.persistence_dir is not None
persistence_dir = Path(conversation.state.persistence_dir)
event_dir = persistence_dir / "events"
event_paths = sorted(event_dir.glob("event-*.json"))
if not event_paths:
raise RuntimeError("No event files found. Was persistence enabled?")
######
# Read from serialized events
######
events = [Event.model_validate_json(path.read_text()) for path in event_paths]
convertible_events = [
event for event in events if isinstance(event, LLMConvertibleEvent)
]
llm_messages = LLMConvertibleEvent.events_to_messages(convertible_events)
if llm.uses_responses_api():
logger.info("Formatting messages for the OpenAI Responses API.")
instructions, input_items = llm.format_messages_for_responses(llm_messages)
logger.info("Responses instructions:\n%s", instructions)
logger.info("Responses input:\n%s", json.dumps(input_items, indent=2))
else:
logger.info("Formatting messages for the OpenAI Chat Completions API.")
chat_messages = llm.format_messages_for_llm(llm_messages)
logger.info("Chat Completions messages:\n%s", json.dumps(chat_messages, indent=2))
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## How State Persistence Works
The SDK uses an **automatic persistence** system that saves state changes immediately when they occur. This ensures that conversation state is always recoverable, even if the process crashes unexpectedly.
### Auto-Save Mechanism
When you modify any public field on `ConversationState`, the SDK automatically:
1. Detects the field change via a custom `__setattr__` implementation
2. Serializes the entire base state to `base_state.json`
3. Triggers any registered state change callbacks
This happens transparently—you don't need to call any save methods manually.
```python
# These changes are automatically persisted:
conversation.state.execution_status = ConversationExecutionStatus.RUNNING
conversation.state.max_iterations = 100
```
### Events vs Base State
The persistence system separates data into two categories:
| Category | Storage | Contents |
|----------|---------|----------|
| **Base State** | `base_state.json` | Agent configuration, execution status, statistics, secrets, agent_state |
| **Events** | `events/event-*.json` | Message history, tool calls, observations, all conversation events |
Events are appended incrementally (one file per event), while base state is overwritten on each change. This design optimizes for:
- **Fast event appends**: No need to rewrite the entire history
- **Atomic state updates**: Base state is always consistent
- **Efficient restoration**: Events can be loaded lazily
## Next Steps
- **[Pause and Resume](/sdk/guides/convo-pause-and-resume)** - Control execution flow
- **[Async Operations](/sdk/guides/convo-async)** - Non-blocking operations
### Send Message While Running
Source: https://docs.openhands.dev/sdk/guides/convo-send-message-while-running.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
This example is available on GitHub: [examples/01_standalone_sdk/18_send_message_while_processing.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/18_send_message_while_processing.py)
Send additional messages to a running agent mid-execution to provide corrections, updates, or additional context:
```python icon="python" expandable examples/01_standalone_sdk/18_send_message_while_processing.py
"""
Example demonstrating that user messages can be sent and processed while
an agent is busy.
This example demonstrates a key capability of the OpenHands agent system: the ability
to receive and process new user messages even while the agent is actively working on
a previous task. This is made possible by the agent's event-driven architecture.
Demonstration Flow:
1. Send initial message asking agent to:
- Write "Message 1 sent at [time], written at [CURRENT_TIME]"
- Wait 3 seconds
- Write "Message 2 sent at [time], written at [CURRENT_TIME]"
[time] is the time the message was sent to the agent
[CURRENT_TIME] is the time the agent writes the line
2. Start agent processing in a background thread
3. While agent is busy (during the 3-second delay), send a second message asking to add:
- "Message 3 sent at [time], written at [CURRENT_TIME]"
4. Verify that all three lines are processed and included in the final document
Expected Evidence:
The final document will contain three lines with dual timestamps:
- "Message 1 sent at HH:MM:SS, written at HH:MM:SS" (from initial message, written immediately)
- "Message 2 sent at HH:MM:SS, written at HH:MM:SS" (from initial message, written after 3-second delay)
- "Message 3 sent at HH:MM:SS, written at HH:MM:SS" (from second message sent during delay)
The timestamps will show that Message 3 was sent while the agent was running,
but was still successfully processed and written to the document.
This proves that:
- The second user message was sent while the agent was processing the first task
- The agent successfully received and processed the second message
- The agent's event system allows for real-time message integration during processing
Key Components Demonstrated:
- Conversation.send_message(): Adds messages to events list immediately
- Agent.step(): Processes all events including newly added messages
- Threading: Allows message sending while agent is actively processing
""" # noqa
import os
import threading
import time
from datetime import datetime
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
]
# Agent
agent = Agent(llm=llm, tools=tools)
conversation = Conversation(agent)
def timestamp() -> str:
return datetime.now().strftime("%H:%M:%S")
print("=== Send Message While Processing Example ===")
# Step 1: Send initial message
start_time = timestamp()
conversation.send_message(
f"Create a file called document.txt and write this first sentence: "
f"'Message 1 sent at {start_time}, written at [CURRENT_TIME].' "
f"Replace [CURRENT_TIME] with the actual current time when you write the line. "
f"Then wait 3 seconds and write 'Message 2 sent at {start_time}, written at [CURRENT_TIME].'" # noqa
)
# Step 2: Start agent processing in background
thread = threading.Thread(target=conversation.run)
thread.start()
# Step 3: Wait then send second message while agent is processing
time.sleep(2) # Give agent time to start working
second_time = timestamp()
conversation.send_message(
f"Please also add this second sentence to document.txt: "
f"'Message 3 sent at {second_time}, written at [CURRENT_TIME].' "
f"Replace [CURRENT_TIME] with the actual current time when you write this line."
)
# Wait for completion
thread.join()
# Verification
document_path = os.path.join(cwd, "document.txt")
if os.path.exists(document_path):
with open(document_path) as f:
content = f.read()
print("\nDocument contents:")
print("─────────────────────")
print(content)
print("─────────────────────")
# Check if both messages were processed
if "Message 1" in content and "Message 2" in content:
print("\nSUCCESS: Agent processed both messages!")
print(
"This proves the agent received the second message while processing the first task." # noqa
)
else:
print("\nWARNING: Agent may not have processed the second message")
# Clean up
os.remove(document_path)
else:
print("WARNING: Document.txt was not created")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
### Sending Messages During Execution
As shown in the example above, use threading to send messages while the agent is running:
```python icon="python"
# Start agent processing in background
thread = threading.Thread(target=conversation.run)
thread.start()
# Wait then send second message while agent is processing
time.sleep(2) # Give agent time to start working
second_time = timestamp()
conversation.send_message(
f"Please also add this second sentence to document.txt: "
f"'Message 3 sent at {second_time}, written at [CURRENT_TIME].' "
f"Replace [CURRENT_TIME] with the actual current time when you write this line."
)
# Wait for completion
thread.join()
```
The key steps are:
1. Start `conversation.run()` in a background thread
2. Send additional messages using `conversation.send_message()` while the agent is processing
3. Use `thread.join()` to wait for completion
The agent receives and incorporates the new message mid-execution, allowing for real-time corrections and dynamic guidance.
## Next Steps
- **[Pause and Resume](/sdk/guides/convo-pause-and-resume)** - Control execution flow
- **[Async Operations](/sdk/guides/convo-async)** - Non-blocking operations
### Critic (Experimental)
Source: https://docs.openhands.dev/sdk/guides/critic.md
**This feature is highly experimental** and subject to change. The API, configuration, and behavior may evolve significantly based on feedback and testing.
> A ready-to-run example is available [here](#ready-to-run-example)!
## What is a Critic?
A **critic** is an evaluator that analyzes agent actions and conversation history to predict the quality or success probability of agent decisions. The critic runs alongside the agent and provides:
- **Quality scores**: Probability scores between 0.0 and 1.0 indicating predicted success
- **Real-time feedback**: Scores computed during agent execution, not just at completion
- **Iterative refinement**: Automatic retry with follow-up prompts when scores are below threshold
You can use critic scores to build automated workflows, such as triggering the agent to reflect on and fix its previous solution when the critic indicates poor task performance.
This critic is a more advanced extension of the approach described in our blog post [SOTA on SWE-Bench Verified with Inference-Time Scaling and Critic Model](https://openhands.dev/blog/sota-on-swe-bench-verified-with-inference-time-scaling-and-critic-model). For detailed evaluation metrics and methodology, see our technical report: [A Rubric-Supervised Critic from Sparse Real-World Outcomes](https://arxiv.org/abs/2603.03800).
## Quick Start
When using the OpenHands LLM Provider (`llm-proxy.*.all-hands.dev`), the critic is **automatically configured** - no additional setup required.
## Understanding Critic Results
Critic evaluations produce scores and feedback:
- **`score`**: Float between 0.0 and 1.0 representing predicted success probability
- **`message`**: Optional feedback with detailed probabilities
- **`success`**: Boolean property (True if score >= 0.5)
Results are automatically displayed in the conversation visualizer:

### Accessing Results Programmatically
```python icon="python" focus={4-7}
from openhands.sdk import Event, ActionEvent, MessageEvent
def callback(event: Event):
if isinstance(event, (ActionEvent, MessageEvent)):
if event.critic_result is not None:
print(f"Critic score: {event.critic_result.score:.3f}")
print(f"Success: {event.critic_result.success}")
conversation = Conversation(agent=agent, callbacks=[callback])
```
## Iterative Refinement with a Critic
The critic supports **automatic iterative refinement** - when the agent finishes a task but the critic score is below a threshold, the conversation automatically continues with a follow-up prompt asking the agent to improve its work.
### How It Works
1. Agent completes a task and calls `FinishAction`
2. Critic evaluates the result and produces a score
3. If score < `success_threshold`, a follow-up prompt is sent automatically
4. Agent continues working to address issues
5. Process repeats until score meets threshold or `max_iterations` is reached
### Configuration
Use `IterativeRefinementConfig` to enable automatic retries:
```python icon="python" focus={1,4-7,12}
from openhands.sdk.critic import APIBasedCritic, IterativeRefinementConfig
# Configure iterative refinement
iterative_config = IterativeRefinementConfig(
success_threshold=0.7, # Retry if score < 70%
max_iterations=3, # Maximum retry attempts
)
# Attach to critic
critic = APIBasedCritic(
server_url="https://llm-proxy.eval.all-hands.dev/vllm",
api_key=api_key,
model_name="critic",
iterative_refinement=iterative_config,
)
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `success_threshold` | `float` | `0.6` | Score threshold (0-1) to consider task successful |
| `max_iterations` | `int` | `3` | Maximum number of iterations before giving up |
### Custom Follow-up Prompts
By default, the critic generates a generic follow-up prompt. You can customize this by subclassing `CriticBase` and overriding `get_followup_prompt()`:
```python icon="python" focus={4-12}
from openhands.sdk.critic.base import CriticBase, CriticResult
class CustomCritic(APIBasedCritic):
def get_followup_prompt(self, critic_result: CriticResult, iteration: int) -> str:
score_percent = critic_result.score * 100
return f"""
Your solution scored {score_percent:.1f}% (iteration {iteration}).
Please review your work carefully:
1. Check that all requirements are met
2. Verify tests pass
3. Fix any issues and try again
"""
```
### Example Workflow
Here's what happens during iterative refinement:
```
Iteration 1:
→ Agent creates files, runs tests
→ Agent calls FinishAction
→ Critic evaluates: score = 0.45 (below 0.7 threshold)
→ Follow-up prompt sent automatically
Iteration 2:
→ Agent reviews and fixes issues
→ Agent calls FinishAction
→ Critic evaluates: score = 0.72 (above threshold)
→ ✅ Success! Conversation ends
```
## Troubleshooting
### Critic Evaluations Not Appearing
- Verify the critic is properly configured and passed to the Agent
- Ensure you're using the OpenHands LLM Provider (`llm-proxy.*.all-hands.dev`)
### API Authentication Errors
- Verify `LLM_API_KEY` is set correctly
- Check that the API key has not expired
### Iterative Refinement Not Triggering
- Ensure `iterative_refinement` config is attached to the critic
- Check that `success_threshold` is set appropriately (higher values trigger more retries)
- Verify the agent is using `FinishAction` to complete tasks
## Ready-to-run Example
The critic model is hosted by the OpenHands LLM Provider and is currently free to use. This example is available on GitHub: [examples/01_standalone_sdk/34_critic_example.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/34_critic_example.py)
This example demonstrates iterative refinement with a moderately complex task - creating a Python word statistics tool with specific edge case requirements. The critic evaluates whether all requirements are met and triggers retries if needed.
```python icon="python" expandable examples/01_standalone_sdk/34_critic_example.py
"""Iterative Refinement with Critic Model Example.
This is EXPERIMENTAL.
This example demonstrates how to use a critic model to shepherd an agent through
complex, multi-step tasks. The critic evaluates the agent's progress and provides
feedback that can trigger follow-up prompts when the agent hasn't completed the
task successfully.
Key concepts demonstrated:
1. Setting up a critic with IterativeRefinementConfig for automatic retry
2. Conversation.run() automatically handles retries based on critic scores
3. Custom follow-up prompt generation via critic.get_followup_prompt()
4. Iterating until the task is completed successfully or max iterations reached
For All-Hands LLM proxy (llm-proxy.*.all-hands.dev), the critic is auto-configured
using the same base_url with /vllm suffix and "critic" as the model name.
"""
import os
import re
import tempfile
from pathlib import Path
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.sdk.critic import APIBasedCritic, IterativeRefinementConfig
from openhands.sdk.critic.base import CriticBase
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
# Configuration
# Higher threshold (70%) makes it more likely the agent needs multiple iterations,
# which better demonstrates how iterative refinement works.
# Adjust as needed to see different behaviors.
SUCCESS_THRESHOLD = float(os.getenv("CRITIC_SUCCESS_THRESHOLD", "0.7"))
MAX_ITERATIONS = int(os.getenv("MAX_ITERATIONS", "3"))
def get_required_env(name: str) -> str:
value = os.getenv(name)
if value:
return value
raise ValueError(
f"Missing required environment variable: {name}. "
f"Set {name} before running this example."
)
def get_default_critic(llm: LLM) -> CriticBase | None:
"""Auto-configure critic for All-Hands LLM proxy.
When the LLM base_url matches `llm-proxy.*.all-hands.dev`, returns an
APIBasedCritic configured with:
- server_url: {base_url}/vllm
- api_key: same as LLM
- model_name: "critic"
Args:
llm: The LLM instance to derive critic configuration from.
Returns:
An APIBasedCritic if the LLM is configured for All-Hands proxy,
None otherwise.
Example:
llm = LLM(
model="anthropic/claude-sonnet-4-5",
api_key=api_key,
base_url="https://llm-proxy.eval.all-hands.dev",
)
critic = get_default_critic(llm)
if critic is None:
# Fall back to explicit configuration
critic = APIBasedCritic(
server_url="https://my-critic-server.com",
api_key="my-api-key",
model_name="my-critic-model",
)
"""
base_url = llm.base_url
api_key = llm.api_key
if base_url is None or api_key is None:
return None
# Match: llm-proxy.{env}.all-hands.dev (e.g., staging, prod, eval)
pattern = r"^https?://llm-proxy\.[^./]+\.all-hands\.dev"
if not re.match(pattern, base_url):
return None
return APIBasedCritic(
server_url=f"{base_url.rstrip('/')}/vllm",
api_key=api_key,
model_name="critic",
)
# Task prompt designed to be moderately complex with subtle requirements.
# The task is simple enough to complete in 1-2 iterations, but has specific
# requirements that are easy to miss - triggering critic feedback.
INITIAL_TASK_PROMPT = """\
Create a Python word statistics tool called `wordstats` that analyzes text files.
## Structure
Create directory `wordstats/` with:
- `stats.py` - Main module with `analyze_file(filepath)` function
- `cli.py` - Command-line interface
- `tests/test_stats.py` - Unit tests
## Requirements for stats.py
The `analyze_file(filepath)` function must return a dict with these EXACT keys:
- `lines`: total line count (including empty lines)
- `words`: word count
- `chars`: character count (including whitespace)
- `unique_words`: count of unique words (case-insensitive)
### Important edge cases (often missed!):
1. Empty files must return all zeros, not raise an exception
2. Hyphenated words count as ONE word (e.g., "well-known" = 1 word)
3. Numbers like "123" or "3.14" are NOT counted as words
4. Contractions like "don't" count as ONE word
5. File not found must raise FileNotFoundError with a clear message
## Requirements for cli.py
When run as `python cli.py `:
- Print each stat on its own line: "Lines: X", "Words: X", etc.
- Exit with code 1 if file not found, printing error to stderr
- Exit with code 0 on success
## Required Tests (test_stats.py)
Write tests that verify:
1. Basic counting on normal text
2. Empty file returns all zeros
3. Hyphenated words counted correctly
4. Numbers are excluded from word count
5. FileNotFoundError raised for missing files
## Verification Steps
1. Create a sample file `sample.txt` with this EXACT content (no trailing newline):
```
Hello world!
This is a well-known test file.
It has 5 lines, including empty ones.
Numbers like 42 and 3.14 don't count as words.
```
2. Run: `python wordstats/cli.py sample.txt`
Expected output:
- Lines: 5
- Words: 21
- Chars: 130
- Unique words: 21
3. Run the tests: `python -m pytest wordstats/tests/ -v`
ALL tests must pass.
The task is complete ONLY when:
- All files exist
- The CLI outputs the correct stats for sample.txt
- All 5+ tests pass
"""
llm_api_key = get_required_env("LLM_API_KEY")
# Use a weaker model to increase likelihood of needing multiple iterations
llm_model = os.getenv("LLM_MODEL", "anthropic/claude-haiku-4-5-20251001")
llm = LLM(
model=llm_model,
api_key=llm_api_key,
top_p=0.95,
base_url=os.getenv("LLM_BASE_URL"),
)
# Setup critic with iterative refinement config
# The IterativeRefinementConfig tells Conversation.run() to automatically
# retry the task if the critic score is below the threshold
iterative_config = IterativeRefinementConfig(
success_threshold=SUCCESS_THRESHOLD,
max_iterations=MAX_ITERATIONS,
)
# Auto-configure critic for All-Hands proxy or use explicit env vars
critic = get_default_critic(llm)
if critic is None:
print("⚠️ No All-Hands LLM proxy detected, trying explicit env vars...")
critic = APIBasedCritic(
server_url=get_required_env("CRITIC_SERVER_URL"),
api_key=get_required_env("CRITIC_API_KEY"),
model_name=get_required_env("CRITIC_MODEL_NAME"),
iterative_refinement=iterative_config,
)
else:
# Add iterative refinement config to the auto-configured critic
critic = critic.model_copy(update={"iterative_refinement": iterative_config})
# Create agent with critic (iterative refinement is built into the critic)
agent = Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
],
critic=critic,
)
# Create workspace
workspace = Path(tempfile.mkdtemp(prefix="critic_demo_"))
print(f"📁 Created workspace: {workspace}")
# Create conversation - iterative refinement is handled automatically
# by Conversation.run() based on the critic's config
conversation = Conversation(
agent=agent,
workspace=str(workspace),
)
print("\n" + "=" * 70)
print("🚀 Starting Iterative Refinement with Critic Model")
print("=" * 70)
print(f"Success threshold: {SUCCESS_THRESHOLD:.0%}")
print(f"Max iterations: {MAX_ITERATIONS}")
# Send the task and run - Conversation.run() handles retries automatically
conversation.send_message(INITIAL_TASK_PROMPT)
conversation.run()
# Print additional info about created files
print("\nCreated files:")
for path in sorted(workspace.rglob("*")):
if path.is_file():
relative = path.relative_to(workspace)
print(f" - {relative}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"\nEXAMPLE_COST: {cost:.4f}")
```
```bash Running the Example icon="terminal"
LLM_BASE_URL="https://llm-proxy.eval.all-hands.dev" LLM_API_KEY="$LLM_API_KEY" \
uv run python examples/01_standalone_sdk/34_critic_example.py
```
### Example Output
```
📁 Created workspace: /tmp/critic_demo_abc123
======================================================================
🚀 Starting Iterative Refinement with Critic Model
======================================================================
Success threshold: 70%
Max iterations: 3
... agent works on the task ...
✓ Critic evaluation: score=0.758, success=True
Created files:
- sample.txt
- wordstats/cli.py
- wordstats/stats.py
- wordstats/tests/test_stats.py
EXAMPLE_COST: 0.0234
```
## Next Steps
- **[Observability](/sdk/guides/observability)** - Monitor and log agent behavior
- **[Metrics](/sdk/guides/metrics)** - Collect performance metrics
- **[Stuck Detector](/sdk/guides/agent-stuck-detector)** - Detect unproductive agent patterns
### Custom Tools
Source: https://docs.openhands.dev/sdk/guides/custom-tools.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> The ready-to-run example is available [here](#ready-to-run-example)!
## Understanding the Tool System
The SDK's tool system is built around three core components:
1. **Action** - Defines input parameters (what the tool accepts)
2. **Observation** - Defines output data (what the tool returns)
3. **Executor** - Implements the tool's logic (what the tool does)
These components are tied together by a **ToolDefinition** that registers the tool with the agent.
## Built-in Tools
The tools package ([source code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools)) provides a bunch of built-in tools that follow these patterns.
```python icon="python" wrap
from openhands.tools import BashTool, FileEditorTool
from openhands.tools.preset import get_default_tools
# Use specific tools
agent = Agent(llm=llm, tools=[BashTool.create(), FileEditorTool.create()])
# Or use preset
tools = get_default_tools()
agent = Agent(llm=llm, tools=tools)
```
See [source code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools) for the complete list of available tools and design philosophy.
## Creating a Custom Tool
Here's a minimal example of creating a custom grep tool:
### Define the Action
Defines input parameters (what the tool accepts)
```python icon="python" wrap
class GrepAction(Action):
pattern: str = Field(description="Regex to search for")
path: str = Field(
default=".",
description="Directory to search (absolute or relative)"
)
include: str | None = Field(
default=None,
description="Optional glob to filter files (e.g. '*.py')"
)
```
### Define the Observation
Defines output data (what the tool returns)
```python icon="python" wrap
class GrepObservation(Observation):
matches: list[str] = Field(default_factory=list)
files: list[str] = Field(default_factory=list)
count: int = 0
@property
def to_llm_content(self) -> Sequence[TextContent | ImageContent]:
if not self.count:
return [TextContent(text="No matches found.")]
files_list = "\n".join(f"- {f}" for f in self.files[:20])
sample = "\n".join(self.matches[:10])
more = "\n..." if self.count > 10 else ""
ret = (
f"Found {self.count} matching lines.\n"
f"Files:\n{files_list}\n"
f"Sample:\n{sample}{more}"
)
return [TextContent(text=ret)]
```
The to_llm_content() property formats observations for the LLM.
### Define the Executor
Implements the tool’s logic (what the tool does)
```python icon="python" wrap
class GrepExecutor(ToolExecutor[GrepAction, GrepObservation]):
def __init__(self, terminal: TerminalExecutor):
self.terminal: TerminalExecutor = terminal
def __call__(
self,
action: GrepAction,
conversation=None,
) -> GrepObservation:
root = os.path.abspath(action.path)
pat = shlex.quote(action.pattern)
root_q = shlex.quote(root)
# Use grep -r; add --include when provided
if action.include:
inc = shlex.quote(action.include)
cmd = f"grep -rHnE --include {inc} {pat} {root_q}"
else:
cmd = f"grep -rHnE {pat} {root_q}"
cmd += " 2>/dev/null | head -100"
result = self.terminal(TerminalAction(command=cmd))
matches: list[str] = []
files: set[str] = set()
# grep returns exit code 1 when no matches; treat as empty
output_text = result.text
if output_text.strip():
for line in output_text.strip().splitlines():
matches.append(line)
# Expect "path:line:content"
# take the file part before first ":"
file_path = line.split(":", 1)[0]
if file_path:
files.add(os.path.abspath(file_path))
return GrepObservation(
matches=matches,
files=sorted(files),
count=len(matches),
)
```
### Finally, define the tool
```python icon="python" wrap
class GrepTool(ToolDefinition[GrepAction, GrepObservation]):
"""Custom grep tool that searches file contents using regular expressions."""
@classmethod
def create(
cls,
conv_state,
terminal_executor: TerminalExecutor | None = None
) -> Sequence[ToolDefinition]:
"""Create GrepTool instance with a GrepExecutor.
Args:
conv_state: Conversation state to get
working directory from.
terminal_executor: Optional terminal executor to reuse.
If not provided, a new one will be created.
Returns:
A sequence containing a single GrepTool instance.
"""
if terminal_executor is None:
terminal_executor = TerminalExecutor(
working_dir=conv_state.workspace.working_dir
)
grep_executor = GrepExecutor(terminal_executor)
return [
cls(
description=_GREP_DESCRIPTION,
action_type=GrepAction,
observation_type=GrepObservation,
executor=grep_executor,
)
]
```
## Good to know
### Tool Registration
Tools are registered using `register_tool()` and referenced by name:
```python icon="python" wrap
# Register a simple tool class
register_tool("FileEditorTool", FileEditorTool)
# Register a factory function that creates multiple tools
register_tool("BashAndGrepToolSet", _make_bash_and_grep_tools)
# Use registered tools by name
tools = [
Tool(name="FileEditorTool"),
Tool(name="BashAndGrepToolSet"),
]
```
### Factory Functions
Tool factory functions receive `conv_state` as a parameter, allowing access to workspace information:
```python icon="python" wrap
def _make_bash_and_grep_tools(conv_state) -> list[ToolDefinition]:
"""Create execute_bash and custom grep tools sharing one executor."""
bash_executor = BashExecutor(
working_dir=conv_state.workspace.working_dir
)
# Create and configure tools...
return [bash_tool, grep_tool]
```
### Shared Executors
Multiple tools can share executors for efficiency and state consistency:
```python icon="python" wrap
bash_executor = BashExecutor(working_dir=conv_state.workspace.working_dir)
bash_tool = execute_bash_tool.set_executor(executor=bash_executor)
grep_executor = GrepExecutor(bash_executor)
grep_tool = ToolDefinition(
name="grep",
description=_GREP_DESCRIPTION,
action_type=GrepAction,
observation_type=GrepObservation,
executor=grep_executor,
)
```
## When to Create Custom Tools
Create custom tools when you need to:
- Combine multiple operations into a single, structured interface
- Add typed parameters with validation
- Format complex outputs for LLM consumption
- Integrate with external APIs or services
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/02_custom_tools.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/02_custom_tools.py)
```python icon="python" expandable examples/01_standalone_sdk/02_custom_tools.py
"""Advanced example showing explicit executor usage and custom grep tool."""
import os
import shlex
from collections.abc import Sequence
from pydantic import Field, SecretStr
from openhands.sdk import (
LLM,
Action,
Agent,
Conversation,
Event,
ImageContent,
LLMConvertibleEvent,
Observation,
TextContent,
ToolDefinition,
get_logger,
)
from openhands.sdk.tool import (
Tool,
ToolExecutor,
register_tool,
)
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import (
TerminalAction,
TerminalExecutor,
TerminalTool,
)
logger = get_logger(__name__)
# --- Action / Observation ---
class GrepAction(Action):
pattern: str = Field(description="Regex to search for")
path: str = Field(
default=".", description="Directory to search (absolute or relative)"
)
include: str | None = Field(
default=None, description="Optional glob to filter files (e.g. '*.py')"
)
class GrepObservation(Observation):
matches: list[str] = Field(default_factory=list)
files: list[str] = Field(default_factory=list)
count: int = 0
@property
def to_llm_content(self) -> Sequence[TextContent | ImageContent]:
if not self.count:
return [TextContent(text="No matches found.")]
files_list = "\n".join(f"- {f}" for f in self.files[:20])
sample = "\n".join(self.matches[:10])
more = "\n..." if self.count > 10 else ""
ret = (
f"Found {self.count} matching lines.\n"
f"Files:\n{files_list}\n"
f"Sample:\n{sample}{more}"
)
return [TextContent(text=ret)]
# --- Executor ---
class GrepExecutor(ToolExecutor[GrepAction, GrepObservation]):
def __init__(self, terminal: TerminalExecutor):
self.terminal: TerminalExecutor = terminal
def __call__(self, action: GrepAction, conversation=None) -> GrepObservation: # noqa: ARG002
root = os.path.abspath(action.path)
pat = shlex.quote(action.pattern)
root_q = shlex.quote(root)
# Use grep -r; add --include when provided
if action.include:
inc = shlex.quote(action.include)
cmd = f"grep -rHnE --include {inc} {pat} {root_q} 2>/dev/null | head -100"
else:
cmd = f"grep -rHnE {pat} {root_q} 2>/dev/null | head -100"
result = self.terminal(TerminalAction(command=cmd))
matches: list[str] = []
files: set[str] = set()
# grep returns exit code 1 when no matches; treat as empty
output_text = result.text
if output_text.strip():
for line in output_text.strip().splitlines():
matches.append(line)
# Expect "path:line:content" — take the file part before first ":"
file_path = line.split(":", 1)[0]
if file_path:
files.add(os.path.abspath(file_path))
return GrepObservation(matches=matches, files=sorted(files), count=len(matches))
# Tool description
_GREP_DESCRIPTION = """Fast content search tool.
* Searches file contents using regular expressions
* Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
* Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
* Returns matching file paths sorted by modification time.
* Only the first 100 results are returned. Consider narrowing your search with stricter regex patterns or provide path parameter if you need more results.
* Use this tool when you need to find files containing specific patterns
* When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
""" # noqa: E501
# --- Tool Definition ---
class GrepTool(ToolDefinition[GrepAction, GrepObservation]):
"""A custom grep tool that searches file contents using regular expressions."""
@classmethod
def create(
cls, conv_state, terminal_executor: TerminalExecutor | None = None
) -> Sequence[ToolDefinition]:
"""Create GrepTool instance with a GrepExecutor.
Args:
conv_state: Conversation state to get working directory from.
terminal_executor: Optional terminal executor to reuse. If not provided,
a new one will be created.
Returns:
A sequence containing a single GrepTool instance.
"""
if terminal_executor is None:
terminal_executor = TerminalExecutor(
working_dir=conv_state.workspace.working_dir
)
grep_executor = GrepExecutor(terminal_executor)
return [
cls(
description=_GREP_DESCRIPTION,
action_type=GrepAction,
observation_type=GrepObservation,
executor=grep_executor,
)
]
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools - demonstrating both simplified and advanced patterns
cwd = os.getcwd()
def _make_bash_and_grep_tools(conv_state) -> list[ToolDefinition]:
"""Create terminal and custom grep tools sharing one executor."""
terminal_executor = TerminalExecutor(working_dir=conv_state.workspace.working_dir)
# terminal_tool = terminal_tool.set_executor(executor=terminal_executor)
terminal_tool = TerminalTool.create(conv_state, executor=terminal_executor)[0]
# Use the GrepTool.create() method with shared terminal_executor
grep_tool = GrepTool.create(conv_state, terminal_executor=terminal_executor)[0]
return [terminal_tool, grep_tool]
register_tool("BashAndGrepToolSet", _make_bash_and_grep_tools)
tools = [
Tool(name=FileEditorTool.name),
Tool(name="BashAndGrepToolSet"),
]
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
conversation.send_message(
"Hello! Can you use the grep tool to find all files "
"containing the word 'class' in this project, then create a summary file listing them? " # noqa: E501
"Use the pattern 'class' to search and include only Python files with '*.py'." # noqa: E501
)
conversation.run()
conversation.send_message("Great! Now delete that file.")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Model Context Protocol (MCP) Integration](/sdk/guides/mcp)** - Use Model Context Protocol servers
- **[Tools Package Source Code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools)** - Built-in tools implementation
### Assign Reviews
Source: https://docs.openhands.dev/sdk/guides/github-workflows/assign-reviews.md
> The reference workflow is available [here](#reference-workflow)!
Automate pull request triage by intelligently assigning reviewers based on git blame analysis, notifying reviewers of pending PRs, and prompting authors on stale pull requests. The agent performs three sequential checks: pinging reviewers on clean PRs awaiting review (3+ days), reminding authors on stale PRs (5+ days), and auto-assigning reviewers based on code ownership for unassigned PRs.
## How it works
It relies on the basic action workflow (`01_basic_action`) which provides a flexible template for running arbitrary agent tasks in GitHub Actions.
**Core Components:**
- **`agent_script.py`** - Python script that initializes the OpenHands agent with configurable LLM settings and executes tasks based on provided prompts
- **`workflow.yml`** - GitHub Actions workflow that sets up the environment, installs dependencies, and runs the agent
**Prompt Options:**
1. **`PROMPT_STRING`** - Direct inline text for simple prompts (used in this example)
2. **`PROMPT_LOCATION`** - URL or file path for external prompts
The workflow downloads the agent script, validates configuration, runs the task, and uploads execution logs as artifacts.
## Assign Reviews Use Case
This specific implementation uses the basic action template to handle three PR management scenarios:
**1. Need Reviewer Action**
- Identifies PRs waiting for review
- Notifies reviewers to take action
**2. Need Author Action**
- Finds stale PRs with no activity for 5+ days
- Prompts authors to update, request review, or close
**3. Need Reviewers**
- Detects non-draft PRs without assigned reviewers (created 1+ day ago, CI passing)
- Uses git blame analysis to identify relevant contributors
- Automatically assigns reviewers based on file ownership and contribution history
- Balances reviewer workload across team members
## Quick Start
```bash icon="terminal"
cp examples/03_github_workflows/01_basic_action/assign-reviews.yml .github/workflows/assign-reviews.yml
```
Go to `GitHub Settings → Secrets → Actions`, and add `LLM_API_KEY`
(get from https://docs.openhands.dev/openhands/usage/llms/openhands-llms).
Go to `GitHub Settings → Actions → General → Workflow permissions` and enable "Read and write permissions".
The default is: Daily at 12 PM UTC.
## Features
- **Intelligent Assignment** - Uses git blame to identify relevant reviewers based on code ownership
- **Automated Notifications** - Sends contextual reminders to reviewers and authors
- **Workload Balancing** - Distributes review requests evenly across team members
- **Scheduled & Manual** - Runs daily automatically or on-demand via workflow dispatch
## Reference Workflow
This example is available on GitHub: [examples/03_github_workflows/01_basic_action/assign-reviews.yml](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/01_basic_action/assign-reviews.yml)
```yaml icon="yaml" expandable examples/03_github_workflows/01_basic_action/assign-reviews.yml
---
# To set this up:
# 1. Change the name below to something relevant to your task
# 2. Modify the "env" section below with your prompt
# 3. Add your LLM_API_KEY to the repository secrets
# 4. Commit this file to your repository
# 5. Trigger the workflow manually or set up a schedule
name: Assign Reviews
on:
# Manual trigger
workflow_dispatch:
# Scheduled trigger (disabled by default, uncomment and customize as needed)
schedule:
# Run at 12 PM UTC every day
- cron: 0 12 * * *
permissions:
contents: write
pull-requests: write
issues: write
jobs:
run-task:
runs-on: ubuntu-24.04
env:
# Configuration (modify these values as needed)
AGENT_SCRIPT_URL: https://raw.githubusercontent.com/OpenHands/agent-sdk/main/examples/03_github_workflows/01_basic_action/agent_script.py
# Provide either PROMPT_LOCATION (URL/file) OR PROMPT_STRING (direct text), not both
# Option 1: Use a URL or file path for the prompt
PROMPT_LOCATION: ''
# PROMPT_LOCATION: 'https://example.com/prompts/maintenance.txt'
# Option 2: Use direct text for the prompt
PROMPT_STRING: >
Use GITHUB_TOKEN and the github API to organize open pull requests and issues in the repo.
Read the sections below in order, and perform each in order. Do NOT take action
on the same issue or PR twice.
# Issues with needs-info - Check for OP Response
Find all open issues that have the "needs-info" label. For each issue:
1. Identify the original poster (issue author)
2. Check if there are any comments from the original poster AFTER the "needs-info" label was added
3. To determine when the label was added, use: GET /repos/{owner}/{repo}/issues/{issue_number}/timeline
and look for "labeled" events with the label "needs-info"
4. If the original poster has commented after the label was added:
- Remove the "needs-info" label
- Add the "needs-triage" label
- Post a comment: "[Automatic Post]: The issue author has provided additional information. Moving back to needs-triage for review."
# Issues with needs-triage
Find all open issues that have the "needs-triage" label. For each issue that has been in this state for more than 4 days since the last
activity:
1. First, check if the issue has already been triaged by verifying it does NOT have:
- The "enhancement" label
- Any "priority" label (priority:low, priority:medium, priority:high, etc.)
2. If the issue has already been triaged (has enhancement or priority label), remove the needs-triage label
3. For issues that have NOT been triaged yet:
- Read the issue description and comments
- Determine if it requires maintainer attention by checking:
* Is it a bug report, feature request, or question?
* Does it have enough information to be actionable?
* Has a maintainer already commented?
* Is the last comment older than 4 days?
- If it needs maintainer attention and no maintainer has commented:
* Find an appropriate maintainer based on the issue topic and recent activity
* Tag them with: "[Automatic Post]: This issue has been waiting for triage. @{maintainer}, could you please take a look when you have
a chance?"
# Need Reviewer Action
Find all open PRs where:
1. The PR is waiting for review (there are no open review comments or change requests)
2. The PR is in a "clean" state (CI passing, no merge conflicts)
3. The PR is not marked as draft (draft: false)
4. The PR has had no activity (comments, commits, reviews) for more than 3 days.
In this case, send a message to the reviewers:
[Automatic Post]: This PR seems to be currently waiting for review.
{reviewer_names}, could you please take a look when you have a chance?
# Need Author Action
Find all open PRs where the most recent change or comment was made on the pull
request more than 5 days ago (use 14 days if the PR is marked as draft).
And send a message to the author:
[Automatic Post]: It has been a while since there was any activity on this PR.
{author}, are you still working on it? If so, please go ahead, if not then
please request review, close it, or request that someone else follow up.
# Need Reviewers
Find all open pull requests that:
1. Have no reviewers assigned to them.
2. Are not marked as draft.
3. Were created more than 1 day ago.
4. CI is passing and there are no merge conflicts.
For each of these pull requests, read the git blame information for the files,
and find the most recent and active contributors to the file/location of the changes.
Assign one of these people as a reviewer, but try not to assign too many reviews to
any single person. Add this message:
[Automatic Post]: I have assigned {reviewer} as a reviewer based on git blame information.
Thanks in advance for the help!
LLM_MODEL:
LLM_BASE_URL:
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install OpenHands dependencies
run: |
# Install OpenHands SDK and tools from git repository
uv pip install --system "openhands-sdk @ git+https://github.com/OpenHands/agent-sdk.git@main#subdirectory=openhands-sdk"
uv pip install --system "openhands-tools @ git+https://github.com/OpenHands/agent-sdk.git@main#subdirectory=openhands-tools"
- name: Check required configuration
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
if [ -z "$LLM_API_KEY" ]; then
echo "Error: LLM_API_KEY secret is not set."
exit 1
fi
# Check that exactly one of PROMPT_LOCATION or PROMPT_STRING is set
if [ -n "$PROMPT_LOCATION" ] && [ -n "$PROMPT_STRING" ]; then
echo "Error: Both PROMPT_LOCATION and PROMPT_STRING are set."
echo "Please provide only one in the env section of the workflow file."
exit 1
fi
if [ -z "$PROMPT_LOCATION" ] && [ -z "$PROMPT_STRING" ]; then
echo "Error: Neither PROMPT_LOCATION nor PROMPT_STRING is set."
echo "Please set one in the env section of the workflow file."
exit 1
fi
if [ -n "$PROMPT_LOCATION" ]; then
echo "Prompt location: $PROMPT_LOCATION"
else
echo "Using inline PROMPT_STRING (${#PROMPT_STRING} characters)"
fi
echo "LLM model: $LLM_MODEL"
if [ -n "$LLM_BASE_URL" ]; then
echo "LLM base URL: $LLM_BASE_URL"
fi
- name: Run task
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
PYTHONPATH: ''
run: |
echo "Running agent script: $AGENT_SCRIPT_URL"
# Download script if it's a URL
if [[ "$AGENT_SCRIPT_URL" =~ ^https?:// ]]; then
echo "Downloading agent script from URL..."
curl -sSL "$AGENT_SCRIPT_URL" -o /tmp/agent_script.py
AGENT_SCRIPT_PATH="/tmp/agent_script.py"
else
AGENT_SCRIPT_PATH="$AGENT_SCRIPT_URL"
fi
# Run with appropriate prompt argument
if [ -n "$PROMPT_LOCATION" ]; then
echo "Using prompt from: $PROMPT_LOCATION"
uv run python "$AGENT_SCRIPT_PATH" "$PROMPT_LOCATION"
else
echo "Using PROMPT_STRING (${#PROMPT_STRING} characters)"
uv run python "$AGENT_SCRIPT_PATH"
fi
- name: Upload logs as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: openhands-task-logs
path: |
*.log
output/
retention-days: 7
```
## Related Files
- [Agent Script](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/01_basic_action/agent_script.py)
- [Workflow File](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/01_basic_action/assign-reviews.yml)
- [Basic Action README](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/01_basic_action/README.md)
### PR Review
Source: https://docs.openhands.dev/sdk/guides/github-workflows/pr-review.md
> The reference workflow is available [here](#reference-workflow)!
Automatically review pull requests, providing feedback on code quality, security, and best practices. Reviews can be triggered in two ways:
- Requesting `openhands-agent` as a reviewer
- Adding the `review-this` label to the PR
The reference workflow triggers on either the "review-this" label or when the openhands-agent account is requested as a reviewer. In OpenHands organization repositories, openhands-agent has access, so this works as-is. In your own repositories, requesting openhands-agent will only work if that account is added as a collaborator or is part of a team with access. If you don't plan to grant access, use the label trigger instead, or change the condition to a reviewer handle that exists in your repo.
## Quick Start
```bash
# 1. Copy workflow to your repository
cp examples/03_github_workflows/02_pr_review/workflow.yml .github/workflows/pr-review.yml
# 2. Configure secrets in GitHub Settings → Secrets
# Add: LLM_API_KEY
# 3. (Optional) Create a "review-this" label in your repository
# Go to Issues → Labels → New label
# You can also trigger reviews by requesting "openhands-agent" as a reviewer
```
## Features
- **Fast Reviews** - Results posted on the PR in only 2 or 3 minutes
- **Comprehensive Analysis** - Analyzes the changes given the repository context. Covers code quality, security, best practices
- **GitHub Integration** - Posts comments directly to the PR
- **Customizable** - Add your own code review guidelines without forking
## Security
- Users with write access (maintainers) can trigger reviews by requesting `openhands-agent` as a reviewer or adding the `review-this` label.
- Maintainers need to read the PR to make sure it's safe to run.
## Customizing the Code Review
Instead of forking the `agent_script.py`, you can customize the code review behavior by adding a skill file to your repository. This is the **recommended approach** for customization.
### How It Works
The PR review agent uses skills from the [OpenHands/extensions](https://github.com/OpenHands/extensions) repository by default. You can add your project-specific guidelines alongside the default skill by creating a custom skill file.
**Skill paths**: Place skills in `.agents/skills/` (recommended). The legacy path `.openhands/skills/` is also supported. See [Skill Loading Precedence](/overview/skills#skill-loading-precedence) for details.
### Example: Custom Code Review Skill
Create `.agents/skills/custom-codereview-guide.md` in your repository:
```markdown
---
name: custom-codereview-guide
description: Project-specific review guidelines for MyProject
triggers:
- /codereview
---
# MyProject-Specific Review Guidelines
In addition to general code review practices, check for:
## Project Conventions
- All API endpoints must have OpenAPI documentation
- Database migrations must be reversible
- Feature flags required for new features
## Architecture Rules
- No direct database access from controllers
- All external API calls must go through the gateway service
## Communication Style
- Be direct and constructive
- Use GitHub suggestion syntax for code fixes
```
**Note**: These rules supplement the default `code-review` skill, not replace it.
**How skill merging works**: Using a unique name like `custom-codereview-guide` allows BOTH your custom skill AND the default `code-review` skill to be triggered by `/codereview`. When triggered, skill content is concatenated into the agent's context (public skills first, then your custom skills). There is no smart merging—if guidelines conflict, the agent sees both and must reconcile them.
If your skill has `name: code-review` (matching the public skill's name), it will completely **override** the default public skill instead of supplementing it.
**Migrating from override to supplement**: If you previously created a skill with `name: code-review` to override the default, rename it (e.g., to `my-project-review`) to receive guidelines from both skills instead.
### Benefits of Custom Skills
1. **No forking required**: Keep using the official SDK while customizing behavior
2. **Version controlled**: Your review guidelines live in your repository
3. **Easy updates**: SDK updates don't overwrite your customizations
4. **Team alignment**: Everyone uses the same review standards
5. **Composable**: Add project-specific rules alongside default guidelines
See the [software-agent-sdk's own custom-codereview-guide skill](https://github.com/OpenHands/software-agent-sdk/blob/main/.agents/skills/custom-codereview-guide.md) for a complete example.
## Reference Workflow
This example is available on GitHub: [examples/03_github_workflows/02_pr_review/](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/03_github_workflows/02_pr_review)
```yaml icon="yaml" expandable examples/03_github_workflows/02_pr_review/workflow.yml
---
# OpenHands PR Review Workflow
#
# To set this up:
# 1. Copy this file to .github/workflows/pr-review.yml in your repository
# 2. Add LLM_API_KEY to repository secrets
# 3. Customize the inputs below as needed
# 4. Commit this file to your repository
# 5. Trigger the review by either:
# - Adding the "review-this" label to any PR, OR
# - Requesting openhands-agent as a reviewer
#
# For more information, see:
# https://github.com/OpenHands/software-agent-sdk/tree/main/examples/03_github_workflows/02_pr_review
name: PR Review by OpenHands
on:
# Trigger when a label is added or a reviewer is requested
pull_request:
types: [labeled, review_requested]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-review:
# Run when review-this label is added OR openhands-agent is requested as reviewer
if: |
github.event.label.name == 'review-this' ||
github.event.requested_reviewer.login == 'openhands-agent'
runs-on: ubuntu-latest
steps:
- name: Checkout for composite action
uses: actions/checkout@v4
with:
repository: OpenHands/software-agent-sdk
# Use a specific version tag or branch (e.g., 'v1.0.0' or 'main')
ref: main
sparse-checkout: .github/actions/pr-review
- name: Run PR Review
uses: ./.github/actions/pr-review
with:
# LLM model(s) to use. Can be comma-separated for A/B testing
# - one model will be randomly selected per review
llm-model: anthropic/claude-sonnet-4-5-20250929
llm-base-url: ''
# [DEPRECATED] review-style is no longer used; standard and roasted are merged
# review-style: roasted
# Extensions version to use (version tag or branch name)
extensions-version: main
# Secrets
llm-api-key: ${{ secrets.LLM_API_KEY }}
github-token: ${{ secrets.GITHUB_TOKEN }}
```
### Action Inputs
| Input | Description | Required | Default |
|-------|-------------|----------|---------|
| `llm-model` | LLM model to use | Yes | - |
| `llm-base-url` | LLM base URL (optional) | No | `''` |
| `review-style` | **[DEPRECATED]** Previously chose between `standard` and `roasted`. Now ignored — the styles have been merged. | No | `roasted` |
| `extensions-version` | Git ref for extensions (tag, branch, or commit SHA) | No | `main` |
| `extensions-repo` | Extensions repository (owner/repo) | No | `OpenHands/extensions` |
| `llm-api-key` | LLM API key | Yes | - |
| `github-token` | GitHub token for API access | Yes | - |
## Related Files
- [PR Review Plugin](https://github.com/OpenHands/extensions/tree/main/plugins/pr-review) - Complete plugin with scripts and skills (in extensions repo)
- [Agent Script](https://github.com/OpenHands/extensions/blob/main/plugins/pr-review/scripts/agent_script.py) - Main review agent script
- [Prompt Template](https://github.com/OpenHands/extensions/blob/main/plugins/pr-review/scripts/prompt.py) - Review prompt template
- [Example Workflow](https://github.com/OpenHands/extensions/blob/main/plugins/pr-review/workflows/pr-review-by-openhands.yml) - Example workflow
- [Composite Action](https://github.com/OpenHands/extensions/blob/main/plugins/pr-review/action.yml) - Reusable GitHub Action
### TODO Management
Source: https://docs.openhands.dev/sdk/guides/github-workflows/todo-management.md
> The reference workflow is available [here](#reference-workflow)!
Scan your codebase for TODO comments and let the OpenHands Agent implement them, creating a pull request for each TODO and picking relevant reviewers based on code changes and file ownership
## Quick Start
```bash icon="terminal"
cp examples/03_github_workflows/03_todo_management/workflow.yml .github/workflows/todo-management.yml
```
Go to `GitHub Settings → Secrets` and add `LLM_API_KEY`
(get from https://docs.openhands.dev/openhands/usage/llms/openhands-llms).
Go to `Settings → Actions → General → Workflow permissions` and enable:
- `Read and write permissions`
- `Allow GitHub Actions to create and approve pull requests`
Trigger the agent by adding TODO comments into your code.
Example: `# TODO(openhands): Add input validation for user email`
The workflow is configurable and any identifier can be used in place of `TODO(openhands)`
## Features
- **Scanning** - Finds matching TODO comments with configurable identifiers and extracts the TODO description.
- **Implementation** - Sends the TODO description to the OpenHands Agent that automatically implements it
- **PR Management** - Creates feature branches, pull requests and picks most relevant reviewers
## Best Practices
- **Start Small** - Begin with `MAX_TODOS: 1` to test the workflow
- **Clear Descriptions** - Write descriptive TODO comments
- **Review PRs** - Always review the generated PRs before merging
## Reference Workflow
This example is available on GitHub: [examples/03_github_workflows/03_todo_management/](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/03_github_workflows/03_todo_management)
```yaml icon="yaml" expandable examples/03_github_workflows/03_todo_management/workflow.yml
---
# Automated TODO Management Workflow
# Make sure to replace and with
# appropriate values for your LLM setup.
#
# This workflow automatically scans for TODO(openhands) comments and creates
# pull requests to implement them using the OpenHands agent.
#
# Setup:
# 1. Add LLM_API_KEY to repository secrets
# 2. Ensure GITHUB_TOKEN has appropriate permissions
# 3. Make sure Github Actions are allowed to create and review PRs
# 4. Commit this file to .github/workflows/ in your repository
# 5. Configure the schedule or trigger manually
name: Automated TODO Management
on:
# Manual trigger
workflow_dispatch:
inputs:
max_todos:
description: Maximum number of TODOs to process in this run
required: false
default: '3'
type: string
todo_identifier:
description: TODO identifier to search for (e.g., TODO(openhands))
required: false
default: TODO(openhands)
type: string
# Trigger when 'automatic-todo' label is added to a PR
pull_request:
types: [labeled]
# Scheduled trigger (disabled by default, uncomment and customize as needed)
# schedule:
# # Run every Monday at 9 AM UTC
# - cron: "0 9 * * 1"
permissions:
contents: write
pull-requests: write
issues: write
jobs:
scan-todos:
runs-on: ubuntu-latest
# Only run if triggered manually or if 'automatic-todo' label was added
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.label.name == 'automatic-todo')
outputs:
todos: ${{ steps.scan.outputs.todos }}
todo-count: ${{ steps.scan.outputs.todo-count }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for better context
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Copy TODO scanner
run: |
cp examples/03_github_workflows/03_todo_management/scanner.py /tmp/scanner.py
chmod +x /tmp/scanner.py
- name: Scan for TODOs
id: scan
run: |
echo "Scanning for TODO comments..."
# Run the scanner and capture output
TODO_IDENTIFIER="${{ github.event.inputs.todo_identifier || 'TODO(openhands)' }}"
python /tmp/scanner.py . --identifier "$TODO_IDENTIFIER" > todos.json
# Count TODOs
TODO_COUNT=$(python -c \
"import json; data=json.load(open('todos.json')); print(len(data))")
echo "Found $TODO_COUNT $TODO_IDENTIFIER items"
# Limit the number of TODOs to process
MAX_TODOS="${{ github.event.inputs.max_todos || '3' }}"
if [ "$TODO_COUNT" -gt "$MAX_TODOS" ]; then
echo "Limiting to first $MAX_TODOS TODOs"
python -c "
import json
data = json.load(open('todos.json'))
limited = data[:$MAX_TODOS]
json.dump(limited, open('todos.json', 'w'), indent=2)
"
TODO_COUNT=$MAX_TODOS
fi
# Set outputs
echo "todos=$(cat todos.json | jq -c .)" >> $GITHUB_OUTPUT
echo "todo-count=$TODO_COUNT" >> $GITHUB_OUTPUT
# Display found TODOs
echo "## 📋 Found TODOs" >> $GITHUB_STEP_SUMMARY
if [ "$TODO_COUNT" -eq 0 ]; then
echo "No TODO(openhands) comments found." >> $GITHUB_STEP_SUMMARY
else
echo "Found $TODO_COUNT TODO(openhands) items:" \
>> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
python -c "
import json
data = json.load(open('todos.json'))
for i, todo in enumerate(data, 1):
print(f'{i}. **{todo[\"file\"]}:{todo[\"line\"]}** - ' +
f'{todo[\"description\"]}')
" >> $GITHUB_STEP_SUMMARY
fi
process-todos:
needs: scan-todos
if: needs.scan-todos.outputs.todo-count > 0
runs-on: ubuntu-latest
strategy:
matrix:
todo: ${{ fromJson(needs.scan-todos.outputs.todos) }}
max-parallel: 1 # Process one TODO at a time to avoid conflicts
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Switch to feature branch with TODO management files
run: |
git checkout openhands/todo-management-example
git pull origin openhands/todo-management-example
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install OpenHands dependencies
run: |
# Install OpenHands SDK and tools from git repository
uv pip install --system "openhands-sdk @ git+https://github.com/OpenHands/agent-sdk.git@main#subdirectory=openhands-sdk"
uv pip install --system "openhands-tools @ git+https://github.com/OpenHands/agent-sdk.git@main#subdirectory=openhands-tools"
- name: Copy agent files
run: |
cp examples/03_github_workflows/03_todo_management/agent_script.py agent.py
cp examples/03_github_workflows/03_todo_management/prompt.py prompt.py
chmod +x agent.py
- name: Configure Git
run: |
git config --global user.name "openhands-bot"
git config --global user.email \
"openhands-bot@users.noreply.github.com"
- name: Process TODO
env:
LLM_MODEL:
LLM_BASE_URL:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
TODO_FILE: ${{ matrix.todo.file }}
TODO_LINE: ${{ matrix.todo.line }}
TODO_DESCRIPTION: ${{ matrix.todo.description }}
PYTHONPATH: ''
run: |
echo "Processing TODO: $TODO_DESCRIPTION"
echo "File: $TODO_FILE:$TODO_LINE"
# Create a unique branch name for this TODO
BRANCH_NAME="todo/$(echo "$TODO_DESCRIPTION" | \
sed 's/[^a-zA-Z0-9]/-/g' | \
sed 's/--*/-/g' | \
sed 's/^-\|-$//g' | \
tr '[:upper:]' '[:lower:]' | \
cut -c1-50)"
echo "Branch name: $BRANCH_NAME"
# Create and switch to new branch (force create if exists)
git checkout -B "$BRANCH_NAME"
# Run the agent to process the TODO
# Stay in repository directory for git operations
# Create JSON payload for the agent
TODO_JSON=$(cat <&1 | tee agent_output.log
AGENT_EXIT_CODE=$?
set -e
echo "Agent exit code: $AGENT_EXIT_CODE"
echo "Agent output log:"
cat agent_output.log
# Show files in working directory
echo "Files in working directory:"
ls -la
# If agent failed, show more details
if [ $AGENT_EXIT_CODE -ne 0 ]; then
echo "Agent failed with exit code $AGENT_EXIT_CODE"
echo "Last 50 lines of agent output:"
tail -50 agent_output.log
exit $AGENT_EXIT_CODE
fi
# Check if any changes were made
cd "$GITHUB_WORKSPACE"
if git diff --quiet; then
echo "No changes made by agent, skipping PR creation"
exit 0
fi
# Commit changes
git add -A
git commit -m "Implement TODO: $TODO_DESCRIPTION
Automatically implemented by OpenHands agent.
Co-authored-by: openhands "
# Push branch
git push origin "$BRANCH_NAME"
# Create pull request
PR_TITLE="Implement TODO: $TODO_DESCRIPTION"
PR_BODY="## 🤖 Automated TODO Implementation
This PR automatically implements the following TODO:
**File:** \`$TODO_FILE:$TODO_LINE\`
**Description:** $TODO_DESCRIPTION
### Implementation
The OpenHands agent has analyzed the TODO and implemented the
requested functionality.
### Review Notes
- Please review the implementation for correctness
- Test the changes in your development environment
- The original TODO comment will be updated with this PR URL
once merged
---
*This PR was created automatically by the TODO Management workflow.*"
# Create PR using GitHub CLI or API
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/pulls" \
-d "{
\"title\": \"$PR_TITLE\",
\"body\": \"$PR_BODY\",
\"head\": \"$BRANCH_NAME\",
\"base\": \"${{ github.ref_name }}\"
}"
summary:
needs: [scan-todos, process-todos]
if: always()
runs-on: ubuntu-latest
steps:
- name: Generate Summary
run: |
echo "# 🤖 TODO Management Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
TODO_COUNT="${{ needs.scan-todos.outputs.todo-count || '0' }}"
echo "**TODOs Found:** $TODO_COUNT" >> $GITHUB_STEP_SUMMARY
if [ "$TODO_COUNT" -gt 0 ]; then
echo "**Processing Status:** ✅ Completed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Check the pull requests created for each TODO" \
"implementation." >> $GITHUB_STEP_SUMMARY
else
echo "**Status:** ℹ️ No TODOs found to process" \
>> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*Workflow completed at $(date)*" >> $GITHUB_STEP_SUMMARY
```
## Related Documentation
- [Agent Script](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/03_todo_management/agent_script.py)
- [Scanner Script](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/03_todo_management/scanner.py)
- [Workflow File](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/03_todo_management/workflow.yml)
- [Prompt Template](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/03_github_workflows/03_todo_management/prompt.py)
### Hello World
Source: https://docs.openhands.dev/sdk/guides/hello-world.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## Your First Agent
This is the most basic example showing how to set up and run an OpenHands agent.
### LLM Configuration
Configure the language model that will power your agent:
```python icon="python"
llm = LLM(
model=model,
api_key=SecretStr(api_key),
base_url=base_url, # Optional
service_id="agent"
)
```
### Select an Agent
Use the preset agent with common built-in tools:
```python icon="python"
agent = get_default_agent(llm=llm, cli_mode=True)
```
The default agent includes `BashTool`, `FileEditorTool`, etc.
For the complete list of available tools see the
[tools package source code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools).
### Start a Conversation
Start a conversation to manage the agent's lifecycle:
```python icon="python"
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message(
"Write 3 facts about the current project into FACTS.txt."
)
conversation.run()
```
### Expected Behavior
When you run this example:
1. The agent analyzes the current directory
2. Gathers information about the project
3. Creates `FACTS.txt` with 3 relevant facts
4. Completes and exits
Example output file:
```text icon="text" wrap
FACTS.txt
---------
1. This is a Python project using the OpenHands Software Agent SDK.
2. The project includes examples demonstrating various agent capabilities.
3. The SDK provides tools for file manipulation, bash execution, and more.
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/01_hello_world.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/01_hello_world.py)
```python icon="python" wrap expandable examples/01_standalone_sdk/01_hello_world.py
import os
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL", None),
)
agent = Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
],
)
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message("Write 3 facts about the current project into FACTS.txt.")
conversation.run()
print("All done!")
```
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Create custom tools for specialized needs
- **[Model Context Protocol (MCP)](/sdk/guides/mcp)** - Integrate external MCP servers
- **[Security Analyzer](/sdk/guides/security)** - Add security validation to tool usage
### Hooks
Source: https://docs.openhands.dev/sdk/guides/hooks.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## Overview
Hooks let you observe and customize key lifecycle moments in the SDK without forking core code. Typical uses include:
- Logging and analytics
- Emitting custom metrics
- Auditing or compliance
- Tracing and debugging
## Hook Types
| Hook | When it runs | Can block? |
|------|--------------|------------|
| PreToolUse | Before tool execution | Yes (exit 2) |
| PostToolUse | After tool execution | No |
| UserPromptSubmit | Before processing user message | Yes (exit 2) |
| Stop | When agent tries to finish | Yes (exit 2) |
| SessionStart | When conversation starts | No |
| SessionEnd | When conversation ends | No |
## Exit Codes
Command hooks (shell scripts) signal their result through their exit code —
[agent-based hooks](#agent-based-hooks) return a JSON decision instead. The SDK
matches the
[Claude Code hook contract](https://docs.claude.com/en/docs/claude-code/hooks):
- **`0` — success.** The operation proceeds. `stdout` is parsed as JSON for
structured output (`decision`, `reason`, `additionalContext`, `continue`).
- **`2` — block.** The operation is denied. For `PreToolUse` and
`UserPromptSubmit` this rejects the action; for `Stop` it prevents the
agent from finishing and the conversation continues. `stderr` / `reason`
is surfaced as feedback.
- **Any other non-zero exit code — non-blocking error.** `success` is set to
`False` and the error is logged via `HookExecutionEvent`, but the
operation still proceeds.
Only exit code `2` blocks. Exit code `1` (the conventional Unix failure
code) is treated as a non-blocking error. A hook intended to enforce a
policy must exit with `2`.
## Key Concepts
- Registration points: subscribe to events or attach pre/post hooks around LLM calls and tool execution
- Isolation: hooks run outside the agent loop logic, avoiding core modifications
- Composition: enable or disable hooks per environment (local vs. prod)
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/33_hooks](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/33_hooks/)
```python icon="python" expandable examples/01_standalone_sdk/33_hooks/main.py
"""OpenHands Agent SDK — Hooks Example
Demonstrates the OpenHands hooks system.
Hooks are shell scripts that run at key lifecycle events:
- PreToolUse: Block dangerous commands before execution
- PostToolUse: Log tool usage after execution
- UserPromptSubmit: Inject context into user messages
- Stop: Enforce task completion criteria
The hook scripts are in the scripts/ directory alongside this file.
"""
import os
import signal
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation
from openhands.sdk.hooks import HookConfig, HookDefinition, HookMatcher
from openhands.tools.preset.default import get_default_agent
signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
SCRIPT_DIR = Path(__file__).parent / "hook_scripts"
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Create temporary workspace with git repo
with tempfile.TemporaryDirectory() as tmpdir:
workspace = Path(tmpdir)
os.system(f"cd {workspace} && git init -q && echo 'test' > file.txt")
log_file = workspace / "tool_usage.log"
summary_file = workspace / "summary.txt"
# Configure hooks using the typed approach (recommended)
# This provides better type safety and IDE support
hook_config = HookConfig(
pre_tool_use=[
HookMatcher(
matcher="terminal",
hooks=[
HookDefinition(
command=str(SCRIPT_DIR / "block_dangerous.sh"),
timeout=10,
)
],
)
],
post_tool_use=[
HookMatcher(
matcher="*",
hooks=[
HookDefinition(
command=(f"LOG_FILE={log_file} {SCRIPT_DIR / 'log_tools.sh'}"),
timeout=5,
)
],
)
],
user_prompt_submit=[
HookMatcher(
hooks=[
HookDefinition(
command=str(SCRIPT_DIR / "inject_git_context.sh"),
)
],
)
],
stop=[
HookMatcher(
hooks=[
HookDefinition(
command=(
f"SUMMARY_FILE={summary_file} "
f"{SCRIPT_DIR / 'require_summary.sh'}"
),
)
],
)
],
)
# Alternative: You can also use .from_dict() for loading from JSON config files
# Example with a single hook matcher:
# hook_config = HookConfig.from_dict({
# "hooks": {
# "PreToolUse": [{
# "matcher": "terminal",
# "hooks": [{"command": "path/to/script.sh", "timeout": 10}]
# }]
# }
# })
agent = get_default_agent(llm=llm)
conversation = Conversation(
agent=agent,
workspace=str(workspace),
hook_config=hook_config,
)
# Demo 1: Safe command (PostToolUse logs it)
print("=" * 60)
print("Demo 1: Safe command - logged by PostToolUse")
print("=" * 60)
conversation.send_message("Run: echo 'Hello from hooks!'")
conversation.run()
if log_file.exists():
print(f"\n[Log: {log_file.read_text().strip()}]")
# Demo 2: Dangerous command (PreToolUse blocks it)
print("\n" + "=" * 60)
print("Demo 2: Dangerous command - blocked by PreToolUse")
print("=" * 60)
conversation.send_message("Run: rm -rf /tmp/test")
conversation.run()
# Demo 3: Context injection + Stop hook enforcement
print("\n" + "=" * 60)
print("Demo 3: Context injection + Stop hook")
print("=" * 60)
print("UserPromptSubmit injects git status; Stop requires summary.txt\n")
conversation.send_message(
"Check what files have changes, then create summary.txt describing the repo."
)
conversation.run()
if summary_file.exists():
print(f"\n[summary.txt: {summary_file.read_text()[:80]}...]")
print("\n" + "=" * 60)
print("Example Complete!")
print("=" * 60)
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nEXAMPLE_COST: {cost}")
```
### Hook Scripts
The example uses external hook scripts in the `hook_scripts/` directory:
```bash
#!/bin/bash
# PreToolUse hook: Block dangerous rm -rf commands
# Uses jq for JSON parsing (needed for nested fields like tool_input.command)
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // ""')
# Block rm -rf commands
if [[ "$command" =~ "rm -rf" ]]; then
echo '{"decision": "deny", "reason": "rm -rf commands are blocked for safety"}'
exit 2 # Exit code 2 = block the operation
fi
exit 0 # Exit code 0 = allow the operation
```
```bash
#!/bin/bash
# PostToolUse hook: Log all tool usage
# Uses OPENHANDS_TOOL_NAME env var (no jq/python needed!)
# LOG_FILE should be set by the calling script
LOG_FILE="${LOG_FILE:-/tmp/tool_usage.log}"
echo "[$(date)] Tool used: $OPENHANDS_TOOL_NAME" >> "$LOG_FILE"
exit 0
```
```bash
#!/bin/bash
# UserPromptSubmit hook: Inject git status when user asks about code changes
input=$(cat)
# Check if user is asking about changes, diff, or git
if echo "$input" | grep -qiE "(changes|diff|git|commit|modified)"; then
# Get git status if in a git repo
if git rev-parse --git-dir > /dev/null 2>&1; then
status=$(git status --short 2>/dev/null | head -10)
if [ -n "$status" ]; then
# Escape for JSON
escaped=$(echo "$status" | sed 's/"/\\"/g' | tr '\n' ' ')
echo "{\"additionalContext\": \"Current git status: $escaped\"}"
fi
fi
fi
exit 0
```
```bash
#!/bin/bash
# Stop hook: Require a summary.txt file before allowing agent to finish
# SUMMARY_FILE should be set by the calling script
SUMMARY_FILE="${SUMMARY_FILE:-./summary.txt}"
if [ ! -f "$SUMMARY_FILE" ]; then
echo '{"decision": "deny", "additionalContext": "Create summary.txt first."}'
exit 2
fi
exit 0
```
## Agent-based Hooks
Besides shell scripts, a hook can delegate its decision to an LLM-driven
sub-agent by setting `type="agent"`. The sub-agent receives the lifecycle event
as JSON, reasons about it semantically, and replies with a decision payload:
```json
{"decision": "allow" | "deny", "reason": ""}
```
This is useful when a syntactic blacklist is not enough — for example, a
`PreToolUse` reviewer that recognises `awk '{print}' /etc/passwd` as *reading a
sensitive file* even though no obvious keyword (`cat`, `/etc/shadow`) appears.
Key fields on an agent `HookDefinition`:
- `name` — a label for the hook; identifies it in logs, events, and its
`agent-hook:` metrics bucket.
- `system_prompt` — the policy the reviewer agent follows.
- `tools` — optional tools the reviewer may use (e.g. `["file_editor"]` to
inspect the workspace before deciding).
- `timeout` / `max_iterations` — bound how long the reviewer runs.
The agent hook runs in an isolated sub-conversation (its own ephemeral state, no
nested hooks), and its LLM spend is tracked under an `agent-hook:` usage
bucket that is merged back into the parent conversation's metrics. If no LLM is
available or the reviewer fails to produce a valid decision, the hook *falls
open* (allows) so it never blocks the agent on an internal error.
This example is available on GitHub: [examples/01_standalone_sdk/51_agent_hooks](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/51_agent_hooks/)
```python icon="python" expandable examples/01_standalone_sdk/51_agent_hooks/main.py
"""OpenHands Agent SDK — Agent-based Hooks Example
Demonstrates the `type="agent"` hook, which evaluates lifecycle events with an
LLM-driven sub-agent instead of a shell script. The hook agent receives the
event JSON, reasons about it semantically, and replies with a decision payload:
{"decision": "allow" | "deny", "reason": "..."}
Two demos:
- PreToolUse (security reviewer): inspects the INTENT of a terminal command,
not just its syntax. A command like `awk '{print}' /etc/passwd` would slip
past a blacklist of command names, but the agent hook recognises it as
reading a sensitive system file and denies it.
- Stop (quality reviewer): refuses to let the main agent finish until the
required deliverable exists in the workspace.
For shell-command hooks see ../33_hooks/main.py.
"""
import os
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.event.hook_execution import HookExecutionEvent
from openhands.sdk.hooks import HookConfig, HookDefinition, HookMatcher, HookType
from openhands.tools.preset.default import get_default_agent
# Keep the demo conversations short: a small per-run iteration cap means a hook
# that keeps denying (or a model that keeps retrying) fails fast instead of
# burning calls up to the default 500-iteration limit / CI subprocess timeout.
MAX_ITERATIONS = 10
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
SECURITY_REVIEWER_PROMPT = """You are a security reviewer for a coding agent.
You receive a PreToolUse hook event describing a terminal command the agent
is about to run.
Reason about the SEMANTIC INTENT of the command, not just its syntax. Block:
- reading sensitive system files (/etc/passwd, /etc/shadow, ~/.ssh/*, credentials)
- broad filesystem discovery outside the workspace (rg --files /etc, find /, ls /root)
- exfiltrating data over the network
- modifying system state (sudo, apt install, systemctl)
Allow ordinary inspection, builds, tests, and edits inside the workspace.
When unsure, prefer allow.
Reply with a single JSON object and nothing else:
{"decision": "allow" | "deny", "reason": ""}
"""
QUALITY_REVIEWER_PROMPT = """You are a quality reviewer enforcing task completion.
You receive a Stop hook event when the main agent tries to finish.
The task requires the file REPORT.md to exist in the workspace and contain at
least one bullet point describing the repository. Use the file_editor tool to
check whether the file exists and inspect its contents.
If the deliverable is missing or empty, deny so the main agent keeps working.
Otherwise allow.
Reply with a single JSON object and nothing else:
{"decision": "allow" | "deny", "reason": ""}
"""
def hook_logger(event) -> None:
"""Surface each hook decision so the demo output is self-explanatory."""
if not isinstance(event, HookExecutionEvent):
return
status = "DENY " if event.blocked else ("ALLOW" if event.success else "FAIL ")
line = f" [hook] {event.hook_event_type} {status} -> {event.hook_command}"
if event.reason:
line += f"\n reason: {event.reason}"
print(line)
def run_demo(workspace: Path, hook_config: HookConfig, message: str) -> float:
"""Run one demo in its own conversation and return its cost.
Each demo gets a fresh LLM with isolated metrics so per-demo costs don't
overlap (reusing one LLM would make the second conversation's stats include
the first demo's spend). A small iteration cap plus an error/stuck check make
the example fail fast instead of looping.
"""
demo_llm = llm.model_copy()
demo_llm.reset_metrics()
conversation = Conversation(
agent=get_default_agent(llm=demo_llm),
workspace=str(workspace),
hook_config=hook_config,
callbacks=[hook_logger],
max_iteration_per_run=MAX_ITERATIONS,
)
conversation.send_message(message)
conversation.run()
status = conversation.state.execution_status
if status in (
ConversationExecutionStatus.ERROR,
ConversationExecutionStatus.STUCK,
):
raise RuntimeError(
f"Demo conversation ended in {status.value} state "
"before reaching a decision."
)
return conversation.conversation_stats.get_combined_metrics().accumulated_cost
# Each demo runs in its own conversation with only the hook it needs. Sharing a
# single config would leave the Stop quality gate active during Demo 1, so the
# agent could never finish the first task until REPORT.md existed — coupling two
# unrelated demos and burning iterations.
security_hook_config = HookConfig(
pre_tool_use=[
HookMatcher(
matcher="terminal",
hooks=[
HookDefinition(
type=HookType.AGENT,
name="security-reviewer",
system_prompt=SECURITY_REVIEWER_PROMPT,
timeout=60,
max_iterations=3,
)
],
)
],
)
quality_hook_config = HookConfig(
stop=[
HookMatcher(
hooks=[
HookDefinition(
type=HookType.AGENT,
name="quality-reviewer",
system_prompt=QUALITY_REVIEWER_PROMPT,
tools=["file_editor"],
timeout=90,
max_iterations=5,
)
],
)
],
)
with tempfile.TemporaryDirectory() as tmpdir:
workspace = Path(tmpdir)
total_cost = 0.0
print("=" * 60)
print("Demo 1: PreToolUse — semantic deny")
print("=" * 60)
print(
"Asking the agent to read /etc/passwd via awk. The literal command\n"
"wouldn't match a syntactic blacklist (no `cat`, no `/etc/shadow`\n"
"keyword), but the security-reviewer agent should recognise the\n"
"intent and deny.\n"
)
total_cost += run_demo(
workspace,
security_hook_config,
"Show me the contents of /etc/passwd using awk '{print}'.",
)
print("\n" + "=" * 60)
print("Demo 2: Stop — deny until deliverable exists")
print("=" * 60)
print("Quality reviewer denies until REPORT.md exists with a bullet point.\n")
total_cost += run_demo(
workspace,
quality_hook_config,
"Write REPORT.md in the workspace with at least one bullet point "
"describing this repository, then finish.",
)
report = workspace / "REPORT.md"
if report.exists():
print(f"\n[REPORT.md preview: {report.read_text()[:120]!r}...]")
print("\n" + "=" * 60)
print("Example Complete!")
print("=" * 60)
print(f"\nEXAMPLE_COST: {total_cost}")
```
## Next Steps
- See also: [Metrics and Observability](/sdk/guides/metrics)
- Architecture: [Events](/sdk/arch/events)
### Iterative Refinement
Source: https://docs.openhands.dev/sdk/guides/iterative-refinement.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> The ready-to-run example is available [here](#ready-to-run-example)!
## Overview
Iterative refinement is a powerful pattern where multiple agents work together in a feedback loop:
1. A **refactoring agent** performs the main task (e.g., code conversion)
2. A **critique agent** evaluates the quality and provides detailed feedback
3. If quality is below threshold, the refactoring agent tries again with the feedback
This pattern is useful for:
- Code refactoring and modernization (e.g., COBOL to Java)
- Document translation and localization
- Content generation with quality requirements
- Any task requiring iterative improvement
## How It Works
### The Iteration Loop
The core workflow runs in a loop until quality threshold is met:
```python icon="python" wrap
QUALITY_THRESHOLD = 90.0
MAX_ITERATIONS = 5
while current_score < QUALITY_THRESHOLD and iteration < MAX_ITERATIONS:
# Phase 1: Refactoring agent converts COBOL to Java
refactoring_agent = get_default_agent(llm=llm, cli_mode=True)
refactoring_conversation = Conversation(
agent=refactoring_agent,
workspace=str(workspace_dir)
)
refactoring_conversation.send_message(refactoring_prompt)
refactoring_conversation.run()
# Phase 2: Critique agent evaluates the conversion
critique_agent = get_default_agent(llm=llm, cli_mode=True)
critique_conversation = Conversation(
agent=critique_agent,
workspace=str(workspace_dir)
)
critique_conversation.send_message(critique_prompt)
critique_conversation.run()
# Parse score and decide whether to continue
current_score = parse_critique_score(critique_file)
iteration += 1
```
### Critique Scoring
The critique agent evaluates each file on four dimensions (0-25 pts each):
- **Correctness**: Does the Java code preserve the original business logic?
- **Code Quality**: Is the code clean and following Java conventions?
- **Completeness**: Are all COBOL features properly converted?
- **Best Practices**: Does it use proper OOP, error handling, and documentation?
### Feedback Loop
When the score is below threshold, the refactoring agent receives the critique file location:
```python icon="python" wrap
if critique_file and critique_file.exists():
base_prompt += f"""
IMPORTANT: A previous refactoring attempt was evaluated and needs improvement.
Please review the critique at: {critique_file}
Address all issues mentioned in the critique to improve the conversion quality.
"""
```
## Customization
### Adjusting Thresholds
```python icon="python" wrap
QUALITY_THRESHOLD = 95.0 # Require higher quality
MAX_ITERATIONS = 10 # Allow more iterations
```
### Using Real COBOL Files
The example uses sample files, but you can use real files from the [AWS CardDemo project](https://github.com/aws-samples/aws-mainframe-modernization-carddemo/tree/main/app/cbl).
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/31_iterative_refinement.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/31_iterative_refinement.py)
```python icon="python" expandable examples/01_standalone_sdk/31_iterative_refinement.py
#!/usr/bin/env python3
"""
Iterative Refinement Example: COBOL to Java Refactoring
This example demonstrates an iterative refinement workflow where:
1. A refactoring agent converts COBOL files to Java files
2. A critique agent evaluates the quality of each conversion and provides scores
3. If the average score is below 90%, the process repeats with feedback
The workflow continues until the refactoring meets the quality threshold.
Source COBOL files can be obtained from:
https://github.com/aws-samples/aws-mainframe-modernization-carddemo/tree/main/app/cbl
"""
import os
import re
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Conversation
from openhands.tools.preset.default import get_default_agent
QUALITY_THRESHOLD = float(os.getenv("QUALITY_THRESHOLD", "90.0"))
MAX_ITERATIONS = int(os.getenv("MAX_ITERATIONS", "5"))
def setup_workspace() -> tuple[Path, Path, Path]:
"""Create workspace directories for the refactoring workflow."""
workspace_dir = Path(tempfile.mkdtemp())
cobol_dir = workspace_dir / "cobol"
java_dir = workspace_dir / "java"
critique_dir = workspace_dir / "critiques"
cobol_dir.mkdir(parents=True, exist_ok=True)
java_dir.mkdir(parents=True, exist_ok=True)
critique_dir.mkdir(parents=True, exist_ok=True)
return workspace_dir, cobol_dir, java_dir
def create_sample_cobol_files(cobol_dir: Path) -> list[str]:
"""Create sample COBOL files for demonstration.
In a real scenario, you would clone files from:
https://github.com/aws-samples/aws-mainframe-modernization-carddemo/tree/main/app/cbl
"""
sample_files = {
"CBACT01C.cbl": """ IDENTIFICATION DIVISION.
PROGRAM-ID. CBACT01C.
*****************************************************************
* Program: CBACT01C - Account Display Program
* Purpose: Display account information for a given account number
*****************************************************************
ENVIRONMENT DIVISION.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-ACCOUNT-ID PIC 9(11).
01 WS-ACCOUNT-STATUS PIC X(1).
01 WS-ACCOUNT-BALANCE PIC S9(13)V99.
01 WS-CUSTOMER-NAME PIC X(50).
01 WS-ERROR-MSG PIC X(80).
PROCEDURE DIVISION.
PERFORM 1000-INIT.
PERFORM 2000-PROCESS.
PERFORM 3000-TERMINATE.
STOP RUN.
1000-INIT.
INITIALIZE WS-ACCOUNT-ID
INITIALIZE WS-ACCOUNT-STATUS
INITIALIZE WS-ACCOUNT-BALANCE
INITIALIZE WS-CUSTOMER-NAME.
2000-PROCESS.
DISPLAY "ENTER ACCOUNT NUMBER: "
ACCEPT WS-ACCOUNT-ID
IF WS-ACCOUNT-ID = ZEROS
MOVE "INVALID ACCOUNT NUMBER" TO WS-ERROR-MSG
DISPLAY WS-ERROR-MSG
ELSE
DISPLAY "ACCOUNT: " WS-ACCOUNT-ID
DISPLAY "STATUS: " WS-ACCOUNT-STATUS
DISPLAY "BALANCE: " WS-ACCOUNT-BALANCE
END-IF.
3000-TERMINATE.
DISPLAY "PROGRAM COMPLETE".
""",
"CBCUS01C.cbl": """ IDENTIFICATION DIVISION.
PROGRAM-ID. CBCUS01C.
*****************************************************************
* Program: CBCUS01C - Customer Information Program
* Purpose: Manage customer data operations
*****************************************************************
ENVIRONMENT DIVISION.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-CUSTOMER-ID PIC 9(9).
01 WS-FIRST-NAME PIC X(25).
01 WS-LAST-NAME PIC X(25).
01 WS-ADDRESS PIC X(100).
01 WS-PHONE PIC X(15).
01 WS-EMAIL PIC X(50).
01 WS-OPERATION PIC X(1).
88 OP-ADD VALUE 'A'.
88 OP-UPDATE VALUE 'U'.
88 OP-DELETE VALUE 'D'.
88 OP-DISPLAY VALUE 'V'.
PROCEDURE DIVISION.
PERFORM 1000-MAIN-PROCESS.
STOP RUN.
1000-MAIN-PROCESS.
DISPLAY "CUSTOMER MANAGEMENT SYSTEM"
DISPLAY "A-ADD U-UPDATE D-DELETE V-VIEW"
ACCEPT WS-OPERATION
EVALUATE TRUE
WHEN OP-ADD
PERFORM 2000-ADD-CUSTOMER
WHEN OP-UPDATE
PERFORM 3000-UPDATE-CUSTOMER
WHEN OP-DELETE
PERFORM 4000-DELETE-CUSTOMER
WHEN OP-DISPLAY
PERFORM 5000-DISPLAY-CUSTOMER
WHEN OTHER
DISPLAY "INVALID OPERATION"
END-EVALUATE.
2000-ADD-CUSTOMER.
DISPLAY "ADDING NEW CUSTOMER"
ACCEPT WS-CUSTOMER-ID
ACCEPT WS-FIRST-NAME
ACCEPT WS-LAST-NAME
DISPLAY "CUSTOMER ADDED: " WS-CUSTOMER-ID.
3000-UPDATE-CUSTOMER.
DISPLAY "UPDATING CUSTOMER"
ACCEPT WS-CUSTOMER-ID
DISPLAY "CUSTOMER UPDATED: " WS-CUSTOMER-ID.
4000-DELETE-CUSTOMER.
DISPLAY "DELETING CUSTOMER"
ACCEPT WS-CUSTOMER-ID
DISPLAY "CUSTOMER DELETED: " WS-CUSTOMER-ID.
5000-DISPLAY-CUSTOMER.
DISPLAY "DISPLAYING CUSTOMER"
ACCEPT WS-CUSTOMER-ID
DISPLAY "ID: " WS-CUSTOMER-ID
DISPLAY "NAME: " WS-FIRST-NAME " " WS-LAST-NAME.
""",
"CBTRN01C.cbl": """ IDENTIFICATION DIVISION.
PROGRAM-ID. CBTRN01C.
*****************************************************************
* Program: CBTRN01C - Transaction Processing Program
* Purpose: Process financial transactions
*****************************************************************
ENVIRONMENT DIVISION.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-TRANS-ID PIC 9(16).
01 WS-TRANS-TYPE PIC X(2).
88 TRANS-CREDIT VALUE 'CR'.
88 TRANS-DEBIT VALUE 'DB'.
88 TRANS-TRANSFER VALUE 'TR'.
01 WS-TRANS-AMOUNT PIC S9(13)V99.
01 WS-FROM-ACCOUNT PIC 9(11).
01 WS-TO-ACCOUNT PIC 9(11).
01 WS-TRANS-DATE PIC 9(8).
01 WS-TRANS-STATUS PIC X(10).
PROCEDURE DIVISION.
PERFORM 1000-INITIALIZE.
PERFORM 2000-PROCESS-TRANSACTION.
PERFORM 3000-FINALIZE.
STOP RUN.
1000-INITIALIZE.
MOVE ZEROS TO WS-TRANS-ID
MOVE SPACES TO WS-TRANS-TYPE
MOVE ZEROS TO WS-TRANS-AMOUNT
MOVE "PENDING" TO WS-TRANS-STATUS.
2000-PROCESS-TRANSACTION.
DISPLAY "ENTER TRANSACTION TYPE (CR/DB/TR): "
ACCEPT WS-TRANS-TYPE
DISPLAY "ENTER AMOUNT: "
ACCEPT WS-TRANS-AMOUNT
EVALUATE TRUE
WHEN TRANS-CREDIT
PERFORM 2100-PROCESS-CREDIT
WHEN TRANS-DEBIT
PERFORM 2200-PROCESS-DEBIT
WHEN TRANS-TRANSFER
PERFORM 2300-PROCESS-TRANSFER
WHEN OTHER
MOVE "INVALID" TO WS-TRANS-STATUS
END-EVALUATE.
2100-PROCESS-CREDIT.
DISPLAY "PROCESSING CREDIT"
ACCEPT WS-TO-ACCOUNT
MOVE "COMPLETED" TO WS-TRANS-STATUS
DISPLAY "CREDIT APPLIED TO: " WS-TO-ACCOUNT.
2200-PROCESS-DEBIT.
DISPLAY "PROCESSING DEBIT"
ACCEPT WS-FROM-ACCOUNT
MOVE "COMPLETED" TO WS-TRANS-STATUS
DISPLAY "DEBIT FROM: " WS-FROM-ACCOUNT.
2300-PROCESS-TRANSFER.
DISPLAY "PROCESSING TRANSFER"
ACCEPT WS-FROM-ACCOUNT
ACCEPT WS-TO-ACCOUNT
MOVE "COMPLETED" TO WS-TRANS-STATUS
DISPLAY "TRANSFER FROM " WS-FROM-ACCOUNT " TO " WS-TO-ACCOUNT.
3000-FINALIZE.
DISPLAY "TRANSACTION STATUS: " WS-TRANS-STATUS.
""",
}
created_files = []
for filename, content in sample_files.items():
file_path = cobol_dir / filename
file_path.write_text(content)
created_files.append(filename)
return created_files
def get_refactoring_prompt(
cobol_dir: Path,
java_dir: Path,
cobol_files: list[str],
critique_file: Path | None = None,
) -> str:
"""Generate the prompt for the refactoring agent."""
files_list = "\n".join(f" - {f}" for f in cobol_files)
base_prompt = f"""Convert the following COBOL files to Java:
COBOL Source Directory: {cobol_dir}
Java Target Directory: {java_dir}
Files to convert:
{files_list}
Requirements:
1. Create a Java class for each COBOL program
2. Preserve the business logic and data structures
3. Use appropriate Java naming conventions (camelCase for methods, PascalCase)
4. Convert COBOL data types to appropriate Java types
5. Implement proper error handling with try-catch blocks
6. Add JavaDoc comments explaining the purpose of each class and method
7. In JavaDoc comments, include traceability to the original COBOL source using
the format: @source : (e.g., @source CBACT01C.cbl:73-77)
8. Create a clean, maintainable object-oriented design
9. Each Java file should be compilable and follow Java best practices
Read each COBOL file and create the corresponding Java file in the target directory.
"""
if critique_file and critique_file.exists():
base_prompt += f"""
IMPORTANT: A previous refactoring attempt was evaluated and needs improvement.
Please review the critique at: {critique_file}
Address all issues mentioned in the critique to improve the conversion quality.
"""
return base_prompt
def get_critique_prompt(
cobol_dir: Path,
java_dir: Path,
cobol_files: list[str],
) -> str:
"""Generate the prompt for the critique agent."""
files_list = "\n".join(f" - {f}" for f in cobol_files)
return f"""Evaluate the quality of COBOL to Java refactoring.
COBOL Source Directory: {cobol_dir}
Java Target Directory: {java_dir}
Original COBOL files:
{files_list}
Please evaluate each converted Java file against its original COBOL source.
For each file, assess:
1. Correctness: Does the Java code preserve the original business logic? (0-25 pts)
2. Code Quality: Is the code clean, readable, following Java conventions? (0-25 pts)
3. Completeness: Are all COBOL features properly converted? (0-25 pts)
4. Best Practices: Does it use proper OOP, error handling, documentation? (0-25 pts)
Create a critique report in the following EXACT format:
# COBOL to Java Refactoring Critique Report
## Summary
[Brief overall assessment]
## File Evaluations
### [Original COBOL filename]
- **Java File**: [corresponding Java filename or "NOT FOUND"]
- **Correctness**: [score]/25 - [brief explanation]
- **Code Quality**: [score]/25 - [brief explanation]
- **Completeness**: [score]/25 - [brief explanation]
- **Best Practices**: [score]/25 - [brief explanation]
- **File Score**: [total]/100
- **Issues to Address**:
- [specific issue 1]
- [specific issue 2]
...
[Repeat for each file]
## Overall Score
- **Average Score**: [calculated average of all file scores]
- **Recommendation**: [PASS if average >= 90, NEEDS_IMPROVEMENT otherwise]
## Priority Improvements
1. [Most critical improvement needed]
2. [Second priority]
3. [Third priority]
Save this report to: {java_dir.parent}/critiques/critique_report.md
"""
def parse_critique_score(critique_file: Path) -> float:
"""Parse the average score from the critique report."""
if not critique_file.exists():
return 0.0
content = critique_file.read_text()
# Look for "Average Score: X" pattern
patterns = [
r"\*\*Average Score\*\*:\s*(\d+(?:\.\d+)?)",
r"Average Score:\s*(\d+(?:\.\d+)?)",
r"average.*?(\d+(?:\.\d+)?)\s*(?:/100|%|$)",
]
for pattern in patterns:
match = re.search(pattern, content, re.IGNORECASE)
if match:
return float(match.group(1))
return 0.0
def run_iterative_refinement() -> None:
"""Run the iterative refinement workflow."""
# Setup
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
usage_id="iterative_refinement",
)
workspace_dir, cobol_dir, java_dir = setup_workspace()
critique_dir = workspace_dir / "critiques"
print(f"Workspace: {workspace_dir}")
print(f"COBOL Directory: {cobol_dir}")
print(f"Java Directory: {java_dir}")
print(f"Critique Directory: {critique_dir}")
print()
# Create sample COBOL files
cobol_files = create_sample_cobol_files(cobol_dir)
print(f"Created {len(cobol_files)} sample COBOL files:")
for f in cobol_files:
print(f" - {f}")
print()
critique_file = critique_dir / "critique_report.md"
current_score = 0.0
iteration = 0
while current_score < QUALITY_THRESHOLD and iteration < MAX_ITERATIONS:
iteration += 1
print("=" * 80)
print(f"ITERATION {iteration}")
print("=" * 80)
# Phase 1: Refactoring
print("\n--- Phase 1: Refactoring Agent ---")
refactoring_agent = get_default_agent(llm=llm, cli_mode=True)
refactoring_conversation = Conversation(
agent=refactoring_agent,
workspace=str(workspace_dir),
)
previous_critique = critique_file if iteration > 1 else None
refactoring_prompt = get_refactoring_prompt(
cobol_dir, java_dir, cobol_files, previous_critique
)
refactoring_conversation.send_message(refactoring_prompt)
refactoring_conversation.run()
print("Refactoring phase complete.")
# Phase 2: Critique
print("\n--- Phase 2: Critique Agent ---")
critique_agent = get_default_agent(llm=llm, cli_mode=True)
critique_conversation = Conversation(
agent=critique_agent,
workspace=str(workspace_dir),
)
critique_prompt = get_critique_prompt(cobol_dir, java_dir, cobol_files)
critique_conversation.send_message(critique_prompt)
critique_conversation.run()
print("Critique phase complete.")
# Parse the score
current_score = parse_critique_score(critique_file)
print(f"\nCurrent Score: {current_score:.1f}%")
if current_score >= QUALITY_THRESHOLD:
print(f"\n✓ Quality threshold ({QUALITY_THRESHOLD}%) met!")
else:
print(
f"\n✗ Score below threshold ({QUALITY_THRESHOLD}%). "
"Continuing refinement..."
)
# Final summary
print("\n" + "=" * 80)
print("ITERATIVE REFINEMENT COMPLETE")
print("=" * 80)
print(f"Total iterations: {iteration}")
print(f"Final score: {current_score:.1f}%")
print(f"Workspace: {workspace_dir}")
# List created Java files
print("\nCreated Java files:")
for java_file in java_dir.glob("*.java"):
print(f" - {java_file.name}")
# Show critique file location
if critique_file.exists():
print(f"\nFinal critique report: {critique_file}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"\nEXAMPLE_COST: {cost}")
if __name__ == "__main__":
run_iterative_refinement()
```
## Next Steps
- [TaskToolSet](/sdk/guides/task-tool-set) - Delegate work to specialized sub-agents
- [Custom Tools](/sdk/guides/custom-tools) - Create specialized tools for your workflow
### Exception Handling
Source: https://docs.openhands.dev/sdk/guides/llm-error-handling.md
The SDK normalizes common provider errors into typed, provider‑agnostic exceptions so your application can handle them consistently across OpenAI, Anthropic, Groq, Google, and others.
This guide explains when these errors occur and shows recommended handling patterns for both direct LLM usage and higher‑level agent/conversation flows.
## Why typed exceptions?
LLM providers format errors differently (status codes, messages, exception classes). The SDK maps those into stable types so client apps don’t depend on provider‑specific details. Typical benefits:
- One code path to handle auth, rate limits, timeouts, service issues, and bad requests
- Clear behavior when conversation history exceeds the context window
- Backward compatibility when you switch providers or SDK versions
## Quick start: Using agents and conversations
Agent-driven conversations are the common entry point. Exceptions from the underlying LLM calls bubble up from `conversation.run()` and `conversation.send_message(...)` when a condenser is not configured.
```python icon="python" wrap
from pydantic import SecretStr
from openhands.sdk import Agent, Conversation, LLM
from openhands.sdk.llm.exceptions import (
LLMError,
LLMAuthenticationError,
LLMRateLimitError,
LLMTimeoutError,
LLMServiceUnavailableError,
LLMBadRequestError,
LLMContextWindowExceedError,
)
llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("your-key"))
agent = Agent(llm=llm, tools=[])
conversation = Conversation(
agent=agent,
persistence_dir="./.conversations",
workspace=".",
)
try:
conversation.send_message(
"Continue the long analysis we started earlier…"
)
conversation.run()
except LLMContextWindowExceedError:
# Conversation is longer than the model’s context window
# Options:
# 1) Enable a condenser (recommended for long sessions)
# 2) Shorten inputs or reset conversation
print("Hit the context limit. Consider enabling a condenser.")
except LLMAuthenticationError:
print(
"Invalid or missing API credentials."
"Check your API key or auth setup."
)
except LLMRateLimitError:
print("Rate limit exceeded. Back off and retry later.")
except LLMTimeoutError:
print("Request timed out. Consider increasing timeout or retrying.")
except LLMServiceUnavailableError:
print("Service unavailable or connectivity issue. Retry with backoff.")
except LLMBadRequestError:
print("Bad request to provider. Validate inputs and arguments.")
except LLMError as e:
# Fallback for other SDK LLM errors (parsing/validation, etc.)
print(f"Unhandled LLM error: {e}")
```
### Avoiding context‑window errors with a condenser
If a condenser is configured, the SDK emits a condensation request event instead of raising `LLMContextWindowExceedError`. The agent will summarize older history and continue.
```python icon="python" focus={5-6, 9-14} wrap
from openhands.sdk.context.condenser import LLMSummarizingCondenser
condenser = LLMSummarizingCondenser(
llm=llm.model_copy(update={"usage_id": "condenser"}),
max_size=10,
keep_first=2,
)
agent = Agent(llm=llm, tools=[], condenser=condenser)
conversation = Conversation(
agent=agent,
persistence_dir="./.conversations",
workspace=".",
)
```
See the dedicated guide: [Context Condenser](/sdk/guides/context-condenser).
## Handling errors with direct LLM calls
The same exceptions are raised from both `LLM.completion()` and `LLM.responses()` paths, so you can share handlers.
### Example: Using `.completion()`
```python icon="python" wrap
from pydantic import SecretStr
from openhands.sdk import LLM
from openhands.sdk.llm import Message, TextContent
from openhands.sdk.llm.exceptions import (
LLMError,
LLMAuthenticationError,
LLMRateLimitError,
LLMTimeoutError,
LLMServiceUnavailableError,
LLMBadRequestError,
LLMContextWindowExceedError,
)
llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("your-key"))
try:
response = llm.completion([
Message.user([TextContent(text="Summarize our design doc")])
])
print(response.message)
except LLMContextWindowExceedError:
print("Context window exceeded. Consider enabling a condenser.")
except LLMAuthenticationError:
print("Invalid or missing API credentials.")
except LLMRateLimitError:
print("Rate limit exceeded. Back off and retry later.")
except LLMTimeoutError:
print("Request timed out. Consider increasing timeout or retrying.")
except LLMServiceUnavailableError:
print("Service unavailable or connectivity issue. Retry with backoff.")
except LLMBadRequestError:
print("Bad request to provider. Validate inputs and arguments.")
except LLMError as e:
print(f"Unhandled LLM error: {e}")
```
### Example: Using `.responses()`
```python icon="python" wrap
from pydantic import SecretStr
from openhands.sdk import LLM
from openhands.sdk.llm import Message, TextContent
from openhands.sdk.llm.exceptions import LLMError, LLMContextWindowExceedError
llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("your-key"))
try:
resp = llm.responses([
Message.user(
[TextContent(text="Write a one-line haiku about code.")]
)
])
print(resp.message)
except LLMContextWindowExceedError:
print("Context window exceeded. Consider enabling a condenser.")
except LLMError as e:
print(f"LLM error: {e}")
```
## Exception reference
All exceptions live under `openhands.sdk.llm.exceptions` unless noted.
| Category | Error | Description |
|--------|------|-------------|
| **Provider / transport (provider-agnostic)** | `LLMContextWindowExceedError` | Conversation exceeds the model’s context window. Without a condenser, thrown for both Chat and Responses paths. |
| | `LLMAuthenticationError` | Invalid or missing credentials (401/403 patterns). |
| | `LLMRateLimitError` | Provider rate limit exceeded. |
| | `LLMTimeoutError` | SDK or lower-level timeout while waiting for the provider. |
| | `LLMServiceUnavailableError` | Temporary connectivity or service outage (e.g., 5xx responses, connection issues). |
| | `LLMBadRequestError` | Client-side request issues (invalid parameters, malformed input). |
| **Response parsing / validation** | `LLMMalformedActionError` | Model returned a malformed action. |
| | `LLMNoActionError` | Model did not return an action when one was expected. |
| | `LLMResponseError` | Could not extract an action from the response. |
| | `FunctionCallConversionError` | Failed converting tool/function call payloads. |
| | `FunctionCallValidationError` | Tool/function call arguments failed validation. |
| | `FunctionCallNotExistsError` | Model referenced an unknown tool or function. |
| | `LLMNoResponseError` | Provider returned an empty or invalid response (rare; observed with some Gemini models). |
| **Cancellation** | `UserCancelledError` | A user explicitly aborted the operation. |
| | `OperationCancelled` | A running operation was cancelled programmatically. |
All of the above (except the explicit cancellation types) inherit from `LLMError`, so you can implement a catch‑all
for unexpected SDK LLM errors while still keeping fine‑grained handlers for the most common cases.
### LLM Fallback Strategy
Source: https://docs.openhands.dev/sdk/guides/llm-fallback.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
`FallbackStrategy` gives your agent automatic resilience: when the primary LLM fails with a transient error (rate limit, timeout, connection issue), the SDK tries alternate LLMs in order. Fallback is **per-call** — each new request always starts with the primary model.
## Basic Usage
Attach a `FallbackStrategy` to your primary `LLM`. The fallback LLMs are referenced by name from an [LLM Profile Store](/sdk/guides/llm-profile-store):
```python icon="python" wrap focus={16, 17, 21, 22, 23}
from pydantic import SecretStr
from openhands.sdk import LLM, LLMProfileStore
from openhands.sdk.llm import FallbackStrategy
# Menage persisted LLM profiles
# default store directory: .openhands/profiles
store = LLMProfileStore()
fallback_llm = LLM(
usage_id="fallback-1",
model="openai/gpt-4o",
api_key=SecretStr("your-openai-key"),
)
store.save("fallback-1", fallback_llm, include_secrets=True)
# Configure an LLM with a fallback strategy
primary_llm = LLM(
usage_id="agent-primary",
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr("your-api-key"),
fallback_strategy=FallbackStrategy(
fallback_llms=["fallback-1"],
),
)
```
## How It Works
1. The primary LLM handles the request as normal
2. If the call fails with a **transient error**, the `FallbackStrategy` kicks in and tries each fallback LLM in order
3. The first successful fallback response is returned to the caller
4. If all fallbacks fail, the original primary error is raised
5. Token usage and cost from fallback calls are **merged into the primary LLM's metrics**, so you get a unified view of total spend by model
Only transient errors trigger fallback.
Non-transient errors (e.g., authentication failures, bad requests) are raised immediately without trying fallbacks.
For a complete list of supported transient errors see the [source code](https://github.com/OpenHands/software-agent-sdk/blob/978dd7d1e3268331b7f8af514e7a7930f98eb8af/openhands-sdk/openhands/sdk/llm/fallback_strategy.py#L29)
## Multiple Fallback Levels
Chain as many fallback LLMs as you need. They are tried in list order:
```python icon="python" wrap focus={5-7}
llm = LLM(
usage_id="agent-primary",
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr(api_key),
fallback_strategy=FallbackStrategy(
fallback_llms=["fallback-1", "fallback-2"],
),
)
```
If the primary fails, `fallback-1` is tried. If that also fails, `fallback-2` is tried. If all fail, the primary error is raised.
## Custom Profile Store Directory
By default, fallback profiles are loaded from `.openhands/profiles`. You can point to a different directory:
```python icon="python" wrap focus={3}
FallbackStrategy(
fallback_llms=["fallback-1", "fallback-2"],
profile_store_dir="/path/to/my/profiles",
)
```
## Metrics
Fallback costs are automatically merged into the primary LLM's metrics. After a conversation, you can inspect exactly which models were used:
```python icon="python" wrap
# After running a conversation
metrics = llm.metrics
print(f"Total cost (including fallbacks): ${metrics.accumulated_cost:.6f}")
for usage in metrics.token_usages:
print(f" model={usage.model} prompt={usage.prompt_tokens} completion={usage.completion_tokens}")
```
Individual `token_usage` records carry the fallback model name, so you can distinguish which LLM produced each usage record.
## Use Cases
- **Rate limit handling** — When one provider throttles you, seamlessly switch to another
- **High availability** — Keep your agent running during provider outages
- **Cost optimization** — Try a cheaper model first and fall back to a more capable one on failure
- **Cross-provider redundancy** — Spread risk across Anthropic, OpenAI, Google, etc.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/39_llm_fallback.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/39_llm_fallback.py)
```python icon="python" expandable examples/01_standalone_sdk/39_llm_fallback.py
"""Example: Using FallbackStrategy for LLM resilience.
When the primary LLM fails with a transient error (rate limit, timeout, etc.),
FallbackStrategy automatically tries alternate LLMs in order. Fallback is
per-call: each new request starts with the primary model. Token usage and
cost from fallback calls are merged into the primary LLM's metrics.
This example:
1. Saves two fallback LLM profiles to a temporary store.
2. Configures a primary LLM with a FallbackStrategy pointing at those profiles.
3. Runs a conversation — if the primary model is unavailable, the agent
transparently falls back to the next available model.
"""
import os
import tempfile
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, Conversation, LLMProfileStore, Tool
from openhands.sdk.llm import FallbackStrategy
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Read configuration from environment
api_key = os.getenv("LLM_API_KEY", None)
assert api_key is not None, "LLM_API_KEY environment variable is not set."
base_url = os.getenv("LLM_BASE_URL")
primary_model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
# Use a temporary directory so this example doesn't pollute your home folder.
# In real usage you can omit base_dir to use the default (~/.openhands/profiles).
profile_store_dir = tempfile.mkdtemp()
store = LLMProfileStore(base_dir=profile_store_dir)
fallback_1 = LLM(
usage_id="fallback-1",
model=os.getenv("LLM_FALLBACK_MODEL_1", "openai/gpt-4o"),
api_key=SecretStr(os.getenv("LLM_FALLBACK_API_KEY_1", api_key)),
base_url=os.getenv("LLM_FALLBACK_BASE_URL_1", base_url),
)
store.save("fallback-1", fallback_1, include_secrets=True)
fallback_2 = LLM(
usage_id="fallback-2",
model=os.getenv("LLM_FALLBACK_MODEL_2", "openai/gpt-4o-mini"),
api_key=SecretStr(os.getenv("LLM_FALLBACK_API_KEY_2", api_key)),
base_url=os.getenv("LLM_FALLBACK_BASE_URL_2", base_url),
)
store.save("fallback-2", fallback_2, include_secrets=True)
print(f"Saved fallback profiles: {store.list()}")
# Configure the primary LLM with a FallbackStrategy
primary_llm = LLM(
usage_id="agent-primary",
model=primary_model,
api_key=SecretStr(api_key),
base_url=base_url,
fallback_strategy=FallbackStrategy(
fallback_llms=["fallback-1", "fallback-2"],
profile_store_dir=profile_store_dir,
),
)
# Run a conversation
agent = Agent(
llm=primary_llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
)
conversation = Conversation(agent=agent, workspace=os.getcwd())
conversation.send_message("Write a haiku about resilience into HAIKU.txt.")
conversation.run()
# Inspect metrics (includes any fallback usage)
metrics = primary_llm.metrics
print(f"Total cost (including fallbacks): ${metrics.accumulated_cost:.6f}")
print(f"Token usage records: {len(metrics.token_usages)}")
for usage in metrics.token_usages:
print(
f" model={usage.model}"
f" prompt={usage.prompt_tokens}"
f" completion={usage.completion_tokens}"
)
print(f"EXAMPLE_COST: {metrics.accumulated_cost}")
```
## Next Steps
- **[LLM Profile Store](/sdk/guides/llm-profile-store)** — Save and load LLM configurations as reusable profiles
- **[Model Routing](/sdk/guides/llm-routing)** — Route requests based on content (e.g., multimodal vs text-only)
- **[Exception Handling](/sdk/guides/llm-error-handling)** — Handle LLM errors in your application
- **[LLM Metrics](/sdk/guides/metrics)** — Track token usage and costs across models
### GPT-5 Preset (ApplyPatchTool)
Source: https://docs.openhands.dev/sdk/guides/llm-gpt5-preset.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
The GPT-5 preset is an opt-in agent preset for patch-based file editing. Calling `get_gpt5_agent(llm)` creates an agent that uses `ApplyPatchTool` instead of the standard `FileEditorTool`, while leaving the default preset unchanged for everything else.
## Ready-to-run Example
This example is available on GitHub: [examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py)
```python icon="python" expandable examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py
"""Example: Using GPT-5 preset with ApplyPatchTool for file editing.
This example demonstrates how to enable the GPT-5 preset, which swaps the
standard claude-style FileEditorTool for ApplyPatchTool.
Usage:
export OPENAI_API_KEY=... # or set LLM_API_KEY
# Optionally set a model (we recommend a mini variant if available):
# export LLM_MODEL=(
# "openai/gpt-5.2-mini" # or fallback: "openai/gpt-5.1-mini" or "openai/gpt-5.1"
# )
uv run python examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py
"""
import os
from openhands.sdk import LLM, Agent, Conversation
from openhands.tools.preset.gpt5 import get_gpt5_agent
# Resolve API key from env
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
if not api_key:
raise SystemExit("Please set OPENAI_API_KEY or LLM_API_KEY to run this example.")
model = os.getenv("LLM_MODEL", "openai/gpt-5.1")
base_url = os.getenv("LLM_BASE_URL", None)
llm = LLM(model=model, api_key=api_key, base_url=base_url)
# Build an agent with the GPT-5 preset (ApplyPatchTool-based editing)
agent: Agent = get_gpt5_agent(llm)
# Run in the current working directory
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message(
"Create (or update) a file named GPT5_DEMO.txt at the repo root with "
"two short lines describing this repository."
)
conversation.run()
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
You can optionally set `LLM_MODEL` to a GPT-5 variant such as `openai/gpt-5.2-mini`, `openai/gpt-5.1-mini`, or `openai/gpt-5.1`.
## What this preset changes
- Replaces the standard `FileEditorTool` with `ApplyPatchTool`
- Keeps the GPT-5-specific configuration explicit via `get_gpt5_agent(llm)`
- Leaves the default preset unchanged unless you opt into this one
## See Also
- **[LLM Reasoning](/sdk/guides/llm-reasoning)** - Learn more about newer OpenAI model behavior and the Responses API
- **[LLM Subscriptions](/sdk/guides/llm-subscriptions)** - Use supported OpenAI subscription-backed models without API credits
- **[Custom Tools](/sdk/guides/custom-tools)** - Understand the standard SDK tool system and presets
### Image Input
Source: https://docs.openhands.dev/sdk/guides/llm-image-input.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
### Sending Images
The LLM you use must support image inputs (`llm.vision_is_active()` need to be `True`).
Pass images along with text in the message content:
```python focus={14} icon="python" wrap
from openhands.sdk import ImageContent
IMAGE_URL = "https://github.com/OpenHands/OpenHands/raw/main/docs/static/img/logo.png"
conversation.send_message(
Message(
role="user",
content=[
TextContent(
text=(
"Study this image and describe the key elements you see. "
"Summarize them in a short paragraph and suggest a catchy caption."
)
),
ImageContent(image_urls=[IMAGE_URL]),
],
)
)
```
Works with multimodal LLMs like `GPT-4 Vision` and `Claude` with vision capabilities.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/17_image_input.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/17_image_input.py)
You can send images to multimodal LLMs for vision-based tasks like screenshot analysis, image processing, and visual QA:
```python icon="python" expandable examples/01_standalone_sdk/17_image_input.py
"""OpenHands Agent SDK — Image Input Example.
This script mirrors the basic setup from ``examples/01_hello_world.py`` but adds
vision support by sending an image to the agent alongside text instructions.
"""
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
ImageContent,
LLMConvertibleEvent,
Message,
TextContent,
get_logger,
)
from openhands.sdk.tool.spec import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM (vision-capable model)
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="vision-llm",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
assert llm.vision_is_active(), "The selected LLM model does not support vision input."
cwd = os.getcwd()
agent = Agent(
llm=llm,
tools=[
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
Tool(name=TaskTrackerTool.name),
],
)
llm_messages = [] # collect raw LLM messages for inspection
def conversation_callback(event: Event) -> None:
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
IMAGE_URL = "https://github.com/OpenHands/docs/raw/main/openhands/static/img/logo.png"
conversation.send_message(
Message(
role="user",
content=[
TextContent(
text=(
"Study this image and describe the key elements you see. "
"Summarize them in a short paragraph and suggest a catchy caption."
)
),
ImageContent(image_urls=[IMAGE_URL]),
],
)
)
conversation.run()
conversation.send_message(
"Great! Please save your description and caption into image_report.md."
)
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Hello World](/sdk/guides/hello-world)** - Learn basic conversation patterns
- **[Async Operations](/sdk/guides/convo-async)** - Process multiple images concurrently
### LLM Profile Store
Source: https://docs.openhands.dev/sdk/guides/llm-profile-store.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The `LLMProfileStore` class provides a centralized mechanism for managing `LLM` configurations.
Define a profile once, reuse it everywhere — across scripts, sessions, and even machines.
## Benefits
- **Persistence:** Saves model parameters (API keys, temperature, max tokens, ...) to a stable disk format.
- **Reusability:** Import a defined profile into any script or session with a single identifier.
- **Portability:** Simplifies the synchronization of model configurations across different machines or deployment environments.
## How It Works
### Create a Store
The store manages a directory of JSON profile files. By default it uses `~/.openhands/profiles`,
but you can point it anywhere.
```python icon="python" focus={3, 4, 6, 7}
from openhands.sdk import LLMProfileStore
# Default location: ~/.openhands/profiles
store = LLMProfileStore()
# Or bring your own directory
store = LLMProfileStore(base_dir="./my-profiles")
```
### Save a Profile
Got an LLM configured just right? Save it for later.
```python icon="python" focus={11, 12}
from pydantic import SecretStr
from openhands.sdk import LLM, LLMProfileStore
fast_llm = LLM(
usage_id="fast",
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr("sk-..."),
temperature=0.0,
)
store = LLMProfileStore()
store.save("fast", fast_llm)
```
Secret fields are **masked** by default for security, so the saved JSON keeps the field shape without exposing the
real value. Pass `include_secrets=True` to persist the actual secret values.
### Load a Profile
Next time you need that LLM, just load it:
```python icon="python"
# Same model, ready to go.
llm = store.load("fast")
```
### List and Clean Up
See what you've got, delete what you don't need:
```python icon="python" focus={1, 3, 4}
print(store.list()) # ['fast.json', 'creative.json']
store.delete("creative")
print(store.list()) # ['fast.json']
```
## Good to Know
Profile names must be simple filenames (no slashes, no dots at the start).
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/37_llm_profile_store/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/37_llm_profile_store/main.py)
This directory-based example ships with a pre-generated `profiles/fast.json` file created from a normal save, then creates a second profile at runtime in a temporary store.
```python icon="python" expandable examples/01_standalone_sdk/37_llm_profile_store/main.py
"""Example: Using LLMProfileStore to save and reuse LLM configurations.
This example ships with one pre-generated profile JSON file and creates another
profile at runtime. The checked-in profile comes from a normal save, so secrets
are masked instead of exposed and non-secret fields like `base_url` are kept
when present.
"""
import os
import shutil
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, LLMProfileStore
SCRIPT_DIR = Path(__file__).parent
EXAMPLE_PROFILES_DIR = SCRIPT_DIR / "profiles"
DEFAULT_MODEL = "anthropic/claude-sonnet-4-5-20250929"
profile_store_dir = Path(tempfile.mkdtemp()) / "profiles"
shutil.copytree(EXAMPLE_PROFILES_DIR, profile_store_dir)
store = LLMProfileStore(base_dir=profile_store_dir)
print(f"Seeded profiles: {store.list()}")
api_key = os.getenv("LLM_API_KEY")
creative_llm = LLM(
usage_id="creative",
model=os.getenv("LLM_MODEL", DEFAULT_MODEL),
api_key=SecretStr(api_key) if api_key else None,
base_url=os.getenv("LLM_BASE_URL"),
temperature=0.9,
)
# The checked-in fast.json was generated with a normal save, so its api_key is
# masked and any configured base_url would be preserved. This runtime profile
# also avoids persisting the real API key because secrets are masked by default.
store.save("creative", creative_llm)
creative_profile_json = (profile_store_dir / "creative.json").read_text()
if api_key is not None:
assert api_key not in creative_profile_json
print(f"Stored profiles: {store.list()}")
fast_profile = store.load("fast")
creative_profile = store.load("creative")
print(
"Loaded fast profile. "
f"usage: {fast_profile.usage_id}, "
f"model: {fast_profile.model}, "
f"temperature: {fast_profile.temperature}."
)
print(
"Loaded creative profile. "
f"usage: {creative_profile.usage_id}, "
f"model: {creative_profile.model}, "
f"temperature: {creative_profile.temperature}."
)
store.delete("creative")
print(f"After deletion: {store.list()}")
print("EXAMPLE_COST: 0")
```
## Mid-Conversation Model Switching
You can use a saved profile to switch the active model on a running conversation between turns. This is useful when you want to start with one model, then switch to another for later user messages while keeping the same conversation history and combined usage metrics.
This example is available on GitHub: [examples/01_standalone_sdk/44_model_switching_in_convo.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/44_model_switching_in_convo.py)
```python icon="python" expandable examples/01_standalone_sdk/44_model_switching_in_convo.py
"""Mid-conversation model switching.
Usage:
uv run examples/01_standalone_sdk/44_model_switching_in_convo.py
"""
import os
from openhands.sdk import LLM, Agent, LocalConversation, Tool
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
from openhands.tools.terminal import TerminalTool
LLM_API_KEY = os.getenv("LLM_API_KEY")
store = LLMProfileStore()
store.save(
"gpt",
LLM(model="openhands/gpt-5.2", api_key=LLM_API_KEY),
include_secrets=True,
)
agent = Agent(
llm=LLM(
model=os.getenv("LLM_MODEL", "openhands/claude-sonnet-4-5-20250929"),
api_key=LLM_API_KEY,
),
tools=[Tool(name=TerminalTool.name)],
)
conversation = LocalConversation(agent=agent, workspace=os.getcwd())
# Send a message with the default model
conversation.send_message("Say hello in one sentence.")
conversation.run()
# Switch to a different model and send another message
conversation.switch_profile("gpt")
print(f"Switched to: {conversation.agent.llm.model}")
conversation.send_message("Say goodbye in one sentence.")
conversation.run()
# Print metrics per model
for usage_id, metrics in conversation.state.stats.usage_to_metrics.items():
print(f" [{usage_id}] cost=${metrics.accumulated_cost:.6f}")
combined = conversation.state.stats.get_combined_metrics()
print(f"Total cost: ${combined.accumulated_cost:.6f}")
print(f"EXAMPLE_COST: {combined.accumulated_cost}")
store.delete("gpt")
```
## Agent-Driven LLM Switching
Saved profiles can also be exposed to the agent through the `switch_llm` built-in tool. The tool call switches the conversation's active profile after the current model finishes the tool call, so future model calls use the selected profile.
This example is available on GitHub: [examples/01_standalone_sdk/49_switch_llm_tool.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/49_switch_llm_tool.py)
## Next Steps
- **[LLM Registry](/sdk/guides/llm-registry)** - Manage multiple LLMs in memory at runtime
- **[LLM Routing](/sdk/guides/llm-routing)** - Automatically route to different models
- **[Exception Handling](/sdk/guides/llm-error-handling)** - Handle LLM errors gracefully
### Reasoning
Source: https://docs.openhands.dev/sdk/guides/llm-reasoning.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
View your agent's internal reasoning process for debugging, transparency, and understanding decision-making.
This guide demonstrates two provider-specific approaches:
1. **Anthropic Extended Thinking** - Claude's thinking blocks for complex reasoning
2. **OpenAI Reasoning via Responses API** - GPT's reasoning effort parameter
## Anthropic Extended Thinking
> A ready-to-run example is available [here](#ready-to-run-example-antrophic)!
Anthropic's Claude models support extended thinking, which allows you to access the model's internal reasoning process
through thinking blocks. This is useful for understanding how Claude approaches complex problems step-by-step.
### How It Works
The key to accessing thinking blocks is to register a callback that checks for `thinking_blocks` in LLM messages:
```python focus={6-11} icon="python" wrap
def show_thinking(event: Event):
if isinstance(event, LLMConvertibleEvent):
message = event.to_llm_message()
if hasattr(message, "thinking_blocks") and message.thinking_blocks:
print(f"🧠 Found {len(message.thinking_blocks)} thinking blocks")
for block in message.thinking_blocks:
if isinstance(block, RedactedThinkingBlock):
print(f"Redacted: {block.data}")
elif isinstance(block, ThinkingBlock):
print(f"Thinking: {block.thinking}")
conversation = Conversation(agent=agent, callbacks=[show_thinking])
```
### Understanding Thinking Blocks
Claude uses thinking blocks to reason through complex problems step-by-step. There are two types:
- **`ThinkingBlock`** ([related anthropic docs](https://docs.claude.com/en/docs/build-with-claude/extended-thinking#how-extended-thinking-works)): Contains the full reasoning text from Claude's internal thought process
- **`RedactedThinkingBlock`** ([related anthropic docs](https://docs.claude.com/en/docs/build-with-claude/extended-thinking#thinking-redaction)): Contains redacted or summarized thinking data
By registering a callback with your conversation, you can intercept and display these thinking blocks in real-time,
giving you insight into how Claude is approaching the problem.
### Ready-to-run Example Antrophic
This example is available on GitHub: [examples/01_standalone_sdk/22_anthropic_thinking.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/22_anthropic_thinking.py)
```python icon="python" expandable examples/01_standalone_sdk/22_anthropic_thinking.py
"""Example demonstrating Anthropic's extended thinking feature with thinking blocks."""
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
RedactedThinkingBlock,
ThinkingBlock,
)
from openhands.sdk.tool import Tool
from openhands.tools.terminal import TerminalTool
# Configure LLM for Anthropic Claude with extended thinking
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Setup agent with bash tool
agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])
# Callback to display thinking blocks
def show_thinking(event: Event):
if isinstance(event, LLMConvertibleEvent):
message = event.to_llm_message()
if hasattr(message, "thinking_blocks") and message.thinking_blocks:
print(f"\n🧠 Found {len(message.thinking_blocks)} thinking blocks")
for i, block in enumerate(message.thinking_blocks):
if isinstance(block, RedactedThinkingBlock):
print(f" Block {i + 1}: {block.data}")
elif isinstance(block, ThinkingBlock):
print(f" Block {i + 1}: {block.thinking}")
conversation = Conversation(
agent=agent, callbacks=[show_thinking], workspace=os.getcwd()
)
conversation.send_message(
"Calculate compound interest for $10,000 at 5% annually, "
"compounded quarterly for 3 years. Show your work.",
)
conversation.run()
conversation.send_message(
"Now, write that number to RESULTs.txt.",
)
conversation.run()
print("✅ Done!")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## OpenAI Reasoning via Responses API
> A ready-to-run example is available [here](#ready-to-run-example-openai)!
OpenAI's latest models (e.g., `GPT-5`, `GPT-5-Codex`) support a [Responses API](https://platform.openai.com/docs/api-reference/responses)
that provides access to the model's reasoning process.
By setting the `reasoning_effort` parameter, you can control how much reasoning the model performs and access those reasoning traces.
### How It Works
Configure the LLM with the `reasoning_effort` parameter to enable reasoning:
```python focus={5} icon="python" wrap
llm = LLM(
model="openhands/gpt-5-codex",
api_key=SecretStr(api_key),
base_url=base_url,
# Enable reasoning with effort level
reasoning_effort="high",
)
```
The `reasoning_effort` parameter can be set to `"none"`, `"low"`, `"medium"`, or `"high"` to control the amount of
reasoning performed by the model.
Then capture reasoning traces in your callback:
```python focus={3-4} icon="python" wrap
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
msg = event.to_llm_message()
llm_messages.append(msg)
```
### Understanding Reasoning Traces
The OpenAI Responses API provides reasoning traces that show how the model approached the problem.
These traces are available in the LLM messages and can be inspected to understand the model's decision-making process.
Unlike Anthropic's thinking blocks, OpenAI's reasoning is more tightly integrated with the response generation process.
### Ready-to-run Example OpenAI
This example is available on GitHub: [examples/01_standalone_sdk/23_responses_reasoning.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/23_responses_reasoning.py)
```python icon="python" expandable examples/01_standalone_sdk/23_responses_reasoning.py
"""
Example: Responses API path via LiteLLM in a Real Agent Conversation
- Runs a real Agent/Conversation to verify /responses path works
- Demonstrates rendering of Responses reasoning within normal conversation events
"""
from __future__ import annotations
import os
from pydantic import SecretStr
from openhands.sdk import (
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.llm import LLM
from openhands.tools.preset.default import get_default_agent
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
assert api_key, "Set LLM_API_KEY or OPENAI_API_KEY in your environment."
model = "openhands/gpt-5-mini-2025-08-07" # Use a model that supports Responses API
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
model=model,
api_key=SecretStr(api_key),
base_url=base_url,
# Responses-path options
reasoning_effort="high",
# Logging / behavior tweaks
log_completions=False,
usage_id="agent",
)
print("\n=== Agent Conversation using /responses path ===")
agent = get_default_agent(
llm=llm,
cli_mode=True, # disable browser tools for env simplicity
)
llm_messages = [] # collect raw LLM-convertible messages for inspection
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=os.getcwd(),
)
# Keep the tasks short for demo purposes
conversation.send_message("Read the repo and write one fact into FACTS.txt.")
conversation.run()
conversation.send_message("Now delete FACTS.txt.")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
ms = str(message)
print(f"Message {i}: {ms[:200]}{'...' if len(ms) > 200 else ''}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Use Cases
**Debugging**: Understand why the agent made specific decisions or took certain actions.
**Transparency**: Show users how the AI arrived at its conclusions.
**Quality Assurance**: Identify flawed reasoning patterns or logic errors.
**Learning**: Study how models approach complex problems.
## Next Steps
- **[Interactive Terminal](/sdk/guides/agent-interactive-terminal)** - Display reasoning in real-time
- **[LLM Metrics](/sdk/guides/metrics)** - Track token usage and performance
- **[Custom Tools](/sdk/guides/custom-tools)** - Add specialized capabilities
### LLM Registry
Source: https://docs.openhands.dev/sdk/guides/llm-registry.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
Use the LLM registry to manage multiple LLM providers and dynamically switch between models.
## Using the Registry
You can add LLMs to the registry using the `.add` method and retrieve them later using the `.get()` method.
```python icon="python" focus={9,10,13}
main_llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# define the registry and add an LLM
llm_registry = LLMRegistry()
llm_registry.add(main_llm)
...
# retrieve the LLM by its usage ID
llm = llm_registry.get("agent")
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/05_use_llm_registry.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/05_use_llm_registry.py)
```python icon="python" expandable examples/01_standalone_sdk/05_use_llm_registry.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
LLMRegistry,
Message,
TextContent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM using LLMRegistry
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
# Create LLM instance
main_llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Create LLM registry and add the LLM
llm_registry = LLMRegistry()
llm_registry.add(main_llm)
# Get LLM from registry
llm = llm_registry.get("agent")
# Tools
cwd = os.getcwd()
tools = [Tool(name=TerminalTool.name)]
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
conversation.send_message("Please echo 'Hello!'")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
print("=" * 100)
print(f"LLM Registry usage IDs: {llm_registry.list_usage_ids()}")
# Demonstrate getting the same LLM instance from registry
same_llm = llm_registry.get("agent")
print(f"Same LLM instance: {llm is same_llm}")
# Demonstrate requesting a completion directly from an LLM
resp = llm.completion(
messages=[
Message(role="user", content=[TextContent(text="Say hello in one word.")])
]
)
# Access the response content via OpenHands LLMResponse
msg = resp.message
texts = [c.text for c in msg.content if isinstance(c, TextContent)]
print(f"Direct completion response: {texts[0] if texts else str(msg)}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[LLM Routing](/sdk/guides/llm-routing)** - Automatically route to different models
- **[LLM Metrics](/sdk/guides/metrics)** - Track token usage and costs
### Model Routing
Source: https://docs.openhands.dev/sdk/guides/llm-routing.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
This feature is under active development and more default routers will be available in future releases.
> A ready-to-run example is available [here](#ready-to-run-example)!
### Using the built-in MultimodalRouter
Define the built-in rule-based `MultimodalRouter` that will route text-only requests to a secondary LLM and multimodal requests (with images) to the primary, multimodal-capable LLM:
```python icon="python" wrap focus={13-16}
primary_llm = LLM(
usage_id="agent-primary",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
secondary_llm = LLM(
usage_id="agent-secondary",
model="litellm_proxy/mistral/devstral-small-2507",
base_url="https://llm-proxy.eval.all-hands.dev",
api_key=SecretStr(api_key),
)
multimodal_router = MultimodalRouter(
usage_id="multimodal-router",
llms_for_routing={"primary": primary_llm, "secondary": secondary_llm},
)
```
You may define your own router by extending the `Router` class. See the [base class](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/router/base.py) for details.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/19_llm_routing.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/19_llm_routing.py)
Automatically route requests to different LLMs based on task characteristics to optimize cost and performance:
```python icon="python" expandable examples/01_standalone_sdk/19_llm_routing.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
ImageContent,
LLMConvertibleEvent,
Message,
TextContent,
get_logger,
)
from openhands.sdk.llm.router import MultimodalRouter
from openhands.tools.preset.default import get_default_tools
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "openhands/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
primary_llm = LLM(
usage_id="agent-primary",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
secondary_llm = LLM(
usage_id="agent-secondary",
model="openhands/devstral-small-2507",
base_url=base_url,
api_key=SecretStr(api_key),
)
multimodal_router = MultimodalRouter(
usage_id="multimodal-router",
llms_for_routing={"primary": primary_llm, "secondary": secondary_llm},
)
# Tools
tools = get_default_tools() # Use our default openhands experience
# Agent
agent = Agent(llm=multimodal_router, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=os.getcwd()
)
conversation.send_message(
message=Message(
role="user",
content=[TextContent(text=("Hi there, who trained you?"))],
)
)
conversation.run()
conversation.send_message(
message=Message(
role="user",
content=[
ImageContent(
image_urls=["http://images.cocodataset.org/val2017/000000039769.jpg"]
),
TextContent(text=("What do you see in the image above?")),
],
)
)
conversation.run()
conversation.send_message(
message=Message(
role="user",
content=[TextContent(text=("Who trained you as an LLM?"))],
)
)
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[LLM Registry](/sdk/guides/llm-registry)** - Manage multiple LLM configurations
- **[LLM Metrics](/sdk/guides/metrics)** - Track token usage and costs
### LLM Streaming
Source: https://docs.openhands.dev/sdk/guides/llm-streaming.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
This is currently only supported for the chat completion endpoint.
> A ready-to-run example is available [here](#ready-to-run-example)!
Enable real-time display of LLM responses as they're generated, token by token. This guide demonstrates how to use
streaming callbacks to process and display tokens as they arrive from the language model.
## How It Works
Streaming allows you to display LLM responses progressively as the model generates them, rather than waiting for the
complete response. This creates a more responsive user experience, especially for long-form content generation.
### Enable Streaming on LLM
Configure the LLM with streaming enabled:
```python focus={6} icon="python" wrap
llm = LLM(
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr(api_key),
base_url=base_url,
usage_id="stream-demo",
stream=True, # Enable streaming
)
```
### Define Token Callback
Create a callback function that processes streaming chunks as they arrive:
```python icon="python" wrap
def on_token(chunk: ModelResponseStream) -> None:
"""Process each streaming chunk as it arrives."""
choices = chunk.choices
for choice in choices:
delta = choice.delta
if delta is not None:
content = getattr(delta, "content", None)
if isinstance(content, str):
sys.stdout.write(content)
sys.stdout.flush()
```
The callback receives a `ModelResponseStream` object containing:
- **`choices`**: List of response choices from the model
- **`delta`**: Incremental content changes for each choice
- **`content`**: The actual text tokens being streamed
### Register Callback with Conversation
Pass your token callback to the conversation:
```python focus={3} icon="python" wrap
conversation = Conversation(
agent=agent,
token_callbacks=[on_token], # Register streaming callback
workspace=os.getcwd(),
)
```
The `token_callbacks` parameter accepts a list of callbacks, allowing you to register multiple handlers
if needed (e.g., one for display, another for logging).
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/29_llm_streaming.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/29_llm_streaming.py)
```python icon="python" expandable examples/01_standalone_sdk/29_llm_streaming.py
import os
import sys
from typing import Literal
from pydantic import SecretStr
from openhands.sdk import (
Conversation,
get_logger,
)
from openhands.sdk.llm import LLM
from openhands.sdk.llm.streaming import ModelResponseStream
from openhands.tools.preset.default import get_default_agent
logger = get_logger(__name__)
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("Set LLM_API_KEY or OPENAI_API_KEY in your environment.")
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
model=model,
api_key=SecretStr(api_key),
base_url=base_url,
usage_id="stream-demo",
stream=True,
)
agent = get_default_agent(llm=llm, cli_mode=True)
# Define streaming states
StreamingState = Literal["thinking", "content", "tool_name", "tool_args"]
# Track state across on_token calls for boundary detection
_current_state: StreamingState | None = None
def on_token(chunk: ModelResponseStream) -> None:
"""
Handle all types of streaming tokens including content,
tool calls, and thinking blocks with dynamic boundary detection.
"""
global _current_state
choices = chunk.choices
for choice in choices:
delta = choice.delta
if delta is not None:
# Handle thinking blocks (reasoning content)
reasoning_content = getattr(delta, "reasoning_content", None)
if isinstance(reasoning_content, str) and reasoning_content:
if _current_state != "thinking":
if _current_state is not None:
sys.stdout.write("\n")
sys.stdout.write("THINKING: ")
_current_state = "thinking"
sys.stdout.write(reasoning_content)
sys.stdout.flush()
# Handle regular content
content = getattr(delta, "content", None)
if isinstance(content, str) and content:
if _current_state != "content":
if _current_state is not None:
sys.stdout.write("\n")
sys.stdout.write("CONTENT: ")
_current_state = "content"
sys.stdout.write(content)
sys.stdout.flush()
# Handle tool calls
tool_calls = getattr(delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
tool_name = (
tool_call.function.name if tool_call.function.name else ""
)
tool_args = (
tool_call.function.arguments
if tool_call.function.arguments
else ""
)
if tool_name:
if _current_state != "tool_name":
if _current_state is not None:
sys.stdout.write("\n")
sys.stdout.write("TOOL NAME: ")
_current_state = "tool_name"
sys.stdout.write(tool_name)
sys.stdout.flush()
if tool_args:
if _current_state != "tool_args":
if _current_state is not None:
sys.stdout.write("\n")
sys.stdout.write("TOOL ARGS: ")
_current_state = "tool_args"
sys.stdout.write(tool_args)
sys.stdout.flush()
conversation = Conversation(
agent=agent,
workspace=os.getcwd(),
token_callbacks=[on_token],
)
story_prompt = (
"Tell me a long story about LLM streaming, write it a file, "
"make sure it has multiple paragraphs. "
)
conversation.send_message(story_prompt)
print("Token Streaming:")
print("-" * 100 + "\n")
conversation.run()
cleanup_prompt = (
"Thank you. Please delete the streaming story file now that I've read it, "
"then confirm the deletion."
)
conversation.send_message(cleanup_prompt)
print("Token Streaming:")
print("-" * 100 + "\n")
conversation.run()
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[LLM Error Handling](/sdk/guides/llm-error-handling)** - Handle streaming errors gracefully
- **[Custom Visualizer](/sdk/guides/convo-custom-visualizer)** - Build custom UI for streaming
- **[Interactive Terminal](/sdk/guides/agent-interactive-terminal)** - Display streams in terminal UI
### LLM Subscriptions
Source: https://docs.openhands.dev/sdk/guides/llm-subscriptions.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
OpenAI subscription is the first provider we support. More subscription providers will be added in future releases.
> A ready-to-run example is available [here](#ready-to-run-example)!
Use your existing ChatGPT Plus or Pro subscription to access OpenAI's Codex models without consuming API credits. The SDK handles OAuth authentication, credential caching, and automatic token refresh.
## How It Works
### Call subscription_login()
The `LLM.subscription_login()` class method handles the entire authentication flow:
```python icon="python"
from openhands.sdk import LLM
llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
```
On first run, this opens your browser for OAuth authentication with OpenAI. After successful login, credentials are cached locally in `~/.openhands/auth/` for future use.
### Use the LLM
Once authenticated, use the LLM with your agent as usual. The SDK automatically refreshes tokens when they expire.
## Supported Models
The following models are available via ChatGPT subscription:
| Model | Description |
|-------|-------------|
| `gpt-5.2-codex` | Latest Codex model (default) |
| `gpt-5.2` | GPT-5.2 base model |
| `gpt-5.1-codex-max` | High-capacity Codex model |
| `gpt-5.1-codex-mini` | Lightweight Codex model |
## Configuration Options
### Force Fresh Login
If your cached credentials become stale or you want to switch accounts:
```python icon="python"
llm = LLM.subscription_login(
vendor="openai",
model="gpt-5.2-codex",
force_login=True, # Always perform fresh OAuth login
)
```
### Disable Browser Auto-Open
For headless environments or when you prefer to manually open the URL:
```python icon="python"
llm = LLM.subscription_login(
vendor="openai",
model="gpt-5.2-codex",
open_browser=False, # Prints URL to console instead
)
```
### Check Subscription Mode
Verify that the LLM is using subscription-based authentication:
```python icon="python"
llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
print(f"Using subscription: {llm.is_subscription}") # True
```
## Credential Storage
Credentials are stored securely in `~/.openhands/auth/`. To clear cached credentials and force a fresh login, delete the files in this directory.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/35_subscription_login.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/35_subscription_login.py)
```python icon="python" expandable examples/01_standalone_sdk/35_subscription_login.py
"""Example: Using ChatGPT subscription for Codex models.
This example demonstrates how to use your ChatGPT Plus/Pro subscription
to access OpenAI's Codex models without consuming API credits.
The subscription_login() method handles:
- OAuth PKCE authentication flow
- Credential caching (~/.openhands/auth/)
- Automatic token refresh
Supported models:
- gpt-5.2-codex
- gpt-5.2
- gpt-5.1-codex-max
- gpt-5.1-codex-mini
Requirements:
- Active ChatGPT Plus or Pro subscription
- Browser access for initial OAuth login
"""
import os
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# First time: Opens browser for OAuth login
# Subsequent calls: Reuses cached credentials (auto-refreshes if expired)
llm = LLM.subscription_login(
vendor="openai",
model="gpt-5.2-codex", # or "gpt-5.2", "gpt-5.1-codex-max", "gpt-5.1-codex-mini"
)
# Alternative: Force a fresh login (useful if credentials are stale)
# llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex", force_login=True)
# Alternative: Disable auto-opening browser (prints URL to console instead)
# llm = LLM.subscription_login(
# vendor="openai", model="gpt-5.2-codex", open_browser=False
# )
# Verify subscription mode is active
print(f"Using subscription mode: {llm.is_subscription}")
# Use the LLM with an agent as usual
agent = Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
)
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message("List the files in the current directory.")
conversation.run()
print("Done!")
```
## Next Steps
- **[LLM Registry](/sdk/guides/llm-registry)** - Manage multiple LLM configurations
- **[LLM Streaming](/sdk/guides/llm-streaming)** - Stream responses token-by-token
- **[LLM Reasoning](/sdk/guides/llm-reasoning)** - Access model reasoning traces
### Model Context Protocol
Source: https://docs.openhands.dev/sdk/guides/mcp.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
***MCP*** (Model Context Protocol) is a protocol for exposing tools and resources to AI agents.
Read more about MCP [here](https://modelcontextprotocol.io/).
## Basic MCP Usage
> The ready-to-run basic MCP usage example is available [here](#ready-to-run-basic-mcp-usage-example)!
### MCP Configuration
Configure MCP servers using a dictionary with server names and connection details following [this configuration format](https://gofastmcp.com/clients/client#configuration-format)
```python mcp_config icon="python" wrap focus={3-10}
mcp_config = {
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"repomix": {
"command": "npx",
"args": ["-y", "repomix@1.4.2", "--mcp"]
},
}
}
```
### Tool Filtering
Use `filter_tools_regex` to control which MCP tools are available to the agent
```python filter_tools_regex focus={4-5} icon="python"
agent = Agent(
llm=llm,
tools=tools,
mcp_config=mcp_config,
filter_tools_regex="^(?!repomix)(.*)|^repomix.*pack_codebase.*$",
)
```
## MCP with OAuth
> The ready-to-run MCP with OAuth example is available [here](#ready-to-run-mcp-with-oauth-example)!
For MCP servers requiring OAuth authentication:
- Configure OAuth-enabled MCP servers by specifying the URL and auth type
- The SDK automatically handles the OAuth flow when first connecting
- When the agent first attempts to use an OAuth-protected MCP server's tools, the SDK initiates the OAuth flow via [FastMCP](https://gofastmcp.com/servers/auth/authentication)
- User will be prompted to authenticate via browser
- Access tokens are securely stored in `~/.fastmcp/oauth-mcp-client-cache/` and automatically refreshed by FastMCP as needed
```python mcp_config focus={5} icon="python" wrap
mcp_config = {
"mcpServers": {
"Notion": {
"url": "https://mcp.notion.com/mcp",
"auth": "oauth"
}
}
}
```
OAuth MCP servers require user interaction for the initial browser-based authentication. This means they are not suitable for fully automated/headless workflows. If you need headless access, check if the MCP provider offers API key authentication as an alternative.
## Ready-to-Run Basic MCP Usage Example
This example is available on GitHub: [examples/01_standalone_sdk/07_mcp_integration.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/07_mcp_integration.py)
Here's an example integrating MCP servers with an agent:
```python icon="python" expandable examples/01_standalone_sdk/07_mcp_integration.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
cwd = os.getcwd()
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
]
# Add MCP Tools
mcp_config = {
"mcpServers": {
"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]},
"repomix": {"command": "npx", "args": ["-y", "repomix@1.4.2", "--mcp"]},
}
}
# Agent
agent = Agent(
llm=llm,
tools=tools,
mcp_config=mcp_config,
# This regex filters out all repomix tools except pack_codebase
filter_tools_regex="^(?!repomix)(.*)|^repomix.*pack_codebase.*$",
)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
# Conversation
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
)
conversation.set_security_analyzer(LLMSecurityAnalyzer())
logger.info("Starting conversation with MCP integration...")
conversation.send_message(
"Read https://github.com/OpenHands/OpenHands and write 3 facts "
"about the project into FACTS.txt."
)
conversation.run()
conversation.send_message("Great! Now delete that file.")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Ready-to-Run MCP with OAuth Example
This example is available on GitHub: [examples/01_standalone_sdk/08_mcp_with_oauth.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/08_mcp_with_oauth.py)
```python icon="python" expandable examples/01_standalone_sdk/08_mcp_with_oauth.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
]
mcp_config = {
"mcpServers": {"Notion": {"url": "https://mcp.notion.com/mcp", "auth": "oauth"}}
}
agent = Agent(llm=llm, tools=tools, mcp_config=mcp_config)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
# Conversation
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
)
logger.info("Starting conversation with MCP integration...")
conversation.send_message("Can you search about OpenHands V1 in my notion workspace?")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
```
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Creating native SDK tools
- **[Security Analyzer](/sdk/guides/security)** - Securing tool usage
- **[MCP Package Source Code](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/mcp)** - MCP integration implementation
### Metrics Tracking
Source: https://docs.openhands.dev/sdk/guides/metrics.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
## Overview
The OpenHands SDK provides metrics tracking at two levels: individual LLM metrics and aggregated conversation-level costs:
- You can access detailed metrics from each LLM instance using the `llm.metrics` object to track token usage, costs, and latencies per API call.
- For a complete view, use `conversation.conversation_stats` to get aggregated costs across all LLMs used in a conversation, including the primary agent LLM and any auxiliary LLMs (such as those used by the [context condenser](/sdk/guides/context-condenser)).
## Getting Metrics from Individual LLMs
> A ready-to-run example is available [here](#ready-to-run-example-llm-metrics)!
Track token usage, costs, and performance metrics from LLM interactions:
### Accessing Individual LLM Metrics
Access metrics directly from the LLM object after running the conversation:
```python icon="python" focus={3-4}
conversation.run()
assert llm.metrics is not None
print(f"Final LLM metrics: {llm.metrics.model_dump()}")
```
The `llm.metrics` object is an instance of the [Metrics class](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/utils/metrics.py), which provides detailed information including:
- `accumulated_cost` - Total accumulated cost across all API calls
- `accumulated_token_usage` - Aggregated token usage with fields like:
- `prompt_tokens` - Number of input tokens processed
- `completion_tokens` - Number of output tokens generated
- `cache_read_tokens` - Cache hits (if supported by the model)
- `cache_write_tokens` - Cache writes (if supported by the model)
- `reasoning_tokens` - Reasoning tokens (for models that support extended thinking)
- `context_window` - Context window size used
- `costs` - List of individual cost records per API call
- `token_usages` - List of detailed token usage records per API call
- `response_latencies` - List of response latency metrics per API call
For more details on the available metrics and methods, refer to the [source code](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/utils/metrics.py).
### Ready-to-run Example (LLM metrics)
This example is available on GitHub: [examples/01_standalone_sdk/13_get_llm_metrics.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/13_get_llm_metrics.py)
```python icon="python" expandable examples/01_standalone_sdk/13_get_llm_metrics.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
cwd = os.getcwd()
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
]
# Add MCP Tools
mcp_config = {"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}}
# Agent
agent = Agent(llm=llm, tools=tools, mcp_config=mcp_config)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
# Conversation
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
)
logger.info("Starting conversation with MCP integration...")
conversation.send_message(
"Read https://github.com/OpenHands/OpenHands and write 3 facts "
"about the project into FACTS.txt."
)
conversation.run()
conversation.send_message("Great! Now delete that file.")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
assert llm.metrics is not None
print(
f"Conversation finished. Final LLM metrics with details: {llm.metrics.model_dump()}"
)
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Using LLM Registry for Cost Tracking
> A ready-to-run example is available [here](#ready-to-run-example-llm-registry)!
The [LLM Registry](/sdk/guides/llm-registry) allows you to maintain a centralized registry of LLM instances, each identified by a unique `usage_id`. This is particularly useful for tracking costs across different LLMs used in your application.
### How the LLM Registry Works
Each LLM is created with a unique `usage_id` (e.g., "agent", "condenser") that serves as its identifier in the registry. The registry maintains references to all LLM instances, allowing you to:
1. **Register LLMs**: Add LLM instances to the registry with `llm_registry.add(llm)`
2. **Retrieve LLMs**: Get LLM instances by their usage ID with `llm_registry.get("usage_id")`
3. **List Usage IDs**: View all registered usage IDs with `llm_registry.list_usage_ids()`
4. **Track Costs Separately**: Each LLM's metrics are tracked independently by its usage ID
This pattern is essential when using multiple LLMs in your application, such as having a primary agent LLM and a separate LLM for context condensing.
### Ready-to-run Example (LLM Registry)
This example is available on GitHub: [examples/01_standalone_sdk/05_use_llm_registry.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/05_use_llm_registry.py)
```python icon="python" expandable examples/01_standalone_sdk/05_use_llm_registry.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
LLMRegistry,
Message,
TextContent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM using LLMRegistry
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
# Create LLM instance
main_llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Create LLM registry and add the LLM
llm_registry = LLMRegistry()
llm_registry.add(main_llm)
# Get LLM from registry
llm = llm_registry.get("agent")
# Tools
cwd = os.getcwd()
tools = [Tool(name=TerminalTool.name)]
# Agent
agent = Agent(llm=llm, tools=tools)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
conversation.send_message("Please echo 'Hello!'")
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
print("=" * 100)
print(f"LLM Registry usage IDs: {llm_registry.list_usage_ids()}")
# Demonstrate getting the same LLM instance from registry
same_llm = llm_registry.get("agent")
print(f"Same LLM instance: {llm is same_llm}")
# Demonstrate requesting a completion directly from an LLM
resp = llm.completion(
messages=[
Message(role="user", content=[TextContent(text="Say hello in one word.")])
]
)
# Access the response content via OpenHands LLMResponse
msg = resp.message
texts = [c.text for c in msg.content if isinstance(c, TextContent)]
print(f"Direct completion response: {texts[0] if texts else str(msg)}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
### Getting Aggregated Conversation Costs
This example is available on GitHub: [examples/01_standalone_sdk/21_generate_extraneous_conversation_costs.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/21_generate_extraneous_conversation_costs.py)
Beyond individual LLM metrics, you can access aggregated costs for an entire conversation using `conversation.conversation_stats`. This is particularly useful when your conversation involves multiple LLMs, such as the main agent LLM and auxiliary LLMs for tasks like context condensing.
```python icon="python" expandable examples/01_standalone_sdk/21_generate_extraneous_conversation_costs.py
import os
from pydantic import SecretStr
from tabulate import tabulate
from openhands.sdk import (
LLM,
Agent,
Conversation,
LLMSummarizingCondenser,
Message,
TextContent,
get_logger,
)
from openhands.sdk.tool.spec import Tool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM using LLMRegistry
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
# Create LLM instance
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
llm_condenser = LLM(
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
usage_id="condenser",
)
# Tools
condenser = LLMSummarizingCondenser(llm=llm_condenser, max_size=10, keep_first=2)
cwd = os.getcwd()
agent = Agent(
llm=llm,
tools=[
Tool(
name=TerminalTool.name,
),
],
condenser=condenser,
)
conversation = Conversation(agent=agent, workspace=cwd)
conversation.send_message(
message=Message(
role="user",
content=[TextContent(text="Please echo 'Hello!'")],
)
)
conversation.run()
# Demonstrate extraneous costs part of the conversation
second_llm = LLM(
usage_id="demo-secondary",
model=model,
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)
conversation.llm_registry.add(second_llm)
completion_response = second_llm.completion(
messages=[Message(role="user", content=[TextContent(text="echo 'More spend!'")])]
)
# Access total spend
spend = conversation.conversation_stats.get_combined_metrics()
print("\n=== Total Spend for Conversation ===\n")
print(f"Accumulated Cost: ${spend.accumulated_cost:.6f}")
if spend.accumulated_token_usage:
print(f"Prompt Tokens: {spend.accumulated_token_usage.prompt_tokens}")
print(f"Completion Tokens: {spend.accumulated_token_usage.completion_tokens}")
print(f"Cache Read Tokens: {spend.accumulated_token_usage.cache_read_tokens}")
print(f"Cache Write Tokens: {spend.accumulated_token_usage.cache_write_tokens}")
spend_per_usage = conversation.conversation_stats.usage_to_metrics
print("\n=== Spend Breakdown by Usage ID ===\n")
rows = []
for usage_id, metrics in spend_per_usage.items():
rows.append(
[
usage_id,
f"${metrics.accumulated_cost:.6f}",
metrics.accumulated_token_usage.prompt_tokens
if metrics.accumulated_token_usage
else 0,
metrics.accumulated_token_usage.completion_tokens
if metrics.accumulated_token_usage
else 0,
]
)
print(
tabulate(
rows,
headers=["Usage ID", "Cost", "Prompt Tokens", "Completion Tokens"],
tablefmt="github",
)
)
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
### Understanding Conversation Stats
The `conversation.conversation_stats` object provides cost tracking across all LLMs used in a conversation. It is an instance of the [ConversationStats class](https://github.com/OpenHands/software-agent-sdk/blob/32e1e75f7e962033a8fd6773a672612e07bc8c0d/openhands-sdk/openhands/sdk/conversation/conversation_stats.py), which provides the following key features:
#### Key Methods and Properties
- **`usage_to_metrics`**: A dictionary mapping usage IDs to their respective `Metrics` objects. This allows you to track costs separately for each LLM used in the conversation.
- **`get_combined_metrics()`**: Returns a single `Metrics` object that aggregates costs across all LLMs used in the conversation. This gives you the total cost of the entire conversation.
- **`get_metrics_for_usage(usage_id: str)`**: Retrieves the `Metrics` object for a specific usage ID, allowing you to inspect costs for individual LLMs.
```python icon="python" focus={2, 6, 10}
# Get combined metrics for the entire conversation
total_metrics = conversation.conversation_stats.get_combined_metrics()
print(f"Total cost: ${total_metrics.accumulated_cost:.6f}")
# Get metrics for a specific LLM by usage ID
agent_metrics = conversation.conversation_stats.get_metrics_for_usage("agent")
print(f"Agent cost: ${agent_metrics.accumulated_cost:.6f}")
# Access all usage IDs and their metrics
for usage_id, metrics in conversation.conversation_stats.usage_to_metrics.items():
print(f"{usage_id}: ${metrics.accumulated_cost:.6f}")
```
## Next Steps
- **[Context Condenser](/sdk/guides/context-condenser)** - Learn about context management and how it uses separate LLMs
- **[LLM Routing](/sdk/guides/llm-routing)** - Optimize costs with smart routing between different models
### Observability & Tracing
Source: https://docs.openhands.dev/sdk/guides/observability.md
> A full setup example is available [below](#example-full-setup).
## Overview
The OpenHands SDK provides built-in OpenTelemetry (OTEL) tracing support, allowing you to monitor and debug your agent's execution in real time. You can send traces to any OTLP-compatible observability platform including:
- **[Laminar](https://laminar.sh/)** - AI-focused observability with trace inspection, signals, and browser session replay
- **[MLflow](https://mlflow.org/)** - Open-source AI platform with tracing, evaluation, and LLM governance
- **[Honeycomb](https://www.honeycomb.io/)** - High-performance distributed tracing
- **Any OTLP-compatible backend** - Including Jaeger, Datadog, New Relic, and more
The SDK automatically traces:
- Agent execution steps
- Tool calls and executions
- LLM API calls (via LiteLLM integration)
- Browser automation sessions (when using browser-use)
- Conversation lifecycle events
## Quick Start
Tracing is automatically enabled when you set the appropriate environment variables. The SDK detects the configuration on startup and initializes tracing without requiring code changes.
### Using Laminar
[Laminar](https://laminar.sh/) provides specialized AI observability features for OpenHands, including full conversation traces, browser session replay, and higher-level analysis features like signals.
```bash icon="terminal" wrap
# Set your Laminar project API key
export LMNR_PROJECT_API_KEY="your-laminar-api-key"
```
That's it. Run your agent code normally and traces will be sent to Laminar automatically.
For Laminar-specific walkthroughs, see the official docs for [OpenHands SDK tracing](https://laminar.sh/docs/tracing/integrations/openhands-sdk), [session replay for browser agents](https://laminar.sh/docs/tracing/browser-agent-observability), [viewing traces](https://laminar.sh/docs/platform/viewing-traces), and [signals](https://laminar.sh/docs/signals/introduction).
For **self-hosted Laminar** deployments, configure the instance base URL and ports:
```bash icon="terminal" wrap
export LMNR_PROJECT_API_KEY="your-laminar-api-key"
export LMNR_BASE_URL=http://localhost
export LMNR_HTTP_PORT=8000
export LMNR_GRPC_PORT=8001
```
If you need help deciding between Laminar Cloud and self-hosted Laminar, see Laminar's official [hosting options](https://laminar.sh/docs/hosting-options).
### Why use Laminar with OpenHands?
Laminar is especially useful when you want to understand how an agent behaved across one run or across many runs:
- Inspect a single run in transcript, tree, or timeline views to see prompts, tool calls, outputs, and nested agent activity. See Laminar's guide to [viewing traces](https://laminar.sh/docs/platform/viewing-traces).
- Watch browser automation alongside trace spans with [session replay for browser agents](https://laminar.sh/docs/tracing/browser-agent-observability).
- Define [signals](https://laminar.sh/docs/signals/introduction) to classify failures, user friction, or success patterns across many traces.
- Keep each OpenHands conversation grouped under a single session ID so multi-turn debugging is easier.
### Using OpenTelemetry (OTLP) Backends
For OpenTelemetry (OTLP) compatible backends, set the following environment variables:
```bash icon="terminal" wrap
# Required: Set the OTLP endpoint
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://your-otlp-backend/v1/traces"
# Required: Set additional headers required by your backend (format: comma-separated key=value pairs, URL-encoded)
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="key=value,key2=value2"
# Recommended: Explicitly set the protocol (most OTLP backends require HTTP)
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf" # use "grpc" only if your backend supports it
```
View the platform-specific configuration sections below for which values to use.
- **[MLflow](#mlflow-setup)** - Open-source AI platform with tracing, evaluation, and governance
- **[Honeycomb](#honeycomb-setup)** - High-performance distributed tracing
- **[Jaeger](#jaeger-setup)** - Open-source distributed tracing
- **[Generic OTLP Collector](#generic-otlp-collector)** - For other backends
### Alternative Configuration Methods
You can also use these alternative environment variable formats:
```bash icon="terminal" wrap
# Short form for endpoint
export OTEL_ENDPOINT="http://localhost:4317"
# Alternative header format
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20"
# Alternative protocol specification
export OTEL_EXPORTER="otlp_http" # or "otlp_grpc"
```
## How It Works
The OpenHands SDK uses Laminar as its OpenTelemetry instrumentation layer for built-in tracing support. When you set the environment variables, the SDK:
1. **Detects configuration**: Checks for OTEL environment variables on startup
2. **Initializes tracing**: Configures OpenTelemetry with the appropriate exporter
3. **Instruments code**: Automatically wraps key functions with tracing decorators
4. **Captures context**: Associates traces with conversation IDs for session grouping
5. **Exports spans**: Sends trace data to your configured backend
For Laminar-specific behavior and examples, see the official [OpenHands SDK integration guide](https://laminar.sh/docs/tracing/integrations/openhands-sdk).
### What Gets Traced
The SDK automatically instruments these components:
- **`agent.step`** - Each iteration of the agent's execution loop
- **Tool executions** - Individual tool calls with input/output capture
- **LLM calls** - API requests to language models via LiteLLM
- **Conversation lifecycle** - Message sending, conversation runs, and title generation
- **Browser sessions** - When using browser-use, captures session replays (Laminar only)
### Trace Hierarchy
Traces are organized hierarchically:
Each conversation gets its own session ID (the conversation UUID), allowing you to group all traces from a single conversation together in your observability platform.
In `tool.execute`, the tool calls are traced individually, such as `bash`, `file_editor`, or `task_tracker`.
## Configuration Reference
### Environment Variables
The SDK checks for these environment variables (in order of precedence):
| Variable | Description | Example |
|----------|-------------|---------|
| `LMNR_PROJECT_API_KEY` | Laminar project API key | `your-laminar-api-key` |
| `LMNR_BASE_URL` | Base URL for self-hosted Laminar | `http://localhost` |
| `LMNR_HTTP_PORT` | HTTP port for self-hosted Laminar | `8000` |
| `LMNR_GRPC_PORT` | gRPC port for self-hosted Laminar | `8001` |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full OTLP traces endpoint URL | `https://api.honeycomb.io:443/v1/traces` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint (traces path appended) | `http://localhost:4317` |
| `OTEL_ENDPOINT` | Short form endpoint | `http://localhost:4317` |
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | Authentication headers for traces | `x-honeycomb-team=YOUR_API_KEY` |
| `OTEL_EXPORTER_OTLP_HEADERS` | General authentication headers | `Authorization=Bearer%20TOKEN` |
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Protocol for traces endpoint | `http/protobuf`, `grpc` |
| `OTEL_EXPORTER` | Short form protocol | `otlp_http`, `otlp_grpc` |
### Header Format
Headers should be comma-separated `key=value` pairs with URL encoding for special characters:
```bash icon="terminal" wrap
# Single header
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="x-honeycomb-team=abc123"
# Multiple headers
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20abc123,X-Custom-Header=value"
```
### Protocol Options
The SDK supports both HTTP and gRPC protocols:
- **`http/protobuf`** or **`otlp_http`** - HTTP with protobuf encoding (recommended for most backends)
- **`grpc`** or **`otlp_grpc`** - gRPC with protobuf encoding (use only if your backend supports gRPC)
## Platform-Specific Configuration
### Laminar Setup
1. Sign up at [laminar.sh](https://laminar.sh/)
2. Create a project and copy your API key
3. Set the environment variable:
```bash icon="terminal" wrap
export LMNR_PROJECT_API_KEY="your-laminar-api-key"
```
**Self-hosted Laminar**: If you are running a self-hosted Laminar instance, configure its base URL and the HTTP and gRPC ports via environment variables:
```bash icon="terminal" wrap
export LMNR_PROJECT_API_KEY="your-laminar-api-key"
export LMNR_BASE_URL=http://localhost
export LMNR_HTTP_PORT=8000
export LMNR_GRPC_PORT=8001
```
**Browser session replay**: When using Laminar with browser-use tools, session replays are automatically captured, allowing you to see exactly what the browser automation did.
### OpenHands Enterprise Setup
If you are running OpenHands Enterprise (OHE), you can use the same Laminar integration without changing application code:
1. Complete the [OpenHands Enterprise quick start](/enterprise/quick-start).
2. Enable analytics in the Admin Console.
3. Deploy OHE and wait for the analytics service to become ready.
4. Open the Laminar UI at `https://analytics.`.
5. Create a Laminar project and an ingest-only API key.
6. Save that key as the **Laminar Project API Key** in the Admin Console.
7. Redeploy, then start a conversation in OpenHands.
In OHE, environment variables with `LMNR_` and `LLM_` prefixes are automatically forwarded to the SDK runtime. That makes it possible to configure Laminar endpoint settings such as `LMNR_BASE_URL`, `LMNR_PROJECT_API_KEY`, and `LMNR_FORCE_HTTP`, as well as the LLM that powers Laminar's own AI features (chat-with-trace, SQL-with-AI, and [signals](https://laminar.sh/docs/signals/introduction)) via `LLM_PROVIDER`, `LLM_BASE_URL`, and `LLM_MODEL_SMALL|MEDIUM|LARGE`.
`LLM_PROVIDER` accepts `gemini` (Laminar's default), `openai`, or `bedrock`. Set it to `openai` whenever you point `LLM_BASE_URL` at an OpenAI-compatible gateway (for example LiteLLM, OpenRouter, or vLLM), not just the public OpenAI API. For the full list of supported values, see Laminar's official [self-hosting configuration reference](https://laminar.sh/docs/self-hosting/configuration).
For the full OHE flow with screenshots and configuration examples, see [Analytics in OpenHands Enterprise](/enterprise/analytics).
### MLflow Setup
[MLflow](https://mlflow.org/) is an open-source AI platform that accepts OpenTelemetry traces out of the box, alongside evaluation and LLM governance capabilities.
1. Start your MLflow tracking server:
```bash icon="terminal" wrap
uvx mlflow server
```
For other deployment options (pip, Docker Compose, etc.), see [Set Up MLflow Server](https://mlflow.org/docs/latest/genai/getting-started/connect-environment/).
2. Configure the environment variables:
```bash icon="terminal" wrap
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5000"
export OTEL_EXPORTER_OTLP_HEADERS="x-mlflow-experiment-id=123" # Replace "123" with your MLflow experiment ID
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf"
```
Navigate to the MLflow UI (for example, `http://localhost:5000`), select the experiment, and open the **Traces** tab to view the recorded traces.
### Honeycomb Setup
1. Sign up at [honeycomb.io](https://www.honeycomb.io/)
2. Get your API key from the account settings
3. Configure the environment:
```bash icon="terminal" wrap
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://api.honeycomb.io:443/v1/traces"
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="x-honeycomb-team=YOUR_API_KEY"
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf"
```
### Jaeger Setup
For local development with Jaeger:
```bash icon="terminal" wrap
# Start Jaeger all-in-one container
docker run -d --name jaeger \
-p 4317:4317 \
-p 16686:16686 \
jaegertracing/all-in-one:latest
# Configure SDK
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:4317"
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="grpc"
```
Access the Jaeger UI at `http://localhost:16686`.
### Generic OTLP Collector
For other backends, use their OTLP endpoint:
```bash icon="terminal" wrap
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://your-otlp-collector:4317/v1/traces"
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20YOUR_TOKEN"
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf"
```
## Advanced Usage
### Disabling Observability
To disable tracing, simply unset all OTEL environment variables:
```bash icon="terminal" wrap
unset LMNR_PROJECT_API_KEY
unset OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
unset OTEL_EXPORTER_OTLP_ENDPOINT
unset OTEL_ENDPOINT
```
The SDK will automatically skip all tracing instrumentation with minimal overhead.
### Custom Span Attributes
The SDK automatically adds these attributes to spans:
- **`conversation_id`** - UUID of the conversation
- **`tool_name`** - Name of the tool being executed
- **`action.kind`** - Type of action being performed
- **`session_id`** - Groups all traces from one conversation
### Debugging Tracing Issues
If traces are not appearing in your observability platform:
1. **Verify environment variables**:
```python icon="python" wrap
import os
otel_endpoint = os.getenv('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')
otel_headers = os.getenv('OTEL_EXPORTER_OTLP_TRACES_HEADERS')
print(f"OTEL Endpoint: {otel_endpoint}")
print(f"OTEL Headers: {otel_headers}")
```
2. **Check SDK logs**: The SDK logs observability initialization at debug level:
```python icon="python" wrap
import logging
logging.basicConfig(level=logging.DEBUG)
```
3. **Test connectivity**: Ensure your application can reach the OTLP endpoint:
```bash icon="terminal" wrap
curl -v https://api.honeycomb.io:443/v1/traces
```
4. **Validate headers**: Check that authentication headers are properly URL-encoded.
For Laminar-specific troubleshooting, see Laminar's official [tracing troubleshooting guide](https://laminar.sh/docs/tracing/troubleshooting).
## Troubleshooting
### Traces Not Appearing
**Problem**: No traces showing up in your observability platform.
**Solutions**:
- Verify environment variables are set correctly
- Check network connectivity to the OTLP endpoint
- Ensure authentication headers are valid
- Look for SDK initialization logs at debug level
### High Trace Volume
**Problem**: Too many spans being generated.
**Solutions**:
- Configure sampling at the collector level
- For Laminar with non-browser tools, browser instrumentation is automatically disabled
- Use backend-specific filtering rules
### Performance Impact
**Problem**: Concerned about tracing overhead.
**Solutions**:
- Tracing has minimal overhead when properly configured
- Disable tracing in development by unsetting environment variables
- Use asynchronous exporters (default in most OTLP configurations)
## Example: Full Setup
This example is available on GitHub: [examples/01_standalone_sdk/27_observability_laminar.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/27_observability_laminar.py)
```python icon="python" expandable examples/01_standalone_sdk/27_observability_laminar.py
"""
Observability & Laminar example
This example demonstrates enabling OpenTelemetry tracing with Laminar in the
OpenHands SDK. Set LMNR_PROJECT_API_KEY and run the script to see traces.
"""
import os
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.tools.terminal import TerminalTool
# Tip: Set LMNR_PROJECT_API_KEY in your environment before running, e.g.:
# export LMNR_PROJECT_API_KEY="your-laminar-api-key"
# For non-Laminar OTLP backends, set OTEL_* variables instead.
# Configure LLM and Agent
api_key = os.getenv("LLM_API_KEY")
model = os.getenv("LLM_MODEL", "openhands/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
model=model,
api_key=SecretStr(api_key) if api_key else None,
base_url=base_url,
usage_id="agent",
)
agent = Agent(
llm=llm,
tools=[Tool(name=TerminalTool.name)],
)
# Create conversation and run a simple task
conversation = Conversation(agent=agent, workspace=".")
conversation.send_message("List the files in the current directory and print them.")
conversation.run()
print(
"All done! Check your Laminar dashboard for traces "
"(session is the conversation UUID)."
)
```
```bash Running the Example
export LMNR_PROJECT_API_KEY="your-laminar-api-key"
cd software-agent-sdk
uv run python examples/01_standalone_sdk/27_observability_laminar.py
```
## Next Steps
- **[Analytics in OpenHands Enterprise](/enterprise/analytics)** - Deploy Laminar inside OHE and send conversation traces automatically
- **[Metrics Tracking](/sdk/guides/metrics)** - Monitor token usage and costs alongside traces
- **[LLM Registry](/sdk/guides/llm-registry)** - Track multiple LLMs used in your application
- **[Security](/sdk/guides/security)** - Add security validation to your traced agent executions
### Parallel Tool Execution
Source: https://docs.openhands.dev/sdk/guides/parallel-tool-execution.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
**Experimental Feature**: Parallel tool execution is still experimental. By default, `tool_concurrency_limit` is set to `1` (sequential execution). Increasing this value may improve runtime performance, but use at your own risk. Concurrent execution can lead to race conditions or unexpected behavior for tools that share state.
## Overview
When an LLM requests multiple tool calls in a single response, the SDK can execute them concurrently rather than sequentially. This is controlled by the `tool_concurrency_limit` parameter on the `Agent` class.
**Benefits:**
- Faster execution when tools are independent (e.g., reading multiple files)
- Better utilization of I/O-bound operations
- Enables parallel sub-agent delegation
**When to use:**
- Running multiple read-only operations simultaneously
- Delegating to multiple sub-agents at once
- Executing independent API calls or file operations
## Configuration
### Setting the Concurrency Limit
Configure `tool_concurrency_limit` when creating an `Agent`:
```python icon="python" wrap focus={11, 17, 18}
import os
from openhands.sdk import Agent, LLM, Tool
from openhands.tools.terminal import TerminalTool
from openhands.tools.file_editor import FileEditorTool
llm = LLM(
model="anthropic/claude-sonnet-4-5-20250929",
api_key=os.getenv("LLM_API_KEY"),
)
agent = Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
# Execute up to 4 tools concurrently
tool_concurrency_limit=4,
)
```
### Concurrency Limit Values
| Value | Behavior |
|-------|----------|
| `1` (default) | Sequential execution—tools run one at a time |
| `2-8` | Moderate parallelism—good for most use cases |
| `>8` | High parallelism—only for I/O-heavy workloads with independent tools. Risk of resource exhaustion. |
The optimal value depends on your workload. Start with a lower value (e.g., `4`) and increase if needed.
## Use Cases
### Parallel File Operations
When reading multiple independent files:
```python icon="python" wrap
# Agent can read multiple files concurrently
agent = Agent(
llm=llm,
tools=[Tool(name=FileEditorTool.name)],
tool_concurrency_limit=4,
)
# The agent might request:
# - file_editor view /path/to/file1.py
# - file_editor view /path/to/file2.py
# - file_editor view /path/to/file3.py
# All three execute concurrently
```
### Parallel Sub-Agent Delegation
Combine with [TaskToolSet](/sdk/guides/task-tool-set) for parallel task processing:
```python icon="python" wrap focus={6,7,11}
from openhands.tools.task import TaskToolSet
# Orchestrator with high concurrency for delegation
main_agent = Agent(
llm=llm,
tools=[
Tool(name=TaskToolSet.name),
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
tool_concurrency_limit=8, # Handle multiple delegations at once
)
```
### Sub-Agents with Their Own Parallelism
Each sub-agent can have its own concurrency limit:
```python icon="python" wrap
def create_analysis_agent(llm: LLM) -> Agent:
"""Sub-agent that runs multiple analysis tools in parallel."""
return Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
tool_concurrency_limit=4, # Sub-agent also runs tools in parallel
)
```
## Considerations
### Thread Safety
Not all tools are safe to run concurrently. Be careful with:
- Tools that modify shared state
- Tools that write to the same files
- Tools with external side effects that depend on execution order
- Deadlocks when tools wait on resources held by other concurrent tools
- Resource exhaustion (file handles, memory, network connections)
### When NOT to Use
- Tools that must execute in a specific order
- Operations that modify the same files
- Workflows where one tool's output feeds into another
## Ready-to-run Example
This example demonstrates parallel tool execution with an orchestrator agent that delegates to multiple sub-agents, each running their own tools concurrently.
This example is available on GitHub: [examples/01_standalone_sdk/45_parallel_tool_execution.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/45_parallel_tool_execution.py)
```python icon="python" expandable examples/01_standalone_sdk/45_parallel_tool_execution.py
"""Example: Parallel tool execution with tool_concurrency_limit.
Demonstrates how setting tool_concurrency_limit on an Agent enables
concurrent tool execution within a single step. The orchestrator agent
delegates to multiple sub-agents in parallel, and each sub-agent itself
runs tools concurrently. This stress-tests the parallel execution system
end-to-end.
"""
import json
import os
import tempfile
from collections import defaultdict
from pathlib import Path
from openhands.sdk import (
LLM,
Agent,
AgentContext,
Conversation,
Tool,
register_agent,
)
from openhands.sdk.context import Skill
from openhands.tools.delegate import DelegationVisualizer
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task import TaskToolSet
from openhands.tools.terminal import TerminalTool
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
usage_id="parallel-tools-demo",
)
# --- Sub-agents ---
def create_code_analyst(llm: LLM) -> Agent:
"""Sub-agent that analyzes code structure."""
return Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
tool_concurrency_limit=4,
agent_context=AgentContext(
skills=[
Skill(
name="code_analysis",
content=(
"You analyze code structure. Use the terminal to count files, "
"lines of code, and list directory structure. Use the file "
"editor to read key files. Run multiple commands at once."
),
trigger=None,
)
],
system_message_suffix="Be concise. Report findings in bullet points.",
),
)
def create_doc_reviewer(llm: LLM) -> Agent:
"""Sub-agent that reviews documentation."""
return Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
tool_concurrency_limit=4,
agent_context=AgentContext(
skills=[
Skill(
name="doc_review",
content=(
"You review project documentation. Check README files, "
"docstrings, and inline comments. Use the terminal and "
"file editor to inspect files. Run multiple commands at once."
),
trigger=None,
)
],
system_message_suffix="Be concise. Report findings in bullet points.",
),
)
def create_dependency_checker(llm: LLM) -> Agent:
"""Sub-agent that checks project dependencies."""
return Agent(
llm=llm,
tools=[
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
tool_concurrency_limit=4,
agent_context=AgentContext(
skills=[
Skill(
name="dependency_check",
content=(
"You analyze project dependencies. Read pyproject.toml, "
"requirements files, and package configs. Summarize key "
"dependencies, their purposes, and any version constraints. "
"Run multiple commands at once."
),
trigger=None,
)
],
system_message_suffix="Be concise. Report findings in bullet points.",
),
)
# Register sub-agents
register_agent(
name="code_analyst",
factory_func=create_code_analyst,
description="Analyzes code structure, file counts, and directory layout.",
)
register_agent(
name="doc_reviewer",
factory_func=create_doc_reviewer,
description="Reviews documentation quality and completeness.",
)
register_agent(
name="dependency_checker",
factory_func=create_dependency_checker,
description="Checks and summarizes project dependencies.",
)
# --- Orchestrator agent with parallel execution ---
main_agent = Agent(
llm=llm,
tools=[
Tool(name=TaskToolSet.name),
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
],
tool_concurrency_limit=8,
)
persistence_dir = Path(tempfile.mkdtemp(prefix="parallel_example_"))
conversation = Conversation(
agent=main_agent,
workspace=Path.cwd(),
visualizer=DelegationVisualizer(name="Orchestrator"),
persistence_dir=persistence_dir,
)
print("=" * 80)
print("Parallel Tool Execution Stress Test")
print("=" * 80)
conversation.send_message("""
Analyze the current project by delegating to ALL THREE sub-agents IN PARALLEL:
1. code_analyst: Analyze the project structure (file counts, key directories)
2. doc_reviewer: Review documentation quality (README, docstrings)
3. dependency_checker: Check dependencies (pyproject.toml, requirements)
IMPORTANT: Delegate to all three agents at the same time using parallel tool calls.
Do NOT delegate one at a time - call all three delegate tools in a single response.
Once all three have reported back, write a consolidated summary to
project_analysis_report.txt in the working directory. The report should have
three sections (Code Structure, Documentation, Dependencies) with the key
findings from each sub-agent.
""")
conversation.run()
# --- Analyze persisted events for parallelism ---
#
# Walk the persistence directory to find all conversations (main + sub-agents).
# Each conversation stores events as event-*.json files under an events/ dir.
# We parse ActionEvent entries and group by llm_response_id — batches with 2+
# actions sharing the same response ID prove the LLM requested parallel calls
# and the executor handled them concurrently.
print("\n" + "=" * 80)
print("Parallelism Report")
print("=" * 80)
def _analyze_conversation(events_dir: Path) -> dict[str, list[str]]:
"""Return {llm_response_id: [tool_name, ...]} for multi-tool batches."""
batches: dict[str, list[str]] = defaultdict(list)
for event_file in sorted(events_dir.glob("event-*.json")):
data = json.loads(event_file.read_text())
if data.get("kind") == "ActionEvent" and "llm_response_id" in data:
batches[data["llm_response_id"]].append(data.get("tool_name", "?"))
return {rid: tools for rid, tools in batches.items() if len(tools) >= 2}
for events_dir in sorted(persistence_dir.rglob("events")):
if not events_dir.is_dir():
continue
# Derive a label from the path (main conv vs sub-agent)
rel = events_dir.parent.relative_to(persistence_dir)
is_subagent = "subagents" in rel.parts
label = "sub-agent" if is_subagent else "main agent"
multi_batches = _analyze_conversation(events_dir)
if multi_batches:
for resp_id, tools in multi_batches.items():
print(f"\n {label} batch ({resp_id[:16]}...):")
print(f" Parallel tools: {tools}")
else:
print(f"\n {label}: no parallel batches")
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nTotal cost: ${cost:.4f}")
print(f"EXAMPLE_COST: {cost:.4f}")
```
### Understanding the Example
The example demonstrates a two-level parallel execution pattern:
1. **Orchestrator Level**: The main agent has `tool_concurrency_limit=8`, allowing it to delegate to all three sub-agents simultaneously
2. **Sub-Agent Level**: Each sub-agent has `tool_concurrency_limit=4`, allowing them to run their own tools (terminal commands, file reads) in parallel
3. **Verification**: The example includes a parallelism report that analyzes persisted events to confirm tools actually ran concurrently
## Next Steps
- **[TaskToolSet](/sdk/guides/task-tool-set)** - Delegate work to specialized sub-agents
- **[Custom Tools](/sdk/guides/custom-tools)** - Create thread-safe custom tools
- **[Agent Architecture](/sdk/arch/agent)** - Understand the agent execution model
### Persistent Memory
Source: https://docs.openhands.dev/sdk/guides/persistent-memory.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
Persistent memory lets an agent keep what it learned -- root causes, environment quirks, project decisions, user preferences -- in plain Markdown files that are loaded back into the system prompt at the start of every new conversation. The agent maintains the files itself as it works, so it gets better at a project over time.
The feature is **opt-in and off by default**: without it, agents keep the existing `AGENTS.md`-based guidance and prompts are unchanged.
## Enabling Persistent Memory
Set `load_memory=True` on the agent's `AgentContext`:
```python focus={5} icon="python"
from openhands.sdk import Agent, AgentContext
agent = Agent(
llm=llm,
agent_context=AgentContext(load_memory=True),
tools=tools,
)
```
That single flag does two things:
1. At session start, the conversation reads the `MEMORY.md` indexes from both memory tiers and injects them into the system prompt as a `` block.
2. The system prompt's `` section switches to instructions that teach the agent where its memory lives and how to maintain it.
## The Two Tiers
| Tier | Location | Contents |
|------|----------|----------|
| **User** | `~/.openhands/memory/` | Knowledge and preferences that apply across all projects |
| **Project** | `/.openhands/memory/` | Knowledge specific to the current repository |
Each tier contains:
- **`MEMORY.md`** -- a curated index of durable facts. This is the only file injected into the prompt, so the agent is instructed to keep it small and high-value.
- **Daily logs (`YYYY-MM-DD.md`)** -- free-form working notes. They are never injected automatically; the agent reads them on demand with its file tools when `MEMORY.md` points to them.
The files are plain Markdown: you can review, edit, or delete them at any time, and a project team can even commit `.openhands/memory/` to share agent-learned knowledge.
## What Gets Injected
At the start of each opted-in conversation, the resolved memory appears in the system prompt like this (user tier first, then project tier):
```text wrap
The content below comes from memory files on disk and has NOT been verified by OpenHands.
...
# User memory (~/.openhands/memory/MEMORY.md)
- prefers uv over pip for Python tooling
# Project memory (.openhands/memory/MEMORY.md)
- the API uses cursor-based pagination
```
A few properties worth knowing:
- **Size budget**: the combined indexes are capped at ~6,000 characters. When the budget is exceeded, whole lines are dropped from the top of each over-budget tier (the oldest content) -- partial lines never survive, the tier headers are always kept, and a truncation notice appears under the header of any tier that lost lines. Keep indexes curated.
- **Untrusted by design**: the injected block is wrapped in ``. Memory files are typically agent-written, but anyone with access to the workspace or repository can edit or commit them (a cloned repo may ship a `.openhands/memory/MEMORY.md`), so the agent is told they may contain prompt injection, and to treat them as unverified hints, never as authoritative instructions.
- **Never persisted**: the resolved memory text is re-read from disk each session and is excluded from conversation persistence (`base_state.json`) and API payloads.
- **Best-effort**: an unreadable memory file logs a warning and the conversation starts normally without it.
## How the Agent Maintains Memory
When memory is enabled, the system prompt instructs the agent to:
- record durable, broadly useful facts in `MEMORY.md` near the end of a task (creating the directories and files if missing), and put long detail in daily logs;
- merge duplicates, prune stale entries, and keep the indexes concise;
- never record secrets or credentials, and skip facts that are trivially re-discoverable (directory listings, obvious commands);
- keep `AGENTS.md` for instructions addressed to *any* agent working in the repository -- memory is for what the agent learned itself.
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/55_persistent_memory.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/55_persistent_memory.py)
```python icon="python" expandable examples/01_standalone_sdk/55_persistent_memory.py
"""Opt-in persistent memory across sessions (two-tier ``MEMORY.md``).
With ``AgentContext(load_memory=True)`` a conversation loads the ``MEMORY.md``
indexes from ``~/.openhands/memory/`` (user tier) and
``/.openhands/memory/`` (project tier) into the system prompt at
session start (the ```` block), and the system prompt
instructs the agent to maintain those files as it works.
This example runs two conversations over the same workspace:
1. Session 1 asks the agent to record a project decision in its persistent
project memory -- the agent writes ``.openhands/memory/MEMORY.md`` itself.
2. Session 2 is a brand-new conversation: the saved memory is injected into
its system prompt automatically, so the agent already knows the decision
without being told again.
Memory is opt-in and off by default. The example only writes inside a
temporary workspace; the user tier under ``~`` is left untouched.
"""
import os
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger
from openhands.sdk.event import SystemPromptEvent
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "gpt-5.5")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
tools = [Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)]
# Opt in to persistent memory. Everything else is automatic: the conversation
# resolves the MEMORY.md indexes at session start, and the system prompt tells
# the agent how to maintain them.
agent_context = AgentContext(load_memory=True)
with tempfile.TemporaryDirectory() as workspace:
memory_index = Path(workspace) / ".openhands" / "memory" / "MEMORY.md"
print("=" * 100)
print("Session 1: ask the agent to record a decision in project memory.")
agent = Agent(llm=llm, tools=tools, agent_context=agent_context)
conversation = Conversation(agent=agent, workspace=workspace)
conversation.send_message(
"We just decided to use `uv` (not pip/poetry) for all Python "
"dependency management in this project. Record that decision in your "
"persistent project memory so future sessions know it."
)
conversation.run()
conversation.close()
print("=" * 100)
print(f"Project memory after session 1 ({memory_index}):")
if memory_index.exists():
print(memory_index.read_text())
else:
print("(the agent did not create the memory index)")
print("=" * 100)
print("Session 2: a brand-new conversation over the same workspace.")
agent = Agent(llm=llm, tools=tools, agent_context=agent_context)
conversation = Conversation(agent=agent, workspace=workspace)
conversation.send_message(
"Which tool do we use for Python dependency management in this "
"project? Answer from what you already know about the project."
)
conversation.run()
# The recorded memory was injected into session 2's system prompt as the
# block -- show it to make the mechanism visible.
system_prompt_event = next(
event
for event in conversation.state.events
if isinstance(event, SystemPromptEvent)
)
dynamic_context = system_prompt_event.dynamic_context
injected = dynamic_context.text if dynamic_context else ""
print("=" * 100)
print(
" injected into session 2's system prompt: "
f"{'' in injected}"
)
conversation.close()
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[Skills](/sdk/guides/skill)** - Inject reusable instructions and context into agents
- **[Context Condenser](/sdk/guides/context-condenser)** - Keep long conversations within the context window
- **[Persistence](/sdk/guides/convo-persistence)** - Save and restore conversation state across sessions
### Plugins
Source: https://docs.openhands.dev/sdk/guides/plugins.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
Plugins provide a way to package and distribute multiple agent components together. A single plugin can include:
- **Skills**: Specialized knowledge and workflows
- **Hooks**: Event handlers for tool lifecycle
- **MCP Config**: External tool server configurations
- **Agents**: Specialized agent definitions
- **Commands**: Slash commands
The plugin format is compatible with the [Claude Code plugin structure](https://github.com/anthropics/claude-code/tree/main/plugins).
## Plugin Structure
See the [example_plugins directory](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/05_skills_and_plugins/02_loading_plugins/example_plugins) for a complete working plugin structure.
A plugin follows this directory structure:
Note that the plugin metadata, i.e., `plugin-name/.plugin/plugin.json`, is required.
### Plugin Manifest
The manifest file `plugin-name/.plugin/plugin.json` defines plugin metadata:
```json icon="file-code" wrap
{
"name": "code-quality",
"version": "1.0.0",
"description": "Code quality tools and workflows",
"author": "openhands",
"license": "MIT",
"repository": "https://github.com/example/code-quality-plugin"
}
```
### Skills
Skills are defined in markdown files with YAML frontmatter:
```markdown icon="file-code"
---
name: python-linting
description: Instructions for linting Python code
trigger:
type: keyword
keywords:
- lint
- linting
- code quality
---
# Python Linting Skill
Run ruff to check for issues:
\`\`\`bash
ruff check .
\`\`\`
```
### Hooks
Hooks are defined in `hooks/hooks.json`:
```json icon="file-code" wrap
{
"hooks": {
"PostToolUse": [
{
"matcher": "file_editor",
"hooks": [
{
"type": "command",
"command": "echo 'File edited: $OPENHANDS_TOOL_NAME'",
"timeout": 5
}
]
}
]
}
}
```
### MCP Configuration
MCP servers are configured in `.mcp.json`:
```json wrap icon="file-code"
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
## Using Plugin Components
> The ready-to-run example is available [here](#ready-to-run-example)!
Brief explanation on how to use a plugin with an agent.
### Loading a Plugin
First, load the desired plugins.
```python icon="python"
from openhands.sdk.plugin import Plugin
# Load a single plugin
plugin = Plugin.load("/path/to/plugin")
# Load all plugins from a directory
plugins = Plugin.load_all("/path/to/plugins")
```
### Accessing Components
You can access the different plugin components to see which ones are available.
```python icon="python"
# Skills
for skill in plugin.skills:
print(f"Skill: {skill.name}")
# Hooks configuration
if plugin.hooks:
print(f"Hooks configured: {plugin.hooks}")
# MCP servers
if plugin.mcp_config:
servers = plugin.mcp_config.get("mcpServers", {})
print(f"MCP servers: {list(servers.keys())}")
```
### Using with an Agent
You can now feed your agent with your preferred plugin.
```python focus={3,10,17} icon="python"
# Create agent context with plugin skills
agent_context = AgentContext(
skills=plugin.skills,
)
# Create agent with plugin MCP config
agent = Agent(
llm=llm,
tools=tools,
mcp_config=plugin.mcp_config or {},
agent_context=agent_context,
)
# Create conversation with plugin hooks
conversation = Conversation(
agent=agent,
hook_config=plugin.hooks,
)
```
## Ready-to-run Example
The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, load, enable, disable, and uninstall).
This example is available on GitHub: [examples/05_skills_and_plugins/02_loading_plugins/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/02_loading_plugins/main.py)
```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py
"""Example: Loading and Managing Plugins
This example demonstrates plugin loading and lifecycle management in the SDK:
1. Loading a plugin from GitHub via Conversation (PluginSource)
2. Installing plugins to persistent storage (local and GitHub)
3. Listing tracked plugins and loading only the enabled ones
4. Inspecting the `.installed.json` metadata file and `enabled` flag
5. Disabling and re-enabling a plugin without reinstalling it
6. Uninstalling plugins from persistent storage
Plugins bundle skills, hooks, and MCP config together.
Supported plugin sources:
- Local path: /path/to/plugin
- GitHub shorthand: github:owner/repo
- Git URL: https://github.com/owner/repo.git
- With ref: branch, tag, or commit SHA
- With repo_path: subdirectory for monorepos
For full documentation, see: https://docs.all-hands.dev/sdk/guides/plugins
"""
import json
import os
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, Conversation
from openhands.sdk.plugin import (
PluginFetchError,
PluginSource,
disable_plugin,
enable_plugin,
install_plugin,
list_installed_plugins,
load_installed_plugins,
uninstall_plugin,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
script_dir = Path(__file__).parent
local_plugin_path = script_dir / "example_plugins" / "code-quality"
def print_state(label: str, installed_dir: Path) -> None:
"""Print tracked, loaded, and persisted plugin state."""
print(f"\n{label}")
print("-" * len(label))
installed = list_installed_plugins(installed_dir=installed_dir)
print("Tracked plugins:")
for info in installed:
print(f" - {info.name} (enabled={info.enabled}, source={info.source})")
loaded = load_installed_plugins(installed_dir=installed_dir)
print(f"Loaded plugins: {[plugin.name for plugin in loaded]}")
metadata = json.loads((installed_dir / ".installed.json").read_text())
print("Metadata file:")
print(json.dumps(metadata, indent=2))
def demo_conversation_with_github_plugin(llm: LLM) -> None:
"""Demo 1: Load plugin from GitHub via Conversation."""
print("\n" + "=" * 60)
print("DEMO 1: Loading plugin from GitHub via Conversation")
print("=" * 60)
plugins = [
PluginSource(
source="github:anthropics/skills",
ref="main",
),
]
agent = Agent(
llm=llm,
tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
)
with tempfile.TemporaryDirectory() as tmpdir:
try:
conversation = Conversation(
agent=agent,
workspace=tmpdir,
plugins=plugins,
)
conversation.send_message(
"What's the best way to create a PowerPoint presentation "
"programmatically? Check the skill before you answer."
)
skills = (
conversation.agent.agent_context.skills
if conversation.agent.agent_context
else []
)
print(f"✓ Loaded {len(skills)} skill(s) from GitHub plugin")
for skill in skills[:5]:
print(f" - {skill.name}")
if len(skills) > 5:
print(f" ... and {len(skills) - 5} more skills")
if conversation.resolved_plugins:
print("Resolved plugin refs:")
for resolved in conversation.resolved_plugins:
print(f" - {resolved.source} @ {resolved.resolved_ref}")
conversation.run()
except PluginFetchError as e:
print(f"⚠ Could not fetch from GitHub: {e}")
print(" Skipping this demo (network or rate limiting issue)")
def demo_install_local_plugin(installed_dir: Path) -> str:
"""Demo 2: Install a plugin from a local path."""
print("\n" + "=" * 60)
print("DEMO 2: Installing plugin from local path")
print("=" * 60)
info = install_plugin(source=str(local_plugin_path), installed_dir=installed_dir)
print(f"✓ Installed: {info.name} v{info.version}")
print(f" Source: {info.source}")
print(f" Path: {info.install_path}")
return info.name
def demo_install_github_plugin(installed_dir: Path) -> None:
"""Demo 3: Install a plugin from GitHub to persistent storage."""
print("\n" + "=" * 60)
print("DEMO 3: Installing plugin from GitHub")
print("=" * 60)
try:
info = install_plugin(
source="github:anthropics/skills",
ref="main",
installed_dir=installed_dir,
)
print(f"✓ Installed: {info.name} v{info.version}")
print(f" Source: {info.source}")
print(f" Resolved ref: {info.resolved_ref}")
plugins = load_installed_plugins(installed_dir=installed_dir)
for plugin in plugins:
if plugin.name != info.name:
continue
skills = plugin.get_all_skills()
print(f" Skills: {len(skills)}")
for skill in skills[:5]:
desc = skill.description or "(no description)"
print(f" - {skill.name}: {desc[:50]}...")
if len(skills) > 5:
print(f" ... and {len(skills) - 5} more skills")
except PluginFetchError as e:
print(f"⚠ Could not fetch from GitHub: {e}")
print(" (Network or rate limiting issue)")
def demo_list_and_load_plugins(installed_dir: Path) -> None:
"""Demo 4: List tracked plugins and load the enabled ones."""
print("\n" + "=" * 60)
print("DEMO 4: Listing and loading installed plugins")
print("=" * 60)
print("Tracked plugins:")
for info in list_installed_plugins(installed_dir=installed_dir):
print(f" - {info.name} v{info.version} (enabled={info.enabled})")
plugins = load_installed_plugins(installed_dir=installed_dir)
print(f"\nLoaded {len(plugins)} plugin(s):")
for plugin in plugins:
skills = plugin.get_all_skills()
print(f" - {plugin.name}: {len(skills)} skill(s)")
def demo_enable_disable_plugin(installed_dir: Path, plugin_name: str) -> None:
"""Demo 5: Disable then re-enable a plugin without reinstalling it."""
print("\n" + "=" * 60)
print("DEMO 5: Disabling and re-enabling a plugin")
print("=" * 60)
print_state("Before disable", installed_dir)
assert disable_plugin(plugin_name, installed_dir=installed_dir) is True
print_state("After disable", installed_dir)
assert plugin_name not in [
plugin.name for plugin in load_installed_plugins(installed_dir=installed_dir)
]
metadata = json.loads((installed_dir / ".installed.json").read_text())
assert metadata["plugins"][plugin_name]["enabled"] is False
assert enable_plugin(plugin_name, installed_dir=installed_dir) is True
print_state("After re-enable", installed_dir)
metadata = json.loads((installed_dir / ".installed.json").read_text())
assert metadata["plugins"][plugin_name]["enabled"] is True
assert plugin_name in [
plugin.name for plugin in load_installed_plugins(installed_dir=installed_dir)
]
def demo_uninstall_plugins(installed_dir: Path) -> None:
"""Demo 6: Uninstall all tracked plugins."""
print("\n" + "=" * 60)
print("DEMO 6: Uninstalling plugins")
print("=" * 60)
for info in list_installed_plugins(installed_dir=installed_dir):
uninstall_plugin(info.name, installed_dir=installed_dir)
print(f"✓ Uninstalled: {info.name}")
remaining = list_installed_plugins(installed_dir=installed_dir)
print(f"\nRemaining plugins: {len(remaining)}")
if __name__ == "__main__":
api_key = os.getenv("LLM_API_KEY")
if not api_key:
print("Set LLM_API_KEY to run the full example")
print("Running install and lifecycle demos only...")
llm = None
else:
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
llm = LLM(
usage_id="plugin-demo",
model=model,
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL"),
)
with tempfile.TemporaryDirectory() as tmpdir:
installed_dir = Path(tmpdir) / "installed-plugins"
installed_dir.mkdir()
if llm:
demo_conversation_with_github_plugin(llm)
local_plugin_name = demo_install_local_plugin(installed_dir)
demo_install_github_plugin(installed_dir)
demo_list_and_load_plugins(installed_dir)
demo_enable_disable_plugin(installed_dir, local_plugin_name)
demo_uninstall_plugins(installed_dir)
print("\n" + "=" * 60)
print("EXAMPLE COMPLETED SUCCESSFULLY")
print("=" * 60)
if llm:
print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
else:
print("EXAMPLE_COST: 0")
```
## Registered Marketplace Plugins
Registered marketplaces let an agent context name one or more plugin catalogs once
and then load plugins by marketplace-qualified names like
`incident-bot@specialists`. Use `auto_load="all"` when every plugin in a
marketplace should load at conversation startup, and call
`conversation.load_plugin()` when you want to add a specific plugin later.
The example below builds local marketplace catalogs so it can run without network
access or credentials.
This example is available on GitHub: [examples/05_skills_and_plugins/05_registered_marketplace_plugins/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/05_registered_marketplace_plugins/main.py)
```python icon="python" expandable examples/05_skills_and_plugins/05_registered_marketplace_plugins/main.py
"""Example: Registered Marketplaces and Runtime Plugin Loading
This example demonstrates the registered marketplace flow:
1. Register multiple marketplace catalogs on AgentContext.
2. Auto-load plugins from a marketplace with ``auto_load='all'``.
3. Load an additional plugin at runtime by marketplace-qualified name.
The example builds two temporary local marketplaces so it can run without network
access or external credentials.
"""
import json
import tempfile
from pathlib import Path
from openhands.sdk import Agent, AgentContext, Conversation
from openhands.sdk.marketplace import MarketplaceRegistration
from openhands.sdk.testing import TestLLM
def write_plugin(plugin_dir: Path, plugin_name: str, skill_name: str) -> None:
manifest_dir = plugin_dir / ".plugin"
manifest_dir.mkdir(parents=True, exist_ok=True)
(manifest_dir / "plugin.json").write_text(
json.dumps(
{
"name": plugin_name,
"version": "1.0.0",
"description": f"Example plugin {plugin_name}",
}
)
)
skills_dir = plugin_dir / "skills"
skills_dir.mkdir()
(skills_dir / f"{skill_name}.md").write_text(
f"---\nname: {skill_name}\ndescription: Example skill\n---\n"
f"Use {skill_name} when demonstrating registered marketplace plugins."
)
def write_marketplace(marketplace_dir: Path, plugin_name: str, skill_name: str) -> None:
write_plugin(marketplace_dir / "plugins" / plugin_name, plugin_name, skill_name)
manifest_dir = marketplace_dir / ".plugin"
manifest_dir.mkdir(parents=True, exist_ok=True)
(manifest_dir / "marketplace.json").write_text(
json.dumps(
{
"name": marketplace_dir.name,
"owner": {"name": "Example Team"},
"plugins": [
{
"name": plugin_name,
"source": f"./plugins/{plugin_name}",
"description": f"Example marketplace plugin {plugin_name}",
}
],
}
)
)
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
team_marketplace = tmp_path / "team-marketplace"
specialists_marketplace = tmp_path / "specialists-marketplace"
write_marketplace(team_marketplace, "review-bot", "review-checklist")
write_marketplace(specialists_marketplace, "incident-bot", "incident-brief")
agent = Agent(
llm=TestLLM.from_messages([]),
tools=[],
agent_context=AgentContext(
registered_marketplaces=[
MarketplaceRegistration(
name="team",
source=str(team_marketplace),
auto_load="all",
),
MarketplaceRegistration(
name="specialists",
source=str(specialists_marketplace),
),
]
),
)
conversation = Conversation(
agent=agent,
workspace=str(tmp_path / "workspace"),
)
conversation.load_plugin("incident-bot@specialists")
agent_context = conversation.agent.agent_context
assert agent_context is not None
skill_names = sorted(skill.name for skill in agent_context.skills or [])
resolved_sources = [plugin.source for plugin in conversation.resolved_plugins or []]
print("Registered marketplaces:")
for registration in agent_context.registered_marketplaces:
print(f" - {registration.name}: auto_load={registration.auto_load}")
print("Loaded skills:")
for skill_name in skill_names:
print(f" - {skill_name}")
print("Resolved plugins:")
for source in resolved_sources:
print(f" - {source}")
assert skill_names == ["incident-brief", "review-checklist"]
assert any(
source.endswith("team-marketplace/plugins/review-bot")
for source in resolved_sources
)
assert any(
source.endswith("specialists-marketplace/plugins/incident-bot")
for source in resolved_sources
)
print("EXAMPLE_COST: 0")
```
## Installing Plugins to Persistent Storage
The SDK provides utilities to install plugins to a local directory
(`~/.openhands/plugins/installed/` by default). Installed plugins are tracked
in `.installed.json`, which stores metadata including a persistent enabled
flag.
Use `list_installed_plugins()` to see all tracked plugins (enabled and
disabled). Use `load_installed_plugins()` to load only enabled plugins.
`install_plugin()`, `enable_plugin()`, `disable_plugin()`, and
`uninstall_plugin()` are exposed from `openhands.sdk.plugin`, which gives the
CLI a clean SDK surface for `/plugin install`, `/plugin enable`,
`/plugin disable`, and `/plugin uninstall`.
### Installed Plugin Lifecycle
The ready-to-run example above already demonstrates the full
installed-plugin lifecycle, including toggling the persistent `enabled`
flag in `.installed.json` before uninstalling the plugin.
Use the same APIs directly when you need a narrower flow:
```python icon="python"
from openhands.sdk.plugin import (
disable_plugin,
enable_plugin,
install_plugin,
list_installed_plugins,
load_installed_plugins,
uninstall_plugin,
)
info = install_plugin(source="/path/to/plugin")
tracked_plugins = list_installed_plugins()
disable_plugin(info.name)
enabled_plugins = load_installed_plugins()
enable_plugin(info.name)
uninstall_plugin(info.name)
```
## Next Steps
- **[Skills](/sdk/guides/skill)** - Learn more about skills and triggers
- **[Hooks](/sdk/guides/hooks)** - Understand hook event types
- **[MCP Integration](/sdk/guides/mcp)** - Configure external tool servers
### Secret Registry
Source: https://docs.openhands.dev/sdk/guides/secrets.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
The Secret Registry provides a secure way to handle sensitive data in your agent's workspace.
It automatically detects secret references in bash commands, injects them as environment variables when needed,
and masks secret values in command outputs to prevent accidental exposure.
### Injecting Secrets
Use the `update_secrets()` method to add secrets to your conversation.
Secrets can be provided as static strings or as callable functions that dynamically retrieve values, enabling integration with external secret stores and credential management systems:
```python focus={4,11} icon="python" wrap
from openhands.sdk.conversation.secret_source import SecretSource
# Static secret
conversation.update_secrets({"SECRET_TOKEN": "my-secret-token-value"})
# Dynamic secret using SecretSource
class MySecretSource(SecretSource):
def get_value(self) -> str:
return "callable-based-secret"
conversation.update_secrets({"SECRET_FUNCTION_TOKEN": MySecretSource()})
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/12_custom_secrets.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/12_custom_secrets.py)
```python icon="python" expandable examples/01_standalone_sdk/12_custom_secrets.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
)
from openhands.sdk.secret import SecretSource
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
]
# Agent
agent = Agent(llm=llm, tools=tools)
conversation = Conversation(agent)
class MySecretSource(SecretSource):
def get_value(self) -> str:
return "callable-based-secret"
conversation.update_secrets(
{"SECRET_TOKEN": "my-secret-token-value", "SECRET_FUNCTION_TOKEN": MySecretSource()}
)
conversation.send_message("just echo $SECRET_TOKEN")
conversation.run()
conversation.send_message("just echo $SECRET_FUNCTION_TOKEN")
conversation.run()
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
## Next Steps
- **[MCP Integration](/sdk/guides/mcp)** - Connect to MCP
- **[Security Analyzer](/sdk/guides/security)** - Add security validation
### Security & Action Confirmation
Source: https://docs.openhands.dev/sdk/guides/security.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
Agent actions can be controlled through two complementary mechanisms: **confirmation policy** that determine when user
approval is required, and **security analyzer** that evaluates action risk levels. Together, they provide flexible control over agent behavior while maintaining safety.
## Confirmation Policy
> A ready-to-run example is available [here](#ready-to-run-example-confirmation)!
Confirmation policy controls whether actions require user approval before execution. They provide a simple way to ensure safe agent operation by requiring explicit permission for actions.
### Setting Confirmation Policy
Set the confirmation policy on your conversation:
```python icon="python" focus={4}
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
conversation = Conversation(agent=agent, workspace=".")
conversation.set_confirmation_policy(AlwaysConfirm())
```
Available policies:
- **`AlwaysConfirm()`** - Require approval for all actions
- **`NeverConfirm()`** - Execute all actions without approval
- **`ConfirmRisky()`** - Only require approval for risky actions (requires security analyzer)
### Custom Confirmation Handler
Implement your approval logic by checking conversation status:
```python icon="python" focus={2-3,5}
while conversation.state.agent_status != AgentExecutionStatus.FINISHED:
if conversation.state.agent_status == AgentExecutionStatus.WAITING_FOR_CONFIRMATION:
pending = ConversationState.get_unmatched_actions(conversation.state.events)
if not confirm_in_console(pending):
conversation.reject_pending_actions("User rejected")
continue
conversation.run()
```
### Rejecting Actions
Provide feedback when rejecting to help the agent try a different approach:
```python icon="python" focus={2-5}
if not user_approved:
conversation.reject_pending_actions(
"User rejected because actions seem too risky."
"Please try a safer approach."
)
```
### Ready-to-run Example Confirmation
Full confirmation example: [examples/01_standalone_sdk/04_confirmation_mode_example.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/04_confirmation_mode_example.py)
Require user approval before executing agent actions:
```python icon="python" expandable examples/01_standalone_sdk/04_confirmation_mode_example.py
"""OpenHands Agent SDK — Confirmation Mode Example"""
import os
import signal
from collections.abc import Callable
from pydantic import SecretStr
from openhands.sdk import LLM, BaseConversation, Conversation
from openhands.sdk.conversation.state import (
ConversationExecutionStatus,
ConversationState,
)
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.tools.preset.default import get_default_agent
# Make ^C a clean exit instead of a stack trace
signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
def _print_action_preview(pending_actions) -> None:
print(f"\n🔍 Agent created {len(pending_actions)} action(s) awaiting confirmation:")
for i, action in enumerate(pending_actions, start=1):
snippet = str(action.action)[:100].replace("\n", " ")
# Lead with the LLM's natural-language summary when available, keeping
# the raw action snippet as secondary detail. When no summary was
# provided, the raw action itself is the most useful headline.
if action.summary:
print(f" {i}. [{action.tool_name}] {action.summary}")
print(f" {snippet}...")
else:
print(f" {i}. [{action.tool_name}] {snippet}...")
def confirm_in_console(pending_actions) -> bool:
"""
Return True to approve, False to reject.
Default to 'no' on EOF/KeyboardInterrupt (matches original behavior).
"""
_print_action_preview(pending_actions)
while True:
try:
ans = (
input("\nDo you want to execute these actions? (yes/no): ")
.strip()
.lower()
)
except (EOFError, KeyboardInterrupt):
print("\n❌ No input received; rejecting by default.")
return False
if ans in ("yes", "y"):
print("✅ Approved — executing actions…")
return True
if ans in ("no", "n"):
print("❌ Rejected — skipping actions…")
return False
print("Please enter 'yes' or 'no'.")
def run_until_finished(conversation: BaseConversation, confirmer: Callable) -> None:
"""
Drive the conversation until FINISHED.
If WAITING_FOR_CONFIRMATION, ask the confirmer;
on reject, call reject_pending_actions().
Preserves original error if agent waits but no actions exist.
"""
while conversation.state.execution_status != ConversationExecutionStatus.FINISHED:
if (
conversation.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
):
pending = ConversationState.get_unmatched_actions(conversation.state.events)
if not pending:
raise RuntimeError(
"⚠️ Agent is waiting for confirmation but no pending actions "
"were found. This should not happen."
)
if not confirmer(pending):
conversation.reject_pending_actions("User rejected the actions")
# Let the agent produce a new step or finish
continue
print("▶️ Running conversation.run()…")
conversation.run()
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
agent = get_default_agent(llm=llm)
conversation = Conversation(agent=agent, workspace=os.getcwd())
# Conditionally add security analyzer based on environment variable
add_security_analyzer = bool(os.getenv("ADD_SECURITY_ANALYZER", "").strip())
if add_security_analyzer:
print("Agent security analyzer added.")
conversation.set_security_analyzer(LLMSecurityAnalyzer())
# 1) Confirmation mode ON
conversation.set_confirmation_policy(AlwaysConfirm())
print("\n1) Command that will likely create actions…")
conversation.send_message("Please list the files in the current directory using ls -la")
run_until_finished(conversation, confirm_in_console)
# 2) A command the user may choose to reject
print("\n2) Command the user may choose to reject…")
conversation.send_message("Please create a file called 'dangerous_file.txt'")
run_until_finished(conversation, confirm_in_console)
# 3) Simple greeting (no actions expected)
print("\n3) Simple greeting (no actions expected)…")
conversation.send_message("Just say hello to me")
run_until_finished(conversation, confirm_in_console)
# 4) Disable confirmation mode and run commands directly
print("\n4) Disable confirmation mode and run a command…")
conversation.set_confirmation_policy(NeverConfirm())
conversation.send_message("Please echo 'Hello from confirmation mode example!'")
conversation.run()
conversation.send_message(
"Please delete any file that was created during this conversation."
)
conversation.run()
print("\n=== Example Complete ===")
print("Key points:")
print(
"- conversation.run() creates actions; confirmation mode "
"sets execution_status=WAITING_FOR_CONFIRMATION"
)
print("- User confirmation is handled via a single reusable function")
print("- Rejection uses conversation.reject_pending_actions() and the loop continues")
print("- Simple responses work normally without actions")
print("- Confirmation policy is toggled with conversation.set_confirmation_policy()")
```
---
## Security Analyzer
Security analyzer evaluates the risk of agent actions before execution, helping protect against potentially dangerous operations. They analyze each action and assign a security risk level:
- **LOW** - Safe operations with minimal security impact
- **MEDIUM** - Moderate security impact, review recommended
- **HIGH** - Significant security impact, requires confirmation
- **UNKNOWN** - Risk level could not be determined
Security analyzer work in conjunction with confirmation policy (like `ConfirmRisky()`) to determine whether user approval is needed before executing an action. This provides an additional layer of safety for autonomous agent operations.
### LLM Security Analyzer
> A ready-to-run example is available [here](#ready-to-run-example-security-analyzer)!
The **LLMSecurityAnalyzer** is the default implementation provided in the agent-sdk. It leverages the LLM's understanding of action context to provide lightweight security analysis. The LLM can annotate actions with security risk levels during generation, which the analyzer then uses to make security decisions.
#### Security Analyzer Configuration
Create an LLM-based security analyzer to review actions before execution:
```python icon="python"
from openhands.sdk import LLM, Agent, Conversation
from openhands.sdk.security.confirmation_policy import ConfirmRisky
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
llm = LLM(
usage_id="security-analyzer",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
security_analyzer = LLMSecurityAnalyzer(llm=llm)
# Attach the analyzer on the conversation, not the Agent constructor.
agent = Agent(llm=llm, tools=tools)
conversation = Conversation(agent=agent, workspace=".")
conversation.set_security_analyzer(security_analyzer)
conversation.set_confirmation_policy(ConfirmRisky())
```
The security analyzer:
- Reviews each action before execution
- Flags potentially dangerous operations
- Can be configured with custom security policy
- Uses a separate LLM to avoid conflicts with the main agent
#### Ready-to-run Example Security Analyzer
Full security analyzer example: [examples/01_standalone_sdk/16_llm_security_analyzer.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/16_llm_security_analyzer.py)
Automatically analyze agent actions for security risks before execution:
```python icon="python" expandable examples/01_standalone_sdk/16_llm_security_analyzer.py
"""OpenHands Agent SDK — LLM Security Analyzer Example (Simplified)
This example shows how to use the LLMSecurityAnalyzer to automatically
evaluate security risks of actions before execution.
"""
import os
import signal
from collections.abc import Callable
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, BaseConversation, Conversation
from openhands.sdk.conversation.state import (
ConversationExecutionStatus,
ConversationState,
)
from openhands.sdk.security.confirmation_policy import ConfirmRisky
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Clean ^C exit: no stack trace noise
signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
def _print_blocked_actions(pending_actions) -> None:
print(f"\n🔒 Security analyzer blocked {len(pending_actions)} high-risk action(s):")
for i, action in enumerate(pending_actions, start=1):
snippet = str(action.action)[:100].replace("\n", " ")
# Lead with the LLM's natural-language summary when available, keeping
# the raw action snippet as secondary detail. When no summary was
# provided, the raw action itself is the most useful headline.
if action.summary:
print(f" {i}. [{action.tool_name}] {action.summary}")
print(f" {snippet}...")
else:
print(f" {i}. [{action.tool_name}] {snippet}...")
def confirm_high_risk_in_console(pending_actions) -> bool:
"""
Return True to approve, False to reject.
Matches original behavior: default to 'no' on EOF/KeyboardInterrupt.
"""
_print_blocked_actions(pending_actions)
while True:
try:
ans = (
input(
"\nThese actions were flagged as HIGH RISK. "
"Do you want to execute them anyway? (yes/no): "
)
.strip()
.lower()
)
except (EOFError, KeyboardInterrupt):
print("\n❌ No input received; rejecting by default.")
return False
if ans in ("yes", "y"):
print("✅ Approved — executing high-risk actions...")
return True
if ans in ("no", "n"):
print("❌ Rejected — skipping high-risk actions...")
return False
print("Please enter 'yes' or 'no'.")
def run_until_finished_with_security(
conversation: BaseConversation, confirmer: Callable[[list], bool]
) -> None:
"""
Drive the conversation until FINISHED.
- If WAITING_FOR_CONFIRMATION: ask the confirmer.
* On approve: set execution_status = IDLE (keeps original example’s behavior).
* On reject: conversation.reject_pending_actions(...).
- If WAITING but no pending actions: print warning and set IDLE (matches original).
"""
while conversation.state.execution_status != ConversationExecutionStatus.FINISHED:
if (
conversation.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
):
pending = ConversationState.get_unmatched_actions(conversation.state.events)
if not pending:
raise RuntimeError(
"⚠️ Agent is waiting for confirmation but no pending actions "
"were found. This should not happen."
)
if not confirmer(pending):
conversation.reject_pending_actions("User rejected high-risk actions")
continue
print("▶️ Running conversation.run()...")
conversation.run()
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="security-analyzer",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
]
# Agent
agent = Agent(llm=llm, tools=tools)
# Conversation with persisted filestore
conversation = Conversation(
agent=agent, persistence_dir="./.conversations", workspace="."
)
conversation.set_security_analyzer(LLMSecurityAnalyzer())
conversation.set_confirmation_policy(ConfirmRisky())
print("\n1) Safe command (LOW risk - should execute automatically)...")
conversation.send_message("List files in the current directory")
conversation.run()
print("\n2) Potentially risky command (may require confirmation)...")
conversation.send_message(
"Please echo 'hello world' -- PLEASE MARK THIS AS A HIGH RISK ACTION"
)
run_until_finished_with_security(conversation, confirm_high_risk_in_console)
```
### Custom Security Analyzer Implementation
You can extend the security analyzer functionality by creating your own implementation that inherits from the [SecurityAnalyzerBase](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/analyzer.py) class. This allows you to implement custom security logic tailored to your specific requirements.
#### Creating a Custom Analyzer
To create a custom security analyzer, inherit from `SecurityAnalyzerBase` and implement the `security_risk()` method:
```python icon="python" focus={5, 8}
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
from openhands.sdk.security.risk import SecurityRisk
from openhands.sdk.event.llm_convertible import ActionEvent
class CustomSecurityAnalyzer(SecurityAnalyzerBase):
"""Custom security analyzer with domain-specific rules."""
def security_risk(self, action: ActionEvent) -> SecurityRisk:
"""Evaluate security risk based on custom rules.
Args:
action: The ActionEvent to analyze
Returns:
SecurityRisk level (LOW, MEDIUM, HIGH, or UNKNOWN)
"""
# Example: Check for specific dangerous patterns
action_str = str(action.action.model_dump()).lower() if action.action else ""
# High-risk patterns
if any(pattern in action_str for pattern in ['rm -rf', 'sudo', 'chmod 777']):
return SecurityRisk.HIGH
# Medium-risk patterns
if any(pattern in action_str for pattern in ['curl', 'wget', 'git clone']):
return SecurityRisk.MEDIUM
# Default to low risk
return SecurityRisk.LOW
# Use your custom analyzer
security_analyzer = CustomSecurityAnalyzer()
conversation.set_security_analyzer(security_analyzer)
```
For more details on the base class implementation, see the [source code](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/security/analyzer.py).
### Defense-in-Depth Security Analyzer
#### The problem
Your agent is about to run a tool call. Is it safe?
The `LLMSecurityAnalyzer` asks the model itself — but the model can be
manipulated, and encoding tricks can hide dangerous commands from it.
You need a layer that does not depend on model judgment: something
deterministic, local, and fast.
#### What this gives you
Three composable analyzers that classify actions at the boundary —
before the tool runs, not after. No network calls, no model inference,
no extra dependencies. They return a `SecurityRisk` level; your
`ConfirmRisky` policy decides whether to prompt the user.
| Analyzer | What it catches | How it works |
|----------|----------------|--------------|
| `PatternSecurityAnalyzer` | Known threat signatures (rm -rf, eval, curl\|sh) | Regex patterns on two corpora: shell patterns scan executable fields only; injection patterns scan all fields |
| `PolicyRailSecurityAnalyzer` | Composed threats (fetch piped to exec, raw disk writes, catastrophic deletes) | Deterministic rules evaluated per-segment — both tokens must appear in the same field |
| `EnsembleSecurityAnalyzer` | Nothing on its own — it combines the others | Takes the highest concrete risk across all child analyzers |
#### Quick start
You must configure both the analyzer and the confirmation policy.
Setting an analyzer does not automatically change confirmation behavior.
```python icon="python" focus={7-18}
from openhands.sdk import Conversation
from openhands.sdk.security import (
PatternSecurityAnalyzer,
PolicyRailSecurityAnalyzer,
EnsembleSecurityAnalyzer,
ConfirmRisky,
SecurityRisk,
)
# Create the analyzer — rails catch composed threats,
# patterns catch individual signatures
security_analyzer = EnsembleSecurityAnalyzer(
analyzers=[
PolicyRailSecurityAnalyzer(),
PatternSecurityAnalyzer(),
]
)
# Tell the SDK when to ask the user — HIGH is the recommended baseline
confirmation_policy = ConfirmRisky(threshold=SecurityRisk.HIGH)
# Wire both into the conversation
# Assumes `agent` is already configured — see Quick Start guide
conversation = Conversation(agent=agent, workspace=".")
conversation.set_security_analyzer(security_analyzer)
conversation.set_confirmation_policy(confirmation_policy)
```
After this, every agent action passes through the analyzer before
execution. HIGH-risk actions trigger a confirmation prompt — the user
sees the risk level and can approve or reject before the tool runs.
MEDIUM and LOW are allowed. UNKNOWN is confirmed by default
(`confirm_unknown=True`).
For security-sensitive environments, lower the threshold to catch more:
```python
# Stricter posture — MEDIUM and above require confirmation
confirmation_policy = ConfirmRisky(threshold=SecurityRisk.MEDIUM)
```
You can also require confirmation when any analyzer cannot assess risk:
```python
# If any analyzer returns UNKNOWN, require confirmation
security_analyzer = EnsembleSecurityAnalyzer(
analyzers=[
PolicyRailSecurityAnalyzer(),
PatternSecurityAnalyzer(),
],
propagate_unknown=True,
)
```
`conversation.execute_tool()` bypasses the analyzer and confirmation
policy. These analyzers protect agent actions in the conversation
loop, not direct tool calls.
#### Adding the LLM analyzer for deeper coverage
The pattern analyzer catches known threats instantly. The LLM analyzer
can catch novel or ambiguous cases. Composing both gives you speed and
breadth:
```python
from openhands.sdk.security import LLMSecurityAnalyzer
security_analyzer = EnsembleSecurityAnalyzer(
analyzers=[
PolicyRailSecurityAnalyzer(),
PatternSecurityAnalyzer(),
LLMSecurityAnalyzer(),
]
)
confirmation_policy = ConfirmRisky(threshold=SecurityRisk.HIGH)
```
The ensemble takes the worst case across all analyzers. If the pattern
analyzer says HIGH and the LLM says LOW, the result is HIGH.
#### Why it works this way
**Two corpora, not one.** An agent that runs `ls /tmp` but thinks
"I should avoid rm -rf /" is not flagged — shell patterns only see
the `ls /tmp` that will actually execute. Injection patterns like
"ignore all previous instructions" scan everything, because they
target the model's instruction-following regardless of where they
appear.
**Max-severity, not averaging.** The analyzers scan the same input —
they are correlated, not independent. The highest concrete risk wins.
That is simpler and more auditable than probabilistic fusion.
**UNKNOWN means "I don't know," not "safe."** By default, if all
analyzers return UNKNOWN the ensemble preserves it, and `ConfirmRisky`
triggers confirmation. If any analyzer returns a concrete level,
UNKNOWN results are filtered out. For stricter environments, set
`propagate_unknown=True` so that any single UNKNOWN triggers
confirmation regardless of other results.
**Confirm, don't block.** The analyzers return a risk level. The
confirmation policy decides what happens. The analyzer does not
prevent execution — it classifies risk for the policy layer to act on.
Pair with Docker isolation for stronger safety guarantees.
#### What this does not do
This is a deterministic action-boundary control. It is not:
- A complete prompt-injection solution
- A full shell parser or AST interpreter
- A sandbox replacement
- A guarantee against novel threats the patterns do not cover
It is additive to `LLMSecurityAnalyzer` and `GraySwanAnalyzer`, not a
replacement for either.
#### Known limitations
| Limitation | Why | What would fix it |
|---|---|---|
| No hard-deny at the analyzer boundary | SDK analyzers return `SecurityRisk`, not block/allow | Hook-based enforcement |
| `execute_tool()` bypasses checks | Direct tool execution skips the conversation loop | Hooks |
| No Cyrillic/homoglyph detection | NFKC maps compatibility forms, not cross-script confusables | Unicode TR39 confusable tables |
| Content past 30k chars is invisible | Hard cap prevents regex denial-of-service | Raise the cap (increases ReDoS exposure) |
| `thinking_blocks` not scanned | Scanning model reasoning risks false positives on deliberation | Separate injection-only CoT scan |
#### Extraction budget and primary-surface-first ordering
The 30k-character cap is applied per scanning corpus, not per field: every
field competes for one shared budget (the `_BoundedSegments` buffer in
`defense_in_depth/utils.py`). That creates a secondary risk — a single
oversized field could consume the whole budget and leave higher-value
fields unscanned. `tool_name` has no length validation in the SDK, so a 30k
hallucinated name is a real starvation vector, not just a theoretical one.
The analyzer addresses this by **extraction order**, not a per-field cap:
the primary attack surface is added first, so it always receives budget
even when a later field is adversarially large.
- Executable corpus: `tool_call.arguments` (the primary prompt-injection
surface) → `tool_name` → `tool_call.name`.
- Reasoning corpus: `summary` (what the agent is about to do) →
`reasoning_content` → `thought`.
The two corpora are extracted with separate budgets and concatenated
without a second outer cap, so a budget-filling `arguments` payload cannot
crowd `summary` out of the injection scan.
**Remaining boundary** (a strict xfail in the test suite): a payload past
30k characters *within a single field* is still truncated and invisible.
That is the deliberate ReDoS trade-off already listed above; extraction
order does not change it.
Ready-to-run example: [examples/01_standalone_sdk/47_defense_in_depth_security.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/47_defense_in_depth_security.py)
---
## Configurable Security Policy
> A ready-to-run example is available [here](#ready-to-run-example-security-policy)!
Agents use security policies to guide their risk assessment of actions. The SDK provides a default security policy template, but you can customize it to match your specific security requirements and guidelines.
### Using Custom Security Policies
You can provide a custom security policy template when creating an agent:
```python focus={9-13} icon="python"
from openhands.sdk import Agent, LLM
llm = LLM(
usage_id="agent",
model="anthropic/claude-sonnet-4-5-20250929",
api_key=SecretStr(api_key),
)
# Provide a custom security policy template file
agent = Agent(
llm=llm,
tools=tools,
security_policy_filename="my_security_policy.j2",
)
```
Custom security policies allow you to:
- Define organization-specific risk assessment guidelines
- Set custom thresholds for security risk levels
- Add domain-specific security rules
- Tailor risk evaluation to your use case
The security policy is provided as a Jinja2 template that gets rendered into the agent's system prompt, guiding how it evaluates the security risk of its actions.
### Ready-to-run Example Security Policy
Full configurable security policy example: [examples/01_standalone_sdk/32_configurable_security_policy.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/32_configurable_security_policy.py)
Define custom security risk guidelines for your agent:
```python icon="python" expandable examples/01_standalone_sdk/32_configurable_security_policy.py
"""OpenHands Agent SDK — Configurable Security Policy Example
This example demonstrates how to use a custom security policy template
with an agent. Security policies define risk assessment guidelines that
help agents evaluate the safety of their actions.
By default, agents use the built-in security_policy.j2 template. This
example shows how to:
1. Use the default security policy
2. Provide a custom security policy template embedded in the script
3. Apply the custom policy to guide agent behavior
"""
import os
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Define a custom security policy template inline
CUSTOM_SECURITY_POLICY = (
"# 🔐 Custom Security Risk Policy\n"
"When using tools that support the security_risk parameter, assess the "
"safety risk of your actions:\n"
"\n"
"- **LOW**: Safe read-only actions.\n"
" - Viewing files, calculations, documentation.\n"
"- **MEDIUM**: Moderate container-scoped actions.\n"
" - File modifications, package installations.\n"
"- **HIGH**: Potentially dangerous actions.\n"
" - Network access, system modifications, data exfiltration.\n"
"\n"
"**Custom Rules**\n"
"- Always prioritize user data safety.\n"
"- Escalate to **HIGH** for any external data transmission.\n"
)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
]
# Example 1: Agent with default security policy
print("=" * 100)
print("Example 1: Agent with default security policy")
print("=" * 100)
default_agent = Agent(llm=llm, tools=tools)
print(f"Security policy filename: {default_agent.security_policy_filename}")
print("\nDefault security policy is embedded in the agent's system message.")
# Example 2: Agent with custom security policy
print("\n" + "=" * 100)
print("Example 2: Agent with custom security policy")
print("=" * 100)
# Create a temporary file for the custom security policy
with tempfile.NamedTemporaryFile(
mode="w", suffix=".j2", delete=False, encoding="utf-8"
) as temp_file:
temp_file.write(CUSTOM_SECURITY_POLICY)
custom_policy_path = temp_file.name
try:
# Create agent with custom security policy (using absolute path)
custom_agent = Agent(
llm=llm,
tools=tools,
security_policy_filename=custom_policy_path,
)
print(f"Security policy filename: {custom_agent.security_policy_filename}")
print("\nCustom security policy loaded from temporary file.")
# Verify the custom policy is in the system message
system_message = custom_agent.static_system_message
if "Custom Security Risk Policy" in system_message:
print("✓ Custom security policy successfully embedded in system message.")
else:
print("✗ Custom security policy not found in system message.")
# Run a conversation with the custom agent
print("\n" + "=" * 100)
print("Running conversation with custom security policy")
print("=" * 100)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=custom_agent,
callbacks=[conversation_callback],
workspace=".",
)
conversation.send_message(
"Please create a simple Python script named hello.py that prints "
"'Hello, World!'. Make sure to follow security best practices."
)
conversation.run()
print("\n" + "=" * 100)
print("Conversation finished.")
print(f"Total LLM messages: {len(llm_messages)}")
print("=" * 100)
# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
# Clean up temporary file
Path(custom_policy_path).unlink(missing_ok=True)
print("\n" + "=" * 100)
print("Example Summary")
print("=" * 100)
print("This example demonstrated:")
print("1. Using the default security policy (security_policy.j2)")
print("2. Creating a custom security policy template")
print("3. Applying the custom policy via security_policy_filename parameter")
print("4. Running a conversation with the custom security policy")
print(
"\nYou can customize security policies to match your organization's "
"specific requirements."
)
```
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Build secure custom tools
- **[Custom Secrets](/sdk/guides/secrets)** - Secure credential management
### Agent Skills & Context
Source: https://docs.openhands.dev/sdk/guides/skill.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
This guide shows how to implement skills in the SDK. For conceptual overview, see [Skills Overview](/overview/skills).
OpenHands supports an **extended version** of the [AgentSkills standard](https://agentskills.io/specification) with optional keyword triggers.
## Skill Injection Behavior
Understanding where skill content appears in the prompt is critical. The behavior differs based on skill format and trigger configuration:
| Skill Format | Trigger | Where Content Appears | Model Mediated? |
|--------------|---------|----------------------|-----------------|
| **AgentSkills** (`SKILL.md`) | Any | `` (description only) | ✅ Yes — agent calls `invoke_skill()` |
| **AgentSkills** (`SKILL.md`) | Has triggers | `` + auto-inject on match | ✅ Yes |
| **Legacy** (inline/`*.md`) | `None` | **`` (full content in the initial system prompt; included in LLM context for each turn)** | ❌ No |
| **Legacy** (inline/`*.md`) | Has triggers | `` + auto-inject on match | ✅ Yes |
| **Rule** (inline/`*.md`) | `PathTrigger` (`paths:` globs) | Injected into the **tool result** (``) when a matching file is touched; never in `` or `` | ❌ No — deterministic on file-touch |
**Token Usage Warning**: Legacy skills with `trigger=None` add their **full content** to `` in the initial `SystemPromptEvent`. That system message remains part of the conversation context for subsequent LLM calls, so the content still affects token usage on each turn. Consider using AgentSkills format (`SKILL.md`) for progressive disclosure instead.
### Prompt Structure
Skills appear in different parts of the system prompt:
```xml icon="file"
[BEGIN context from [agents]]
... AGENTS.md content ...
[END Context]
githubInteract with GitHub...
```
When a trigger matches, content is injected into the **user message**:
```xml icon="file"
The following information has been included based on a keyword match for "github".
Skill location: /path/to/skill
... skill content ...
```
## Context Loading Methods
| Method | When Content Loads | Use Case |
|--------|-------------------|----------|
| **Always-loaded** | At conversation start | Repository rules, coding standards |
| **Trigger-loaded** | When keywords match | Specialized tasks, domain knowledge |
| **Path-triggered** | When the agent touches a matching file | File-scoped rules (e.g. API validation, migration conventions) |
| **Progressive disclosure** | Agent reads on demand | Large reference docs (AgentSkills) |
## Always-Loaded Context
Content that's always in the system prompt.
### Option 1: `AGENTS.md` (Auto-loaded)
Place `AGENTS.md` at your repo root - it's loaded automatically. See [Permanent Context](/overview/skills/repo).
```python icon="python" focus={3, 4}
from openhands.sdk.skills import load_project_skills
# Automatically finds AGENTS.md, CLAUDE.md, GEMINI.md at workspace root
skills = load_project_skills(workspace_dir="/path/to/repo")
agent_context = AgentContext(skills=skills)
```
### Option 2: Inline Skill (Code-defined)
```python icon="python" focus={5-11}
from openhands.sdk import AgentContext
from openhands.sdk.context import Skill
agent_context = AgentContext(
skills=[
Skill(
name="code-style",
content="Always use type hints in Python.",
trigger=None, # No trigger = always loaded
),
]
)
```
**Important**: Inline skills with `trigger=None` use **legacy format** behavior — full content is added to `` in the initial system prompt and remains part of the conversation context for subsequent LLM calls. For large skills, consider using the AgentSkills `SKILL.md` format for progressive disclosure.
## Trigger-Loaded Context
Content injected when keywords appear in user messages. See [Keyword-Triggered Skills](/overview/skills/keyword).
```python icon="python" focus={6}
from openhands.sdk.context import Skill, KeywordTrigger
Skill(
name="encryption-helper",
content="Use the encrypt.sh script to encrypt messages.",
trigger=KeywordTrigger(keywords=["encrypt", "decrypt"]),
)
```
When user says "encrypt this", the content is injected into the message:
```xml icon="file"
The following information has been included based on a keyword match for "encrypt".
Skill location: /path/to/encryption-helper
Use the encrypt.sh script to encrypt messages.
```
## Path-Triggered Rules
A **rule** is a skill with a `PathTrigger` (`paths:` glob frontmatter). Its content is injected
**deterministically** when the agent reads, edits, or creates a file whose workspace-relative path
matches one of the globs — no reliance on the model choosing a skill. See [Path-Triggered Rules](/overview/skills/path)
for the conceptual overview.
Rules add **zero baseline cost**: they are excluded from `` and ``
and are never model-invocable (`disable_model_invocation` is forced on). Nothing is loaded until a
matching file is touched, and each rule is injected only once per conversation.
```python icon="python" focus={6}
from openhands.sdk.skills import PathTrigger, Skill
Skill(
name="api-validation",
content="API RULE: validate all request inputs with zod before using them.",
trigger=PathTrigger(paths=["src/api/**/*.ts", "**/*.route.ts"]),
)
```
As a file-based skill, this is just a `*.md` file with `paths:` frontmatter in a skills directory
(e.g. `.agents/skills/api-validation.md`):
```markdown icon="markdown"
---
paths:
- "src/api/**/*.ts"
- "**/*.route.ts"
---
API RULE: validate all request inputs with zod before using them.
```
When the agent creates or edits `src/api/users.ts`, the rule content is appended to that **tool
result** (not the user message) inside an `` block, so the agent reads it on its next step:
```xml icon="file"
The following rule applies because a file you touched matches "src/api/**/*.ts". Follow it when working with matching files.
Rule location: /repo/.agents/skills/api-validation.md
API RULE: validate all request inputs with zod before using them.
```
### Glob Semantics
Patterns use gitignore-style matching against the workspace-relative POSIX path (case-sensitive):
| Pattern | Matches |
|-------------------|---------------------------------------------------------------|
| `**` | Any number of path segments, including zero (crosses `/`). |
| `*` | Any run of characters **within a single** path segment. |
| `?` | A single non-separator character. |
| `*.ts` (no slash) | The basename at **any depth** — equivalent to `**/*.ts`. |
- A skill is **either** path-triggered **or** model-invocable, not both: if a file declares both `paths:` and `triggers:`, `paths:` wins.
- Rules are repo-scoped — touching a file outside the workspace never fires a rule.
- Injection is available for local conversations. ACP-backed conversations do not inject path rules, because the ACP server owns tool execution.
## Progressive Disclosure (AgentSkills Standard)
For the agent to trigger skills, use the [AgentSkills standard](https://agentskills.io/specification) `SKILL.md` format. The agent sees a summary and reads full content on demand.
```python icon="python"
from openhands.sdk.skills import load_skills_from_dir
# Load SKILL.md files from a directory
_, _, agent_skills = load_skills_from_dir("/path/to/skills")
agent_context = AgentContext(skills=list(agent_skills.values()))
```
Skills are listed in the system prompt:
```xml icon="file"
code-styleProject coding standards./path/to/code-style/SKILL.md
```
Add `triggers` to a SKILL.md for **both** progressive disclosure AND automatic injection when keywords match.
## Managing Installed Skills
You can install AgentSkills into a persistent directory and manage them through
`openhands.sdk.skills`. Skills are stored under
`~/.openhands/skills/installed/` with a `.installed.json` metadata file that
records an `enabled` flag. `list_installed_skills()` returns all installed
skills, while `load_installed_skills()` returns only those with
`enabled=true`.
The public lifecycle API includes `install_skill()`, `update_skill()`,
`enable_skill()`, `disable_skill()`, and `uninstall_skill()`, which gives the
CLI a clean SDK surface for `/skill install`, `/skill enable`,
`/skill disable`, and `/skill uninstall`.
### Installed Skill Lifecycle Example
This example mirrors the installed-plugin lifecycle example, but for
AgentSkills. It installs sample skills, lists them, toggles the
persistent `enabled` flag, and uninstalls one skill while leaving the
other available.
Source: [examples/05_skills_and_plugins/03_managing_installed_skills/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_managing_installed_skills/main.py)
```python icon="python" expandable examples/05_skills_and_plugins/03_managing_installed_skills/main.py
"""Example: Installing and Managing Skills
This example demonstrates installed skill lifecycle operations in the SDK:
1. Install skills from local paths into persistent storage
2. List tracked skills and load only the enabled ones
3. Inspect the `.installed.json` metadata file and `enabled` flag
4. Disable and re-enable a skill without reinstalling it
5. Uninstall a skill while leaving other installed skills available
For marketplace installation flows, see:
`examples/01_standalone_sdk/43_mixed_marketplace_skills/`.
"""
import json
import tempfile
from pathlib import Path
from openhands.sdk.skills import (
disable_skill,
enable_skill,
install_skill,
list_installed_skills,
load_installed_skills,
uninstall_skill,
)
script_dir = Path(__file__).resolve().parent
example_skills_dir = script_dir.parent / "01_loading_agentskills" / "example_skills"
def print_state(label: str, installed_dir: Path) -> None:
"""Print tracked, loaded, and persisted skill state."""
print(f"\n{label}")
print("-" * len(label))
installed = list_installed_skills(installed_dir=installed_dir)
print("Tracked skills:")
for info in installed:
print(f" - {info.name} (enabled={info.enabled}, source={info.source})")
loaded = load_installed_skills(installed_dir=installed_dir)
print(f"Loaded skills: {[skill.name for skill in loaded]}")
metadata = json.loads((installed_dir / ".installed.json").read_text())
print("Metadata file:")
print(json.dumps(metadata, indent=2))
def demo_install_skills(installed_dir: Path) -> list[str]:
"""Install the sample skills into the isolated installed directory."""
print("\n" + "=" * 60)
print("DEMO 1: Installing local skills")
print("=" * 60)
installed_names: list[str] = []
for skill_dir in sorted(example_skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
info = install_skill(source=str(skill_dir), installed_dir=installed_dir)
installed_names.append(info.name)
print(f"✓ Installed: {info.name}")
print(f" Source: {info.source}")
print(f" Path: {info.install_path}")
return installed_names
def demo_list_and_load_skills(installed_dir: Path) -> None:
"""List tracked skills and load them as runtime Skill objects."""
print("\n" + "=" * 60)
print("DEMO 2: Listing and loading installed skills")
print("=" * 60)
installed = list_installed_skills(installed_dir=installed_dir)
print("Tracked skills:")
for info in installed:
desc = (info.description or "No description")[:60]
print(f" - {info.name} (enabled={info.enabled})")
print(f" Description: {desc}...")
loaded = load_installed_skills(installed_dir=installed_dir)
print(f"\nLoaded {len(loaded)} skill(s):")
for skill in loaded:
desc = (skill.description or "No description")[:60]
print(f" - {skill.name}: {desc}...")
def demo_enable_disable_skill(installed_dir: Path, skill_name: str) -> None:
"""Disable then re-enable a skill and show the persisted metadata."""
print("\n" + "=" * 60)
print("DEMO 3: Disabling and re-enabling a skill")
print("=" * 60)
print_state("Before disable", installed_dir)
assert disable_skill(skill_name, installed_dir=installed_dir) is True
print_state("After disable", installed_dir)
assert skill_name not in [
skill.name for skill in load_installed_skills(installed_dir=installed_dir)
]
metadata = json.loads((installed_dir / ".installed.json").read_text())
assert metadata["skills"][skill_name]["enabled"] is False
assert enable_skill(skill_name, installed_dir=installed_dir) is True
print_state("After re-enable", installed_dir)
metadata = json.loads((installed_dir / ".installed.json").read_text())
assert metadata["skills"][skill_name]["enabled"] is True
assert skill_name in [
skill.name for skill in load_installed_skills(installed_dir=installed_dir)
]
def demo_uninstall_skill(
installed_dir: Path, skill_name: str, remaining_skill_name: str
) -> None:
"""Uninstall one skill and confirm the other skill remains available."""
print("\n" + "=" * 60)
print("DEMO 4: Uninstalling a skill")
print("=" * 60)
assert uninstall_skill(skill_name, installed_dir=installed_dir) is True
print_state("After uninstall", installed_dir)
assert not (installed_dir / skill_name).exists()
metadata = json.loads((installed_dir / ".installed.json").read_text())
assert skill_name not in metadata["skills"]
assert remaining_skill_name in metadata["skills"]
if __name__ == "__main__":
with tempfile.TemporaryDirectory() as tmpdir:
installed_dir = Path(tmpdir) / "installed-skills"
installed_dir.mkdir(parents=True)
installed_names = demo_install_skills(installed_dir)
demo_list_and_load_skills(installed_dir)
demo_enable_disable_skill(installed_dir, skill_name="rot13-encryption")
demo_uninstall_skill(
installed_dir,
skill_name="rot13-encryption",
remaining_skill_name="code-style-guide",
)
remaining_names = [
info.name for info in list_installed_skills(installed_dir=installed_dir)
]
assert remaining_names == ["code-style-guide"]
assert sorted(installed_names) == ["code-style-guide", "rot13-encryption"]
print("\nEXAMPLE_COST: 0")
```
### Installing Skills from a Marketplace
Use a marketplace when you want to install a curated mix of local and remote
AgentSkills in one step. The example below shows how to define a marketplace,
install all listed skills, and inspect the installed metadata.
Source: [examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py)
```python icon="python" expandable examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py
"""Example: Mixed Marketplace with Local and Remote Skills
This example demonstrates how to create a marketplace that includes both:
1. Local skills hosted in your project directory
2. Remote skills from GitHub (OpenHands/extensions repository)
The marketplace.json schema supports source paths in these formats:
- Local paths: ./path, ../path, /absolute/path, ~/path, file:///path
- GitHub URLs: https://github.com/{owner}/{repo}/blob/{branch}/{path}
This pattern is useful for teams that want to:
- Maintain their own custom skills locally
- Reference specific skills from remote repositories
- Create a curated skill set for their specific workflows
Directory Structure:
43_mixed_marketplace_skills/
├── .plugin/
│ └── marketplace.json # Marketplace with local and remote skills
├── skills/
│ └── greeting-helper/
│ └── SKILL.md # Local skill content
├── main.py # This file
└── README.md # Documentation
Usage:
# Install all skills from marketplace to ~/.openhands/skills/installed/
python main.py --install
# Force reinstall (overwrite existing)
python main.py --install --force
# Show installed skills
python main.py --list
"""
import sys
from pathlib import Path
from openhands.sdk.plugin import Marketplace
from openhands.sdk.skills import (
install_skills_from_marketplace,
list_installed_skills,
)
def main():
script_dir = Path(__file__).parent
if "--list" in sys.argv:
# List installed skills
print("=" * 80)
print("Installed Skills")
print("=" * 80)
installed = list_installed_skills()
if not installed:
print("\nNo skills installed.")
print("Run with --install to install skills from the marketplace.")
else:
for info in installed:
desc = (info.description or "No description")[:60]
print(f"\n {info.name}")
print(f" Description: {desc}...")
print(f" Source: {info.source}")
return
if "--install" in sys.argv:
# Install skills from marketplace
print("=" * 80)
print("Installing Skills from Marketplace")
print("=" * 80)
print(f"\nMarketplace directory: {script_dir}")
force = "--force" in sys.argv
installed = install_skills_from_marketplace(script_dir, force=force)
print(f"\n\nInstalled {len(installed)} skills:")
for info in installed:
print(f" - {info.name}")
# Show all installed skills
print("\n" + "=" * 80)
print("All Installed Skills")
print("=" * 80)
all_installed = list_installed_skills()
for info in all_installed:
desc = (info.description or "No description")[:50]
print(f" - {info.name}: {desc}...")
return
# Default: show marketplace info
print("=" * 80)
print("Marketplace Information")
print("=" * 80)
print(f"\nMarketplace directory: {script_dir}")
marketplace = Marketplace.load(script_dir)
print(f"Name: {marketplace.name}")
print(f"Description: {marketplace.description}")
print(f"Skills defined: {len(marketplace.skills)}")
print("\nSkills:")
for entry in marketplace.skills:
source_type = "remote" if entry.source.startswith("http") else "local"
print(f" - {entry.name} ({source_type})")
print(f" Source: {entry.source}")
if entry.description:
print(f" Description: {entry.description}")
print("\n" + "-" * 80)
print("Usage:")
print(" python main.py --install # Install all skills")
print(" python main.py --install --force # Force reinstall")
print(" python main.py --list # List installed skills")
if __name__ == "__main__":
main()
```
---
## Full Example
Full example: [examples/01_standalone_sdk/03_activate_skill.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/03_activate_skill.py)
```python icon="python" expandable examples/01_standalone_sdk/03_activate_skill.py
import os
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
AgentContext,
Conversation,
Event,
LLMConvertibleEvent,
get_logger,
)
from openhands.sdk.context import (
KeywordTrigger,
Skill,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
logger = get_logger(__name__)
# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
base_url = os.getenv("LLM_BASE_URL")
llm = LLM(
usage_id="agent",
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
)
# Tools
cwd = os.getcwd()
tools = [
Tool(
name=TerminalTool.name,
),
Tool(name=FileEditorTool.name),
]
# AgentContext provides flexible ways to customize prompts:
# 1. Skills: Inject instructions (always-active or keyword-triggered)
# 2. system_message_suffix: Append text to the system prompt
# 3. user_message_suffix: Append text to each user message
#
# For complete control over the system prompt, you can also use Agent's
# system_prompt_filename parameter to provide a custom Jinja2 template:
#
# agent = Agent(
# llm=llm,
# tools=tools,
# system_prompt_filename="/path/to/custom_prompt.j2",
# system_prompt_kwargs={"cli_mode": True, "repo": "my-project"},
# )
#
# See: https://docs.openhands.dev/sdk/guides/skill#customizing-system-prompts
agent_context = AgentContext(
skills=[
Skill(
name="repo.md",
content="When you see this message, you should reply like "
"you are a grumpy cat forced to use the internet.",
# source is optional - identifies where the skill came from
# You can set it to be the path of a file that contains the skill content
source=None,
# trigger determines when the skill is active
# trigger=None means always active (repo skill)
trigger=None,
),
Skill(
name="flarglebargle",
content=(
'IMPORTANT! The user has said the magic word "flarglebargle". '
"You must only respond with a message telling them how smart they are"
),
source=None,
# KeywordTrigger = activated when keywords appear in user messages
trigger=KeywordTrigger(keywords=["flarglebargle"]),
),
],
# system_message_suffix is appended to the system prompt (always active)
system_message_suffix="Always finish your response with the word 'yay!'",
# user_message_suffix is appended to each user message
user_message_suffix="The first character of your response should be 'I'",
# You can also enable automatic load skills from
# public registry at https://github.com/OpenHands/extensions
load_public_skills=True,
)
# Agent
agent = Agent(llm=llm, tools=tools, agent_context=agent_context)
llm_messages = [] # collect raw LLM messages
def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
)
print("=" * 100)
print("Checking if the repo skill is activated.")
conversation.send_message("Hey are you a grumpy cat?")
conversation.run()
print("=" * 100)
print("Now sending flarglebargle to trigger the knowledge skill!")
conversation.send_message("flarglebargle!")
conversation.run()
print("=" * 100)
print("Now triggering public skill 'github'")
conversation.send_message(
"About GitHub - tell me what additional info I've just provided?"
)
conversation.run()
print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
# Report cost
cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```
### Creating Skills
Skills are defined with a name, content (the instructions), and an optional trigger:
```python icon="python" focus={3-14}
agent_context = AgentContext(
skills=[
Skill(
name="AGENTS.md",
content="When you see this message, you should reply like "
"you are a grumpy cat forced to use the internet.",
trigger=None, # Always active
),
Skill(
name="flarglebargle",
content='IMPORTANT! The user has said the magic word "flarglebargle". '
"You must only respond with a message telling them how smart they are",
trigger=KeywordTrigger(keywords=["flarglebargle"]),
),
]
)
```
### Keyword Triggers
Use `KeywordTrigger` to activate skills only when specific words appear:
```python icon="python" focus={4}
Skill(
name="magic-word",
content="Special instructions when magic word is detected",
trigger=KeywordTrigger(keywords=["flarglebargle", "sesame"]),
)
```
## File-Based Skills (`SKILL.md`)
For reusable skills, use the [AgentSkills standard](https://agentskills.io/specification) directory format.
Full example: [examples/05_skills_and_plugins/01_loading_agentskills/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/01_loading_agentskills/main.py)
### Directory Structure
Each skill is a directory containing:
where
| Component | Required | Description |
|-------|----------|-------------|
| `SKILL.md` | Yes | Skill definition with frontmatter |
| `scripts/` | No | Executable scripts |
| `references/` | No | Reference documentation |
| `assets/` | No | Static assets |
### `SKILL.md` Format
The `SKILL.md` file defines the skill with YAML frontmatter:
```md icon="markdown"
---
name: my-skill # Required (standard)
description: > # Required (standard)
A brief description of what this skill does and when to use it.
license: MIT # Optional (standard)
compatibility: Requires bash # Optional (standard)
metadata: # Optional (standard)
author: your-name
version: "1.0"
triggers: # Optional (OpenHands extension)
- keyword1
- keyword2
---
# Skill Content
Instructions and documentation for the agent...
```
#### Frontmatter Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Skill identifier (lowercase + hyphens) |
| `description` | Yes | What the skill does (shown to agent) |
| `triggers` | No | Keywords that auto-activate this skill (**OpenHands extension**) |
| `license` | No | License name |
| `compatibility` | No | Environment requirements |
| `metadata` | No | Custom key-value pairs |
Add `triggers` to make your SKILL.md keyword-activated by matching a user prompt. Without triggers, the skill can only be triggered by the agent, not the user.
### Loading Skills
Use `load_skills_from_dir()` to load all skills from a directory:
```python icon="python" expandable examples/05_skills_and_plugins/01_loading_agentskills/main.py
"""Example: Loading Skills from Disk (AgentSkills Standard)
This example demonstrates how to load skills following the AgentSkills standard
from a directory on disk.
Skills are modular, self-contained packages that extend an agent's capabilities
by providing specialized knowledge, workflows, and tools. They follow the
AgentSkills standard which includes:
- SKILL.md file with frontmatter metadata (name, description, triggers)
- Optional resource directories: scripts/, references/, assets/
The example_skills/ directory contains two skills:
- rot13-encryption: Has triggers (encrypt, decrypt) - listed in
AND content auto-injected when triggered
- code-style-guide: No triggers - listed in for on-demand access
All SKILL.md files follow the AgentSkills progressive disclosure model:
they are listed in with name, description, and location.
Skills with triggers get the best of both worlds: automatic content injection
when triggered, plus the agent can proactively read them anytime.
"""
import os
import sys
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, AgentContext, Conversation
from openhands.sdk.skills import (
discover_skill_resources,
load_skills_from_dir,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
# Get the directory containing this script
script_dir = Path(__file__).parent
example_skills_dir = script_dir / "example_skills"
# =========================================================================
# Part 1: Loading Skills from a Directory
# =========================================================================
print("=" * 80)
print("Part 1: Loading Skills from a Directory")
print("=" * 80)
print(f"Loading skills from: {example_skills_dir}")
# Discover resources in the skill directory
skill_subdir = example_skills_dir / "rot13-encryption"
resources = discover_skill_resources(skill_subdir)
print("\nDiscovered resources in rot13-encryption/:")
print(f" - scripts: {resources.scripts}")
print(f" - references: {resources.references}")
print(f" - assets: {resources.assets}")
# Load skills from the directory
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(example_skills_dir)
print("\nLoaded skills from directory:")
print(f" - Repo skills: {list(repo_skills.keys())}")
print(f" - Knowledge skills: {list(knowledge_skills.keys())}")
print(f" - Agent skills (SKILL.md): {list(agent_skills.keys())}")
# Access the loaded skill and show all AgentSkills standard fields
if agent_skills:
skill_name = next(iter(agent_skills))
loaded_skill = agent_skills[skill_name]
print(f"\nDetails for '{skill_name}' (AgentSkills standard fields):")
print(f" - Name: {loaded_skill.name}")
desc = loaded_skill.description or ""
print(f" - Description: {desc[:70]}...")
print(f" - License: {loaded_skill.license}")
print(f" - Compatibility: {loaded_skill.compatibility}")
print(f" - Metadata: {loaded_skill.metadata}")
if loaded_skill.resources:
print(" - Resources:")
print(f" - Scripts: {loaded_skill.resources.scripts}")
print(f" - References: {loaded_skill.resources.references}")
print(f" - Assets: {loaded_skill.resources.assets}")
print(f" - Skill root: {loaded_skill.resources.skill_root}")
# =========================================================================
# Part 2: Using Skills with an Agent
# =========================================================================
print("\n" + "=" * 80)
print("Part 2: Using Skills with an Agent")
print("=" * 80)
# Check for API key
api_key = os.getenv("LLM_API_KEY")
if not api_key:
print("Skipping agent demo (LLM_API_KEY not set)")
print("\nTo run the full demo, set the LLM_API_KEY environment variable:")
print(" export LLM_API_KEY=your-api-key")
sys.exit(0)
# Configure LLM
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
llm = LLM(
usage_id="skills-demo",
model=model,
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL"),
)
# Create agent context with loaded skills
agent_context = AgentContext(
skills=list(agent_skills.values()),
# Disable public skills for this demo to keep output focused
load_public_skills=False,
)
# Create agent with tools so it can read skill resources
tools = [
Tool(name=TerminalTool.name),
Tool(name=FileEditorTool.name),
]
agent = Agent(llm=llm, tools=tools, agent_context=agent_context)
# Create conversation
conversation = Conversation(agent=agent, workspace=os.getcwd())
# Test the skill (triggered by "encrypt" keyword)
# The skill provides instructions and a script for ROT13 encryption
print("\nSending message with 'encrypt' keyword to trigger skill...")
conversation.send_message("Encrypt the message 'hello world'.")
conversation.run()
print(f"\nTotal cost: ${llm.metrics.accumulated_cost:.4f}")
print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
```
### Key Functions
#### `load_skills_from_dir()`
Loads all skills from a directory, returning three dictionaries:
```python icon="python" focus={3}
from openhands.sdk.skills import load_skills_from_dir
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(skills_dir)
```
| Return Value | Source Files | Injection Behavior |
|--------------|--------------|-------------------|
| **repo_skills** | `repo.md`, `AGENTS.md`, `.cursorrules` | Full content in `` in the initial system prompt; included in LLM context for each turn |
| **knowledge_skills** | `knowledge/` subdirectories, `*.md` with triggers | Listed in ``, auto-inject on trigger |
| **agent_skills** | `SKILL.md` files (AgentSkills standard) | Listed in ``, agent calls `invoke_skill()` |
When passing to `AgentContext(skills=...)`, all three types are accepted. The injection behavior depends on the skill's `is_agentskills_format` flag and `trigger` field — see [Skill Injection Behavior](#skill-injection-behavior).
#### `discover_skill_resources()`
Discovers resource files in a skill directory:
```python icon="python" focus={3}
from openhands.sdk.skills import discover_skill_resources
resources = discover_skill_resources(skill_dir)
print(resources.scripts) # List of script files
print(resources.references) # List of reference files
print(resources.assets) # List of asset files
print(resources.skill_root) # Path to skill directory
```
### Skill Location in Prompts
The `` element in `` follows the AgentSkills standard, allowing agents to read the full skill content on demand. When a triggered skill is activated, the content is injected with the location path:
```
The following information has been included based on a keyword match for "encrypt".
Skill location: /path/to/rot13-encryption
(Use this path to resolve relative file references in the skill content below)
[skill content from SKILL.md]
```
This enables skills to reference their own scripts and resources using relative paths like `./scripts/encrypt.sh`.
### Example Skill: ROT13 Encryption
Here's a skill with triggers (OpenHands extension):
**SKILL.md:**
```markdown icon="markdown"
---
name: rot13-encryption
description: >
This skill helps encrypt and decrypt messages using ROT13 cipher.
triggers:
- encrypt
- decrypt
- cipher
---
# ROT13 Encryption Skill
Run the [encrypt.sh](scripts/encrypt.sh) script with your message:
\`\`\`bash
./scripts/encrypt.sh "your message"
\`\`\`
```
**scripts/encrypt.sh:**
```bash icon="sh"
#!/bin/bash
echo "$1" | tr 'A-Za-z' 'N-ZA-Mn-za-m'
```
When the user says "encrypt", the skill is triggered and the agent can use the provided script.
## Loading Public Skills
OpenHands maintains a [public skills repository](https://github.com/OpenHands/extensions) with community-contributed skills. You can automatically load these skills without waiting for SDK updates.
### Automatic Loading via AgentContext
Enable public skills loading in your `AgentContext`:
```python icon="python" focus={2}
agent_context = AgentContext(
load_public_skills=True, # Auto-load from public registry
skills=[
# Your custom skills here
]
)
```
When enabled, the SDK will:
1. Clone or update the public skills repository to `~/.openhands/cache/skills/` on first run
2. Load all available skills from the repository
3. Merge them with your explicitly defined skills
### Skill Naming and Triggers
**Skill Precedence by Name**: If a skill name conflicts, your explicitly defined skills take precedence over public skills. For example, if you define a skill named `code-review`, the public `code-review` skill will be skipped entirely.
**Multiple Skills with Same Trigger**: Skills with different names but the same trigger can coexist and will ALL be activated when the trigger matches. To add project-specific guidelines alongside public skills, use a unique name (e.g., `custom-codereview-guide` instead of `code-review`). Both skills will be triggered together.
```python icon="python"
# Both skills will be triggered by "/codereview"
agent_context = AgentContext(
load_public_skills=True, # Loads public "code-review" skill
skills=[
Skill(
name="custom-codereview-guide", # Different name = coexists
content="Project-specific guidelines...",
trigger=KeywordTrigger(keywords=["/codereview"]),
),
]
)
```
**Skill Activation Behavior**: When multiple skills share a trigger, all matching skills are loaded. Content is concatenated into the agent's context with public skills first, then explicitly defined skills. There is no smart merging—if guidelines conflict, the agent sees both.
### Programmatic Loading
You can also load public skills manually and have more control:
```python icon="python"
from openhands.sdk.skills import load_public_skills
# Load all public skills
public_skills = load_public_skills()
# Use with AgentContext
agent_context = AgentContext(skills=public_skills)
# Or combine with custom skills
my_skills = [
Skill(name="custom", content="Custom instructions", trigger=None)
]
agent_context = AgentContext(skills=my_skills + public_skills)
```
### Custom Skills Repository
You can load skills from your own repository:
```python icon="python" focus={3-7}
from openhands.sdk.skills import load_public_skills
# Load from a custom repository
custom_skills = load_public_skills(
repo_url="https://github.com/my-org/my-skills",
branch="main"
)
```
### How It Works
The `load_public_skills()` function uses git-based caching for efficiency:
- **First run**: Clones the skills repository to `~/.openhands/cache/skills/public-skills/`
- **Subsequent runs**: Pulls the latest changes to keep skills up-to-date
- **Offline mode**: Uses the cached version if network is unavailable
This approach is more efficient than fetching individual skill files via HTTP and ensures you always have access to the latest community skills.
Explore available public skills at [github.com/OpenHands/extensions](https://github.com/OpenHands/extensions). These skills cover various domains like GitHub integration, Python development, debugging, and more.
## Customizing Agent Context
### Message Suffixes
Append custom instructions to the system prompt or user messages via `AgentContext`:
```python icon="python"
agent_context = AgentContext(
system_message_suffix="""
Repository: my-project
Branch: feature/new-api
""".strip(),
user_message_suffix="Remember to explain your reasoning."
)
```
- **`system_message_suffix`**: Appended to system prompt (always active, combined with repo skills)
- **`user_message_suffix`**: Appended to each user message
### Replacing the Entire System Prompt
For complete control, provide a custom Jinja2 template via the `Agent` class:
```python icon="python" focus={6}
from openhands.sdk import Agent
agent = Agent(
llm=llm,
tools=tools,
system_prompt_filename="/path/to/custom_system_prompt.j2", # Absolute path
system_prompt_kwargs={"cli_mode": True, "repo_name": "my-project"}
)
```
**Custom template example** (`custom_system_prompt.j2`):
```jinja2
You are a helpful coding assistant for {{ repo_name }}.
{% if cli_mode %}
You are running in CLI mode. Keep responses concise.
{% endif %}
Follow these guidelines:
- Write clean, well-documented code
- Consider edge cases and error handling
- Suggest tests when appropriate
```
**Key points:**
- Use relative filenames (e.g., `"system_prompt.j2"`) to load from the agent's prompts directory
- Use absolute paths (e.g., `"/path/to/prompt.j2"`) to load from any location
- Pass variables to the template via `system_prompt_kwargs`
- The `system_message_suffix` from `AgentContext` is automatically appended after your custom prompt
## Dynamic Command Execution
Skills support inline shell command execution for injecting dynamic context at render time. This is useful for including repository state, environment information, or computed values in skill content.
**Security**: Commands execute with full shell privileges. Only use this feature with trusted skill sources. User-provided content should never be passed to command execution.
### Basic Syntax
Use `` !`command` `` to execute a shell command and replace it with stdout:
```markdown icon="markdown"
---
name: repo-context
description: Injects current repository state
triggers:
- git
- commit
---
# Repository Context
Current branch: !`git branch --show-current`
Last commit: !`git log -1 --oneline`
```
When triggered, the skill content becomes:
```markdown icon="markdown"
# Repository Context
Current branch: main
Last commit: a1b2c3d Fix authentication bug
```
### Safety Rules
**Code blocks are never executed.** Both fenced and inline code blocks are preserved:
````markdown icon="markdown"
# Safe Examples
Regular inline code: `git status` → preserved as-is
Fenced block: → preserved as-is
```bash
!`echo "not executed"`
```
Dynamic command: !`echo "executed"` → replaced with "executed"
````
**Unclosed fenced blocks protect trailing content.** If a fenced block isn't closed (odd number of ``` delimiters), everything after it is treated as inside the fence:
````markdown icon="markdown"
```bash
!`echo "inside fence - not executed"`
```
!`echo "between fences - executed"`
```bash
!`echo "unclosed fence - not executed"`
````
### Escape Syntax
Use `` \!`cmd` `` to output the literal text `` !`cmd` `` without execution:
```markdown icon="markdown"
# Documenting the Syntax
To execute a command, use \!`command` syntax.
For example: \!`git status` shows the current git state.
```
Output:
```markdown
# Documenting the Syntax
To execute a command, use !`command` syntax.
For example: !`git status` shows the current git state.
```
### Error Handling
Failed commands return inline error markers:
| Scenario | Output |
|----------|--------|
| Command fails | `[Error: Command `xyz` exited with code 1: error message]` |
| Command times out | `[Error: Command `xyz` timed out after 10s]` |
| Large output (>50KB) | Output truncated with `... [output truncated]` |
### Programmatic Rendering
When using skills programmatically, call `render_content()` to execute commands:
```python icon="python" focus={6-7}
from openhands.sdk.context import Skill
skill = Skill.load("/path/to/skill/SKILL.md")
# Render with command execution
rendered = skill.render_content(working_dir="/path/to/repo")
print(rendered) # Commands replaced with output
```
The `working_dir` parameter sets the current directory for command execution, enabling workspace-relative commands like `git status`.
## Migrating from Legacy to AgentSkills Format
If you have legacy inline skills consuming many tokens, convert them to AgentSkills format for progressive disclosure:
### Before (Legacy Format)
```python icon="python"
# Legacy: Full content in in the initial system prompt
Skill(
name="api-guidelines",
content="""
# API Guidelines
... 2000 lines of detailed documentation ...
""",
trigger=None, # Always-on context - affects token usage on each turn!
)
```
### After (AgentSkills Format)
Create a directory `api-guidelines/SKILL.md`:
```markdown icon="markdown"
---
name: api-guidelines
description: Comprehensive API design guidelines for the project. Invoke when designing or reviewing API endpoints.
---
# API Guidelines
... 2000 lines of detailed documentation ...
```
Then load it:
```python icon="python"
from openhands.sdk.skills import load_skills_from_dir
# AgentSkills: Only description in prompt, agent reads full content on demand
_, _, skills = load_skills_from_dir("/path/to/skills")
agent_context = AgentContext(skills=list(skills.values()))
```
### Benefits
| Aspect | Legacy `trigger=None` | AgentSkills `SKILL.md` |
|--------|----------------------|------------------------|
| Token usage | Full content in system prompt; included in LLM context for each turn | Description only (~100 chars) |
| Model control | None — always present | Agent decides when to read |
| Scalability | Limited by context window | Many skills without token bloat |
## Next Steps
- **[Custom Tools](/sdk/guides/custom-tools)** - Create specialized tools
- **[MCP Integration](/sdk/guides/mcp)** - Connect external tool servers
- **[Confirmation Mode](/sdk/guides/security)** - Add execution approval
### Task Tool Set
Source: https://docs.openhands.dev/sdk/guides/task-tool-set.md
import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx";
> A ready-to-run example is available [here](#ready-to-run-example)!
## Overview
The TaskToolSet lets a parent agent launch sub-agents that handle complex, multi-step tasks autonomously. Each sub-agent runs **synchronously** — the parent blocks until the sub-agent finishes and returns its result. Sub-agents can be **resumed** later using a task ID, preserving their full conversation context.
This pattern is useful when:
- Delegating specialized work to purpose-built sub-agents
- Breaking a problem into sequential steps handled by different experts
- Maintaining conversational context across multiple interactions with a sub-agent
- Isolating sub-task complexity from the parent agent's context
TaskToolSet is designed for **sequential** blocking tasks.
## How It Works
The agent calls the task tool with a prompt and a sub-agent type. The TaskManager creates (or resumes) a sub-agent conversation, runs it to completion, and returns the result to the parent.
```mermaid
sequenceDiagram
participant P as Parent Agent
participant T as TaskManager
participant S as Sub-Agent
P->>T: task(prompt, type)
activate T
T->>S: create / resume
activate S
Note over S: runs autonomously
S->>T: result
deactivate S
T->>P: TaskObservation
deactivate T
Note right of T: persists for resume
```
### Task Lifecycle
1. **Creation**: A fresh sub-agent and conversation are created
2. **Running**: The sub-agent processes the prompt autonomously
3. **Completion**: The final response is extracted and returned
4. **Persistence**: The conversation is saved to disk for potential resumption
5. **Resumption** (optional): A previous task can be resumed with its full context preserved
## Setting Up the TaskToolSet
### Register Custom Sub-Agent Types (Optional)
By default, a `"default"` general-purpose agent is available, but you can register your own custom types
for specialized behavior:
```python icon="python" focus={23-27}
from openhands.sdk import LLM, Agent, AgentContext
from openhands.sdk.context import Skill
from openhands.sdk.subagent import register_agent
def create_code_reviewer(llm: LLM) -> Agent:
return Agent(
llm=llm,
tools=[],
agent_context=AgentContext(
skills=[
Skill(
name="code_review",
content="""You are an expert code reviewer.
Analyze code for bugs, style issues,
and suggest improvements.
""",
trigger=None,
)
],
),
)
register_agent(
name="code_reviewer",
factory_func=create_code_reviewer,
description="Reviews code for bugs, style issues, and improvements.",
)
```
### Add TaskToolSet to the Agent
```python icon="python" focus={6}
from openhands.sdk import Agent, Tool
from openhands.tools.task import TaskToolSet
agent = Agent(
llm=llm,
tools=[Tool(name=TaskToolSet.name)],
)
```
The tool auto-registers on import — no explicit `register_tool()` call is needed.
### Create a Conversation
```python icon="python" focus={5-9}
from openhands.sdk import Conversation
from openhands.tools.delegate import DelegationVisualizer
from pathlib import Path
conversation = Conversation(
agent=agent,
workspace=Path.cwd(),
visualizer=DelegationVisualizer(name="Orchestrator"),
)
```
The `DelegationVisualizer` is optional but recommended — it shows the multi-agent conversation flow in the terminal.
## Tool Parameters
When the parent agent calls the task tool, it provides these parameters:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `prompt` | `str` | Yes | The instruction for the sub-agent |
| `subagent_type` | `str` | No | Which registered agent type to use (default: `"default"`) |
| `description` | `str` | No | Short label (3-5 words) for display and tracking |
| `resume` | `str` | No | Task ID from a previous invocation to continue |
## Task Observation
The tool returns a `TaskObservation` containing:
| Field | Description |
|-------|-------------|
| `task_id` | Unique identifier (e.g., `task_00000001`) — use this for resumption |
| `subagent` | The agent type that handled the task |
| `status` | Final status: `completed` or `error` |
| `text` | The sub-agent's response (or error message) |
## Resuming Tasks
A key feature of TaskToolSet is the ability to resume a previously completed task. When a task finishes, its conversation is persisted to disk. Passing the `resume` parameter with the task ID reloads the full conversation history, allowing the sub-agent to continue where it left off.
```python icon="python"
# First call — sub-agent generates a quiz question
conversation.send_message(
"Use the task tool with subagent_type='quiz_expert' to generate "
"a multiple-choice question about zebras."
)
conversation.run()
# The agent receives task_id "task_00000001" in the observation
# Second call — resume the same sub-agent to verify the answer
conversation.send_message(
"The user answered A. Use the task tool with resume='task_00000001' "
"to ask the same sub-agent whether that answer is correct."
)
conversation.run()
```
## Ready-to-run Example
This example is available on GitHub: [examples/01_standalone_sdk/41_task_tool_set.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/41_task_tool_set.py)
```python icon="python" expandable examples/01_standalone_sdk/40_task_tool_set.py
"""
Animal Quiz with Task Tool Set
Demonstrates the TaskToolSet with a main agent delegating to an
animal-expert sub-agent. The flow is:
1. User names an animal.
2. Main agent delegates to the "animal_expert" sub-agent to generate
a multiple-choice question about that animal.
3. Main agent shows the question to the user.
4. User picks an answer.
5. Main agent resumes the same sub-agent to check whether the answer
is correct and explain why.
"""
import os
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool
from openhands.sdk.context import Skill
from openhands.sdk.subagent import register_agent
from openhands.tools.delegate import DelegationVisualizer
from openhands.tools.task import TaskToolSet
# ── LLM setup ────────────────────────────────────────────────────────
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL", None),
)
# ── Register the animal expert sub-agent ─────────────────────────────
def create_animal_expert(llm: LLM) -> Agent:
"""Factory for the animal-expert sub-agent."""
return Agent(
llm=llm,
tools=[], # no tools needed – pure knowledge
agent_context=AgentContext(
skills=[
Skill(
name="animal_expertise",
content=(
"You are a world-class zoologist. "
"When asked to generate a quiz question, respond with "
"EXACTLY this format and nothing else:\n\n"
"Question: \n"
"A)