DocsChatGuidesWriting an Adapter

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 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.

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

FieldTypeWhat you supply
messagesSignal<Message[]>A signal of the conversation messages so far
statusSignal<AgentStatus>'idle' | 'running' | 'error'
isLoadingSignal<boolean>true while a run is in flight
errorSignal<unknown>Last error, or null
toolCallsSignal<ToolCall[]>Tool invocations and their results
stateSignal<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
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.

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.

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.

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.

// 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 }) },
  ],
};
// 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);
}
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.

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:

// 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:

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:

{
  "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 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