AG-UI · Concepts

Architecture

@threadplane/ag-ui is an adapter. It does not replace @threadplane/chat, and it does not define a new chat runtime.

The package takes an AG-UI AbstractAgent, listens to its protocol events, and exposes the runtime-neutral Agent contract that the chat components already understand.

Angular component
  |
  v
@threadplane/chat Agent contract
  |  messages, status, isLoading, error, toolCalls, state, events$
  |
  v
@threadplane/ag-ui toAgent()
  |  reduces AG-UI events into Angular signals
  |
  v
@ag-ui/client AbstractAgent
  |
  v
AG-UI backend or in-process fake agent

#The boundary

The important boundary is the Agent contract from @threadplane/chat.

Your components should depend on Agent, not on AG-UI transport details. That keeps the UI portable across AG-UI, LangGraph, and custom adapters.

toAgent(source) is the low-level boundary. It accepts any AG-UI AbstractAgent implementation:

import { toAgent } from '@threadplane/ag-ui';
import { HttpAgent } from '@ag-ui/client';
 
const source = new HttpAgent({ url: '/api/agent' });
const agent = toAgent(source);

Most Angular apps should use DI instead:

import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent({ url: '/api/agent' }),
  ],
};

provideAgent() creates an AG-UI HttpAgent and registers the wrapped Agent under an internal DI token. Retrieve it with injectAgent().

import { Component } from '@angular/core';
import { ChatComponent } from '@threadplane/chat';
import { injectAgent } from '@threadplane/ag-ui';
 
@Component({
  standalone: true,
  imports: [ChatComponent],
  template: `<chat [agent]="agent" />`,
})
export class ChatPage {
  protected readonly agent = injectAgent();
}

You can also inject the token directly:

import { inject } from '@angular/core';
import { injectAgent } from '@threadplane/ag-ui';
 
const agent = injectAgent();

#Runtime data flow

toAgent() subscribes to source.subscribe({ onEvent, onRunFailed }).

Every AG-UI event is passed through the reducer. The reducer updates Angular signals:

  • messages for user, assistant, and reasoning content.
  • status, isLoading, and error for run lifecycle.
  • toolCalls for tool call starts, arguments, results, and completion.
  • state for AG-UI state snapshots and JSON Patch deltas.
  • interrupt cleared on RUN_STARTED and set by the CUSTOM on_interrupt event.
  • events$ for custom events.

When the user submits input, the adapter builds a user message, appends it locally, adds it to the AG-UI source with source.addMessage(), then calls source.runAgent().

This is optimistic on purpose. The user message appears immediately while the backend starts the run.

#Live a2ui streaming via customEvents

The adapter exposes a customEvents signal on the agent returned by toAgent / injectAgent, accumulating every non-on_interrupt CUSTOM AG-UI event for the current run (reset on each RUN_STARTED). The chat composition feature-detects this signal to drive progressive a2ui surface rendering — token-by-token, as the backend streams a2ui-partial events — matching the LangGraph adapter. Without it, a2ui still renders from the final tool-call surface; with it, surfaces build up live.

The consuming side — how the chat composition turns these events into rendered surfaces — lives in chat's A2UI overview.

#Provider choices

Use provideAgent() when you have a real AG-UI HTTP endpoint.

provideAgent({
  url: '/api/agent',
  agentId: 'support-agent',
  threadId: 'thread-123',
  headers: { Authorization: `Bearer ${token}` },
});

The config maps to the AG-UI HttpAgent options exposed by this package, plus an optional telemetry sink:

OptionTypeDescription
urlstringRequired. AG-UI backend HTTP/SSE endpoint.
agentIdstringOptional. Identifies a specific agent on the backend.
threadIdstringOptional. Resume an existing conversation thread.
headersRecord<string, string>Optional. Custom request headers (auth, tracing).
telemetryAgentRuntimeTelemetrySink | falseOptional. App-owned telemetry sink — opt-in, emits nothing unless supplied. See @threadplane/telemetry.

#Factory config for route params and DI

provideAgent() also accepts a () => AgentConfig factory. The factory runs inside an Angular injection context, so it can call inject() to read services or route params when it builds the config:

import { inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { provideAgent } from '@threadplane/ag-ui';
 
providers: [
  provideAgent(() => ({
    url: '/api/agent',
    threadId: inject(ActivatedRoute).snapshot.params['threadId'],
  })),
];

This is the cleanest way to derive threadId (or auth headers) from runtime state at construction time — see the threadId-recreation note below.

threadId here is a plain string consumed once at construction — the provider does not accept an Angular Signal, and the adapter does not observe changes to it at runtime. The AG-UI protocol carries events, not snapshots, and defines no server-side endpoint for "fetch the messages of thread X". To move a user between threads with their prior conversation restored, you have two options:

  • Recreate the provider. Inject provideAgent({ ..., threadId: newId }) from a fresh injector when the active thread changes. Any prior message history must come from your own host service — pre-populate setMessages() on the source before the adapter boots, or render a "loading…" surface while you fetch it.
  • Use the LangGraph adapter instead. @threadplane/langgraph accepts threadId: Signal<string | null> and hydrates messages from the latest checkpoint on every change. See its Persistence guide. Use AG-UI when your runtime publishes events without checkpoint storage; use LangGraph when the server owns durable thread state.

Use provideFakeAgent() when you need the UI to run without a backend:

import { provideFakeAgent } from '@threadplane/ag-ui';
 
providers: [
  provideFakeAgent({
    tokens: ['Offline', ' demo', ' response.'],
    delayMs: 40,
  }),
];

Use toAgent() directly when you own a custom AbstractAgent subclass or need to test a specific event stream.

#Lifecycle gotchas

The wrapped Agent does not own the AG-UI source lifecycle. toAgent() subscribes to the source and expects the source instance to live for the same lifetime as the adapter.

In Angular apps, prefer the provider API so the agent instance is scoped by DI. If you construct agents manually, create one adapter per source instance and keep that pairing stable.

stop() calls source.abortRun(). The actual cancellation behavior depends on the AG-UI source. HttpAgent implements abort behavior; a custom source may treat it as a no-op unless you implement cancellation.

regenerate(index) is supported by the shared Agent contract. It requires the target message to be an assistant message, finds the preceding user message, trims later messages, syncs the trimmed list back to the AG-UI source with setMessages(), and runs again. It throws if another run is loading.

#Current scope

The AG-UI adapter currently covers:

  • Streaming assistant messages from TEXT_MESSAGE_*.
  • Reasoning messages from REASONING_MESSAGE_*.
  • Run status and errors from RUN_*.
  • Tool calls from TOOL_CALL_*.
  • Shared state from STATE_SNAPSHOT and STATE_DELTA.
  • Message replacement from MESSAGES_SNAPSHOT.
  • Custom events from CUSTOM.
  • Citations stored under state.citations.

These features are intentionally out of scope for the AG-UI adapter today:

  • Interrupt workflows.
  • Subagents.
  • History and time-travel. AG-UI is an event-stream protocol — it doesn't define a server-side "fetch state of thread X" endpoint, so the adapter can't hydrate prior messages on a threadId change the way a checkpoint-aware runtime can. The Provider choices section above describes the two patterns AG-UI consumers use to work around this.

If those are central to your product, use the LangGraph adapter for that surface or build a custom adapter against the @threadplane/chat Agent contract. The Writing an Adapter guide walks through the thread-loading design choice in detail.

#Next steps