• English
  • AgentStateProvider

    One arbiter for a subtree; hooks to feed it and read it.

    idle
    no sessions
    import { useState } from 'react';
    import { AgentStateProvider, useAgentState, CharacterMark, ActivityTicker } from '@aora-bot/react';
    
    function Sessions() {
      const agent = useAgentState();
      // The provider only re-renders when the *displayed* state changes, so a
      // table of every session needs its own nudge.
      const [, bump] = useState(0);
      const update = (u: Parameters<typeof agent.update>[0]) => (agent.update(u), bump((n) => n + 1));
      const remove = (id: string) => (agent.remove(id), bump((n) => n + 1));
      const sessions = Array.from(agent.arbiter.sessions().entries());
      return (
        <table style={{ margin: 0, fontSize: 13 }}>
          <tbody>
            {sessions.length === 0 && (
              <tr>
                <td style={{ opacity: 0.6 }}>no sessions</td>
              </tr>
            )}
            {sessions.map(([id, s]) => (
              <tr key={id}>
                <td>
                  <code>{id}</code>
                </td>
                <td>{s.state}</td>
                <td>{s.headless ? 'headless' : ''}</td>
                <td>
                  <button className="site-btn" onClick={() => remove(id)}>
                    end
                  </button>
                </td>
              </tr>
            ))}
            <tr>
              <td colSpan={4}>
                <div className="site-controls">
                  <button className="site-btn" onClick={() => update({ sessionId: 'chat', state: 'thinking' })}>
                    chat: thinking
                  </button>
                  <button className="site-btn" onClick={() => update({ sessionId: 'chat', state: 'composing' })}>
                    chat: composing
                  </button>
                  <button
                    className="site-btn"
                    onClick={() => update({ sessionId: 'build', state: 'working', headless: true })}
                  >
                    build: working (headless)
                  </button>
                  <button
                    className="site-btn"
                    onClick={() => update({ sessionId: 'build', emotionId: '34', tips: 'Build failed' })}
                  >
                    build: 34 error
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      );
    }
    
    function Face() {
      const { state, tips } = useAgentState();
      return (
        <div className="site-demo site-demo--column" style={{ alignItems: 'center' }}>
          <CharacterMark shape="mochi" color="cyan" state={state} sizePx={96} />
          <ActivityTicker text={tips ?? state} />
        </div>
      );
    }
    
    export default function Demo() {
      return (
        <AgentStateProvider minDisplayMs={600}>
          <div className="site-demo" style={{ alignItems: 'flex-start' }}>
            <Face />
            <Sessions />
          </div>
        </AgentStateProvider>
      );
    }

    The headless build session is tracked — it appears in the table — but never shown. When it errors it is no longer headless and takes over the face, because an error needs a person.

    useArbiter

    Runs an arbiter for the lifetime of a component and re-renders it when the displayed state or caption changes — not when sessions come and go underneath without changing the result.

    const { state, emotionId, tips, current, update, remove, arbiter } = useArbiter({
      minDisplayMs: 900,
      priority: { searching: 45 },
      tickMs: 15_000, // how often silent sessions are expired; 0 disables
    });

    Options are read once, at mount, the same contract as createCharacter. Every ArbiterOptions field is accepted; see the arbiter section of the integration guide for what each rule does.

    AgentStateProvider and useAgentState

    AgentStateProvider calls useArbiter with its props and shares the value. Put it above the part of the page that shows the agent; feed it from wherever events arrive — a WebSocket handler, useStateChannel, a hook script — and read it with useAgentState in the component that renders the character.

    <AgentStateProvider minDisplayMs={{ defaultMs: 900, byState: { celebrate: 2000 } }}>
      <Sidebar />
    </AgentStateProvider>;
    
    function Sidebar() {
      const { state, tips } = useAgentState();
      return (
        <span title={tips}>
          <CharacterMark state={state} />
        </span>
      );
    }

    useAgentState throws outside a provider, because there is no sensible default to fall back to. useOptionalAgentState returns null instead, for components that work either way — the channel hooks use it to find their default sink.

    The HUD travels with the state

    A message may carry a hud alongside tips — see the protocol. The arbiter keeps it on the winning session's ArbiterResult, and the value exposes it as hud, so the dial follows the same arbitration as the face: a session that loses on priority does not get to draw its progress ring over the winner's expression. Pass it straight to a character, or let AgentHud read the provider for you.

    import { AgentStateProvider, CharacterMark, useAgentState } from '@aora-bot/react';
    
    function Face() {
      const { state, hud, update } = useAgentState();
      return (
        <div className="site-demo site-demo--center">
          <CharacterMark shape="tofu" color="blue" state={state} hud={hud ?? null} sizePx={110} />
          <div className="site-controls">
            <button
              className="site-btn"
              onClick={() => update({ sessionId: 's1', state: 'working', hud: { kind: 'progress', value: 0.4 } })}
            >
              working, 40%
            </button>
            <button
              className="site-btn"
              onClick={() => update({ sessionId: 's1', state: 'working', hud: { kind: 'progress', value: 0.9 } })}
            >
              working, 90%
            </button>
            <button className="site-btn" onClick={() => update({ sessionId: 's1', state: 'idle' })}>
              idle, no dial
            </button>
          </div>
        </div>
      );
    }
    
    export default function Demo() {
      return (
        <AgentStateProvider minDisplayMs={0}>
          <Face />
        </AgentStateProvider>
      );
    }

    useAgentHud() returns the same dial, or null when there is no provider, for a component that only wants the ring.

    Channels

    Two hooks carry messages in from outside the React tree. Without an explicit onUpdate both deliver to the nearest provider.

    // postMessage, for iframes and extension panels
    useStateChannel({ allowOrigin: 'https://app.example' });
    
    // server-sent events, for a local relay
    const status = useEventSourceChannel('http://127.0.0.1:27182/events');
    // 'idle' | 'connecting' | 'open' | 'error'

    useStateChannel accepts only { type: 'aora:state', payload } messages from allowOrigin, which defaults to the page's own origin. Pass '*' only when the page is itself a sandboxed widget. useEventSourceChannel parses each event's data as one protocol message; EventSource reconnects on its own, so the hook only reports status. Both take enabled: false to disconnect without unmounting.

    Both run every message through normalize, so a malformed one goes to onError and never reaches the arbiter.

    Without React

    The provider is a thin wrapper. The same arbiter runs anywhere:

    import { createArbiter } from '@aora-bot/engine/agent';
    import { createCharacter, getInkColorCss } from '@aora-bot/engine';
    
    const character = createCharacter(el, { shape: 'blob', ink: { flat: getInkColorCss('blue') } });
    const arbiter = createArbiter({ onApply: ({ state }) => character.update({ state }) });

    API

    PropTypeDefaultDescription
    children*string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null——
    cancel((handle: unknown) => void)——
    idleState"idle" | "sleeping" | "drowsy" | "bored" | "waking" | "powering-down" | "listening" | "thinking" | "searching" | "reading" | "writing" | "working" | "loading" | "progress" | "radar" | "orbit" | "dictating" | "uploading" | "sending" | "receiving" | "messaging" | "waiting" | "spawning" | "composing" | "dragging" | "happy" | "excited" | "laughing" | "playful" | "proud" | "celebrate" | "bouncing" | "shy" | "sad" | "angry" | "confused" | "suspicious" | "surprised" | "scared" | "curious" | "humming" | "alerting" | "notifying" | "refusing"—What to show when no session is visible. Default `idle`.
    maxSessionsnumber—Upper bound on tracked sessions; the stalest is evicted. Default 32.
    minDisplayMsnumber | { defaultMs?: number | undefined; byState?: Partial<Record<"idle" | "sleeping" | "drowsy" | "bored" | "waking" | "powering-down" | "listening" | "thinking" | "searching" | "reading" | "writing" | "working" | "loading" | "progress" | "radar" | "orbit" | "dictating" | "uploading" | "sending" | "receiving" | "messaging" | "waiting" | "spawning" | "composing" | "dragging" | "happy" | "excited" | "laughing" | "playful" | "proud" | "celebrate" | "bouncing" | "shy" | "sad" | "angry" | "confused" | "suspicious" | "surprised" | "scared" | "curious" | "humming" | "alerting" | "notifying" | "refusing", number>> | undefined; }—Dwell before a state may be replaced. A number, or per-state overrides. Default 900 ms.
    now(() => number)—Injectable clock and timers.
    onApply((result: ArbiterResult) => void)—Called whenever the displayed state changes.
    oneshotreadonly ("idle" | "sleeping" | "drowsy" | "bored" | "waking" | "powering-down" | "listening" | "thinking" | "searching" | "reading" | "writing" | "working" | "loading" | "progress" | "radar" | "orbit" | "dictating" | "uploading" | "sending" | "receiving" | "messaging" | "waiting" | "spawning" | "composing" | "dragging" | "happy" | "excited" | "laughing" | "playful" | "proud" | "celebrate" | "bouncing" | "shy" | "sad" | "angry" | "confused" | "suspicious" | "surprised" | "scared" | "curious" | "humming" | "alerting" | "notifying" | "refusing")[]—Override which states are events. Defaults to the protocol's one-shot ids.
    priorityPartial<Record<"idle" | "sleeping" | "drowsy" | "bored" | "waking" | "powering-down" | "listening" | "thinking" | "searching" | "reading" | "writing" | "working" | "loading" | "progress" | "radar" | "orbit" | "dictating" | "uploading" | "sending" | "receiving" | "messaging" | "waiting" | "spawning" | "composing" | "dragging" | "happy" | "excited" | "laughing" | "playful" | "proud" | "celebrate" | "bouncing" | "shy" | "sad" | "angry" | "confused" | "suspicious" | "surprised" | "scared" | "curious" | "humming" | "alerting" | "notifying" | "refusing", number>>—Priority overrides, by state. Higher wins.
    schedule((fn: () => void, ms: number) => unknown)——
    sessionTtlMsnumber—Forget a session after this long without an update. Default 120 s.
    tickMsnumber—How often to expire silent sessions. Default 15 s; 0 disables.

    AgentStateValue

    interface AgentStateValue {
      arbiter: Arbiter;
      current: ArbiterResult; // { state, emotionId, tips?, hud?, sessionId? }
      state: CharacterState;
      emotionId: EmotionId;
      tips: string | undefined;
      hud: HudSpec | undefined; // the winning session's dial; travels with tips
      update(update: ArbiterUpdate): void; // arbiter.update
      remove(sessionId: string): void; // arbiter.remove
    }

    Next