Agent integration
Show what an AI agent is doing, as a face.
The pieces: a numeric protocol any script can emit; an arbiter that turns several noisy sources into one calm state; channels that carry messages over HTTP, stdin, postMessage or server-sent events; and React hooks that wire the result into a character.
Press b: 34 error while a is searching: the error wins. Press a: 33 done immediately after a: 30: the completion is queued behind the dwell and plays anyway, because it is an event. End both sessions and the face returns to idle.
The wire format
One message shape, every transport:
session_idis required. Later messages with the same id update the same session.- Then one of: a protocol id (
aoraBotIdoremotionId), a state name (state), or anagent_idplus the tool's owneventname, translated through an adapter. tipsis an optional caption the host may show beside the character.hudis an optional dial — aHudSpecdrawn in the ring outside the body. It is validated withisHudSpec; a malformed one is dropped and the message still applies.headless: truemarks a session that should be tracked but never shown — a background job that must not take over the face.
normalize(message) turns any of these into an arbiter update, or an error string. It never throws.
@aora-bot/engine/agent is a separate entry with no DOM or animation code, so it loads in a Node relay as readily as in the browser.
Arbitration
One character, several sources: a coding agent with three sub-agents, or a chat window and a background build. Each reports its own state; the arbiter decides what the character shows. Four rules:
- Priority. Among visible sessions the highest-priority state wins. One session erroring while three work shows the error. Errors and refusals beat everything because they need a human; completion beats ongoing work because it is the result; work beats idling.
- Minimum dwell. A state holds the screen for
minDisplayMs(900 ms by default) before it can be replaced. Without this a burst of tool events makes the face flicker; with it each expression lands and is read. - Events and conditions. A one-shot id — done, error, surprised — is an event and is never dropped: arriving during a dwell it is queued and played when the dwell ends. A continuous state is a condition: when its dwell ends the arbiter re-resolves, because the situation may have moved on.
- Expiry. A session silent for
sessionTtlMs(two minutes) is forgotten, so a tool with no end-of-session hook cannot pin the character forever.
resolve() ignores dwell and returns the session that wins on priority; current() returns what the character is showing, which catches up (through onApply) once the dwell ends.
tips and hud belong to a session and travel with it: the result carries the winning session's caption and dial, and nothing from the sessions that lost. A background job cannot draw its progress ring over the foreground agent's face.
The clock and timers are injectable, so the whole thing runs deterministically in a test.
In React, useArbiter runs one for the lifetime of a component and AgentStateProvider shares it with a subtree; hud is on the value beside tips, and AgentHud renders the character, the dial and the caption from the provider with no wiring.
Adapters
An adapter maps one tool's event vocabulary onto the protocol. It is pure data: supporting another agent is a new object, not new logic.
Claude Code claude-code | ||
|---|---|---|
SessionStart | 02 | idle |
UserPromptSubmit | 30 | thinking |
PreToolUse | 40 | searching |
PostToolUse | 32 | working |
PostToolUseFailure | 34 | alerting |
Notification | 38 | refusing |
Stop | 33 | celebrate |
SessionEnd | 00 | sleeping |
Cursor cursor | ||
|---|---|---|
sessionStart | 02 | idle |
userMessage | 30 | thinking |
planning | 30 | thinking |
toolCall | 40 | searching |
fileEdit | 32 | working |
shellCommand | 32 | working |
webSearch | 36 | loading |
error | 34 | alerting |
awaitingApproval | 38 | refusing |
responding | 39 | composing |
taskComplete | 33 | celebrate |
sessionEnd | 00 | sleeping |
For hosts that know which tool is running rather than which lifecycle event fired, stateForTool('grep') reads a second table, TOOL_STATES, which is the one ActivityMark uses.
Activity verbs and roster facts
A third table answers a third question. TOOL_STATES answers "which tool is running"; ACTIVITY_VERBS answers "what would you tell the user it is doing" — the sentence already on screen next to the character. stateForActivity('coding') resolves a verb to a state, and anything unrecognised is generic work, so a host that has only the sentence — "Running commands…" — can still drive a character. The lookup uses Object.hasOwn, because the verb comes off the wire and "constructor" must not find something on the prototype.
A roster row usually knows a little more than one verb: whether the agent is running, whether it is composing a reply, and what activity it named. characterStateForRoster(facts, surface) resolves those RosterFacts to a state, and the answer depends on where the character is drawn. In a chat row the composing state is the point — the reply is being written in front of you. On an avatar surface, composing comes and goes every few hundred milliseconds, and twenty faces flicking between two states is noise, so the avatar stays idle until there is something worth showing. ActivityMark with activityMode="facts" and the mark commands registry both go through this function, so every surface for one agent agrees.
Recipes
A relay
Agents run in a terminal; the character runs in a page. Something has to carry messages between them. The repository ships one in examples/agent-relay: a zero-dependency Node script that accepts POST /state and newline-delimited JSON on stdin, normalises both, and broadcasts the result to browsers over server-sent events.
The whole of it is normalize, readLineStream and a Set of response streams. Adapt it rather than depending on it.
A message may carry a hud block. The relay validates it like the rest of the payload — an invalid hud is dropped, the message still applies — and it rides along with the winning session's state, so /events and the relay's /preview.svg both show it:
Claude Code hooks
Claude Code fires shell hooks on lifecycle events. Each one posts to the relay with the adapter id and the event name; the adapter does the translation. Put them in ~/.claude/settings.json:
Sub-agents report under their own session_id, so three of them working while the parent waits is three working sessions and the face works.
Cursor hooks
The same shape with agent_id: "cursor" and Cursor's event names — toolCall, fileEdit, shellCommand, webSearch, awaitingApproval, responding, taskComplete, error. Anything the adapter does not list can send an id directly:
An iframe or extension panel
When the agent lives in another document, post to the frame:
useStateChannel accepts messages of type aora:state from the allowed origin only — the page's own origin by default — and feeds the nearest AgentStateProvider.
Sleeping when the visitor does
A character in a sidebar should not stare for an hour. useIdleSleep tracks input on the document and yields drowsy, then sleeping, then a brief waking when input resumes. It returns null while the visitor is active, so it composes with the agent state by falling back:
See useIdleSleep.
Sprites
A painted mascot can stand in for the procedural character without any change to the above: SpriteMark takes the same state or emotionId and resolves frames through the same protocol.
Next
- Emotion protocol — two-digit ids over the wire
- AgentStateProvider — arbitrating sessions into one face
- AgentHud — character, dial and caption together