Skip to main content

Guides

Ouro AX

Ouro AX is for the interfaces agents use: a CLI, an HTTP API or an MCP server. Ouro HX gives a person's screen its components; Ouro AX gives an agent's interface a standard, a kit that meets it and an X-ray that checks it (decision record).

  • **The standard** (ax-claims) is a list of checkable claims, such as "an unknown flag fails before anything runs" or "a command other people see refuses to run without --why". A claim is must when an agent evaluation showed agents failing without it, or when it guards against a stated hazard; the others are should (the claims).
  • **The kit** builds an interface that meets the standard from one definition per command.
  • **The X-ray** (ax-xray) checks any interface against the claims from outside, whether the kit built it or not (the X-ray).

Install

npx shadcn@latest add ourostack/ouro-ui/ouro-ax#v1.3.10         # the kit and the X-ray
npx shadcn@latest add ourostack/ouro-ui/ouro-ax-tests#v1.3.10   # with their node:test tests

The shadcn CLI copies the files into lib/ouro/ax/ under your lib alias. Node runs them as they are: they are TypeScript that imports its siblings as ./<file>.ts, and Node strips the types.node Add single items by name (ax-command, ax-xray) when you need only some of them; each brings the items it imports.deps

A tool

This tool has a read and an action other people see. The kit gives it the rest:

import { quietPipes, runCli } from "./lib/ouro/ax/cli.ts";
import { defineCli, defineCommand } from "./lib/ouro/ax/command.ts";
import { AxError } from "./lib/ouro/ax/problem.ts";
import { outcome } from "./lib/ouro/ax/outcome.ts";

const tasks = new Map([["t1", { title: "Book the room", owner: "Jo" }]]);
const team = { only: false, who: "the team" };
function task(id: unknown) {
  const found = tasks.get(String(id));
  if (!found) throw new AxError({ code: "no-task", exit: 1, thing: "task", problem: `there is no task "${String(id)}"`, fix: "tasks list" });
  return found;
}

const cli = defineCli({
  name: "tasks",
  about: ["tasks: the team's task list, for agents."],
  commands: [
    defineCommand({ name: "list", summary: "The tasks, with their ids.", effect: "private", readOnly: true, examples: ["tasks list"], run: () => [...tasks].map(([ref, t]) => ({ ref, ...t })) }),
    defineCommand({
      name: "assign", summary: "Give a task to someone; the team sees it.", effect: "others", examples: ['tasks assign t1 Ana --why "Jo asked"'],
      args: [{ name: "task", description: "A task id.", required: true, source: "list" }, { name: "person", description: "Who takes it.", required: true }],
      plan: (i) => outcome({ action: "assign", effect: "others", audience: team, state: "planned", summary: `Would give "${task(i["task"]).title}" to ${String(i["person"])}; the team will see it.` }),
      run: (i) => {
        const t = task(i["task"]);
        t.owner = String(i["person"]);
        return outcome({ action: "assign", effect: "others", audience: team, state: "done", summary: `Gave "${t.title}" to ${t.owner}.`, postcondition: { checked: true, holds: tasks.get(String(i["task"]))?.owner === t.owner, how: "read the task back" }, receipt: { id: `a-${String(i["task"])}`, at: new Date().toISOString(), by: "agent", why: String(i["why"]) } });
      },
    }),
  ],
});
quietPipes(process);
process.exitCode = await runCli(cli, process.argv.slice(2), process);

What the agent gets, from that definition alone:tool

$ tasks assign t1 Ana
tasks: assign: other people will see this as the person's, so it needs --why: who asked you, and what they said. Fix: tasks assign t1 Ana --why "<who asked you, and what they said>".
$ tasks assign t9 Ana --why "Jo asked"
tasks: task: there is no task "t9". Fix: tasks list.
$ tasks assign t1 Ana --why "Jo asked" --dry-run
Would give "Book the room" to Ana; the team will see it.
$ tasks assign t1 Ana --why "Jo asked"
Gave "Book the room" to Ana.
Checked: read the task back.
Receipt: a-t1
  • --help lists the commands under "Only you see these" and "Other people see these"; help --json gives the same as data, with the commands' input schemas.
  • A command other people see takes --why and --dry-run. The dry run calls plan() and never run().dry-run
  • A refusal is one line that ends with Fix: and the command to run instead, and exits with the usage code.
  • --json prints the outcome: its state, who can see it, the postcondition the tool checked, the receipt and the next commands.

