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","aoi":{"seq":0,"run":"01JZQ7X4TQ9"},"tool":"hello-aoi","tool_version":"0.1.0","aoi_version":"1.0-alpha","schema_name":"com.example.hello.events","schema_version":"1.0.0","command":"search"}{"type":"hit","aoi":{"seq":1,"run":"01JZQ7X4TQ9"},"rank":1,"id":"doc_0","title":"Result 0"}{"type":"aoi:summary","aoi":{"seq":2,"run":"01JZQ7X4TQ9"},"ok":true,"event_count":3,"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 os
import sys
import uuid
TOOL = "hello-aoi"
TOOL_VERSION = "0.1.0"
AOI_VERSION = "1.0-alpha"
SCHEMA_NAME = "com.example.hello.events"
SCHEMA_VERSION = "1.0.0"
RUN = uuid.uuid4().hex[:12]
_seq = 0
def emit(event: dict) -> None:
"""One compact JSON line, flushed immediately so consumers can stream.
The framework envelope lives under "aoi" so it can never collide with a
domain field; "type" is the only other reserved top-level name.
"""
global _seq
line = {"type": event.pop("type"), "aoi": {"seq": _seq, "run": RUN}, **event}
_seq += 1
sys.stdout.write(json.dumps(line, 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, "event_count": _seq + 1, "count": 3, "truncated": False})
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except BrokenPipeError:
# Downstream closed the pipe. Point stdout at devnull BEFORE exiting so
# the interpreter's shutdown flush cannot raise a second time and print
# "Exception ignored ... BrokenPipeError" to stderr. Catching the
# exception alone is not enough: machine mode must leave stderr clean
# on pipe close (§ 7). It is not a traceback, but it is diagnostic
# noise a consumer capturing 2>&1 would have to filter.
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
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";
import { randomUUID } from "node:crypto";
const TOOL = "hello-aoi";
const TOOL_VERSION = "0.1.0";
const AOI_VERSION = "1.0-alpha";
const SCHEMA_NAME = "com.example.hello.events";
const SCHEMA_VERSION = "1.0.0";
// Framework envelope lives under "aoi"; "type" is the only other reserved
// top-level name, so domain fields can never collide with a future spec field.
const RUN = randomUUID().replace(/-/g, "").slice(0, 12);
let seq = 0;
function emit(event: Record<string, unknown> & { type: string }): void {
const { type, ...rest } = event;
stdout.write(JSON.stringify({ type, aoi: { seq: seq++, run: RUN }, ...rest }) + "\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, event_count: seq + 1, count: 3, truncated: false });Hello AOI in Go.
package main
import (
"encoding/json"
"fmt"
"math/rand"
"os"
)
const (
tool = "hello-aoi"
toolVersion = "0.1.0"
aoiVersion = "1.0-alpha"
schemaName = "com.example.hello.events"
schemaVersion = "1.0.0"
)
// env is the reserved framework envelope. Structs, not map[string]any:
// encoding/json sorts map keys, which would put "type" last on every line.
type env struct {
Seq int `json:"seq"`
Run string `json:"run"`
}
type meta struct {
Type string `json:"type"`
AOI env `json:"aoi"`
Tool string `json:"tool"`
ToolVersion string `json:"tool_version"`
AOIVersion string `json:"aoi_version"`
SchemaName string `json:"schema_name"`
SchemaVersion string `json:"schema_version"`
Command string `json:"command"`
}
type hit struct {
Type string `json:"type"`
AOI env `json:"aoi"`
Rank int `json:"rank"`
ID string `json:"id"`
Title string `json:"title"`
}
type summary struct {
Type string `json:"type"`
AOI env `json:"aoi"`
OK bool `json:"ok"`
EventCount int `json:"event_count"`
Count int `json:"count"`
Truncated bool `json:"truncated"`
}
var (
run = fmt.Sprintf("%012x", rand.Int63())
seq = 0
)
func next() env { e := env{Seq: seq, Run: run}; seq++; return e }
func emit(v any) {
line, err := json.Marshal(v)
if err != nil {
os.Exit(70) // EX_SOFTWARE
}
// Two separate facts, often conflated. io.ErrClosedPipe is an io.Pipe
// sentinel and never comes back from an *os.File write. And on fd 1 or 2
// you never observe EPIPE either, because the Go runtime lets SIGPIPE kill
// the process — signal 13, which the shell reports as 141. That is exactly
// the behaviour § 7 wants, so don't write a broken-pipe branch here.
if _, err := fmt.Fprintln(os.Stdout, string(line)); err != nil {
os.Exit(74) // EX_IOERR
}
}
func main() {
emit(meta{"aoi:meta", next(), tool, toolVersion, aoiVersion, schemaName, schemaVersion, "search"})
for i := 0; i < 3; i++ {
emit(hit{"hit", next(), i + 1, fmt.Sprintf("doc_%d", i), fmt.Sprintf("Result %d", i)})
}
emit(summary{"aoi:summary", next(), true, seq, 3, false})
}Hello AOI in Rust.
serde_json's Map is a BTreeMapunless you ask otherwise, which sorts keys and puts type last on every line. Turn on preserve_order:
[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }use serde_json::json;
use std::io::{self, Write};
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
const TOOL: &str = "hello-aoi";
const TOOL_VERSION: &str = "0.1.0";
const AOI_VERSION: &str = "1.0-alpha";
const SCHEMA_NAME: &str = "com.example.hello.events";
const SCHEMA_VERSION: &str = "1.0.0";
/// The framework envelope lives under "aoi"; "type" is the only other
/// reserved top-level name, so domain fields can never collide with it.
struct Emitter {
run: String,
seq: u64,
}
impl Emitter {
fn new() -> Self {
let n = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
Emitter { run: format!("{:012x}", n as u64 & 0xffff_ffff_ffff), seq: 0 }
}
fn emit(&mut self, ty: &str, mut body: serde_json::Value) -> io::Result<()> {
let mut line = serde_json::Map::new();
line.insert("type".into(), json!(ty));
line.insert("aoi".into(), json!({ "seq": self.seq, "run": self.run }));
if let Some(obj) = body.as_object_mut() {
for (k, v) in obj.iter() {
line.insert(k.clone(), v.clone());
}
}
self.seq += 1;
let mut out = io::stdout().lock();
writeln!(out, "{}", serde_json::Value::Object(line))
}
}
fn run() -> io::Result<()> {
let mut e = Emitter::new();
e.emit(
"aoi:meta",
json!({
"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 {
e.emit("hit", json!({ "rank": i + 1, "id": format!("doc_{}", i), "title": format!("Result {}", i) }))?;
}
let total = e.seq + 1;
e.emit("aoi:summary", json!({ "ok": true, "event_count": total, "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="1.0-alpha"
SCHEMA_NAME="com.example.hello.events"
SCHEMA_VERSION="1.0.0"
# Framework envelope under "aoi"; "type" is the only other reserved top name.
RUN=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n')
SEQ=0
emit() { # emit <type> <extra-jq-object>
jq -c -n --arg t "$1" --argjson seq "$SEQ" --arg run "$RUN" \
"{type:\$t, aoi:{seq:\$seq, run:\$run}} + $2"
SEQ=$((SEQ + 1))
}
emit 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 hit "{rank:$((i+1)), id:\"doc_$i\", title:\"Result $i\"}"
done
emit aoi:summary "{ok:true, event_count:$((SEQ+1)), 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, consume aoi:warning and aoi:error and fold them into your own aoi:summary counts rather than forwarding them unchanged (§ 13.1), 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";
import { randomUUID } from "node:crypto";
const TOOL = "hello-aoi-consumer";
const TOOL_VERSION = "0.1.0";
const AOI_VERSION = "1.0-alpha";
const SCHEMA_NAME = "com.example.consumer.events";
const SCHEMA_VERSION = "1.0.0";
// Framework envelope under "aoi"; this tool emits its OWN envelope rather
// than forwarding the upstream's (§ 13.1).
const RUN = randomUUID().replace(/-/g, "").slice(0, 12);
let seq = 0;
function emit(event: Record<string, unknown> & { type: string }): void {
const { type, ...rest } = event;
stdout.write(JSON.stringify({ type, aoi: { seq: seq++, run: RUN }, ...rest }) + "\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;
// § 13.1: retry detail MUST survive the pipe. Retain the most severe upstream
// error and the upstream identity, and republish both on our own summary.
let causedBy: Record<string, unknown> | null = null;
let upstream: Record<string, unknown> | null = null;
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;
}
// input_count is DOMAIN events consumed (§ 8.3). Framework events are
// control plane and are accounted for separately, not counted here.
if (!String(event.type ?? "").startsWith("aoi:")) inputCount++;
switch (event.type) {
case "aoi:meta":
upstream = {
tool: event.tool,
tool_version: event.tool_version,
schema_name: event.schema_name,
schema_version: event.schema_version,
};
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++;
// Keep the first error's retry disposition; a bare ok:false would
// discard retry_after_ms and strand the agent.
causedBy ??= {
origin: event.origin ?? upstream?.tool,
category: event.category,
code: event.code,
retryable: event.retryable,
...(event.retry_after_ms !== undefined
? { retry_after_ms: event.retry_after_ms }
: {}),
};
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,
reason: errors === 0 ? undefined : "failed",
event_count: seq + 1,
count: outputCount,
input_count: inputCount,
warning_count: warnings,
error_count: errors,
partial: errors > 0,
...(causedBy && errors > 0 ? { caused_by: causedBy } : {}),
...(upstream ? { upstream } : {}),
});
// Set exitCode; do NOT call exit(). process.exit() discards pending async
// stdout writes, which on a pipe truncates the stream and loses the terminal
// summary you just emitted — manufacturing the crash signature of § 4.
if (errors > 0) process.exitCode = 74; // EX_IOERR
});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 a directory listing as AOI-CLI events instead of a human-readable table.
set -uo pipefail
# Framework envelope under "aoi"; "type" is the only other reserved top name.
RUN=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n')
SEQ=0
emit() { # emit <type> <extra-jq-object>
jq -c -n --arg t "$1" --argjson seq "$SEQ" --arg run "$RUN" \
"{type:\$t, aoi:{seq:\$seq, run:\$run}} + $2"
SEQ=$((SEQ + 1))
}
fail() { # fail <category> <code> <message> <exit>
# --arg, never interpolation: $3 carries a user-supplied path, and a quote in
# it would otherwise break the jq program and emit nothing at all.
jq -c -n --argjson seq "$SEQ" --arg run "$RUN" \
--arg c "$1" --arg k "$2" --arg m "$3" \
'{type:"aoi:error", aoi:{seq:$seq, run:$run}, category:$c, code:$k, message:$m, retryable:false}'
SEQ=$((SEQ + 1))
emit aoi:summary "{ok:false, reason:\"failed\", event_count:$((SEQ+1)), count:0, error_count:1, partial:false}"
exit "$4"
}
# `stat` is not portable. BSD/macOS uses -f, GNU uses -c, and their format
# verbs disagree: BSD %t is a tab, GNU %t is the device type in hex. Use a
# literal delimiter both honour rather than relying on either one's escapes.
if stat -f '%Sp' . >/dev/null 2>&1; then
stat_fmt() { stat -f '%Sp|%z' "$1"; }
else
stat_fmt() { stat -c '%A|%s' "$1"; }
fi
case "${1:-}" in
-*) fail usage BAD_OPTION "Unknown option: $1. Usage: ls-aoi [DIR]" 64 ;;
esac
dir="${1:-.}"
[ -d "$dir" ] || fail not_found DIR_NOT_FOUND "No such directory: $dir" 74
[ -r "$dir" ] || fail io DIR_NOT_READABLE "Directory not readable: $dir" 74
emit aoi:meta '{tool:"ls-aoi", tool_version:"0.3.0", aoi_version:"1.0-alpha",
schema_name:"com.example.ls.events", schema_version:"1.0.0", command:"list"}'
# Capture find's output AND its exit status. Inside a process substitution the
# status is invisible to both `set -e` and pipefail, so a failing find would
# otherwise produce a cheerful ok:true with count:0.
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if ! find "$dir" -mindepth 1 -maxdepth 1 -print0 > "$tmp" 2>/dev/null; then
fail io LIST_FAILED "Could not enumerate $dir" 74
fi
count=0
# -mindepth 1 drops "." and ".."; -print0 with `read -d ''` survives spaces,
# quotes and newlines in filenames. Reading from a file (not a pipe) keeps the
# loop in THIS shell, so `count` is still readable after it — a pipe would run
# the loop in a subshell and the summary would report 0.
while IFS= read -r -d '' path; do
IFS='|' read -r perms size < <(stat_fmt "$path")
# jq --arg does the JSON escaping. Never hand-build JSON from shell strings.
jq -c -n --argjson seq "$SEQ" --arg run "$RUN" --arg name "${path##*/}" \
--arg perms "$perms" --argjson size "$size" \
'{type:"entry", aoi:{seq:$seq, run:$run}, name:$name, perms:$perms, size:$size}'
SEQ=$((SEQ + 1))
count=$((count + 1))
done < "$tmp"
emit aoi:summary "{ok:true, event_count:$((SEQ+1)), count:$count, truncated:false}"Five things in that script are the whole lesson, and the naive version gets all five wrong. The loop reads from a file rather than a pipe, so count survives — piping into while runs the loop in a subshell and the summary reports 0 while real entries stream past, which is precisely the failure this standard exists to prevent. find's exit status is captured, so a missing directory produces a structured error instead of a cheerful ok:true. jq --arg does the JSON escaping, so a file named re"port.txt can't emit a broken line. -print0 survives spaces and newlines. And the terminal event is aoi:summary — spelled bare, it is a domain event and a conforming consumer sees no terminal framework event at all.
Now notice what happened: the section is called adapt an existing CLI, and the working version doesn't call ls anywhere. Every attempt to parse ls -la died on a real filename — column 9 truncates at the first space, the format shifts between GNU and BSD, and the total header is indistinguishable from data. The honest lesson of this section is the one the wrapper strategy keeps teaching: wrapping a human table is not a migration path, it's a bug surface. Wrap a JSON-emitting upstream, or call the underlying syscall. That is why the coreutils work is split into a wrapper for breadth and native implementations for the tools that matter.
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 > /tmp/aoi-test.jsonl; rc=$?
# 1. Every line is independently valid JSON (not just "the file parses").
while IFS= read -r line; do
printf '%s' "$line" | jq -e . >/dev/null || { echo "fail: invalid JSON line"; exit 1; }
done < /tmp/aoi-test.jsonl
# 2. The file ends with a newline — a half-written final line must not be
# mistaken for a whole event.
[ -n "$(tail -c1 /tmp/aoi-test.jsonl)" ] && { echo "fail: no trailing newline"; exit 1; }
# 3. Exactly one aoi:summary, and it is the LAST line.
n=$(jq -s '[.[]|select(.type=="aoi:summary")]|length' /tmp/aoi-test.jsonl)
[ "$n" = "1" ] || { echo "fail: expected 1 aoi:summary, got $n"; exit 1; }
[ "$(tail -1 /tmp/aoi-test.jsonl | jq -r .type)" = "aoi:summary" ] \
|| { echo "fail: summary is not the terminal event"; exit 1; }
# 4. It reports success, and the process agreed.
[ "$(tail -1 /tmp/aoi-test.jsonl | jq -r .ok)" = "true" ] || { echo "fail: ok is not true"; exit 1; }
[ "$rc" = "0" ] || { echo "fail: summary says ok but exit was $rc"; exit 1; }
# 5. If aoi.seq is emitted, it must be contiguous and reconcile with event_count
# — the check that detects a stream elided in the MIDDLE.
if jq -e 'has("aoi") and (.aoi|has("seq"))' <(head -1 /tmp/aoi-test.jsonl) >/dev/null 2>&1; then
jq -s -e 'map(.aoi.seq) == [range(length)]' /tmp/aoi-test.jsonl >/dev/null \
|| { echo "fail: aoi.seq not contiguous from 0"; exit 1; }
[ "$(tail -1 /tmp/aoi-test.jsonl | jq -r .event_count)" = "$(wc -l < /tmp/aoi-test.jsonl | tr -d ' ')" ] \
|| { echo "fail: event_count does not reconcile with line count"; exit 1; }
fi
echo "ok: conforming stream"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 · aoi: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.