ollie/doc/WRITING_TOOLS.md

5.3 KiB

Writing Ollie Tools

A tool is any executable with a .meta sidecar file. That's it.

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. The .meta file tells the registry how to present it to the model.

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)

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.

Example: wrapping a system binary

{
  "description": "Search files with ripgrep.",
  "cmd": "rg",
  "args": {"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string"},"path":{"type":"string"}}},
  "readOnly": true
}

No executable in the tools directory — the registry finds rg via PATH.

Example: wrapper that shadows a complex binary

Put this in $OLLIE_TOOLS_PATH/docker_ps:

#!/bin/sh
echo '{"containers":'$(docker ps --format json | jq -s '.')'}'

The wrapper gives the model a clean JSON interface to docker ps without exposing docker's full CLI surface.

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

Every tool runs inside a Landlock sandbox by default. The sandbox profile (~/.config/ollie/sandbox/default.yaml) controls filesystem and network access.

Elevation

Tools that need to escape the sandbox don't handle it themselves. The agent passes "elevated": true in the tool call; the dispatcher runs the tool outside Landlock. From the tool's perspective, elevation is transparent.

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)