View source on GitHub
tool_module_qualnames mechanism.
The example ships a small demo tool (the Bureau of Bug Registration) and proves,
end to end, that the agent registers, uses, and reports output from it.
Why this tool? It does something an LLM would never produce on its own and could not
fake: it assigns each bug a deterministic, hash-derived Case ID (e.g. BUG-59C20D)
plus a gloriously bureaucratic classification. Because the Case ID is a SHA-256 slice
of the bug report, the only way the agent’s answer can contain the correct ID is if
it actually called the tool — making the demonstration unfalsifiable.
How It Works
- Create a sandbox (
POST /api/v1/sandboxes) and wait forRUNNING. Read theAGENT_SERVERURL and thesession_api_keyfrom the sandbox record. - Deploy the tool as an importable package in the conversation’s working
directory,
/workspace/bug_registry/, using the agent-server file API (POST /api/file/upload?path=...). Two files are uploaded:__init__.pyandtool.py(the contents ofcustom_tool_definition.py). - Create a conversation (
POST /api/conversations) that lists the tool inagent.toolsand maps it intool_module_qualnames. - Run and verify (
POST .../run, then read events). The custom tool must show up in theSystemPromptEvent.toolslist (registered) and as anActionEventtool_name(used).
Why upload into the working directory (and not pip install)?
On OpenHands Cloud the agent-server is a frozen, self-contained binary (built
with PyInstaller). A normal pip install targets a different Python interpreter
that the frozen server cannot see, so the module would never be importable and the
tool would fail to register.
The conversation’s working directory is on the agent-server’s import path, so
dropping the package there makes import rubber_duck.tool work with no install
step. The tool’s own imports (openhands.sdk, pydantic) resolve from inside the
frozen server, where they’re always available.
Uploading the file (rather than writing it through a shell heredoc) also means the
tool source can contain anything — quotes, EOF markers, backslashes — with no
escaping or injection pitfalls.
The Core Idea: Declare + Locate
Creating a conversation with a custom tool is a two-part contract:agent.tools— which tools to activate, by name:[{"name": "terminal"}, {"name": "file_editor"}, {"name": "bug_registry"}]tool_module_qualnames— where a tool comes from, as an importable module:{"bug_registry": "bug_registry.tool"}
register_tool("bug_registry", BugRegistryTool) call
runs and registers the tool. If the name is requested but nothing registered it,
the server returns ToolDefinition '<name>' is not registered.
Built-in tools (terminal, file_editor, …) don’t need an entry — the server
already knows where those live. Only your custom tools need a qualname mapping.
Prerequisites
Run It
Verifying Registration (Same Technique as custom-agent-no-browser)
The agent-server records a SystemPromptEvent whose tools array is the exact set
of tools the agent was given. Both examples read it the same way:
The Example Tool
custom_tool_definition.py defines a bug_registry tool with the OpenHands SDK: a
ToolDefinition + typed Action/Observation + an Executor. It takes a problem
(and optional code) and deterministically derives a hash-based Case ID plus an
absurd bureaucratic classification (“Haunted Copy-Paste Residue”, “Gaslighting Boolean”,
etc.) from the SHA-256 of the report. Importing the module registers the tool.
The prompt in working_example.py is directive — it explicitly asks the agent to
file the bug and report the official Case ID — so the happy path is deterministic and
unfalsifiable: the hash-derived ID can only come from the tool. This proves the tool is
registered, callable, returns output, and that output demonstrably reaches the agent’s
answer.
Files
Cleanup
Without--keep the sandbox is deleted automatically. With --keep, delete it when
done:
Note:
DELETE /api/v1/sandboxes/{id} requires sandbox_id as both the path
segment and a query parameter; omitting the query parameter returns HTTP 422 and
leaks the sandbox.
