astryx-agui
v0.1.0
Published
Headless React binding layer between AG-UI and Astryx Chat components.
Readme
astryx-agui
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.useAguiComposerstill needs the store to reachstore.agentwhen sending, so call it asuseAguiComposer({}, store). All hooks accept an optional trailingstoreargument 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 toEncodedInputContent[]; pass it asuseAguiComposer'sattachmentsoption 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 statususeAguiSelector(selector, store?)— subscribe to any slice ofAguiState
Store & provider:
createAguiStore(agent, options?)/AguiProvider/useAguiContext()createVanillaStore(initialState)and theStoreAdapterinterface
Citations & attachments utilities:
parseCitations,createCitationPlugin,matchCitationTokenencodeFile,inputContentTypeCITATION_MARKERS,CITATION_SOURCE_EVENT,CITATION_PROMPT_INSTRUCTIONS
See BACKEND.md for the agent-side contract (emitting citation markers and source events).
