@cyronius/lm-ag-ui

July 27, 2026 · View on GitHub

React hooks and utilities for building chat interfaces powered by the AG-UI streaming protocol.

Installation

npm install @cyronius/lm-ag-ui

Peer Dependencies

{
  "react": ">=18",
  "rxjs": "^7.8.0",
  "@ag-ui/client": "^0.0.47",
  "@ag-ui/core": "^0.0.47"
}

Quick Start

Use useAgent to initialize the agent client and wrap your UI with AgentProvider:

import { useAgent, AgentProvider, useAgentContext } from '@cyronius/lm-ag-ui';

function App() {
  const agent = useAgent({
    baseUrl: 'http://localhost:8000',
    agentId: 'my-agent',
    tools: { /* your tool definitions */ },
  });

  return (
    <AgentProvider value={agent}>
      <ChatUI />
    </AgentProvider>
  );
}

Inside AgentProvider, access agent state via useAgentContext:

import { useAgentContext } from '@cyronius/lm-ag-ui';

function ChatUI() {
  const {
    messages,
    addMessage,
    currentMessage,
    isStreaming,
    agentClient,
    agentSubscriber,
  } = useAgentContext();

  const sendMessage = async (text: string) => {
    const userMsg = { id: `msg_${Date.now()}`, role: 'user', content: text };
    addMessage(userMsg);
    agentClient.startNewRun();
    await agentClient.runAgent(
      [...messages, userMsg],
      [],
      agentSubscriber
    );
  };

  return (
    <div>
      {messages.map(m => <div key={m.id}>{m.content}</div>)}
      {isStreaming && <div>{currentMessage}</div>}
    </div>
  );
}

Architecture

In doubt? Use useAgent. It composes useAgentSession + useAgentStream + useFrontendToolRunner into a single hook with the right wiring. The lower-level hooks are exposed only for consumers building a custom runner (e.g. running tools in a worker, or skipping the frontend tool layer entirely).

Layered structure

  • Transport layersrc/CustomHttpAgent.ts: subclasses @ag-ui/client's HttpAgent to route the request pipeline through a pluggable RequestHandler. Auth headers, retries, and custom fetch behavior hook in here.
  • Session layersrc/AgentClient.ts: wraps the HttpAgent with session semantics (threadId, runId, isActive), system-context injection with per-thread content-based deduplication, token-provider-based auth refresh, and the two entry points runAgent() (new turn) and submitToolResults() (tool feedback round-trip).
  • React layersrc/useAgent.ts + src/AgentClientContext.tsx: subscribes to AG-UI streaming events, accumulates text deltas, buffers incremental tool calls, executes frontend tools at RunFinished, and exposes the agent state to the tree via AgentProvider / useAgentContext.

Data flow (one user turn)

user input
   → addMessage(user)
   → agentClient.startNewRun()
   → agentClient.runAgent(history, tools, subscriber)
     → HttpAgent streams events ──→ subscriber callbacks
        ├─ TextMessageContent  → text buffer += delta
        ├─ ToolCallStart/Args  → tool buffer[id] accumulates
        ├─ ToolCallResult      → backend tool message added, onResult fired
        └─ RunFinished         → assembleFinalMessages(...),
                                 execute pending frontend tools,
                                 submitToolResults() if any ran
   → agentClient.endRun()

The RunFinished branching logic lives in the pure helper src/assembleFinalMessages.ts, which handles the four branches (text-only / tools-only / text+tools-all-resolved / text+tools-pending) and duplicate-suppression.

Tool system

A ToolDefinition bundles: OpenAI-compatible definition, optional handler (frontend execution), optional renderer (UI), optional onResult (side-effect hook fired for both frontend and backend tools), and an isFrontend routing flag. Frontend tools execute on RunFinished; backend tools execute remotely and their results arrive as ToolCallResult events.

Frontend handlers receive a ctx: ToolContext with one escape hatch:

  • ctx.stopAfterToolCall() — ends the run with no LLM follow-up turn. It sets forwardedProps.stopAfterToolCall = true on the tool-result submission; a backend that honors the flag short-circuits the model entirely. Use it when the tool's output is the final answer (e.g. the tool rendered an artifact and there is nothing left to narrate). Idempotent and batch-scoped — if any tool in a batched submission sets it, it applies to the whole submission.

