machinemode.io
/
adopt · v1.0‑alpha

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.

§ 01
What every conforming tool does

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}
The smallest possible AOI-CLI output
  • One JSON object per line. No pretty-printing. No arrays. No mixed prose. Newline-delimited. UTF-8.
  • Every event carries a type field. 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 with ok:true is 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.

§ 02
~30 lines, no dependencies

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.

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)

Run it through a downstream consumer 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'
§ 03
Node 22+ or Bun

Hello AOI in TypeScript.

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 });
§ 04
Single file, zero dependencies

Hello AOI in Go.

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

	emit(summary{"aoi:summary", next(), true, seq, 3, false})
}
§ 05
serde_json + std

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:

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
    }
}
§ 06
bash + jq

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.

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}"
§ 07
A transformer in TypeScript

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.

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;
// § 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:

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

§ 08
Brownfield

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.

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

§ 09
A 30-second sanity check

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:

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"))' &lt;(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.

§ 10
The badge

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

Machine Mode Ready
Embed snippets · variants · usage criteria
§ 11
Ecosystem in flight

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 log as 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.