Skip to main content

Ouro AX

ax-component

A component's agent side: what an agent may read from it and which of its actions an agent may take, each bound to the CLI command that does the same (its twin). defineComponentAx declares it once beside the component; the app binds the twins with bindAx, because a component does not know the app's command names; the entries bindAx returns go into the X-ray's inventory under ui, and the X-ray checks every twin from outside. 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 defineComponentAx from the kit's ouro/ax/component under the registry alias, which the shadcn CLI rewrites to the app's lib alias (@/lib/ouro/ax/component), and its item brings ax-component with it. The path is not spelled out here, since an installed file may not name the registry.

npx shadcn@latest add ourostack/ouro-ui/ax-component#v1.3.10

It installs lib/ouro/ax/component.ts, with ax-effects. Its tests are ax-component-tests. Every item runs on Node 22.18 or later as it is copied: the files import each other as ./<file>.ts. The Ouro AX guide shows how they fit together.

Exports

AX_ACTION_ATTRIBUTE

The attribute a component renders on the control for an action it declares, holding the action's id.

const AX_ACTION_ATTRIBUTE: "data-ax-action"

AxAction

An action: either agent: true or personOnly: "<why>", exactly one; a missing or doubled choice is a type error.

type AxAction = AxAgentAction | AxPersonOnlyAction;

AxAgentAction

An action an agent may take; the app binds its CLI twin.

interface AxAgentAction extends AxActionBase {
  agent: true;
  personOnly?: never;
  scope?: AxScope;
}

AxBindings

The app's commands for a component's twins, by read and action id, written as an agent types them, with a reference as a placeholder ("call mute <meeting>"). refuseAs names, for every person-only action, the CLI words an agent might try, which refuse ("call unmute").

interface AxBindings {
  reads?: Readonly<Record<string, ReadTwin>>;
  actions?: Readonly<Record<string, string>>;
  refuseAs?: Readonly<Record<string, string>>;
}

AxPersonOnlyAction

An action only the person takes, with the reason in one line, said to you, the agent ("You can't turn on the person's camera; only they can."): bindAx refuses a twin for it and asks for the CLI words that refuse.

interface AxPersonOnlyAction extends AxActionBase {
  personOnly: string;
  agent?: never;
  scope?: never;
}

AxRead

Something an agent may read from the component, with the fields its twin's --json carries.

interface AxRead {
  id: string;
  summary?: string;
  fields: readonly string[];
  consent: ReadConsent;
  push?: "filtered";
}

AxScope

Whose item an agent action may act on: self, only the calling agent's own, such as its own row in AgentPresence. An action without a scope acts on any item.

type AxScope = "self";

bindAx

Binds a component's twins to the app's commands and returns its entries for the X-ray inventory (ui). It throws when a read or an agent action has no twin, a read with push: "filtered" has no stream, a person-only action is given a twin or has no refuseAs words, refuseAs names an action that is not person-only, a scope: "self" action's twin does not say who is calling (--agent <me>), or a binding names nothing the component declares. A scoped action's entry carries its scope.

function bindAx(ax: ComponentAx, bindings: AxBindings): UiEntry[]

Example:

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" },
  }),
];
writeFileSync("inventory.json", JSON.stringify({ cli: commandNames, ui }));

ComponentAx

interface ComponentAx {
  readonly component: string;
  readonly reads: readonly AxRead[];
  readonly actions: readonly AxAction[];
}

defineComponentAx

Declares a component's agent side and checks it: unique read and action ids, a field list on each read, a known consent and effect, each action either agent: true or personOnly with a one-line reason, never both or neither, a reserved id (isReservedPersonOnly) never agent: true, and a scope only "self", only on an agent action. A problem throws one line with its fix.

function defineComponentAx(def: { component: string; reads: readonly AxRead[]; actions: readonly AxAction[]; }): ComponentAx

Example:

