-
diff --git a/apps/website/content/docs/chat/components/chat-subagent-card.mdx b/apps/website/content/docs/chat/components/chat-subagent-card.mdx
index 525e1fbf7..71781c7fa 100644
--- a/apps/website/content/docs/chat/components/chat-subagent-card.mdx
+++ b/apps/website/content/docs/chat/components/chat-subagent-card.mdx
@@ -31,8 +31,10 @@ The `Subagent` type comes from `@threadplane/chat`. It provides reactive state f
| Property | Type | Description |
|----------|------|-------------|
| `toolCallId` | `string` | The ID linking this subagent to its parent tool call |
+| `name` | `string \| undefined` | Optional human-readable name. The card's "Subagent" label uses it when present |
| `status()` | `Signal<'pending' \| 'running' \| 'complete' \| 'error'>` | Current execution status |
| `messages()` | `Signal
` | Messages produced by the subagent |
+| `state()` | `Signal>` | Arbitrary subagent state exposed by the runtime |
## Card Behavior
diff --git a/apps/website/content/docs/chat/components/chat-tool-call-card.mdx b/apps/website/content/docs/chat/components/chat-tool-call-card.mdx
index 4eda2daa1..1a4751b2c 100644
--- a/apps/website/content/docs/chat/components/chat-tool-call-card.mdx
+++ b/apps/website/content/docs/chat/components/chat-tool-call-card.mdx
@@ -55,3 +55,31 @@ interface ToolCallInfo {
```
+
+Define `tc` as a `ToolCallInfo` in the component class:
+
+```typescript
+import { Component } from '@angular/core';
+import { ChatToolCallCardComponent, type ToolCallInfo } from '@threadplane/chat';
+
+@Component({
+ selector: 'app-tool-call-demo',
+ standalone: true,
+ imports: [ChatToolCallCardComponent],
+ template: ``,
+})
+export class ToolCallDemoComponent {
+ protected readonly tc: ToolCallInfo = {
+ id: 'call_1',
+ name: 'search_web',
+ args: { query: 'angular' },
+ status: 'complete',
+ result: [{ title: 'Angular', url: 'https://angular.dev' }],
+ };
+}
+```
+
+## See also
+
+- [``](/docs/chat/components/chat-tool-calls) — renders many cards for an agent's tool-call list; the usual place this card is mounted.
+- [`chatToolCallTemplate`](/docs/chat/components/chat-tool-call-template) — replace the card UX for a specific tool name.
diff --git a/apps/website/content/docs/chat/components/chat-trace.mdx b/apps/website/content/docs/chat/components/chat-trace.mdx
index 0c43cb1fc..c4c0bc057 100644
--- a/apps/website/content/docs/chat/components/chat-trace.mdx
+++ b/apps/website/content/docs/chat/components/chat-trace.mdx
@@ -123,6 +123,61 @@ export class TracedChatComponent {
}
```
+### Driving traces from `agent.toolCalls()`
+
+The agent's `toolCalls()` signal is the real source for tool-call traces. `ToolCall.status` and `TraceState` use almost the same names, but `ToolCall` reports `'complete'` where `TraceState` expects `'done'` — map that one explicitly:
+
+```typescript
+import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
+import { injectAgent, provideAgent } from '@threadplane/langgraph';
+import { signal } from '@angular/core';
+import {
+ ChatTraceComponent,
+ ChatMessageListComponent,
+} from '@threadplane/chat';
+import type { ToolCallStatus } from '@threadplane/chat';
+import type { TraceState } from '@threadplane/chat';
+
+function toTraceState(status: ToolCallStatus): TraceState {
+ // 'pending' | 'running' | 'error' carry over; only 'complete' is renamed.
+ return status === 'complete' ? 'done' : status;
+}
+
+@Component({
+ selector: 'app-tool-traces',
+ standalone: true,
+ imports: [ChatMessageListComponent, ChatTraceComponent],
+ providers: [provideAgent({ assistantId: 'chat', threadId: signal(null) })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ @for (call of traces(); track call.id) {
+
+ {{ call.name }}
+ {{ call.args | json }}
+
+ }
+ {{ message.content }}
+
+
+ `,
+})
+export class ToolTracesComponent {
+ protected readonly chatAgent = injectAgent();
+
+ // Map each ToolCall to a trace-friendly view, translating status -> TraceState.
+ protected readonly traces = computed(() =>
+ this.chatAgent.toolCalls().map((call) => ({
+ id: call.id,
+ name: call.name,
+ args: call.args,
+ state: toTraceState(call.status),
+ })),
+ );
+}
+```
+
## Styling
The component uses the following `--ngaf-chat-*` tokens:
diff --git a/apps/website/content/docs/chat/components/chat.mdx b/apps/website/content/docs/chat/components/chat.mdx
index a2655de3b..5176d7c45 100644
--- a/apps/website/content/docs/chat/components/chat.mdx
+++ b/apps/website/content/docs/chat/components/chat.mdx
@@ -138,6 +138,41 @@ export class ChatWithThreadsComponent {
}
```
+## Message Feedback Outputs
+
+Beyond `threadSelected`, `` emits three outputs for assistant-message feedback and regeneration. Each fires from the hover controls on an assistant message:
+
+```typescript
+@Component({
+ template: `
+
+ `,
+})
+export class ChatWithFeedbackComponent {
+ protected readonly chatRef = injectAgent();
+
+ // (regenerate) emits void — the agent already re-ran the last assistant turn.
+ onRegenerate() {
+ console.log('user requested regeneration');
+ }
+
+ // (rate) payload: { messageIndex, rating }
+ onRate(event: { messageIndex: number; rating: 'up' | 'down' }) {
+ console.log(`message ${event.messageIndex} rated ${event.rating}`);
+ }
+
+ // (messageCopy) payload: { messageIndex, content }
+ onCopy(event: { messageIndex: number; content: string }) {
+ console.log(`copied message ${event.messageIndex}`, event.content);
+ }
+}
+```
+
## Message Templates
`ChatComponent` ships with four built-in message templates:
@@ -168,6 +203,31 @@ const myViews = views({
AI messages containing JSON are parsed character-by-character as tokens stream. Components render incrementally — string props grow visibly as tokens arrive. See the [Generative UI guide](/docs/chat/guides/generative-ui) for full setup.
+### Interactive specs and `[store]`
+
+A **`StateStore`** is the shared reactive state that interactive generative-UI specs read from and write to. When a spec uses `$state` / `$bindState` prop expressions — for forms, selections, toggles — those bindings resolve against the store you pass via `[store]`. Read-only specs (a weather card, a chart) don't need one; reach for `[store]` only when a spec is interactive. Create it with `signalStateStore()` from `@threadplane/render`:
+
+```typescript
+import { Component } from '@angular/core';
+import { injectAgent } from '@threadplane/langgraph';
+import { ChatComponent } from '@threadplane/chat';
+import { signalStateStore } from '@threadplane/render';
+
+@Component({
+ selector: 'app-interactive-chat',
+ standalone: true,
+ imports: [ChatComponent],
+ template: ``,
+})
+export class InteractiveChatComponent {
+ protected readonly chatRef = injectAgent();
+ protected readonly myViews = myViews;
+ protected readonly store = signalStateStore({ selectedItem: null });
+}
+```
+
+See [State Store](/docs/chat/guides/generative-ui#state-store) in the Generative UI guide for the full binding model.
+
## A2UI Rendering
When AI messages contain A2UI content (prefixed with `---a2ui_JSON---`), the component auto-detects A2UI mode. Pass `a2uiBasicCatalog()` to `[views]` to render the built-in catalog:
diff --git a/apps/website/content/docs/chat/concepts/message-model.mdx b/apps/website/content/docs/chat/concepts/message-model.mdx
index 38e99cbcb..c21a91630 100644
--- a/apps/website/content/docs/chat/concepts/message-model.mdx
+++ b/apps/website/content/docs/chat/concepts/message-model.mdx
@@ -144,6 +144,33 @@ interface Citation {
`@threadplane/langgraph` exports `extractCitations()` for advanced adapters that need to normalize nonstandard citation payloads. Chat markdown views can render citation markers against the message citation list.
+The resolve path from a `[^id]` marker to a rendered citation runs through `CitationsResolverService` (exported from `@threadplane/chat`). A custom citation renderer provides the service, sets the current message, then calls `lookup(refId)` — which returns a reactive `Signal` matching the marker id against `message.citations` (falling back to inline markdown definitions):
+
+```ts
+import { Component, computed, inject, input } from '@angular/core';
+import { CitationsResolverService } from '@threadplane/chat';
+import type { Message } from '@threadplane/chat';
+
+@Component({
+ selector: 'app-citation-ref',
+ standalone: true,
+ providers: [CitationsResolverService],
+ template: `@if (resolved(); as r) {[{{ r.citation.index }}]}`,
+})
+export class CitationRefComponent {
+ private readonly resolver = inject(CitationsResolverService);
+
+ readonly message = input.required();
+ readonly refId = input.required(); // the `id` from a [^id] marker
+
+ // lookup() returns a Signal; resolved.source is 'message' or 'markdown'.
+ protected readonly resolved = computed(() => {
+ this.resolver.message.set(this.message());
+ return this.resolver.lookup(this.refId())();
+ });
+}
+```
+
Keep citation data on the message that owns the content. Avoid a separate global citation store unless your product has a cross-message source panel.
## Interrupts
@@ -170,7 +197,7 @@ Subagents are also optional agent state:
agent.subagents?.(); // Map
```
-Each `Subagent` has a tool-call id, optional name, status signal, message signal, and state signal.
+Each `Subagent` has a tool-call id, optional name, status signal, messages signal (`Signal`), and state signal.
Use subagents when the backend delegates work to child graphs or specialized workers. Don't encode subagent progress as fake assistant messages if the runtime can expose structured subagent state. Structured state lets UI render progress, nested transcripts, and failure states without parsing prose.
diff --git a/apps/website/content/docs/chat/concepts/primitives-vs-compositions.mdx b/apps/website/content/docs/chat/concepts/primitives-vs-compositions.mdx
index b9157a9b4..548ebeb0d 100644
--- a/apps/website/content/docs/chat/concepts/primitives-vs-compositions.mdx
+++ b/apps/website/content/docs/chat/concepts/primitives-vs-compositions.mdx
@@ -56,7 +56,21 @@ import { ChatDebugComponent } from '@threadplane/chat/debug';
Use `chat-debug` when you need timeline and state inspection while building. Don't treat it as a customer-facing control surface unless you intentionally design around that.
-If you use ``, pass `[agent]="agent"` and `[debug]="true"` to get the built-in footer launcher instead of adding a separate floating debug button.
+Three more compositions wrap `` in a layout shell. Each takes `[agent]` and forwards the rest of the `` inputs:
+
+- `` — a collapsible side panel with a built-in footer launcher.
+- `` — a floating launcher button that opens chat in an overlay.
+- `` — a docked rail pinned to the edge of the viewport.
+
+```ts
+import {
+ ChatSidenavComponent,
+ ChatPopupComponent,
+ ChatSidebarComponent,
+} from '@threadplane/chat';
+```
+
+See the [Layout Modes](/docs/chat/guides/layout-modes) guide for when to reach for each. If you use ``, pass `[agent]="agent"` and `[debug]="true"` to get the built-in footer launcher instead of adding a separate floating debug button.
## Primitives
diff --git a/apps/website/content/docs/chat/getting-started/changelog.mdx b/apps/website/content/docs/chat/getting-started/changelog.mdx
index b8926a9b6..ae577c4d0 100644
--- a/apps/website/content/docs/chat/getting-started/changelog.mdx
+++ b/apps/website/content/docs/chat/getting-started/changelog.mdx
@@ -1,5 +1,27 @@
# Changelog
+
+This changelog tracks selected highlight releases, not every patch. The published package is at `0.0.49` — see the entry below for the notable additions since `0.0.19`.
+
+
+## 0.0.49
+
+### Citations
+
+- New `` primitive renders a message's `citations` list, with `chatCitationCardTemplate` for per-citation card customization and `CitationsResolverService` for resolving `[^id]` markers against the citation list. Adapters populate `Message.citations`; `@threadplane/langgraph` exposes `extractCitations()`.
+
+### A2UI
+
+- New A2UI surface rendering: `` plus the `a2uiBasicCatalog` of catalog components (text field, checkbox, button, multiple choice, slider, date/time, card, row, column, list, modal, tabs, and more) for agent-driven generative UI. Compose custom catalogs with `withViews`.
+
+### Layout compositions
+
+- New layout compositions: `` (collapsible side panel), `` (floating launcher), and `` (docked rail). See [Layout Modes](/docs/chat/guides/layout-modes).
+
+### Agent contract
+
+- New `regenerate(assistantMessageIndex)` action on the `Agent` contract. Discards the assistant message at the given index and everything after it, then re-runs against the preceding user message. Surfaced as the `(regenerate)` output on ``.
+
## 0.0.19
### Reasoning
diff --git a/apps/website/content/docs/chat/getting-started/installation.mdx b/apps/website/content/docs/chat/getting-started/installation.mdx
index de116c11a..927f70314 100644
--- a/apps/website/content/docs/chat/getting-started/installation.mdx
+++ b/apps/website/content/docs/chat/getting-started/installation.mdx
@@ -138,22 +138,34 @@ provideChat({
## 4. Render your first chat
-Wire `provideAgent({...})` into your `app.config.ts`, then bind `` to the result of `injectAgent()` in any component. The full working example:
+Add `assistantId` and `threadId` to the same root `provideAgent({...})` from step 3, then bind `` to the result of `injectAgent()` in your component. Extend the config from step 3 rather than declaring a second provider:
```ts
// src/app/app.config.ts
-import { signal } from '@angular/core';
+import { ApplicationConfig, provideZonelessChangeDetection, signal } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
+import { provideChat } from '@threadplane/chat';
+import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
+ provideZonelessChangeDetection(),
provideAgent({
- assistantId: 'chat', // The graph/agent ID exposed by your backend
- threadId: signal(null), // null = new thread on first send
+ apiUrl: environment.langgraphApiUrl, // e.g. 'http://localhost:2024'
+ assistantId: 'chat', // The graph/agent ID exposed by your backend
+ threadId: signal(null), // null = new thread on first send
+ }),
+ provideChat({
+ license: environment.threadplaneLicense,
+ assistantName: 'Assistant',
}),
],
};
+```
+
+The component just injects that agent — no second provider:
+```ts
// src/app/chat-page.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { injectAgent } from '@threadplane/langgraph';
@@ -177,6 +189,10 @@ export class ChatPageComponent {
Add the route, run `ng serve`, and open the page. You should see the chat surface mount, an input at the bottom, and streaming responses as the agent replies.
+
+Want several `` surfaces pointing at different graphs? Keep `apiUrl` (and `license`) on the root provider, then re-provide `provideAgent({ assistantId, threadId })` in each component's `providers: []`. Angular's hierarchical DI merges the component-level config over the root, so each subtree gets its own `assistantId` while sharing the backend URL. `injectAgent()` resolves the nearest provider.
+
+
## 5. Verify the license activated
Open the browser DevTools console and look for messages prefixed with `[threadplane]`.
diff --git a/apps/website/content/docs/chat/getting-started/quickstart.mdx b/apps/website/content/docs/chat/getting-started/quickstart.mdx
index 42c566a6d..7930dae33 100644
--- a/apps/website/content/docs/chat/getting-started/quickstart.mdx
+++ b/apps/website/content/docs/chat/getting-started/quickstart.mdx
@@ -6,6 +6,14 @@ Let's build a streaming chat UI with `@threadplane/chat` in 5 minutes.
Angular 20+ project with an agent provider configured. See [Agent Installation](/docs/langgraph/getting-started/installation) if you need help.
+
+The provider steps below assume a running LangGraph server at `http://localhost:2024`. If you don't have one, jump to [Run with no backend](#run-with-no-backend) — `mockAgent()` drives the UI with canned messages so you can see `` render before wiring a real agent.
+
+
+
+Evaluating? No license needed — the chat runs without a token (with a one-time advisory console warning). Shipping commercially? See [Installation](/docs/chat/getting-started/installation) for license activation.
+
+
@@ -59,11 +67,45 @@ export class ChatPageComponent {
}
```
+The second `provideAgent()` isn't a duplicate. Angular's hierarchical DI merges it with the root provider from Step 2: `apiUrl` comes from the root, `assistantId` and `threadId` come from this component-level call. Splitting config this way lets one app host several `` surfaces that share a backend URL but target different graphs. See the [Multiple agents](/docs/chat/getting-started/installation) note in Installation for the full pattern.
+
That's it — no Tailwind setup, no PostCSS config, no global stylesheet import. The chat ships with its own design tokens and component-scoped styles.
+## Run with no backend
+
+No LangGraph server yet? Bind `` to `mockAgent()` from `@threadplane/chat`. It implements the same `Agent` contract with canned messages, so the UI renders exactly as it will against a real agent — only the responses are static.
+
+```ts
+// chat-page.component.ts
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChatComponent, mockAgent } from '@threadplane/chat';
+
+@Component({
+ selector: 'app-chat-page',
+ standalone: true,
+ imports: [ChatComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ `,
+})
+export class ChatPageComponent {
+ protected readonly chatAgent = mockAgent({
+ messages: [
+ { id: 'm1', role: 'user', content: 'Hello' },
+ { id: 'm2', role: 'assistant', content: 'Hi there — I am a mock agent.' },
+ ],
+ });
+}
+```
+
+With `mockAgent()` you can drop both `provideAgent()` calls — the agent is constructed directly in the component, no DI wiring or `apiUrl` required. Swap it for `injectAgent()` once your backend is up.
+
## What's Next
@@ -76,4 +118,7 @@ That's it — no Tailwind setup, no PostCSS config, no global stylesheet import.
Full primitive and composition reference.
+
+ Bake in your license at build time and swap the mock for a real adapter — see [Installation step 2](/docs/chat/getting-started/installation) for build-time license injection and [Agent deployment](/docs/langgraph/guides/deployment) for the backend.
+
diff --git a/apps/website/content/docs/chat/guides/custom-catalogs.mdx b/apps/website/content/docs/chat/guides/custom-catalogs.mdx
index 76a491a30..885f28a16 100644
--- a/apps/website/content/docs/chat/guides/custom-catalogs.mdx
+++ b/apps/website/content/docs/chat/guides/custom-catalogs.mdx
@@ -76,6 +76,9 @@ If you don't provide `views`, no generative UI renders. Specs and A2UI surfaces
Custom components receive the standard `AngularComponentInputs`:
```typescript
+import { Component, input } from '@angular/core';
+import type { AngularComponentInputs } from '@threadplane/render';
+
@Component({
selector: 'my-chart',
standalone: true,
@@ -85,10 +88,24 @@ export class MyChartComponent {
readonly data = input([]);
readonly title = input('');
readonly childKeys = input([]);
- readonly spec = input.required();
+ readonly spec = input.required();
readonly emit = input<(event: string) => void>(() => {});
readonly loading = input();
}
```
-The component receives resolved props as inputs. `childKeys` and `spec` enable recursive child rendering, and `emit` triggers event handlers defined in the spec's `on` bindings.
+The component receives resolved props as inputs. `childKeys` and `spec` enable recursive child rendering, and `emit` triggers event handlers defined in the spec's `on` bindings. `Spec` isn't exported on its own — type the input via `AngularComponentInputs['spec']` (or implement `AngularComponentInputs` directly).
+
+## What's Next
+
+
+
+ Wire `[views]`, `[store]`, and `[handlers]` on ``.
+
+
+ The JSON spec shape catalogs render against.
+
+
+ Agent-driven surfaces and the basic catalog.
+
+
diff --git a/apps/website/content/docs/chat/guides/generative-ui.mdx b/apps/website/content/docs/chat/guides/generative-ui.mdx
index ed227c9fe..553545a37 100644
--- a/apps/website/content/docs/chat/guides/generative-ui.mdx
+++ b/apps/website/content/docs/chat/guides/generative-ui.mdx
@@ -1,6 +1,8 @@
# Generative UI
-Generative UI lets your LangGraph agent return structured JSON specs that render as Angular components in the chat. `ChatComponent` auto-detects JSON specs in AI messages and renders them — no manual wiring needed.
+Generative UI lets your agent return structured JSON specs that render as Angular components in the chat. `ChatComponent` auto-detects JSON specs in AI messages and renders them — no manual wiring needed.
+
+The detection and rendering are adapter-neutral: the same `[views]` path works whether the agent is driven by the LangGraph adapter or the [AG-UI adapter](/docs/ag-ui/guides/custom-events). The examples below use `@threadplane/langgraph`, but only the `provideAgent`/`injectAgent` imports change for AG-UI.
## How It Works
@@ -134,6 +136,50 @@ export class InteractiveChatComponent {
The store enables two-way data binding between generative UI components and your application via `$state` and `$bindState` prop expressions in specs.
+## Event Handlers
+
+When a spec wires an `on` event binding (for example a button's `on: { click: 'submitForm' }`), the named handler is looked up in the `[handlers]` map you pass to ``. The map is `Record unknown | Promise>` — each key is a handler name a spec can reference, and the value runs when the event fires.
+
+```typescript
+import { Component, signal } from '@angular/core';
+import { injectAgent, provideAgent } from '@threadplane/langgraph';
+import { ChatComponent, views } from '@threadplane/chat';
+import { ContactFormComponent } from './contact-form.component';
+
+@Component({
+ selector: 'app-chat',
+ standalone: true,
+ imports: [ChatComponent],
+ providers: [
+ provideAgent({
+ apiUrl: 'http://localhost:2024',
+ assistantId: 'gen_ui_agent',
+ threadId: signal(null),
+ }),
+ ],
+ template: `
+
+
+
+ `,
+})
+export class ChatPageComponent {
+ protected readonly chatRef = injectAgent();
+
+ protected readonly myViews = views({ contact_form: ContactFormComponent });
+
+ // A spec's `on` binding referencing "submitForm" invokes this function,
+ // passing the element's resolved params.
+ protected readonly handlers = {
+ submitForm: (params: Record) => {
+ console.log('form submitted', params);
+ },
+ };
+}
+```
+
+Handlers can return a value or a `Promise` — async handlers let a spec await a result (such as a network call) before the UI advances.
+
## A2UI Protocol
For agents that emit A2UI JSONL payloads, `ChatComponent` auto-detects content prefixed with `---a2ui_JSON---`. Pass `a2uiBasicCatalog()` to `[views]` when you want those surfaces rendered with the built-in components. See the [A2UI guide](/docs/chat/a2ui/overview) for details.
diff --git a/apps/website/content/docs/chat/guides/streaming.mdx b/apps/website/content/docs/chat/guides/streaming.mdx
index 2a95ec91e..776c58539 100644
--- a/apps/website/content/docs/chat/guides/streaming.mdx
+++ b/apps/website/content/docs/chat/guides/streaming.mdx
@@ -2,6 +2,10 @@
`ChatComponent` automatically classifies AI message content and routes it to the right renderer. This page walks through how the streaming pipeline works, and how to use the classification APIs directly for custom integrations.
+
+This page is the advanced generative-UI plumbing: how `` decides whether an AI message is markdown, a JSON-render spec, or A2UI, and how partial JSON is parsed as tokens arrive. If you just want to know how ordinary assistant text streams token-by-token into `` — no generative UI involved — start with the [LangGraph streaming guide](/docs/langgraph/guides/streaming). The basic case needs no setup: `` renders streaming markdown out of the box.
+
+
## Content Classification
Each AI message is processed by a `ContentClassifier` that examines the content as it streams token-by-token. The classifier determines the content type from the first non-whitespace character:
@@ -9,8 +13,11 @@ Each AI message is processed by a `ContentClassifier` that examines the content
| Trigger | Content Type | What Happens |
|---------|-------------|--------------|
| First non-whitespace is `{` | `json-render` | Parsed as a JSON spec via `@cacheplane/partial-json` |
+| Prose with inline JSON-render specs | `markdown` | Markdown path, with embedded specs rendered in place |
| Any other text | `markdown` | Rendered as markdown prose |
+Prose that interleaves inline JSON-render specs still classifies as `'markdown'` — the markdown path renders the embedded specs in place. The `ContentType` union also includes `'mixed'`, but `createContentClassifier` doesn't currently emit it, so treat it as reserved: don't branch on `classifier.type() === 'mixed'` expecting inline-spec content to land there. (`'a2ui'` is covered under [A2UI Content Detection](#a2ui-content-detection) below.)
+
Each message gets its own classifier instance. Classification happens once per message — the type is determined by the first meaningful character and never changes.