Skip to content

Writing a Tool

A tool is a Python class in jenny/agent/tools/ that the LLM can call; this page walks through registering one, its anatomy, and what a PR needs before it can merge.

Every built-in tool is a Tool subclass (jenny/agent/tools/base.py) living in a module under jenny/agent/tools/. There is no plugin directory scanned at runtime and no entry-point discovery: the set of tool modules is a fixed, hardcoded list, and each module explicitly declares which classes it exports as tools.

This is a deliberate fork boundary (see FORK_BOUNDARY.md): upstream nanobot used implicit dir() reflection to find tool classes, which is order-sensitive and lets a typo silently drop a tool. Jenny replaced that with two explicit lists that fail loudly instead.

jenny/agent/tools/loader.py defines _HARDCODED_TOOL_MODULES, the list of module names under jenny.agent.tools that get imported at startup:

_HARDCODED_TOOL_MODULES = [
"filesystem", "python_exec", "android_web", "download", "location",
"long_task", "spawn", "cron", "self", "search", "message",
"apply_patch", "exec_session", "introspect", "diagnostics", "ui_view",
]

To add a new tool:

  1. Write your Tool subclass in a new module (or add it to an existing one if it’s closely related, e.g. another filesystem operation).
  2. Add a module-level TOOLS = [YourToolClass, ...] list at the bottom of the file — every module ToolLoader imports must declare this, or startup raises ToolLoadError: Tool module '<name>' does not declare a TOOLS list. This is the single point the loader reads instead of scanning the module’s attributes.
  3. If it’s a brand-new module (not an addition to an existing one), add its name to _HARDCODED_TOOL_MODULES in loader.py.

One module can declare more than one tool in its TOOLS list — filesystem.py, search.py, and exec_session.py each export several.

ToolLoader.load() registers each discovered tool by tool.name. If two tools resolve to the same name, registration raises and the gateway does not boot:

ToolLoadError: Tool name collision: '<name>' from <ClassName> conflicts with an already-registered tool.

There is no silent overwrite and no skip-and-log — this used to be a warning that clobbered the earlier registration, which is exactly the kind of bug that’s invisible until the wrong tool runs. Pick a name that doesn’t already exist in another module (grep -rn ' name = "' jenny/agent/tools/ is a quick way to check).

ToolLoadError (a RuntimeError subclass, defined in loader.py) marks the failures the loader treats as fatal, and it is never caught by the loader:

  • a name collision;
  • a module without a TOOLS list;
  • a TOOLS entry that isn’t a Tool subclass.

These are programming errors: deterministic, reproducible on every boot, and catchable in a test. Failing loudly is cheaper than shipping a gateway that’s quietly missing a tool.

Tolerated failures are the other half of the contract, and the distinction is explicit in load(). If your enabled() or create() raises, that depends on the runtime environment (config shape, an Android service that isn’t there), and the gateway is the only way the user has to fix it — so that single tool is dropped for the run instead of taking the process down. It is not silent:

  • the exception is logged at ERROR with its traceback (Tool <ClassName> disabled: create() raised), which also lands in the get_recent_logs ring buffer;
  • one summary ERROR line lists everything dropped;
  • the failures stay inspectable as ToolLoadFailure(tool, stage, error) entries on ToolLoader.failures.

After adding a tool, check loader.failures (or the startup log) rather than assuming “no crash” means “registered”.

Jenny App actions (<slug>_<action>, see Write a mini-app) are re-synced into the same registry every turn and follow a different rule: a name colliding with a built-in tool is skipped with a warning rather than raising, so a malformed or malicious app manifest can’t crash the gateway or shadow a core tool.

GetLocationTool (jenny/agent/tools/location.py) is a compact, complete example — it shows every piece a tool typically needs:

@tool_parameters(
tool_parameters_schema(
precise=BooleanSchema(
description="Force a fresh GPS fix (turns the radio on, costs battery)...",
default=False,
),
required=[],
)
)
class GetLocationTool(Tool):
_scopes = {"core", "subagent"}
name = "get_location"
description = "Get the user's current location (reverse-geocoded place plus latitude/longitude)..."
config_key = "location"
@classmethod
def config_cls(cls):
return LocationConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return (
bool(ctx.android_context)
and getattr(ctx.config, "location", None) is not None
and ctx.config.location.enable
)
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(config=ctx.config.location)
def __init__(self, config: Any = None):
self.config = config
@property
def read_only(self) -> bool:
return True
async def execute(self, precise: bool = False, **kwargs: Any) -> str:
...

Piece by piece:

Piece Purpose
name, description Required abstract properties on Tool; description is what the LLM reads to decide when to call it — write it for the model, not for a human changelog.
@tool_parameters(...) Class decorator (jenny/agent/tools/base.py) that attaches a JSON Schema and synthesizes the parameters property for you, instead of hand-writing @property def parameters(self): return {...}. Build the schema with the helpers in jenny/agent/tools/schema.py (StringSchema, BooleanSchema, tool_parameters_schema, etc.).
_scopes Which execution contexts register this tool — see below. Defaults to {"core"} if omitted.
config_key / config_cls() Declares which config section (if any) backs this tool’s settings, as a self-description hook. config_cls() defaults to None on Tool; override it to return the Pydantic-compat config class (see the config toggle pattern below). Nothing in the framework currently reads these back automatically — they document the tool’s config binding for humans and future tooling, not a live wiring mechanism.
enabled(ctx) classmethod Gate that decides whether this tool registers at all for a given ToolContext — return False and the tool simply doesn’t exist in that run (no error, no stub). Defaults to always True on Tool.
create(ctx) classmethod Builds the tool instance from a ToolContext (workspace path, config, bus, subagent manager, cron service, …). Defaults to cls() (no-arg construction) — override it whenever your tool needs config or workspace access.
read_only property Defaults to False. Set it to True when the tool has no side effects — the runner uses this to decide what can run concurrently with other read-only tools (see concurrency_safe / exclusive on Tool).
async def execute(self, **kwargs) The actual work. Return a string (or a list of content blocks); raise nothing you don’t want surfaced verbatim to the model — tools are expected to catch their own failures and return an "Error: ..." string rather than let an exception escape.

