Adoption guide · Companion to AOI‑CLI v1.0-alpha

Make your CLI agent-operable

Adopting AOI‑CLI means adding an --output jsonl machine mode that emits aoi:meta, your typed events and a terminal aoi:summary, while your human output stays exactly as it is.

The contract is small. Write JSONL to stdout, open with aoi:meta and close with aoi:summary. Exit cleanly when a downstream reader closes the pipe, and don't echo secrets. Don't let exit 0 stand in for success when no terminal summary arrived. Everything else is detail, and most of it fits in an afternoon.

Below are minimal working implementations in five languages, a consumer, a wrapper for an existing CLI and a shell check you can run against your own tool today.

The minimum

Every conforming tool emits this shape, whatever it's written in.

Three kinds of event, in order: aoi:meta once, then hit (or any domain event) zero or more times, then aoi:summary once, followed by a clean exit. The rest of 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}
The smallest possible AOI-CLI output
  • One JSON object per line, UTF‑8 and newline‑delimited, with no pretty‑printing, no arrays and no prose mixed in.
  • Every event carries a type field.It's the discriminator a consumer dispatches on.
  • The first emission is aoi:meta, which declares the tool, the AOI version, the command being run and the schema name and version.
  • The last emission is aoi:summary.If it's missing at EOF, the producer crashed or the stream was cut, in any language; its presence with ok:true is the only honest success signal.

The shell already speaks this format. jq reads JSONL natively, because by default it consumes consecutive JSON values separated by whitespace, newlines included. The patterns below use jq -c (compact output) and jq -e(exit on a false or null result) throughout, so there's no new toolchain to install. When a long‑running stream needs to get past jq's output buffering, prefix the command with stdbuf -oL.

A conforming tool plays one of three pipeline roles. A sourceemits JSONL but doesn't read it, and a transformer reads JSONL on stdin and writes JSONL to stdout. A sink reads JSONL and performs side effects, keeping an audit trail in its own output. The next five sections build the same source in five languages, and the consumer section shows a minimal transformer. Whatever the role, the tool still emits aoi:meta and aoi:summary.

Hello AOI in Python

~30 lines, no dependencies

This is the reference greenfield implementation, in Python 3.10+. Every other language below follows the same pattern: compact JSON, stdout flushed after every line, and clean handling of BrokenPipeError when the reader closes the pipe early.

hello_aoi.pypython
#!/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)

Pipe it into a downstream check with set -o pipefail to confirm the contract holds end to end:

bashbash
set -o pipefail
python3 hello_aoi.py | jq -e 'select(.type=="aoi:summary") | .ok == true'

Hello AOI in TypeScript

Node 22+ or Bun
hello-aoi.tstypescript
#!/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

Single file, zero dependencies
main.gogo
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)})
	}

	env := next() // take the summary's seq first, so event_count is exact
	emit(summary{"aoi:summary", env, true, seq, 3, false})
}

Hello AOI in Rust

serde_json + std

serde_json's Map is a BTreeMap unless you ask otherwise, which sorts keys and puts type last on every line. Turn on preserve_order:

Cargo.tomltoml
[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }
src/main.rsrust
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

bash + jq

Shell is the quickest way to turn a JSON‑emitting upstream into an AOI tool. Use jq -c -n to build compact one‑line objects, and keep every bit of human prose on stderr.

hello-aoibash
#!/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

A transformer in TypeScript

In the spec's vocabulary, an AOI tool that reads --input-jsonl - is either a transformer (reads JSONL, emits JSONL) or a sink (reads JSONL and performs side effects). Both have the same shape. Dispatch on event.type, and fold upstream aoi:warning and aoi:error events into your own aoi:summary counts instead of forwarding them unchanged (§ 13.1). If the upstream stream ends without a terminal aoi:summary, fail loudly. The example below is a transformer that uppercases each upstream hit and emits its own shouted events.

hello-aoi-consumer.tstypescript
#!/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;
let uninterpreted = false; // an aoi: event we didn't handle (§ 13)
let lineNo = 0;
// § 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) => {
  lineNo++;
  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,
      scope: "item", // the run continues with the next line
      line_number: lineNo,
    });
    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++;
      // Republish the most severe upstream error; a bare ok:false would
      // discard retry_after_ms and strand the agent (§ 13.1). The spec leaves
      // the ordering open; here non-retryable beats retryable, then the
      // longer retry_after_ms wins.
      if (moreSevere(event, causedBy)) {
        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;
    case "aoi:progress":
    case "aoi:heartbeat":
      // Liveness only; safe to drop.
      break;
    default:
      // § 13: unknown unprefixed types are always safe to ignore, but an aoi:
      // event we didn't interpret may carry failure, so we can't report success.
      if (String(event.type ?? "").startsWith("aoi:")) uninterpreted = true;
      break;
  }
});