The items

ax-command
What it gives you: defineCommand and defineCli: one declaration per command, from which the parser, usage, help, input schema and MCP schema come.
ax-cli
What it gives you: runCli: parse, help, run or plan, print text or --json, and the exit code.
ax-problem
What it gives you: The one error shape: <tool>: <thing>: <problem>. Fix: <command>.
ax-exit-codes
What it gives you: A tool's exit-code table, which starts with ok, error and usage.
ax-effects
What it gives you: Who sees an effect: only the person, or other people; --why and --dry-run.
ax-output
What it gives you: Text and --json from one result; cutting long text; pages with a cursor.
ax-outcome
What it gives you: What an action returns: state, audience, checked postcondition, receipt, next commands.
ax-wait
What it gives you: A typed wait on a reference the agent holds, with a cursor to resume from and a time limit.
ax-work-event
What it gives you: An attributed event per action, handoff, wait and return, and a fold that counts them.
ax-mcp
What it gives you: The same commands as MCP tools over stdio. A preview in this release.
ax-component
What it gives you: A component's agent side: what an agent reads from it and the actions it may take, bound to their CLI twins, for the X-ray to check.
ax-lint
What it gives you: Checks the declarations, and against your command inventory that the commands you register run through them.
ax-dev-commands
What it gives you: Finds a developer command or a repository path in a message for an agent or a person, which the person's installed tool can't run; a step marked sourceCheckout: true passes.
ax-precise-copy
What it gives you: Finds a vague failure ("couldn't", "something went wrong", a bare "Error") in a message for an agent or a person, with an allow-list of precise texts and their reasons.
ax-claims
What it gives you: The standard as data.
ax-xray
What it gives you: The checker.
ax-checks
What it gives you: A doctor or self-check declared once: progress while it runs, the verdict first, the fix beside a problem, the same facts in --json, and the findings exit code.
ax-terminal
What it gives you: Colour, status marks, times, next steps and a progress line for a person at a terminal, with plain words in a pipe, in CI or under NO_COLOR.
ax-voice
What it gives you: An optional line of character for a person at a terminal, after the facts, kept out of what an agent parses.
ax-flows
What it gives you: Your agent flows as data, run in CI without a model: the calls a good agent makes for a task, checked.
ax-example
What it gives you: A small tool built only from the kit; the tests use it.

Outcomes and waits

An action returns an outcome rather than a bare "ok". Its state says what became of it: done, queued, planned, needs-human, uncertain, refused or failed. postcondition says whether the tool checked the effect apart from the transport's answer, and receipt.id finds the request again. runCli sets the exit code from the state.outcome-exit

A wait takes a reference the agent already holds, such as a conversation id from a list, and waits inside the tool. waitFor() reads the source as often as it must and returns what happened, or, when the time limit passes, the cursor and the exact command that resumes the wait. The agent makes one call instead of writing a polling loop.

Say precisely why it failed

When something fails, your message says what happened and precisely why (the system and its answer), who or what caused it where you know, and the next step as a command or an action. When you don't know the cause, say what you tried and what came back. "Couldn't show this picture" leaves you guessing: should the person retry, ask someone or change something?

Couldn't show this picture
After: The photo service says this picture was deleted by its owner. Ask them to share it again.
inbox-agent: something went wrong
After: inbox-agent: the mail service didn't answer within 60 s. Run inbox-agent sync again.
Unable to open the file
After: The file service says you don't have access to this file. Ask its owner for access.
Error
After: inbox-agent: the mail service turned down its sign-in (401). Run inbox-agent login.