DownloadFileTool (jenny/agent/tools/download.py) is a second useful reference: it shows a tool that owns an injectable httpx.AsyncClient (__init__(self, workspace, client=None)) purely so tests can pass an httpx.MockTransport instead of hitting the network — a pattern worth copying for any tool that talks to the outside world.

_scopes on the class controls which registries a tool ends up in:

  • "core" — the main agent loop, i.e. the conversation the user is actually having.
  • "subagent" — spawned subagents (spawn tool, jenny/agent/subagent.py), which run with a separate ToolRegistry built via ToolLoader().load(ctx, registry, scope="subagent").

A tool with _scopes = {"core", "subagent"} (the common case for read/write file tools, search, download, location, exec sessions, …) is available in both. A handful of tools are intentionally core-only — spawn itself doesn’t register inside a subagent (no recursive spawning), and things tied to the primary conversation (e.g. message, cron) generally aren’t handed to subagents either. If you don’t set _scopes at all, the tool defaults to core-only.

Subagents are blind to the main conversation history and get their own scoped ToolsConfig (see AgentLoop._build_subagent_context / jenny/agent/subagent.py) — keep that in mind if your tool’s enabled()/create() assumes the full config shape.

Tool-specific settings are Pydantic-compat Base dataclasses in jenny/config/tool_schemas.py — deliberately a light module (only imports Base + Field, no tool code) so config/schema.py and the tool modules can both import from it without an import cycle:

class LocationConfig(Base):
enable: bool = True
telegram_ttl_s: int = Field(default=3600, ge=60) # 1 h
fresh_timeout_s: int = Field(default=15, ge=1, le=60)

To wire a new toggle:

  1. Add a <Thing>Config(Base) class to tool_schemas.py with your fields and defaults (use Field(..., ge=..., le=...) for anything that needs range validation).
  2. Add a field for it on ToolsConfig in jenny/config/schema.py (location: LocationConfig = Field(default_factory=LocationConfig) is the existing example). ToolContext.config is set to the app’s ToolsConfig instance directly (see AgentLoop._register_default_tools in jenny/agent/loop.py), so inside a tool it’s reached as ctx.config.location, not ctx.config.tools.location; from the top-level Config object elsewhere in the codebase it’s config.tools.location.
  3. In your tool, use enabled(ctx) to check the toggle (ctx.config.location.enable) and create(ctx) to hand the config object to the instance.
  4. Set config_key and override config_cls() on the tool class so the binding is self-documented next to the tool, even though nothing currently consumes those two attributes automatically.

camelCase aliases (e.g. a JSON key like freshTimeoutS) are handled by the Base/Field layer the same way as the rest of the config — see Configuration reference for how the alias system works; you don’t need anything tool-specific for it.

Tests mirror the package layout: jenny/agent/tools/download.pytests/agent/tools/test_download.py. When you add a tool, add its test file at the matching path under tests/agent/tools/.

What existing tests check, worth copying:

  • Unit-test execute() directly, constructing the tool with Tool.create-equivalent arguments rather than going through the full AgentRunner.
  • Mock outbound I/O. test_download.py builds an httpx.MockTransport and monkeypatches validate_url_target to bypass DNS/SSRF resolution in tests (monkeypatch.setattr(download_mod, "validate_url_target", lambda url: (True, None))) — don’t let a tool’s test suite make real network calls.
  • Test the registration surface too, not just execute(): tests/agent/tools/test_tool_loader.py checks the defaults every Tool subclass gets (config_cls() is None, config_key == "", enabled(None) is True, _plugin_discoverable is True) and exercises ToolContext’s required fields — useful as a checklist for anything you override.
  • The loader’s failure policy is under testtests/agent/tools/test_tool_loader.py asserts that a collision and a module without TOOLS raise ToolLoadError out of load(), that a failing enabled()/create() is recorded in ToolLoader.failures instead, and that the shipped tool set loads with failures == []. If you touch that code path, extend those tests rather than relying on it only failing at gateway startup.

Run the full check from Code style:

Terminal window
ruff check jenny/ tests/
npx pyright jenny/bus jenny/command jenny/runtime jenny/session
pytest -q

A new tool module isn’t in the blocking pyright subset (jenny/bus, jenny/command, jenny/runtime, jenny/session) unless you’re touching one of those packages directly, but run the full, non-blocking npx pyright || true too and don’t introduce new errors in jenny/agent/tools/.

  • Tool reference — every built-in tool, its parameters, and its config toggle.
  • Write a mini-app — the other way to give the agent a new capability, without touching Python.
  • Testing / Code style — the checks a tool PR has to pass.

Jenny · code underAGPL-3.0 · name and mascot undertrademark

No cookies, no trackers, no analytics. This page loads nothing from a third party.