function moreSevere(
  e: { retryable?: unknown; retry_after_ms?: unknown },
  current: Record<string, unknown> | null,
): boolean {
  if (!current) return true;
  if (current.retryable === false) return false;
  if (e.retryable === false) return true;
  return Number(e.retry_after_ms ?? 0) > Number(current.retry_after_ms ?? 0);
}

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++;
  }
  const ok = errors === 0 && !uninterpreted;
  emit({
    type: "aoi:summary",
    ok,
    reason: ok ? undefined : "failed",
    event_count: seq + 1,
    count: outputCount,
    input_count: inputCount,
    warning_count: warnings,
    error_count: errors,
    // § 8.3: partial means usable output AND at least one failed item.
    partial: outputCount > 0 && 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 (!ok) process.exitCode = 74; // EX_IOERR
});

Run it as a transformer between a source and your terminal:

bashbash
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 carries over directly to Python, Go, Rust and shell. Read line by line, parse, dispatch on event.type and emit your own events, with your own aoi:meta first and aoi:summary last; an upstream EOF without a summary counts as failure.

To make a sink instead of a transformer, drop the per‑input emit in the data branch. Your output is then just aoi:meta, audit events for the side effects you performed, and aoi:summary.

Adapt an existing CLI

Brownfield

Rewriting a mature CLI to be AOI‑native is rarely the right first step. It's faster to ship an --output jsonl mode that wraps the existing implementation, either inside the tool or as a sibling shell script.

The script below is a shell wrapper that turns a directory listing, the job ls -la does for people, into a conforming stream. The same pattern works for any line‑oriented or JSON‑emitting upstream.

ls-aoibash
#!/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. Because the script captures find's exit status, a failed listing 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, and -print0 survives spaces and newlines in names. Finally, the terminal event is aoi:summary. Spelled bare as summary, it would be a domain event, and a conforming consumer would see no terminal framework event at all.

Now notice what happened. The section is called adapt an existing CLI, yet 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 wrapper strategy keeps teaching the same lesson: 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's why the coreutils work is split into a wrapper for breadth and native implementations for the tools that matter most.

The planned machinemode wrapper generalizes this pattern for common tools, with one adapter module per non‑conforming CLI and all of them sharing the same AOI event vocabulary.

Test your conformance

A 30-second sanity check

Until aoi-lint ships, the cheapest conformance test is a shell script that asserts every line is valid JSON and that a terminal aoi:summary arrives with ok:true. Run this against your CLI:

bashbash
# 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, including schema validity, signal handling, redaction and dry‑run behavior. Still, the five checks above (valid JSON on every line, a trailing newline, one terminal summary reporting ok:true with a matching exit status, and contiguous sequence numbers) catch most broken implementations.

For the full normative checklist, see § 18 of the AOI‑CLI specification.

Declare conformance

The badge

Tools that pass the conformance checklist may display the Machine Mode Readybadge. It's self‑declared, not certified: there is no central authority, and there won't be one. The claim is only as credible as the tool's reputation and the consumer's ability to check it, using the checklist today or aoi-lint once it ships.

Machine Mode Ready
Embed snippets, variants and usage criteria →

What's next

In development
SDKs
Per‑language packages with the boilerplate above factored out: event emitters, schema helpers, redaction utilities, pipe‑safe stdout and terminal‑summary contracts. The TypeScript SDK is in development, with Python, Go and Rust to follow. Repos live under github.com/agentoperable.
aoi-lint
The conformance test as a real tool. It will run the § 18 checklist against your CLI invocation and report structured pass/fail events, as an AOI‑CLI tool itself.
machinemode
machinemode <tool> ...argsinvokes a tool that doesn't speak AOI and wraps its output as AOI events, through one adapter module per tool. machinemode curl, machinemode rg, machinemode find and machinemode git log make up the seed set.
Registry
The tools registrylists tools that implement, or aim to implement, the standard, with each one's status stated plainly. Add yours with a PR against content/tools.json.

The specification has the full normative requirements, and the examples show each scenario on the wire.