const mediaTileAx = defineComponentAx({
  component: "MediaTile",
  reads: [{ id: "participant", summary: "Who is in the tile, and their state.", 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 }],
});
const theirs = "You can't turn on the person's microphone, camera or screen; only they can.";
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." },
  ],
});

expectAxRendered

Asserts that a rendered component links its declaration to its controls: each declared action (or each of ids, for a state that shows only some) is a control in container carrying data-ax-action="<id>". It throws one line naming the actions with no such control.

function expectAxRendered(ax: ComponentAx, container: Rendered, options?: { ids?: readonly string[] | undefined; }): void

Example:

render(<CallControls />);
expectAxRendered(callControlsAx, document.body);

isReservedPersonOnly

Whether an action id is reserved for the person, by meaning rather than spelling: Unmute, mic-on, share-screen, startRecording and pick_up are. So is a bare device or capture (mic, camera, video, screen, transcript) alone or turned on, started, resumed or enabled, and any toggle of one (toggle-mic, mute-toggle), since a toggle can turn it on. Admitting is the person's whoever it admits (admit-all, let-in-guest). A call verb (answer, accept, decline, reject, share, record) is the person's alone or beside a call word (accept-call, record-meeting), and join only beside one (join-meeting), so answer-message, share-link, record-note and join-channel stay an agent's. One-word compounds such as sharescreen and turnon count as their parts. An id that only reduces what the person exposes (with off, stop, end, disable or mute in it and no on, toggle, resume, start, enable or unmute, such as mute, camera-off or stop-share) is not reserved, but mute-off is, since it unmutes. Captions are not reserved: they are the person's own and private, and a component declares its captions action person-only itself. The declaration and the X-ray both ask it.

function isReservedPersonOnly(id: string): boolean

READ_CONSENTS

A component's agent side: what an agent may read from it and which of its actions an agent may take, each bound to the CLI command that does the same (its twin). defineComponentAx declares it once beside the component; the app binds the twins with bindAx, because a component does not know the app's command names; the entries bindAx returns go into the X-ray's inventory under ui, and the X-ray checks every twin from outside. 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 defineComponentAx from the kit's ouro/ax/component under the registry alias, which the shadcn CLI rewrites to the app's lib alias (@/lib/ouro/ax/component), and its item brings ax-component with it. The path is not spelled out here, since an installed file may not name the registry.

const READ_CONSENTS: readonly ["none", "listening"]

ReadConsent

What a read needs before an agent gets its content: none, or listening, for what people say (captions, a transcript), which an agent reads only once the person has asked it to follow that meeting. Until then the twin answers needs-human, never the content.

type ReadConsent = (typeof READ_CONSENTS)[number];

ReadTwin

A read's twin: the read command, or, for a read with push: "filtered", the read, the untriggered stream (push, which must refuse) and the words trigger that makes it deliver (trigger, such as --on mention).

type ReadTwin = string | { read: string; push: string; trigger: string };

Rendered

What expectAxRendered looks in: a rendered element or document.

interface Rendered {
  querySelector(selectors: string): unknown;
}

RESERVED_PERSON_ONLY

The canonical ids of the actions that are the person's own, whatever the component: their microphone (unmute), camera (camera, camera-on) and screen (share), answering, joining or declining a call (answer, decline), who comes in from the lobby (admit), and what is recorded (record), each with the reason to declare it with. Aliases count too (isReservedPersonOnly): declaring one agent: true throws, and the X-ray fails a ui entry with one that is not person-only. The reverse stays an agent's, since an agent may always reduce what the person exposes: mute, camera-off, stop-share.

const RESERVED_PERSON_ONLY: Readonly<Record<string, string>>

UiEntry

One line of the X-ray inventory's ui list: a bound read or action.

interface UiEntry {
  component: string;
  kind: "read" | "action";
  id: string;
  effect?: Effect;
  twin?: string;
  personOnly?: string;
  consent?: ReadConsent;
  fields?: string[];
  push?: string;
  trigger?: string;
  refuseAs?: string;
  scope?: AxScope;
}