Repository navigation
Custom Tools
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).
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.
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.
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-31The 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'}))"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 receives (ctx, args):
-
argsis a dict of the model's arguments, shaped byparameters. -
ctxis the call'sToolContext:ctx.trust(what the call has proved, see Security Model),ctx.channel("phone"or"local"),ctx.callerandctx.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.
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 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.
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_pinordispatch_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_pinortimeout_s.
uv run keryx tools # each tool, whether it needs the PIN, and its file
uv run keryx tools --jsonThis 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: …).
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 runkeryx 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 timekeryx 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.
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.
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.
User documentation for Keryx.
For the threat model, see SECURITY.md;
for settings, the coding agents, and the tools, see docs/.
Apache-2.0, Copyright 2026 William Koch.
Setting up
Running it
Security and data
Tools and advanced features