From 6abbb72d1e17f183927439d502310870a5fa4577 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 11:39:36 -0700 Subject: [PATCH] docs(chat): adapter authoring guide with worked echo example Walks through every Agent contract field, includes a complete in-process EchoAgent factory (~80 lines) demonstrating the runtime-neutral signal pattern, shows DI wiring + conformance test usage, and notes peer-deps for publishing your own adapter. Co-Authored-By: Claude Opus 4.7 --- .../docs/chat/guides/writing-an-adapter.mdx | 253 ++++++++++++++++++ apps/website/src/lib/docs-config.ts | 1 + 2 files changed, 254 insertions(+) create mode 100644 apps/website/content/docs/chat/guides/writing-an-adapter.mdx diff --git a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx new file mode 100644 index 000000000..b3ee35136 --- /dev/null +++ b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx @@ -0,0 +1,253 @@ +# Writing an Adapter + +Learn how to implement a custom `Agent` adapter so `@ngaf/chat` components work with any backend — whether that is a custom RPC service, an in-process mock, or an exotic streaming protocol. + +## When to Write Your Own Adapter + +`@ngaf/langgraph` covers LangGraph backends and `@ngaf/ag-ui` covers any [AG-UI-compatible](https://docs.ag-ui.com) backend. Everything else needs a hand-rolled adapter. Common scenarios: + +- **Custom RPC or HTTP API** — your backend speaks neither LangGraph Server nor the AG-UI protocol. +- **In-process logic** — you want the chat UI without any network call (demos, playgrounds, offline-first apps). +- **Testing** — a deterministic in-process adapter is faster and more reliable than hitting a real agent in unit tests. +- **Exotic transports** — WebSockets, gRPC-Web, or any other streaming mechanism. + +## The Contract + +Every `@ngaf/chat` primitive and composition accepts an `Agent` object. The type lives in `@ngaf/chat` and is intentionally runtime-neutral — it says nothing about HTTP, LangGraph, or any specific backend. + +```typescript +import type { Agent } from '@ngaf/chat'; +``` + +An `Agent` is a set of Angular **signals** (reactive state) plus an RxJS **observable** of events, a `submit` method to send a message or resume an interrupted run, and a `stop` method to abort the in-flight run. + +## Field-by-Field Reference + +| Field | Type | What you supply | +|---|---|---| +| `messages` | `Signal` | A signal of the conversation messages so far | +| `status` | `Signal` | `'idle' \| 'running' \| 'error'` | +| `isLoading` | `Signal` | `true` while a run is in flight | +| `error` | `Signal` | Last error, or `null` | +| `toolCalls` | `Signal` | Tool invocations and their results | +| `state` | `Signal>` | Backend-defined state snapshot | +| `events$` | `Observable` | Discriminated `state_update` / `custom` events | +| `submit` | `(input, opts?) => Promise` | Send a message or resume | +| `stop` | `() => Promise` | Abort the in-flight run | +| `interrupt?` | `Signal` | (optional) Current pause-for-input | +| `subagents?` | `Signal>` | (optional) Spawned subagents | + + +`interrupt` and `subagents` are optional. Runtimes that do not support these concepts can leave them undefined. Components that need them gracefully fall back when they are absent. + + + +The design invariant is: **state lives on signals; `events$` carries only things that are not derivable from signals.** If your runtime produces no custom events, set `events$` to `EMPTY` from RxJS — the type system requires the field to be present, but nothing forces you to emit. + + +## Worked Example: An In-Process Echo Adapter + +Below is a complete `EchoAgent` factory — roughly 80 lines — that satisfies the full `Agent` contract without any network call. It demonstrates the signal pattern clearly and is a solid starting point for your own adapter. + +On `submit`, the factory optimistically appends the user message, then after a short delay appends an assistant message that echoes the input back. There are no tool calls, no custom events, and no interrupts. + +```typescript +import { signal, type Signal } from '@angular/core'; +import { EMPTY, type Observable } from 'rxjs'; +import type { + Agent, Message, AgentStatus, ToolCall, + AgentEvent, AgentSubmitInput, AgentSubmitOptions, +} from '@ngaf/chat'; + +export interface EchoAgentOptions { + /** Delay before the echoed reply appears, in ms. Defaults to 400. */ + delayMs?: number; +} + +export function createEchoAgent(opts: EchoAgentOptions = {}): Agent { + const messages = signal([]); + const status = signal('idle'); + const isLoading = signal(false); + const error = signal(null); + const toolCalls = signal([]); + const state = signal>({}); + let pending: ReturnType | undefined; + + const submit = async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { + if (input.message === undefined) return; + + const text = typeof input.message === 'string' + ? input.message + : input.message.map((b) => b.type === 'text' ? b.text : '').join(''); + + // Optimistic user message + messages.update((prev) => [ + ...prev, + { id: cryptoRandomId(), role: 'user', content: text }, + ]); + + status.set('running'); + isLoading.set(true); + error.set(null); + + pending = setTimeout(() => { + messages.update((prev) => [ + ...prev, + { id: cryptoRandomId(), role: 'assistant', content: `You said: ${text}` }, + ]); + status.set('idle'); + isLoading.set(false); + pending = undefined; + }, opts.delayMs ?? 400); + }; + + const stop = async () => { + if (pending !== undefined) clearTimeout(pending); + pending = undefined; + status.set('idle'); + isLoading.set(false); + }; + + return { + messages, + status, + isLoading, + error, + toolCalls, + state, + events$: EMPTY satisfies Observable, + submit, + stop, + }; +} + +function cryptoRandomId(): string { + return Math.random().toString(36).slice(2); +} +``` + +## Wiring It Into a Component + +The cleanest approach is to register your factory behind an Angular injection token and inject it into your component. + +```typescript +// app.config.ts +import { ApplicationConfig, InjectionToken } from '@angular/core'; +import type { Agent } from '@ngaf/chat'; +import { createEchoAgent } from './echo-agent'; + +export const ECHO_AGENT = new InjectionToken('ECHO_AGENT'); + +export const appConfig: ApplicationConfig = { + providers: [ + { provide: ECHO_AGENT, useFactory: () => createEchoAgent({ delayMs: 250 }) }, + ], +}; +``` + +```typescript +// app.ts +import { Component, inject } from '@angular/core'; +import { ChatComponent } from '@ngaf/chat'; +import { ECHO_AGENT } from './app.config'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ChatComponent], + template: ``, +}) +export class App { + protected readonly agent = inject(ECHO_AGENT); +} +``` + + +`provideAgent()` and `agent()` are LangGraph-specific. When you bring your own adapter, skip them entirely — inject your token directly. + + +## Validating with the Conformance Suite + +`@ngaf/chat` ships a conformance helper that checks every contract field and a handful of semantic invariants (for example, `isLoading()` must only be `true` when `status() === 'running'`). Run it against your factory in a Vitest spec: + +```typescript +// echo-agent.conformance.spec.ts +import { runAgentConformance } from '@ngaf/chat/testing'; +import { createEchoAgent } from './echo-agent'; + +runAgentConformance('createEchoAgent', () => createEchoAgent()); +``` + +The conformance suite verifies: + +- Every required signal is present and returns the correct type. +- `isLoading()` is `false` when `status()` is `'idle'`. +- `events$` is a valid RxJS `Observable`. +- `submit` and `stop` return a `Promise`. + +There is no separate package to install — the testing entry point ships as part of `@ngaf/chat`. + +## AgentWithHistory (Optional) + +If your backend supports checkpointing or thread history, extend the basic contract with `AgentWithHistory`: + +```typescript +import type { AgentWithHistory } from '@ngaf/chat'; +``` + +`AgentWithHistory` adds a `history: Signal` field. The implementation pattern is identical — add the signal to your factory return value. + +Use `runAgentWithHistoryConformance` from `@ngaf/chat/testing` in your spec instead of `runAgentConformance` to cover the additional field. + +## Publishing Your Adapter + +If you want to distribute your adapter as an npm package, keep the following in mind. + +**Peer dependencies** to declare in your `package.json`: + +```json +{ + "peerDependencies": { + "@angular/core": "^20.0.0", + "@ngaf/chat": "^0.0.2", + "rxjs": "^7.0.0" + }, + "devDependencies": { + "@ngaf/chat": "^0.0.2" + } +} +``` + +The `@ngaf/chat/testing` entry point is part of the same package as the main entry point, so there is nothing extra to install for the conformance tests. + +**Naming convention:** `@your-org/your-backend-agent` works well (e.g., `@acme/supabase-realtime-agent`). The `-agent` suffix signals that the package satisfies the `Agent` contract. + +**Angular library setup:** Use Nx (`nx g @nx/angular:library`) or the Angular CLI (`ng g library`) to scaffold an Angular library with ng-packagr. Point your `package.json` exports at the compiled output. See the [Nx Angular library guide](https://nx.dev/recipes/angular/create-an-angular-library) for the full setup. + +**Optional: license-key gating.** If you want to restrict usage to paying customers, `@ngaf/licensing` provides a browser-safe license verification API. Declare it as an optional peer dependency. + +## What's Next + + + + All provideChat() options for global chat configuration. + + + Deep dive into the ChatComponent API and inputs. + + + The built-in AG-UI adapter for compatible backends. + + diff --git a/apps/website/src/lib/docs-config.ts b/apps/website/src/lib/docs-config.ts index 7ba12a0c5..182c43a84 100644 --- a/apps/website/src/lib/docs-config.ts +++ b/apps/website/src/lib/docs-config.ts @@ -150,6 +150,7 @@ export const docsConfig: DocsLibrary[] = [ { title: 'Generative UI', slug: 'generative-ui', section: 'guides' }, { title: 'Streaming', slug: 'streaming', section: 'guides' }, { title: 'Configuration', slug: 'configuration', section: 'guides' }, + { title: 'Writing an Adapter', slug: 'writing-an-adapter', section: 'guides' }, ], }, {