astryx-agui

July 9, 2026 · View on GitHub

Headless React binding layer between AG-UI and Astryx Chat components. It consumes an AG-UI agent's event stream, normalizes it into a predictable chat state (messages, tool calls, reasoning, citations, sources, run status), and exposes small hooks that hand you exactly the props Astryx's @astryxdesign/core/Chat components expect — no UI of its own.

Install

npm install astryx-agui @ag-ui/client @ag-ui/core @astryxdesign/core react

@ag-ui/client, @ag-ui/core, @astryxdesign/core, and react are peer dependencies — install the versions your app already uses.

Quick start

Create a store from an AG-UI agent, wrap your tree in <AguiProvider>, and render Astryx components with the hook output.

import { HttpAgent } from '@ag-ui/client';
import { createAguiStore, AguiProvider, useAguiMessages, useAguiComposer } from 'astryx-agui';
import {
  ChatLayout,
  ChatMessageList,
  ChatMessage,
  ChatMessageBubble,
  ChatComposer,
} from '@astryxdesign/core/Chat';

const agent = new HttpAgent({ url: 'https://your-backend/agent' });
const store = createAguiStore(agent);

function App() {
  return (
    <AguiProvider store={store}>
      <Chat store={store} />
    </AguiProvider>
  );
}

function Chat({ store }: { store: ReturnType<typeof createAguiStore> }) {
  const { messages, messageListProps } = useAguiMessages();
  // The composer's send path needs `store.agent`, so pass the store explicitly.
  const { composerProps } = useAguiComposer({}, store);

  return (
    <ChatLayout>
      <ChatMessageList {...messageListProps}>
        {messages.map((m) => (
          <ChatMessage key={m.id} {...m.bubbleProps}>
            <ChatMessageBubble>{m.content}</ChatMessageBubble>
          </ChatMessage>
        ))}
      </ChatMessageList>
      <ChatComposer {...composerProps} />
    </ChatLayout>
  );
}

Each AguiMessageView gives you id, content, isStreaming, bubbleProps ({ sender }), toolCallIds, citations, and reasoning.

Passing the store explicitly: hooks resolve the store from <AguiProvider> by default. useAguiComposer still needs the store to reach store.agent when sending, so call it as useAguiComposer({}, store). All hooks accept an optional trailing store argument if you prefer not to use the provider.

Streaming

Message views carry content and isStreaming. Apply Astryx's useStreamingText where you render the bubble text so partial tokens animate in as they arrive:

import { useStreamingText } from '@astryxdesign/core/Chat';

function Bubble({ content, isStreaming }: { content: string; isStreaming: boolean }) {
  const text = useStreamingText(content, isStreaming);
  return <ChatMessageBubble>{text}</ChatMessageBubble>;
}

// in the message map:
messages.map((m) => (
  <ChatMessage key={m.id} {...m.bubbleProps}>
    <Bubble content={m.content} isStreaming={m.isStreaming} />
  </ChatMessage>
));

Tool calls

useAguiToolCalls(messageId, options?, store?) returns { toolCallProps: { calls } }, where calls are real Astryx ChatToolCallItem[] (duration is a preformatted string like "340ms" / "1.2s"). Use resolvers — keyed by tool name — to turn raw arguments into display fields:

import { useAguiToolCalls } from 'astryx-agui';
import { ChatToolCalls } from '@astryxdesign/core/Chat';

function ToolCalls({ messageId }: { messageId: string }) {
  const { toolCallProps } = useAguiToolCalls(messageId, {
    resolvers: {
      read: (args: { path: string }) => ({ target: args.path }),
    },
  });
  return <ChatToolCalls {...toolCallProps} />;
}

A resolver returns a Partial<ChatToolCallItem> that is merged over the mechanical defaults (name, status, duration, resultDetail, errorMessage).

Citations

useAguiCitations(messageId, store?) parses OpenAI-style inline citation markers out of the message text, resolves each one against the registered sources, and shapes props for Astryx's <Markdown> plus a source list:

import { useAguiCitations } from 'astryx-agui';
import { Markdown } from '@astryxdesign/core/Chat';

function CitedBubble({ messageId }: { messageId: string }) {
  const { markdownProps, sourceListProps } = useAguiCitations(messageId);
  return (
    <>
      <Markdown {...markdownProps} />
      <SourceList {...sourceListProps} />
    </>
  );
}
  • markdownProps{ children, inlinePlugins } for <Markdown> (the inline plugin renders citation tokens).
  • citations → the resolved citations (each parsed citation joined with its source, if known).
  • sourceListProps{ sources }, deduplicated in first-seen order.

Sources arrive by default on the CUSTOM_CITATION_SOURCE custom event channel. See BACKEND.md for how to emit markers and source events from your agent.

Attachments

useAguiAttachments(options?) manages a pending attachment list and encodes files to AG-UI input content:

import { useAguiAttachments, useAguiComposer } from 'astryx-agui';

