11 KiB
Writing Ollie Tools
A tool is any executable with a .meta sidecar file. That's it.
This is declarative tool registration. You don't write code to register a
tool, you don't edit config files, you don't restart a server. You write a
.meta file that declares:
- What the tool does (description, prompt, args schema)
- Where to find it (cmd, co-located executable, or PATH)
- When it's available (match conditions per variant)
- What privileges it needs (sudo)
The registry reads the .meta file, presents the tool to the model, and
executes the binary when called. The model sees a typed function. The runtime
sees an exec call. The .meta bridges the two.
The executable can be a bash script, a Python script, a compiled Go binary, a
Rust binary, a symlink to /usr/bin/jq — anything that reads JSON from stdin
and writes results to stdout.
No integration with the ollie source tree is required. Drop a .meta file in
$OLLIE_TOOLS_PATH and the tool is live on the next session.
Quick start
~/.config/ollie/tools/
├── my_tool ← executable (any language)
└── my_tool.meta ← JSON metadata
That's a complete tool. The agent can now tool_load("my_tool") and call it.
The .meta sidecar
The .meta file is the sole source of discovery and schema. The registry
reads only .meta files — it never inspects the executable itself.
{
"description": "One-liner shown in tool listings.",
"prompt": "## my_tool\n\nFull documentation shown when loaded.\n\n**Args**: `path` (required)\n\n```\nmy_tool(path=\"/foo\")\n```",
"args": {
"type": "object",
"required": ["path"],
"properties": {
"path": {"type": "string", "description": "File path"}
}
},
"tier": "hot",
"readOnly": false
}
| Field | Type | Purpose |
|---|---|---|
description |
string | One-liner shown in the tool listing (system prompt) |
prompt |
string | Full documentation injected when the tool is loaded |
args |
JSON Schema | Input schema exposed to the model |
tier |
"hot"|"warm"|"cold" |
Context retention tier (default: hot) |
readOnly |
bool | Safe for parallel execution with other read tools |
cmd |
string | Executable path or name (see Resolution below) |
sudo |
bool | Requires root privileges; implies elevation |
variants |
array | Conditional definitions for heterogeneous hosts (see Variants below) |
Executable resolution
The cmd field is optional. When the dispatcher needs to run a tool, it
resolves the executable in this order:
$OLLIE_TOOLS_PATH/<name>— if an executable with the tool's name exists in the tools directory, use it. This always wins, allowing local wrappers to shadow system binaries..metacmdfield — if set, use it:- Absolute path → use directly
- Bare name → resolve via
$PATH
$PATHlookup — final fallback, search PATH for the tool name.
This means:
- Co-located scripts (the common case): no
cmdneeded, just put the executable next to the.meta. - System binaries: write a
.metawith"cmd": "/usr/bin/rg"or"cmd": "rg"— no copying or symlinking required. - Wrappers: put a script in
$OLLIE_TOOLS_PATHthat wraps a complex binary with simpler arguments the model can understand. The wrapper shadows the system binary automatically.
Variants: conditional tool definitions
Tools can declare multiple variants gated by match conditions. The first variant where all conditions pass determines the tool's schema, documentation, and executable. If no variant matches, the tool is hidden from the registry — it doesn't exist on this host.
This is the mechanism for network transparency across heterogeneous hosts.
One .meta file works on any machine; the capabilities adapt to what's
actually available.
Example: system log reader
{
"description": "Read system logs.",
"variants": [
{
"match": {"binary": "journalctl"},
"description": "Read system logs (journald).",
"prompt": "## system_logs\n\nFilter by unit, time, pattern.\n\n```\nsystem_logs(unit=\"sshd\", since=\"1 hour ago\", grep=\"error\")\n```",
"args": {
"type": "object",
"properties": {
"unit": {"type": "string", "description": "Systemd unit name"},
"since": {"type": "string", "description": "Time filter"},
"lines": {"type": "string", "description": "Number of lines"},
"grep": {"type": "string", "description": "Pattern filter"}
}
},
"cmd": "system_logs_journald",
"readOnly": true
},
{
"match": {"file": "/var/log/syslog"},
"description": "Read system logs (syslog).",
"prompt": "## system_logs\n\nTail syslog with optional grep.\n\n```\nsystem_logs(lines=\"50\", grep=\"error\")\n```",
"args": {
"type": "object",
"properties": {
"lines": {"type": "string", "description": "Number of lines"},
"grep": {"type": "string", "description": "Pattern filter"}
}
},
"cmd": "system_logs_syslog",
"readOnly": true
}
]
}
On a systemd host: model sees unit filtering, time ranges, grep. Runs system_logs_journald.
On Alpine/BSD: model sees tail + grep only. Runs system_logs_syslog.
On a host with neither: tool doesn't appear in the registry.
Match conditions
All conditions in a match must be true (AND logic).
| Key | Semantics | Example |
|---|---|---|
binary |
Binary exists in $PATH (or absolute path stat) |
"binary": "journalctl" |
file |
File or directory exists | "file": "/var/log/syslog" |
os |
runtime.GOOS matches |
"os": "linux" |
arch |
runtime.GOARCH matches |
"arch": "amd64" |
env |
Environment variable is non-empty | "env": "DISPLAY" |
nenv |
Environment variable is empty | "nenv": "SSH_CONNECTION" |
Variant field merging
Variant fields override the top-level .meta fields where set. Unset variant
fields inherit from the top level. This means you can put common fields
(like tier or readOnly) at the top level and only override args, prompt,
and cmd per variant.
Network transparency
When ollie-remote deploys to a remote host, it sends the .meta files.
The remote's tool registry resolves variants against that host's capabilities.
No configuration, no host-specific tool sets — declarations adapt automatically.
Input/Output contract
Input: JSON object on stdin. Fields match the args schema in the .meta.
Output (success): print result to stdout, exit 0.
Output (error): print error to stdout or stderr, exit non-zero.
Structured output: return {"content": [...]} JSON to pass content blocks directly.
That's the entire protocol. The dispatcher pipes the model's tool-call arguments to stdin and captures stdout. No flags, no argv, no environment negotiation.
Examples by language
Bash
#!/usr/bin/env bash
set -e
input=$(cat)
path=$(echo "$input" | jq -r '.path')
echo "result: $path"
Python
#!/usr/bin/env python3
import json, sys
args = json.load(sys.stdin)
path = args["path"]
print(f"result: {path}")
Go
package main
import (
"encoding/json"
"fmt"
"os"
)
func main() {
var args struct {
Path string `json:"path"`
}
json.NewDecoder(os.Stdin).Decode(&args)
fmt.Printf("result: %s\n", args.Path)
}
Any other language
The contract is language-agnostic. If it's executable and reads JSON from stdin, it's a valid tool.
Sandboxing & Privilege Escalation
Every tool runs inside a Landlock sandbox by default. The sandbox profile
(~/.config/ollie/sandbox/default.yaml) controls filesystem and network access.
Elevation: escape the sandbox
Tools that need to escape the sandbox declare "elevated": true in their
.meta, or the agent passes "elevated": true in the tool call. The
dispatcher requests approval from the elevation broker, which notifies the
user (desktop notification, D-Bus, etc.). If approved, the tool runs outside
Landlock but still as the current user.
{
"description": "Write to a protected path.",
"elevated": true,
"cmd": "/usr/local/bin/my-tool"
}
Sudo: run as root
Tools that need root privileges declare "sudo": true in their .meta.
This implies elevation — can't sudo inside a sandbox. The dispatch chain
becomes two sequential gates:
tool call → elevation gate → credential gate → sudo -S tool
- Elevation gate — broker asks: "approve escaping the sandbox?" User approves or denies. If denied, the credential gate is never reached.
- Credential gate — broker prompts for sudo password (via
kdialog,zenity, or terminal). The password is sent over an encrypted channel (SSH for remote) and piped tosudo -S. - Execution — tool runs as root, output streams back to the agent.
The tool itself has no knowledge of sudo. It reads JSON from stdin and writes to stdout as always. The privilege wrapping is entirely in the dispatch layer.
{
"description": "Read system logs (dmesg).",
"sudo": true,
"cmd": "system_logs_dmesg",
"args": {
"type": "object",
"properties": {
"lines": {"type": "string", "description": "Number of lines"},
"source": {"type": "string", "description": "Log source"}
}
},
"readOnly": true
}
Network transparency for privileges
The same .meta works on any host:
- Local desktop: kdialog or zenity prompts for password
- Remote (ollie-remote): credential request travels back over the encrypted SSH tunnel to the local broker, which prompts the user
- Headless: terminal-based fallback (or
sudo -nif NOPASSWD is configured)
No assumptions about GUI availability, init system, or sudoers configuration. The tool adapts to what's available.
Example: full privilege chain
# system_logs calls dmesg with sudo
system_logs(source="dmesg", lines="10")
# Behind the scenes:
# 1. Elevation approved → "escape sandbox"
# 2. kdialog prompts for sudo password → "credentials accepted"
# 3. sudo -S dmesg --time-format iso | tail -10
# 4. Output streams to agent
Environment Variables
Tools receive:
| Variable | Purpose |
|---|---|
OLLIE_TOOLS_PATH |
Path to the tools directory |
OLLIE_ELEVATE_SOCKET |
Unix socket for elevation broker |
PWD |
Session's current working directory |
Bundled examples
See data/tools/ in the ollie repo:
file_read,file_edit,file_grep— filesystem I/O (Python)gui_*— KDE desktop automation (Bash/Python)lsp_*— language server protocol (Go, compiled fromtools/lsp/cmd/)memory_*— persistent memory (Bash)