330 lines
11 KiB
Markdown
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)
|