ollie/doc/writing-tools.md

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:

  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

{
  "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
  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.

{
  "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

# 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)