function Composer({ store }: { store: ReturnType<typeof createAguiStore> }) {
  const { add, remove, clear, drawerProps, tokenProps, encodeAll } = useAguiAttachments();
  const { composerProps } = useAguiComposer({ attachments: encodeAll }, store);

  return (
    <>
      <AttachmentDrawer {...drawerProps} />
      {tokenProps.map((t) => (
        <AttachmentToken key={t.id} {...t} />
      ))}
      <ChatComposer {...composerProps} onFiles={add} />
    </>
  );
}
  • add(files) / remove(id) / clear() manage the pending list.
  • drawerProps{ count }, tokenProps → per-file { id, label, onRemove }.
  • encodeAll() resolves to EncodedInputContent[]; pass it as useAguiComposer's attachments option to send them alongside the text.

The low-level encodeFile(file) and inputContentType(mime) helpers are exported too.

Pluggable store

createAguiStore(agent, options?) defaults to an internal vanilla store, but you can bring your own state container (Zustand, Redux, etc.) via the adapter option. An adapter satisfies this interface:

interface StoreAdapter<S> {
  getState(): S;
  setState(updater: (prev: S) => S): void;
  subscribe(listener: () => void): () => void;
}

A Zustand adapter is a thin wrapper around a vanilla Zustand store — its getState / setState / subscribe map almost one-to-one. The adapter must be seeded with a valid initial AguiState:

import { createStore } from 'zustand/vanilla';
import type { StoreAdapter, AguiState } from 'astryx-agui';

const initialState: AguiState = {
  messageOrder: [],
  messages: {},
  toolCalls: {},
  sources: {},
  runStatus: 'idle',
};

function createZustandAdapter(initial: AguiState): StoreAdapter<AguiState> {
  const store = createStore<AguiState>()(() => initial);

  return {
    getState: () => store.getState(),
    // pass `true` to replace instead of shallow-merge, since the
    // reducer's updater already returns the complete next state
    setState: (updater) => store.setState((prev) => updater(prev), true),
    subscribe: (listener) => store.subscribe(listener),
  };
}

const myAdapter = createZustandAdapter(initialState);
import { createAguiStore } from 'astryx-agui';
import type { AguiState } from 'astryx-agui';

const store = createAguiStore(agent, {
  adapter: myAdapter, // StoreAdapter<AguiState>
  // citation configuration is also accepted here:
  // markers, citationSourceEvents, citationSourceTools, parseSource
});

Call store.dispose() to unsubscribe from the agent when you tear down.

Typically you create the store once for the lifetime of the component that owns the chat, and dispose it on unmount. With React that's a useEffect cleanup:

import { useState, useEffect } from 'react';
import { HttpAgent } from '@ag-ui/client';
import { createAguiStore, AguiProvider } from 'astryx-agui';

function ChatContainer() {
  // create the store once — not on every render
  const [store] = useState(() =>
    createAguiStore(new HttpAgent({ url: 'https://your-backend/agent' })),
  );

  // unsubscribe from the agent when this component unmounts
  useEffect(() => () => store.dispose(), [store]);

  return (
    <AguiProvider store={store}>
      <Chat store={store} />
    </AguiProvider>
  );
}

Outside React, call it wherever you dispose of the owning scope — e.g. when closing a chat session, navigating away, or during server/test teardown:

const store = createAguiStore(agent);
try {
  // ...drive the agent, read state...
} finally {
  store.dispose();
}

Escape hatch

Need something the shaped props don't cover? useAguiMessages().raw exposes the full normalized AguiState (messages, tool calls, sources, reasoning, run status), so you can read any slice directly:

const { raw } = useAguiMessages();
raw.runStatus; // 'idle' | 'running' | 'done' | 'error'
raw.toolCalls; // Record<string, NormalizedToolCall>
raw.sources;   // Record<string, CitationSource>

API reference

Hooks (each accepts an optional trailing store argument):

  • useAguiMessages(store?){ messages, messageListProps, raw }
  • useAguiComposer(options?, store?){ composerProps, isRunning, stop }
  • useAguiAttachments(options?){ add, remove, clear, drawerProps, tokenProps, encodeAll, isEmpty }
  • useAguiToolCalls(messageId, options?, store?){ toolCallProps }
  • useAguiReasoning(messageId, store?)
  • useAguiCitations(messageId, store?){ markdownProps, citations, sourceListProps }
  • useAguiSources(store?){ sources }
  • useAguiRun(store?){ status, error, isRunning }
  • useAguiChat(options?, store?) — facade combining messages, composer, and run status
  • useAguiSelector(selector, store?) — subscribe to any slice of AguiState

Store & provider:

  • createAguiStore(agent, options?) / AguiProvider / useAguiContext()
  • createVanillaStore(initialState) and the StoreAdapter interface

Citations & attachments utilities:

  • parseCitations, createCitationPlugin, matchCitationToken
  • encodeFile, inputContentType
  • CITATION_MARKERS, CITATION_SOURCE_EVENT, CITATION_PROMPT_INSTRUCTIONS

See BACKEND.md for the agent-side contract (emitting citation markers and source events).

License

MIT