Skip to content

Custom Tools

William Koch edited this page Sep 30, 2026 · 5 revisions

A custom tool is a Python file that gives the voice model a new ability it can use during the call. For example, it could check the tide, read out a countdown, or check a device on your network. It answers within a second or two, before a silence gets awkward. A dispatched task would take half a minute. For anything that can wait, a task is still the better choice. See how the voice model decides between answering and starting a task.

A custom tool belongs to you, not to Keryx. It's stored with your data, isn't committed to the repository, and isn't included when someone else clones Keryx. The plugins are custom tools of the same kind (see plugins are custom tools too).

Asking for one out loud

The simplest way to get a custom tool is to ask for it during a call:

"Give yourself a way to tell me the next two trains from my station."

This is an ordinary task. Like every dispatch from the phone, it needs the PIN. Every subagent is told where custom tools belong and is pointed to skills/keryx-custom-tools/SKILL.md, which describes the complete contract: the file, the wording, credentials, and how to test the tool. The subagent writes the file, runs keryx tools until the file loads, and tells you what you can ask for. The tool is available from your next call.

Where they live

Custom tools live in DATA_DIR/tools, which is ~/.local/share/keryx/tools/ by default. The first line of keryx tools output shows the directory on your machine. Keryx creates it with mode 0700.

Each call reads that directory again when it starts, so a new, edited, or deleted tool takes effect from the next call without a restart. The loader reads only the *.py files directly inside the directory. It skips a file whose name starts with _, such as a draft, and ignores subdirectories. Other files, such as a .toml of settings or a .key, can be stored beside the tools.

Writing one by hand

A tool file is a self-contained module. Decorate a function with @custom_tool. The following example is complete and loads as written. It's a countdown to the dates listed in a settings file beside the tool:

~/.local/share/keryx/tools/countdown.py

"""How many days until the dates in countdown.toml, beside this file."""

import datetime as dt
import tomllib
from pathlib import Path

from keryx.tools.custom import custom_tool

DATES = Path(__file__).with_suffix(".toml")


@custom_tool(
    description=(
        "How many days until one of the owner's dates: a trip, a deadline, a birthday. "
        "Use it when they ask how long it is until one of them. Say the number of days "
        "in one sentence; do not read the date back unless they ask for it."
    ),
    parameters={
        "type": "object",
        "properties": {
            "event": {
                "type": "string",
                "description": "The date they asked about, in their words, e.g. 'the trip'.",
            },
        },
        "required": ["event"],
    },
)
def days_until(ctx, args):
    dates = tomllib.loads(DATES.read_text(encoding="utf-8"))
    wanted = str(args.get("event", "")).strip().lower()
    for name, day in dates.items():
        if wanted and (name.lower() in wanted or wanted in name.lower()):
            return {"event": name, "days": (day - dt.date.today()).days}
    return {"error": f"There is no date called {wanted}. I know: {', '.join(dates)}."}

~/.local/share/keryx/tools/countdown.toml

# The dates days_until knows: a name you would say, then the date (YYYY-MM-DD, unquoted).
trip = 2026-11-14
tax-return = 2027-01-31

The tool is named days_until, after the function. It keeps the default needs_pin=True because the dates are yours. After you save both files, check the tool, then run its handler once yourself from the Keryx checkout:

uv run keryx tools
uv run python -c "import runpy, pathlib; \
  tool = runpy.run_path(str(pathlib.Path('~/.local/share/keryx/tools/countdown.py').expanduser()))['days_until']; \
  print(tool.handler(None, {'event': 'the trip'}))"

The decorator

custom_tool is defined in src/keryx/tools/custom.py.

Argument Default What it is
name the function's name What the model calls it: letters, digits, _ and -, up to 64 characters
description required Everything the model knows about the tool. Must not be empty
parameters no arguments A JSON-schema object ("type": "object") for the arguments
needs_pin True The gate (see the gate: needs_pin). Must be exactly True or False
timeout_s 20 How long the caller waits before the model is told the tool didn't answer
silent False Submit the result without a spoken turn. Only for bookkeeping the caller has already heard. Almost never needed

Write the description for a voice model. It should say when to call the tool, how to say the result (usually one sentence), and what not to say. Keryx follows one rule here: one action is one sentence. The common failure isn't silence, but the model restating a result or reading out fields nobody asked for. The skill covers the wording in detail.

If a tool may need more than timeout_s seconds, it isn't a voice tool. Ask for that work as a task instead.

The handler

The handler receives (ctx, args):

  • args is a dict of the model's arguments, shaped by parameters.
  • ctx is the call's ToolContext: ctx.trust (what the call has proved, see Security Model), ctx.channel ("phone" or "local"), ctx.caller and ctx.session.session_id. Most tools use none of these fields.