To suppress intermediate assistant narration during an agentic chain — keeping the first and final messages of the user's turn but dropping middle narration — set suppressIntermediateAssistantMessages: true on useAgentSetup / useAgent options. The flag is sticky for the lifetime of the agent component and is frontend-local (never sent to the backend). The runtime applies these rules on every run while the flag is on:

  • The first text emitted in a user turn (the first TEXT_MESSAGE_* group seen since the user submitted) streams live as it arrives.
  • Text in any subsequent run is buffered until that run's RUN_FINISHED. If the run emitted any tool calls (chain continues), the buffered text is dropped — it was intermediate narration. If the run had no tool calls (chain ends), the buffered text is committed as the final-result message.
  • A new turn (a fresh runAgent call rather than a tool-result chain continuation) resets the first-text tracking. useFrontendToolRunner signals continuation by calling stream.markChainedRun() immediately before submitting tool results. Consumers calling agentClient.runAgent directly to start a fresh user-initiated run while this flag is enabled should call agent.clearPendingChain() first as a defensive guard; useAgent.invokeToolByName does this automatically.

State ownership

  • Session (AgentClient): threadId, runId, isActive.
  • Chat state (useAgent): messages, streaming buffer, tool-call buffers, per-tool global state.

sendFullHistory modes

AgentClient (and by extension useAgent) accepts sendFullHistory:

  • truestateless / frontend-controlled agents. The frontend owns the full conversation history and ships it every turn. The backend holds no per-thread state; it can scale horizontally, be restarted, or be load-balanced freely. The library's threadId is still forwarded for observability / logging, but the backend does not rehydrate from it.
  • false (default) — stateful / backend-controlled agents. The backend persists history against threadId and only needs the last turn (user message or tool results). Use this when the backend owns memory, summarization, or multi-turn context trimming.

Pick based on where history lives. Mismatching the flag with the backend contract causes either context loss (false against a stateless server) or duplicate history (true against a stateful server that also stores it).

Configuration bootstrap

src/configService.ts fetches GET /agent/{agentId} to retrieve backend tool configs, suggestions, and KV config. src/useAgentSetup.ts constructs the AgentClient lazily once config loads, so baseUrl/agentId are never captured stale. Backend tool configs can be hydrated into full ToolDefinitions either by the consumer (in onConfigLoaded) or automatically via the frontendToolImpls option, which joins backend-declared tools with caller-supplied handlers/renderers.

Build & packaging

