ollie/doc/writing-tools.md

330 lines
11 KiB
Markdown

# 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 bypass |
| `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/<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.
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 `"bypass": true` in their
`.meta`, or the agent passes `"bypass": true` in the tool call. The
dispatcher requests approval from the **bypass 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.",
"bypass": true,
"cmd": "/usr/local/bin/my-tool"
}
```
### Sudo: run as root
Tools that need root privileges declare `"sudo": true` in their `.meta`.
This **implies bypass** — can't sudo inside a sandbox. The dispatch chain
becomes two sequential gates:
```
tool call → bypass 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_BYPASS_SOCKET` | Unix socket for bypass 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)