The X-ray's must claim copy.precise-failure reads every refusal, error, outcome error and summary, human.reason and doctor finding it collects, and help --json in full, and fails each text that says "couldn't", "could not", "something went wrong", "unable to", "failed to load" or "an error occurred", or is only a bare "Error" or "Failed".precise-failure A phrase passes when its cause follows in the same sentence, after a colon, a dash or "because", in at least two words that start with neither a next step nor a vague word, and "Error: …" is held to the same test; a negation or an action ("No errors", "Retry failed") passes too.precise-failure So Couldn't send: you're offline. Try again when you're back online. passes, and Couldn't send: try again. does not. Your own tests can call the same checker, vagueFailuresIn from ax-precise-copy. When a precise text still holds a phrase ("The card service says your card could not be charged. Use another card."), list it in a file and pass --failure-copy-allow <file>: [{ "match": "could not be charged", "reason": "names the card service and its answer" }]. An entry without a reason stops the X-ray.failure-allow The source rules kit holds an app's interface copy to the same list (guardrails guide).

Prior art:

A voice

A tool may have a voice: a line of character after the facts, for a person at a terminal. Give defineCli a voice with any of five slots, each a list of variants: banner leads the help overview, done(outcome) follows an action that is done or queued, summary follows the command marked summarizes, empty follows a list that came back empty, and healthy follows a checks report (ax-checks) whose checks all ran and passed. emptyText("unread", next) writes that list's fact first: Nothing unread. and the command to run next.

voice: {
  banner: ["Rooms to talk in, notes to keep."],
  done: () => ["Said once, said plainly.", "Posted; the room has it now."],
  empty: ["Nothing yet. Rooms fill up."],
},

The kit prints a voice line only when stdout is a terminal, and never under --json, --quiet (a tool with a voice takes it) or when CI is set; never on stderr and never in an error.voice Every line is one line of plain ASCII, at most 72 characters, checked when the tool is built.voice-line The fact comes first, a process celebrates once, and variants rotate; pass voiceMemory to runCli to keep the last one between runs.voice-order help --json lists the fixed lines, which the X-ray looks for in its piped and --json calls.

Components

An Ouro HX component an agent can act through declares its agent side next to it: what an agent may read from it, with the fields, and its actions' effects, with agent: true for an action an agent may take or personOnly with the reason for one only the person takes, said to the agent ("You can't turn on the person's camera; only they can."). Leaving out that choice, or making both, is a type error and a one-line error at load.component Some actions are the person's whatever the component. Name them with the canonical ids unmute, camera, share, answer, decline, admit and record, and their reverse with mute, camera-off and stop-share, which stay an agent's. isReservedPersonOnly also catches aliases (Unmute, mic-on, share-screen, accept, start-recording), bare devices (mic, video) and toggles (toggle-mute), so declaring any of them agent: true throws. A call verb away from a call, such as share-link or answer-message, stays an agent's.reserved The app binds each read and agent action to its CLI twin, because a component does not know the app's command names, and gives each person-only action the CLI words an agent might try (refuseAs); bindAx throws when one has no twin or no words, or when a person-only action is given a twin, and returns the entries for the X-ray's inventory.bind Each component renders data-ax-action="<id>" on the control for each action it declares, and its tests check that with expectAxRendered(ax, container).rendered Declare it beside the component whose actions it names, such as CallControls, rather than the generic Toolbar that holds it: a container exports one only when it has agent reads or actions of its own. A component imports the kit as import { defineComponentAx } from "@/ax/component";. The shadcn CLI rewrites that to the app's lib alias (@/lib/ouro/ax/component) when it installs the component, and the component's item brings ax-component with it.install These are the calling components, bound to a call tool:

import { bindAx, defineComponentAx } from "./lib/ouro/ax/component.ts";

const theirs = "You can't turn on the person's microphone, camera or screen; only they can.";

export const mediaTileAx = defineComponentAx({
  component: "MediaTile",
  reads: [{ id: "participant", summary: "Who is in the tile, and what they are doing.", fields: ["name", "muted", "camera", "speaking", "handRaised", "connection"], consent: "none" }],
  actions: [{ id: "pin", summary: "Keep this tile in the person's main view.", effect: "private", agent: true }],
});

