Skip to content

Custom tools

The API a tool of your own is written against. Generated from the docstrings in src/keryx/tools/. Voice tools explains where the files go.

keryx.tools.custom

The owner's own voice tools: Python files in DATA_DIR/tools, read at every call.

The built-in tools are Keryx's and live in this package. These are the owner's — written by them, or by a subagent they asked — and live beside their other data, never in the repository, so a clone of Keryx carries none of them and theirs are never committed.

A tool file is one self-contained module that defines one or more tools with the decorator here::

from keryx.tools.custom import custom_tool

@custom_tool(
    description="Say the phase of the moon tonight. Answer in one sentence.",
    needs_pin=False,
)
async def moon_phase(ctx, args):
    return {"phase": "waxing gibbous"}

The directory is read afresh at the top of every call (ToolRegistry.for_call), so a new or edited tool is there from the next call, with no restart. Everything that can go wrong with a file costs that file and is logged — never the call, and never another tool:

  • a file that will not import, or defines nothing, is skipped; one that raises ToolUnavailable while it loads is skipped with the reason it gave (a plugin that is missing a sign-in says which);
  • a name that is already taken — by any built-in tool, offered on this machine or not, or by an earlier file — is refused, so an owner's tool can never stand in for dispatch_task or submit_pin;
  • a file or directory anyone but the owner could write is refused unread, because loading it runs its code inside the service;
  • a handler that raises, hangs past timeout_s or returns something that is not JSON comes back to the model as an error it can say.

needs_pin is the gate, and it defaults to True: a tool that acts, or reads anything of the owner's, answers only once the PIN is given (pin_gate). needs_pin=False answers anyone who rings — for tools that only read what is public anyway. The finer tiers the built-in tools use (keryx.trust) are deliberately not offered here: a yes or a no is a decision an owner can check at a glance.

keryx tools prints what the directory holds and what was refused, which is how a subagent checks its work before saying it is done.

The plugins (keryx.plugins) are files of exactly this kind, written by keryx plugins install: a line that calls into the package, and a TOML file of settings beside it. While a file loads, LOADING_SETTINGS holds the settings of whoever is loading it — the running service, or the command asking — so a plugin reads the same configuration they do.

CustomTool dataclass

One tool a file defines: what the model is told, and what runs.

ToolUnavailable

Bases: Exception

Raised while a tool file loads to refuse it, with the reason as the whole message.

For a file that is fine but cannot answer on this machine yet — not signed in, nothing configured — so keryx tools says what to do rather than printing a traceback.

custom_tool

custom_tool(name: str | None = None, *, description: str, parameters: dict | None = None, needs_pin: bool = True, silent: bool = False, timeout_s: float = DEFAULT_TIMEOUT_S) -> Callable[[CustomHandler], CustomTool]

Make the decorated function a voice tool, named after it unless name is given.

The function takes (ctx, args) — the ToolContext of the call and the model's arguments — and returns a dict for the model to speak from. It may be async or not; a plain function runs in a thread, so it may block.

keryx.tools.registry

The tool layer the model calls into.

A tool is a name + an OpenAI function schema + an async handler. The registry is the only thing the voice session knows about tools: it hands the schemas to the provider at connect time and routes FunctionCall events to call().

call() never raises (except cancellation): a missing tool or a broken handler comes back as {"error": ...} so the model can apologize and carry on instead of the session dying on a bug in one tool.

A tool may also be silent. Submitting a tool result normally asks the provider for a new response, which is why every call the model makes costs a spoken turn — and why pure bookkeeping (mark_reported) used to make it say the thing it had just said all over again. silent=True says "this result has nothing to speak about": the session submits the output without asking for a response, and the model's next turn is the caller's. Only for tools the model calls after it has spoken, never for one whose answer they are waiting to hear. A tool the model may call either way — before it has finished, or after — passes a function of the call's arguments instead, and the model says which in the call (mark_reported's still_to_say): a turn left unasked-for is a call that goes quiet with the news half told.

ToolContext dataclass

What a handler gets to know about the session it was called from.

session is duck-typed (.authorized, .trust, .channel, .caller, .session_id, .opening_task_id, .request_end(), .authorize()) so tools can be tested with a stub. authorized and trust are properties rather than snapshots: a PIN entered during a long-running tool call must be visible to the next check.

trust property

trust: TrustLevel

What this call has proved, right now (keryx.trust).