vite.config.ts builds ES-only output to dist/, with declarations in dist/types/. react, react-dom, @ag-ui/*, and rxjs are external — they resolve from the consumer's install as peer dependencies. @ag-ui/client ships its own nested copy of rxjs, so consumers should dedupe rxjs in their bundler (resolve.dedupe: ['rxjs'] in Vite) to keep one Observable identity across the boundary. That duplication is also why src/CustomHttpAgent.ts carries an as any cast at the observable boundary — documented inline.

Core API

useAgent(options)

Core hook that creates an AgentClient and manages streaming state, messages, and tool execution.

OptionTypeRequiredDescription
baseUrlstringYesBackend server URL
agentIdstringYesAgent identifier
tokenProvider() => Promise<string | null>NoAuth token provider
requestHandlerRequestHandlerNoCustom fetch implementation (e.g., for session management)
timeoutnumberNoRequest timeout in ms (default: 300000)
toolsRecord<string, ToolDefinition>NoTool definitions
buildForwardedProps() => Record<string, any>NoProps injected into each agent call via RunAgentInput.forwardedProps
sendFullHistorybooleanNoSend full message history vs. only the latest turn (default: false)
initialThreadIdstringNoResume an existing conversation thread
onLifecycleEvent(event: AgentLifecycleEvent) => voidNoCallback for observing agent lifecycle events (run started, tool used, message added)
systemContextBuilder() => string | nullNoZero-arg renderer for the system-context snapshot. When not provided, no system context is injected. Independent of buildForwardedProps
debugbooleanNoEnable backend LLM-input capture (appends ?debug=true to the agent URL). Set once at init; drive from env var or URL flag
onError(err: { code; message; raw? }) => voidNoFires on run errors, timeout aborts, and intentional aborts. Additive to in-stream error messages
safetyTimeoutMsnumberNoAbsolute hard cap for a whole run, never reset (default: 900000)
idleTimeoutMsnumberNoIdle window, reset on every AG-UI event — only a genuine stall trips it (default: 180000)
pruneOutboundMessages(messages: Message[]) => Message[]NoOutbound transformer applied immediately before every wire send. Must preserve ordering and tool-call/tool-result pairing — only content may change
suppressIntermediateAssistantMessagesbooleanNoDrop middle narration in agentic chains; keep the first and final messages of the turn (default: false)
configParamsRecord<string, string | string[]>NoExtra query params appended to both the config-init GET and every run POST. Array values are sent as repeated keys (?ids=a&ids=b)

Returns: AgentClientContextValue with all agent state and methods.

Busy state: isStreaming reads false while frontend tools are executing between runs. Use isBusy (isStreaming || hasPendingToolWork) to gate the send button and typing indicators — otherwise a second message can race the pending tool chain's continuation call on the same thread.

AgentClient

Service class wrapping AG-UI's HttpAgent for backend communication.

const client = new AgentClient('http://localhost:8000', 'my-agent', {
  tokenProvider: async () => getAccessToken(),
  timeout: 60000,
  sendFullHistory: false,
});

Key methods:

  • startNewRun() / endRun() / endSession() - Session lifecycle
  • runAgent(messages, tools, subscriber, forwardedProps) - Send messages to backend
  • submitToolResults(messages, subscriber, tools, forwardedProps) - Submit tool execution results
  • abortRun() - Abort the current streaming run
  • getConfig() - Returns { baseUrl, agentId, timeout }

Tool System

Tools define capabilities the agent can invoke. Each tool can run on the frontend (in React) or the backend (on the server).

import type { ToolDefinition } from '@cyronius/lm-ag-ui';

const myTool: ToolDefinition = {
  definition: {
    name: 'show_calendar',
    description: 'Shows a calendar booking widget',
    parameters: { type: 'object', properties: {}, required: [] }
  },
  isFrontend: true,
  handler: (args, updateState, getState, configJson, ctx) => {
    // Execute tool logic, return result string
    ctx?.stopAfterToolCall();   // optional: when the artifact IS the answer
    return JSON.stringify({ shown: true });
  },
  renderer: (args, result, updateState, getState) => {
    // Return React element to display
    return <CalendarWidget />;
  },
  onResult: (args, result, updateState, getState) => {
    // Side effects when result is received (e.g., accumulation)
  },
};

Handlers may return string | null or a Promise<string | null> — the runner awaits the return either way, so an async tool opts in simply by being declared async.

FieldTypeDescription
definitionStandardToolOpenAI-compatible tool schema
isFrontendbooleantrue = runs in React, false = runs on server
handlerToolHandlerExecutes tool logic (frontend tools only)
rendererToolRendererReact component for displaying results
onResultToolOnResultCallback when tool result is received
configJsonRecord<string, unknown>Tool configuration from backend

File Attachments

The library supports file attachments via AG-UI's native BinaryInputContent type. Two strategies are available:

Inline base64 (simple, no upload infrastructure)

Use filesToBinaryContent() to read files client-side and embed them directly in message content:

import { filesToBinaryContent } from '@cyronius/lm-ag-ui';

const binaryParts = await filesToBinaryContent(files);
const message = {
  id: `msg_${Date.now()}`,
  role: 'user',
  content: [
    ...binaryParts,
    { type: 'text', text: 'Process these files' }
  ]
};

URL reference (large files, existing upload infrastructure)

Upload files to your own storage, then reference them via BinaryInputContent.url:

import type { BinaryInputContent } from '@cyronius/lm-ag-ui';

// Upload to your own endpoint
const uploaded = await myUploadService(files);

const binaryParts: BinaryInputContent[] = uploaded.map(f => ({
  type: 'binary',
  mimeType: f.mimeType,
  url: f.downloadUrl,
  filename: f.filename,
}));

const message = {
  id: `msg_${Date.now()}`,
  role: 'user',
  content: [...binaryParts, { type: 'text', text: 'Process these files' }]
};

Lifecycle Events

Observe agent events for analytics or tracking without coupling your app to the library internals:

useAgent({
  baseUrl: 'http://localhost:8000',
  agentId: 'my-agent',
  onLifecycleEvent: (event) => {
    switch (event.type) {
      case 'run_started':
        analytics.trackInteractionStart();
        break;
      case 'tool_used':
        analytics.trackToolUsage(event.toolName);
        break;
      case 'message_added':
        analytics.trackMessage(event.role, event.content);
        break;
    }
  },
});

Authentication

Inject auth via tokenProvider:

useAgent({
  baseUrl: 'http://localhost:8000',
  agentId: 'my-agent',
  tokenProvider: async () => {
    const session = await getSession();
    return session?.accessToken ?? null;
  },
});

Custom HTTP Pipeline

Use requestHandler to inject middleware (retries, session management):

useAgent({
  baseUrl: 'http://localhost:8000',
  agentId: 'my-agent',
  requestHandler: async (url, init) => {
    // Add custom headers, retry logic, etc.
    return fetch(url, { ...init, headers: { ...init?.headers, 'X-Custom': 'value' } });
  },
});

Config Loading (optional)

If your backend provides a GET /agent/{agentId} endpoint that returns tool definitions and suggestions, useAgentSetup loads it and mounts the agent once config is ready:

import { useAgentSetup } from '@cyronius/lm-ag-ui';
import type { AgentConfig } from '@cyronius/lm-ag-ui';

function App() {
  const { config, isLoading, error, AgentLayer } = useAgentSetup({
    baseUrl: 'http://localhost:8000',
    agentId: 'my-agent',
    onConfigLoaded: (config: AgentConfig) => {
      // Transform config, merge tool definitions, etc.
      return config;
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <AgentLayer>
      <ChatUI />
    </AgentLayer>
  );
}

useAgentSetup loads config, then mounts useAgent + AgentProvider inside AgentLayer. This is a convenience wrapper — you can always use useAgent directly if you manage config loading yourself.

Auto-hydrating tools from backend configs

The backend owns each tool's schema and configJson; the frontend owns the code that runs handlers and renders results. For the common case, pass frontendToolImpls to useAgentSetup and let it join them for you:

import { useAgentSetup } from '@cyronius/lm-ag-ui';

const frontendToolImpls = {
  show_calendar: {
    isFrontend: true,
    handler: (args, updateState, getState) => JSON.stringify({ shown: true }),
    renderer: () => <CalendarWidget />,
  },
  // backend-only tools can be omitted — they'll still be registered from the backend config
};

const { config, AgentLayer } = useAgentSetup({
  baseUrl,
  agentId,
  frontendToolImpls,
});

For full control (conditional tool registration, runtime filtering), use onConfigLoaded instead and call hydrateToolConfigs(loadedConfig.toolConfigs, frontendToolImpls) yourself.

Config loading API

ExportDescription
useAgentSetup(options)Hook: loads config + initializes agent
loadAgentConfig(baseUrl, agentId, tokenProvider?, requestHandler?, timeout?)Standalone function to load config
AgentConfigConfig response type (tools, suggestions, config KV pairs)
SuggestionSuggestion type
ToolConfigResponseRaw tool config from API

Exports

Everything is exported from the package root (@cyronius/lm-ag-ui).

Classes: AgentClient, HttpAgent (re-export from @ag-ui/client)

Hooks: useAgent, useAgentContext, useAgentSetup

Components: AgentProvider

Functions: filesToBinaryContent, loadAgentConfig, hydrateToolConfigs, getAllToolDefinitions, getFrontendToolDefinitions, getBackendToolDefinitions, getFrontEndTools, getToolRenderers, groupSuggestionsByCategory

Types: ToolDefinition, ToolHandler, ToolRenderer, ToolOnResult, ToolContext, AgentClientContextValue, UseAgentOptions, UseAgentSetupOptions, UseAgentSetupResult, AgentConfig, Suggestion, ToolConfigResponse, AgentLifecycleEvent, Session, TokenProvider, RequestHandler, SystemContextBuilder, BinaryInputContent, InputContent, AG-UI re-exports (Message, Tool, BaseEvent, EventType, and all event types)

Advanced — lower-level building blocks; most consumers want useAgent: useAgentSession, useAgentStream, useFrontendToolRunner

Development

npm install     # also runs the build via `prepare`
npm test        # vitest
npm run build   # ES bundle + declarations into dist/

License

MIT © Cyrus Attoun