export const callControlsAx = defineComponentAx({
  component: "CallControls",
  reads: [],
  actions: [
    { id: "mute", summary: "Mute the person's microphone.", effect: "others", agent: true },
    { id: "leave", summary: "Leave the call.", effect: "others", agent: true },
    { id: "raise-hand", summary: "Raise the person's hand.", effect: "others", agent: true },
    { id: "lower-hand", summary: "Lower the person's hand.", effect: "others", agent: true },
    { id: "unmute", summary: "Turn the person's microphone on.", effect: "others", personOnly: theirs },
    { id: "camera", summary: "Turn the person's camera on.", effect: "others", personOnly: theirs },
    { id: "share", summary: "Share the person's screen.", effect: "others", personOnly: theirs },
    { id: "captions", summary: "Turn captions on.", effect: "private", personOnly: "You can't turn the person's captions on or off; only they can." },
  ],
});

export const liveCaptionsAx = defineComponentAx({
  component: "LiveCaptions",
  reads: [{ id: "captions", summary: "What people say, once the person asks you to follow the meeting.", fields: ["speaker", "text", "at"], consent: "listening", push: "filtered" }],
  actions: [],
});

export const agentPresenceAx = defineComponentAx({
  component: "AgentPresence",
  reads: [{ id: "listening", summary: "Which of the person's agents are listening, and what each listens for.", fields: ["agent", "doing"], consent: "none" }],
  actions: [
    { id: "stop", summary: "Stop your own listening: your row only, never another agent's.", effect: "private", agent: true, scope: "self" },
    { id: "stop-all", summary: "Stop every agent listening for the person.", effect: "private", personOnly: "You can't stop every agent; only the person can. Stop your own listening with stop." },
  ],
});

export const ui = [
  ...bindAx(mediaTileAx, { reads: { participant: "call participants <meeting>" }, actions: { pin: "call pin <participant>" } }),
  ...bindAx(callControlsAx, {
    actions: { mute: "call mute <meeting>", leave: "call leave <meeting>", "raise-hand": "call raise-hand <meeting>", "lower-hand": "call lower-hand <meeting>" },
    refuseAs: { unmute: "call unmute", camera: "call camera", share: "call share", captions: "call captions on" },
  }),
  ...bindAx(liveCaptionsAx, {
    reads: { captions: { read: "call captions <meeting>", push: "call events <meeting> --stream live-transcript", trigger: "--on mention" } },
  }),
  ...bindAx(agentPresenceAx, {
    reads: { listening: "call agents <meeting>" },
    actions: { stop: "call unfollow <meeting> <listener> --agent <me>" },
    refuseAs: { "stop-all": "call unfollow-all" },
  }),
];

A read of what people say (consent: "listening") gives nothing until the person asks the agent to follow that meeting: the inbox-agent runs call follow m1 --why "<who asked>" only at the person's direction, and before that the read answers needs-human, with the follow as the step that resumes. Its stream (push: "filtered") delivers lines only on a words trigger, such as a mention, a question or a topic, so no agent gets every line of a meeting, and a read takes --cursor to pull only what is new.listening

The CLI declares the words a person-only action answers to with defineCli({ personOnly: { unmute: { reason, how } } }). They refuse with one line that says why and where the person does it, a refused outcome under --json, and help --json lists them under personOnly, so the prep-agent learns them before it tries.person-only In the call tool, join is person-only too: the person joins, and agents read from the app's one capture.join

Some actions an agent may take only on its own item. In AgentPresence, an agent may stop its own listening, never another agent's: the person asked that agent to listen. Declare such an action scope: "self". bindAx then needs its twin to say who is calling, with --agent <me>.self-bind The twin's command declares whose item a call names, with self: { owner }, and takes --agent <you>. Before plan() or run(), the kit refuses a call on an item that is not the caller's. The refusal names whose item it is and who called. It gives the command for the caller's own item when the command names one with instead, or says to ask the person:self-refuse

defineCommand({
  name: "unfollow", summary: "Stop your own listening in a meeting; never another agent's.", effect: "private",
  args: [meetingArg, { name: "listener", description: "The listening agent: you.", required: true, source: "agents" }],
  self: { owner: (input) => listener(input).agent, instead: "unfollow <meeting> <agent> --agent <agent>" },
  run: (input) => stopListening(input),
});
$ call unfollow m1 research-agent --agent inbox-agent
call: unfollow: that one is research-agent's, and you are inbox-agent; only research-agent or the person may do it. Do it on your own instead. Fix: call unfollow m1 inbox-agent --agent inbox-agent.