It can be async def or a plain def. A plain function runs in a thread, so blocking I/O is safe there. The handler returns a small dict for the model to speak from. Use short strings and numbers, not a page of JSON. Keryx wraps a value that isn't a dict as {"result": ...} and converts anything that isn't valid JSON to a string. To report a failure, return {"error": "a sentence the model can say"}. If the handler raises an exception or exceeds its timeout, Keryx catches it and returns an error to the model. The call continues. The exception's message can reach the model, so it should never contain a secret.

A tool can import only what Keryx's own environment provides: the standard library, httpx, numpy, pydantic and Keryx itself. Don't install packages into Keryx's environment for a tool. Keep the module level to definitions, because every call imports the file again.

The gate: needs_pin

Caller ID is spoofable, so this is the one security decision in the file. It's enforced in code, not in the description.

  • needs_pin=True (the default): the tool answers only after the caller has given the PIN on this call. Use it for anything that acts, such as sending, booking, writing or deleting. Also use it for anything that reads your data, such as accounts, messages, files, devices or location.
  • needs_pin=False: the tool answers anyone who calls. Use it only for information that is public anyway, such as the weather, the tide, a public timetable or a calculation.

If you're unsure, keep the default. The finer trust levels used by the built-in tools are deliberately not available here. The gate is a yes or no that you can check at a glance.

Settings and secrets

Settings go in a file beside the tool, which the handler reads at call time. The countdown example reads countdown.toml through Path(__file__).with_suffix(".toml"). The plugins use the same pattern. Add a comment for every key. Because the handler reads the file on each call, an edit takes effect immediately.

A secret never goes in the .py file. Store it in a file beside the tool that isn't a .py file, such as tides.key, set it to chmod 600, and read it in the handler. It does not belong in Keryx's config.toml or secrets.toml: keryx config set accepts only Keryx's own settings, and the running service may not write a secret at all. To have keryx tools report a missing key instead of letting a call fail, the file can check for the key when it loads and raise ToolUnavailable:

from pathlib import Path

from keryx.tools.custom import ToolUnavailable, custom_tool

KEY = Path(__file__).with_suffix(".key")
if not KEY.is_file():
    raise ToolUnavailable(f"no key: put it in {KEY.name} beside this file (chmod 600)")

keryx tools then lists that file as refused, with your message as the reason.

What's refused

A problem in one file affects only that file. The loader logs the problem and skips the file, and the call continues with every other tool. It refuses:

  • A built-in tool's name, whether or not this machine offers that tool. No custom tool can replace submit_pin or dispatch_task. When two files define the same name, the first file in alphabetical order wins and the second file is refused. Don't reuse a plugin's name either.
  • A file or directory that anyone other than you can write. This covers a file owned by another user, or one with group or world write permission. Loading a file runs its code inside the service, so Keryx refuses it without reading it. The fix is usually chmod go-w.
  • A broken file, meaning one that won't import, defines no tool, or has an invalid name, description, parameters, needs_pin or timeout_s.

Checking them: keryx tools

uv run keryx tools          # each tool, whether it needs the PIN, and its file
uv run keryx tools --json

This runs the same load as a call, using every built-in tool's name, so a name clash or an import error appears here first. It lists every refused file with the reason and exits 1 while any file is refused. Run it after every edit. The service log also records the refusals (custom tool … refused: …).

Plugins are custom tools too

The four optional tools (send_to_slack, check_email, check_billing, cluster_stats) are plugins. When you turn one on, it becomes an ordinary custom tool made of two files in the same directory:

  • <name>.py: one line that calls into Keryx (keryx.plugins.<module>), so an update to Keryx reaches your copy.
  • <name>.toml: its settings, with a comment on every one. Edit it by hand, or run keryx setup → Plugins again.
uv run keryx plugins                             # on, off or refused, and why
uv run keryx plugins install NAME [--set KEY=VALUE]
uv run keryx plugins install NAME --template     # write the settings and a draft, leave it off
uv run keryx plugins remove NAME                 # off; its settings are kept for next time

keryx plugins install knows only those four plugins. It isn't a general installer. To change a plugin, edit its .toml, never its .py. A plugin's secrets stay in secrets.toml (keryx config set KEY --stdin), and the Gmail sign-in stays in its own file. Only you can install or remove a plugin from a terminal, because the running service is refused. docs/tools.md has the table of what each plugin does.

Changing or removing one

To change a tool, edit its file. To remove a tool, delete the file. The change takes effect from the next call. If you rename a tool, the model calls it by the new name.

To use tools that someone else wrote, see Sharing Custom Tools.

The trust model

A custom tool is code that Keryx runs inside the service, with everything the service can reach. A subagent that can write one could equally edit Keryx's source. What a tool reads, and who may hear it, is decided by the file. keryx tools shows what each file declares. See what SECURITY.md says about your own tools and Security Model.

Clone this wiki locally