Adopt.
Make your CLI agent-operable in an afternoon. Minimal working implementations in five languages, plus a wrapper pattern for adopting incrementally.
The conformance contract is small. Emit JSONL on stdout. Open with aoi:meta. Close with aoi:summary. Exit cleanly on broken pipes. Don't echo secrets. Don't pretend exit 0 means success when there's no terminal summary. Everything else is detail.
The minimum.
A conforming AOI-CLI tool, at minimum, emits a stream shaped like this. Three event types in order — aoi:meta first, hit (or any domain event) zero or more times, aoi:summary last — terminated by a clean exit. Everything else in the specification builds on this shape.
$ hello-aoi search "machine" --output jsonl{"type":"aoi:meta","tool":"hello-aoi","tool_version":"0.1.0","aoi_version":"0.2","schema_name":"com.example.hello.events","schema_version":"1.0.0","command":"search"}{"type":"hit","rank":1,"id":"doc_0","title":"Result 0"}{"type":"aoi:summary","ok":true,"count":1,"truncated":false}
- One JSON object per line. No pretty-printing. No arrays. No mixed prose. Newline-delimited. UTF-8.
- Every event carries a
typefield. That field is the discriminator a consumer dispatches on. - The first emission is
aoi:meta. It declares the schema name + version, the tool, the AOI version, and the command being run. - The last emission is
aoi:summary. Its absence at EOF is the cross-language crash signal. Its presence withok:trueis the only honest success signal.
The shell ecosystem already speaks this format. jq reads JSONL natively — its default is to consume consecutive JSON values separated by whitespace, which includes newlines. The patterns below use jq -c (compact output) and jq -e (exit-on-falsey) throughout. No new toolchain to install. When you need to bypass jq's default output buffering in a long-running stream, prefix with stdbuf -oL.
A conforming tool plays one of three pipeline roles. A source emits JSONL but doesn't read it. A transformer reads JSONL on stdin and emits JSONL on stdout. A sink reads JSONL and performs side effects (with an audit trail in its own output). Sections § 02–§ 06 below show greenfield sources in five languages; § 11 shows a minimal transformer. Every role still emits aoi:meta and aoi:summary.
Hello AOI in Python.
The reference greenfield implementation, in Python 3.10+. The same pattern translates to every other language below — compact JSON, line-buffered stdout, clean handling of BrokenPipeError on early pipe close.
#!/usr/bin/env python3
"""Minimal AOI-CLI: emits a typed JSONL search-result stream."""
import json
import sys
TOOL = "hello-aoi"
TOOL_VERSION = "0.1.0"
AOI_VERSION = "0.2"
SCHEMA_NAME = "com.example.hello.events"
SCHEMA_VERSION = "1.0.0"
def emit(event: dict) -> None:
"""One compact JSON line, flushed immediately so consumers can stream."""
sys.stdout.write(json.dumps(event, separators=(",", ":")) + "\n")
sys.stdout.flush()
def main() -> int:
emit({
"type": "aoi:meta",
"tool": TOOL,
"tool_version": TOOL_VERSION,
"aoi_version": AOI_VERSION,
"schema_name": SCHEMA_NAME,
"schema_version": SCHEMA_VERSION,
"command": "search",
})
for i in range(3):
emit({"type": "hit", "rank": i + 1, "id": f"doc_{i}", "title": f"Result {i}"})
emit({"type": "aoi:summary", "ok": True, "count": 3, "truncated": False})
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except BrokenPipeError:
# Downstream closed the pipe — exit cleanly, no stack trace.
sys.exit(141)Run it through a downstream consumer with set -o pipefail to confirm the contract holds end to end:
set -o pipefail
python3 hello_aoi.py | jq -e 'select(.type=="aoi:summary") | .ok == true'Hello AOI in TypeScript.
#!/usr/bin/env node
import { stdout, exit } from "node:process";
const TOOL = "hello-aoi";
const TOOL_VERSION = "0.1.0";
const AOI_VERSION = "0.2";
const SCHEMA_NAME = "com.example.hello.events";
const SCHEMA_VERSION = "1.0.0";
function emit(event: Record<string, unknown>): void {
stdout.write(JSON.stringify(event) + "\n");
}
// Treat downstream pipe-close as ordinary termination, no stack trace.
stdout.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EPIPE") exit(141);
throw err;
});
emit({
type: "aoi:meta",
tool: TOOL,
tool_version: TOOL_VERSION,
aoi_version: AOI_VERSION,
schema_name: SCHEMA_NAME,
schema_version: SCHEMA_VERSION,
command: "search",
});
for (let i = 0; i < 3; i++) {
emit({ type: "hit", rank: i + 1, id: `doc_${i}`, title: `Result ${i}` });
}
emit({ type: "aoi:summary", ok: true, count: 3, truncated: false });Hello AOI in Go.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
)
const (
tool = "hello-aoi"
toolVersion = "0.1.0"
aoiVersion = "0.2"
schemaName = "com.example.hello.events"
schemaVersion = "1.0.0"
)
func emit(event map[string]any) {
line, _ := json.Marshal(event)
if _, err := fmt.Fprintln(os.Stdout, string(line)); err != nil {
// Treat broken pipe as normal termination.
if errors.Is(err, io.ErrClosedPipe) || errors.Is(err, os.ErrClosed) {
os.Exit(141)
}
os.Exit(74) // EX_IOERR
}
}
func main() {
emit(map[string]any{
"type": "aoi:meta",
"tool": tool,
"tool_version": toolVersion,
"aoi_version": aoiVersion,
"schema_name": schemaName,
"schema_version": schemaVersion,
"command": "search",
})
for i := 0; i < 3; i++ {
emit(map[string]any{
"type": "hit",
"rank": i + 1,
"id": fmt.Sprintf("doc_%d", i),
"title": fmt.Sprintf("Result %d", i),
})
}
emit(map[string]any{
"type": "aoi:summary",
"ok": true,
"count": 3,
"truncated": false,
})
}Hello AOI in Rust.
use serde_json::json;
use std::io::{self, Write};
use std::process::ExitCode;
const TOOL: &str = "hello-aoi";
const TOOL_VERSION: &str = "0.1.0";
const AOI_VERSION: &str = "0.2";
const SCHEMA_NAME: &str = "com.example.hello.events";
const SCHEMA_VERSION: &str = "1.0.0";
fn emit(event: serde_json::Value) -> io::Result<()> {
let line = event.to_string();
let mut out = io::stdout().lock();
writeln!(out, "{}", line)
}
fn run() -> io::Result<()> {
emit(json!({
"type": "aoi:meta",
"tool": TOOL,
"tool_version": TOOL_VERSION,
"aoi_version": AOI_VERSION,
"schema_name": SCHEMA_NAME,
"schema_version": SCHEMA_VERSION,
"command": "search",
}))?;
for i in 0..3 {
emit(json!({
"type": "hit",
"rank": i + 1,
"id": format!("doc_{}", i),
"title": format!("Result {}", i),
}))?;
}
emit(json!({
"type": "aoi:summary",
"ok": true,
"count": 3,
"truncated": false,
}))
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
// BrokenPipe — exit cleanly, no panic.
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::from(141),
Err(_) => ExitCode::from(74), // EX_IOERR
}
}Hello AOI in shell.
Shell is the lowest-friction way to convert a JSON-emitting upstream into an AOI tool. The trick is using jq -c -n to produce compact one-line objects, and keeping all human prose on stderr.
#!/usr/bin/env bash
set -euo pipefail
TOOL="hello-aoi"
TOOL_VERSION="0.1.0"
AOI_VERSION="0.2"
SCHEMA_NAME="com.example.hello.events"
SCHEMA_VERSION="1.0.0"
emit() {
jq -c -n "$1"
}
emit '{
"type": "aoi:meta",
"tool": "'"$TOOL"'",
"tool_version": "'"$TOOL_VERSION"'",
"aoi_version": "'"$AOI_VERSION"'",
"schema_name": "'"$SCHEMA_NAME"'",
"schema_version": "'"$SCHEMA_VERSION"'",
"command": "search"
}'
for i in 0 1 2; do
emit "{
\"type\": \"hit\",
\"rank\": $((i+1)),
\"id\": \"doc_$i\",
\"title\": \"Result $i\"
}"
done
emit '{"type":"aoi:summary","ok":true,"count":3,"truncated":false}'Hello AOI consumer.
Every AOI tool that reads --input-jsonl - is, by the spec's vocabulary, either a transformer (reads JSONL, emits JSONL) or a sink (reads JSONL, just performs side effects). The shape is symmetric: dispatch on event.type, propagate aoi:warning and aoi:error events, fail loudly if the upstream stream ends without a terminal aoi:summary. The example below is a transformer that uppercases each upstream hit and emits its own shouted events.
#!/usr/bin/env node
import { stdin, stdout, exit } from "node:process";
import { createInterface } from "node:readline";
const TOOL = "hello-aoi-consumer";
const TOOL_VERSION = "0.1.0";
const AOI_VERSION = "0.2";
const SCHEMA_NAME = "com.example.consumer.events";
const SCHEMA_VERSION = "1.0.0";
function emit(event: Record<string, unknown>): void {
stdout.write(JSON.stringify(event) + "\n");
}
stdout.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EPIPE") exit(141);
throw err;
});
emit({
type: "aoi:meta",
tool: TOOL,
tool_version: TOOL_VERSION,
aoi_version: AOI_VERSION,
schema_name: SCHEMA_NAME,
schema_version: SCHEMA_VERSION,
command: "shout",
input_schemas: ["com.example.outline.events@1.0.0"],
});
let inputCount = 0;
let outputCount = 0;
let warnings = 0;
let errors = 0;
let sawTerminalSummary = false;
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
rl.on("line", (line) => {
if (line.trim() === "") return;
let event: { type?: string; [k: string]: unknown };
try {
event = JSON.parse(line);
} catch {
emit({
type: "aoi:error",
category: "validation",
code: "INPUT_JSONL_PARSE_ERROR",
message: "Invalid JSON on input line",
retryable: false,
});
errors++;
return;
}
inputCount++;
switch (event.type) {
case "aoi:meta":
// Could record upstream tool/schema for traceability. Ignored here.
break;
case "hit": {
const title = String(event.title ?? "").toUpperCase();
emit({ type: "shouted", source_id: event.id, rank: event.rank, title });
outputCount++;
break;
}
case "aoi:warning":
warnings++;
break;
case "aoi:error":
errors++;
break;
case "aoi:summary":
// Upstream finished cleanly. Note it; emit our own summary on close.
sawTerminalSummary = true;
break;
default:
// Unknown event type — ignored by default, per § 13.
break;
}
});
rl.on("close", () => {
if (!sawTerminalSummary) {
// Upstream EOF without a summary == crash signal.
emit({
type: "aoi:error",
category: "io",
code: "UPSTREAM_NO_SUMMARY",
message: "Upstream stream ended without a terminal summary event",
retryable: false,
});
errors++;
}
emit({
type: "aoi:summary",
ok: errors === 0,
count: outputCount,
input_count: inputCount,
warning_count: warnings,
error_count: errors,
partial: errors > 0,
});
});Run it as a transformer between a source and your terminal:
set -o pipefail
outline search "agent" --output jsonl --limit 3 \
| node hello-aoi-consumer.ts \
| jq -e 'select(.type=="aoi:summary") | .ok == true'The same shape translates directly to Python, Go, Rust, and shell — read line by line, parse, dispatch on event.type, emit your own events, emit your own aoi:meta first and aoi:summary last, and treat upstream EOF without summary as failure.
To make a sink instead of a transformer, omit the per‑input emit in the data branch (your output is just aoi:meta, audit events for side effects you performed, and aoi:summary).
Adapt an existing CLI.
Rewriting a mature CLI to be AOI-native is rarely the right first step. The faster path is to ship a --output jsonl mode that wraps the existing implementation — either inside the tool, or as a sibling shell script.
Below: a shell wrapper that converts ls -la (decidedly not AOI) into a conforming stream. The same pattern works for any line-oriented or JSON-emitting upstream.
#!/usr/bin/env bash
# Wraps `ls -la` to emit AOI-CLI events instead of a human-readable table.
set -euo pipefail
emit() { jq -c -n "$1"; }
emit '{"type":"aoi:meta","tool":"ls-aoi","tool_version":"0.1.0","aoi_version":"0.2","schema_name":"com.example.ls.events","schema_version":"1.0.0","command":"list"}'
count=0
# Skip the "total" line from `ls -la`, parse each remaining line.
ls -la "$@" | tail -n +2 | while IFS= read -r line; do
# Naive parse — production would use `stat` for typed fields.
perms=$(echo "$line" | awk '{print $1}')
size=$(echo "$line" | awk '{print $5}')
name=$(echo "$line" | awk '{print $9}')
emit "{\"type\":\"entry\",\"name\":\"$name\",\"perms\":\"$perms\",\"size\":$size}"
count=$((count+1))
done
emit "{\"type\":\"summary\",\"ok\":true,\"count\":$count,\"truncated\":false}"The same pattern, generalized, is what the eventual machinemode uber-wrapper provides for common tools — one wrapper module per non-conforming CLI, all sharing the same AOI event vocabulary.
Test your conformance.
Until aoi-lint ships, the cheapest conformance test is a shell pipeline that asserts every line is valid JSON and that a terminal aoi:summary arrives with ok:true. Run this against your CLI:
# Replace `your-tool ...` with your actual command.
set -o pipefail
your-tool --output jsonl \
| tee /tmp/aoi-test.jsonl \
| jq -c '.' >/dev/null \
&& jq -e 'select(.type=="aoi:summary") | .ok == true' /tmp/aoi-test.jsonl \
&& echo "ok: conforming stream" \
|| echo "fail: invalid JSON or missing summary"The full conformance lint will check more than this — schema validity, signal handling, redaction, dry-run behavior — but the three checks above (valid JSONL · terminal summary · summary.ok) catch the vast majority of broken implementations.
For the full normative checklist, see §18 of the AOI‑CLI specification.
Declare conformance.
Tools that pass the conformance checklist may display the Machine Mode Ready badge. The badge is self-declared, not certified — there is no central authority, and there will not be one. The credibility of the claim lives with the tool's reputation and the consumer's ability to verify it (via the checklist or, in time, aoi-lint).
What's next.
- SDKs
- Per-language packages with the boilerplate above factored out: event emitters, schema helpers, redaction utilities, pipe-safe stdout, terminal-summary contracts. Initial packages: TypeScript, Python, Go, Rust. Repos under github.com/agentoperable.
- aoi-lint
- The conformance test as a real tool. Will run the §18 checklist against your CLI invocation and report structured pass/fail events — itself an AOI-CLI tool.
- machinemode (uber-wrapper)
machinemode <subcommand> <tool> ...args— invokes a non-AOI tool and wraps its output as AOI events. Per-tool adapter modules;machinemode curl,machinemode rg,machinemode find,machinemode git logas the seed set.- Registry
- A list of conforming tools so consumers can discover them. Self-submission via PR to a registry repo; the badge links back here.