Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions apps/website/content/docs/chat/guides/writing-an-adapter.mdx
Original file line number Diff line number Diff line change
@@ -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<Message[]>` | A signal of the conversation messages so far |
| `status` | `Signal<AgentStatus>` | `'idle' \| 'running' \| 'error'` |
| `isLoading` | `Signal<boolean>` | `true` while a run is in flight |
| `error` | `Signal<unknown>` | Last error, or `null` |
| `toolCalls` | `Signal<ToolCall[]>` | Tool invocations and their results |
| `state` | `Signal<Record<string, unknown>>` | Backend-defined state snapshot |
| `events$` | `Observable<AgentEvent>` | Discriminated `state_update` / `custom` events |
| `submit` | `(input, opts?) => Promise<void>` | Send a message or resume |
| `stop` | `() => Promise<void>` | Abort the in-flight run |
| `interrupt?` | `Signal<AgentInterrupt \| undefined>` | (optional) Current pause-for-input |
| `subagents?` | `Signal<Map<string, Subagent>>` | (optional) Spawned subagents |

<Callout type="info" title="Optional fields">
`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.
</Callout>

<Callout type="tip" title="events$ and signals">
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.
</Callout>

## 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<Message[]>([]);
const status = signal<AgentStatus>('idle');
const isLoading = signal(false);
const error = signal<unknown>(null);
const toolCalls = signal<ToolCall[]>([]);
const state = signal<Record<string, unknown>>({});
let pending: ReturnType<typeof setTimeout> | 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<AgentEvent>,
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<Agent>('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: `<chat [agent]="agent" />`,
})
export class App {
protected readonly agent = inject(ECHO_AGENT);
}
```

<Callout type="tip" title="Using provideAgent() from @ngaf/langgraph is not required">
`provideAgent()` and `agent()` are LangGraph-specific. When you bring your own adapter, skip them entirely — inject your token directly.
</Callout>

## 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<AgentCheckpoint[]>` 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

<CardGroup cols={3}>
<Card
title="Configuration"
icon="settings"
href="/docs/chat/guides/configuration"
>
All provideChat() options for global chat configuration.
</Card>
<Card
title="ChatComponent"
icon="layout"
href="/docs/chat/components/chat"
>
Deep dive into the ChatComponent API and inputs.
</Card>
<Card
title="AG-UI Adapter"
icon="plug"
href="/docs/ag-ui/getting-started/introduction"
>
The built-in AG-UI adapter for compatible backends.
</Card>
</CardGroup>
1 change: 1 addition & 0 deletions apps/website/src/lib/docs-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
Loading