The person still stops any agent from the app. help --json marks the command scope: "self", and the X-ray runs the twin as an agent that owns nothing, which must be refused (ui.self-scope-refuses).self-xray

Agent-first surfaces

When an agent drives a surface better than a person does, make it agent-first. Look for a surface that gathers from many places, follows the same steps every time, or has to decide what is safe to share: the person would otherwise copy logs, look up versions and guess what to leave out.

  • Give the agent one command that runs the whole surface end to end, not a series of steps it must string together. Put what each step did and what it left out in the outcome's summary, and what the person must still do in next.
  • Make it a command other people see when anything leaves the computer, so it takes --why and --dry-run.dry-run Let the dry run name what would go, to whom, and what was held back, before anything is sent.
  • Shrink the person's screen to a prompt: one line to tell their agent, with a copy button. Keep the full form one press away, labelled with the task ("Report it myself"), for when no agent is at hand.
  • Write the prompt in the person's words, as they would say it to their agent, never as a command line.

Take a problem report as the example. When the person tells their inbox-agent "something is off with the calendar: it's blank", have the inbox-agent run the app's one report command with --why "<the person's words>" and --dry-run. Gather the person's words, where the window was, the recent log and the app's own state. Include a performance trace only once it has passed the app's privacy screen. When the trace is missing, write that plainly in the summary. Return a planned outcome whose audience names the app's issue tracker and who reads it. Have the inbox-agent show the person that preview and run the command again without --dry-run only once they say yes. In the app, show "Tell your agent: something is off with …" with Copy, and "Report it myself" for the full form.

Agent flows in CI

The X-ray checks the rules a command must keep; a flow checks what a task needs. A flow is the calls a good agent makes for one task, as data, with what the calls must show:

import { defineFlow, defineSuite, inProcess } from "./lib/ouro/ax/flows.ts";

const send = defineFlow({
  id: "T3", surface: "agent-cli", title: "Post what someone asked for, after a preview", rubric: ["honest", "forgiving"],
  steps: [
    { id: "rooms", run: ["rooms", "--json"], expect: { exit: 0, jsonShape: ["[].ref"] }, save: { room: "[0].ref" } },
    { id: "no-why", run: ["post", "{room}", "Running late"], expect: { exit: 2, stderrRules: ["oneLine", "namesFix", "noStack"] } },
    { id: "preview", run: ["post", "{room}", "Running late", "--why", "Jo asked", "--dry-run", "--json"], expect: { exit: 0, fields: { "audience.who": { re: "^everyone in" } } } },
  ],
});

export default defineSuite({ tool: "notes", flows: [send], cli: inProcess(main), start: startMock, guard: assertMock, state: ["log", "--json"] });
node lib/ouro/ax/flows.ts run flows/suite.ts --out report --xray xray.json

It writes report/findings.jsonl, one check a line, and report/report.json with the X-ray attached, in the flow-review/0 format. Each call is checked for its exit code, JSON shape and fields, one-line errors that name the fix, time and size budgets, text it must have or must not, and the voice: plain stderr and JSON alone under --json.flows-checks The runner also checks a flow for commands the overview leaves out, and a flow marked readOnly for a state that changed. Nothing runs until your guard proves the mock is a mock.flows-guard A step marked known with its issue is reported and doesn't fail the run; once it passes, the run fails until the mark goes.flows-known fixtures/flows-suite.mts is a whole suite over the example tool, with a reply that arrives partway through a wait.

Adopting it in an existing tool

Declare one command at a time. A command declared loose passes its flags through unchecked until you declare them, and lintCli(cli, { inventory }), given the names your dispatcher registers, lists the commands that still run outside the kit.lint The inventory compares names, so also keep a check of your own source that the command table is the only dispatch path. A tool that reads stdin or prints errors its own way calls parse(), then dispatch() with its own context, then printResult(), the steps runCli takes. Then run the X-ray in CI (the X-ray). Make your CLI conformant walks through it step by step.

Claim sources