# 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. ```json { "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: 1. **`$OLLIE_TOOLS_PATH/`** — 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. 2. **`.meta` `cmd` field** — if set, use it: - Absolute path → use directly - Bare name → resolve via `$PATH` 3. **`$PATH` lookup** — final fallback, search PATH for the tool name. This means: - **Co-located scripts** (the common case): no `cmd` needed, just put the executable next to the `.meta`. - **System binaries**: write a `.meta` with `"cmd": "/usr/bin/rg"` or `"cmd": "rg"` — no copying or symlinking required. - **Wrappers**: put a script in `$OLLIE_TOOLS_PATH` that 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 ```json { "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 ```bash #!/usr/bin/env bash set -e input=$(cat) path=$(echo "$input" | jq -r '.path') echo "result: $path" ``` ### Python ```python #!/usr/bin/env python3 import json, sys args = json.load(sys.stdin) path = args["path"] print(f"result: {path}") ``` ### Go ```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. ```json { "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 ``` 1. **Elevation gate** — broker asks: "approve escaping the sandbox?" User approves or denies. If denied, the credential gate is never reached. 2. **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 to `sudo -S`. 3. **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. ```json { "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 -n` if NOPASSWD is configured) No assumptions about GUI availability, init system, or sudoers configuration. The tool adapts to what's available. ### Example: full privilege chain ```bash # 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 from `tools/lsp/cmd/`) - `memory_*` — persistent memory (Bash)