Threads, memory, attachments, and headless UI

Build persistent and multimodal Angular agent experiences, or own the complete UI with signal-based APIs.


CopilotChat covers the common conversation path. Use the lower-level Angular APIs when you need saved threads, user memory, file input, or a fully custom interface.

Resume a specific thread#

Pass threadId to connect the chat to an existing conversation:

<copilot-chat agentId="support" [threadId]="selectedThreadId()" />

For a custom thread list, use injectThreads. Its inputs accept plain values or signals.

src/app/thread-list.component.ts
import { Component } from "@angular/core";
import { injectThreads } from "@copilotkit/angular";

@Component({
  selector: "app-thread-list",
  template: `
    <button type="button" (click)="threads.startNewThread()">
      New conversation
    </button>

    @if (threads.isLoading()) {
      <p>Loading conversations…</p>
    } @else {
      @for (thread of threads.threads(); track thread.id) {
        <button type="button" (click)="select(thread.id)">
          {{ thread.name ?? "Untitled conversation" }}
        </button>
      }
    }

    @if (threads.listError()) {
      <button type="button" (click)="threads.refetchThreads()">Retry</button>
    }
  `,
})
export class ThreadListComponent {
  readonly threads = injectThreads({
    agentId: "support",
    limit: 20,
  });

  protected select(threadId: string): void {
    // Store this id and bind it to CopilotChat's threadId input.
    console.log(threadId);
  }
}

The list is server-authoritative and uses realtime updates when the platform supplies a WebSocket URL. Rename, archive, unarchive, and delete return promises. Deletion is permanent; ask the user before calling deleteThread.

CopilotThreadsDrawer supplies a ready-made list, selection, filtering, pagination, and mutation controls. Put the drawer and chat under the same provideCopilotChatConfiguration provider so selection and new-thread actions update the chat. The drawer reads the platform's threads license feature and shows its locked state when that feature is unavailable.

import { Component } from "@angular/core";
import {
  CopilotChat,
  CopilotThreadsDrawer,
  provideCopilotChatConfiguration,
} from "@copilotkit/angular";

@Component({
  selector: "app-conversations",
  imports: [CopilotChat, CopilotThreadsDrawer],
  providers: [provideCopilotChatConfiguration({ agentId: "support" })],
  template: `
    <copilot-threads-drawer agentId="support" [limit]="20" />
    <copilot-chat />
  `,
})
export class ConversationsComponent {}

Read and manage memory#

injectMemories exposes the current runtime-authenticated user's memory list. Check isAvailable() before showing memory controls because a runtime may not provide the memory routes.

src/app/memory-list.component.ts
import { Component } from "@angular/core";
import { injectMemories } from "@copilotkit/angular";

@Component({
  selector: "app-memory-list",
  template: `
    @if (!memory.isAvailable()) {
      <p>Memory is not available for this runtime.</p>
    } @else {
      @for (item of memory.memories(); track item.id) {
        <article>
          <p>{{ item.content }}</p>
          <button type="button" (click)="remove(item.id)">Forget</button>
        </article>
      }
    }
  `,
})
export class MemoryListComponent {
  readonly memory = injectMemories();

  protected remove(id: string): void {
    this.memory.removeMemory(id).catch(() => undefined);
  }

  protected addPreference(): void {
    this.memory
      .addMemory({
        kind: "operational",
        content: "Prefer concise status updates.",
      })
      .catch(() => undefined);
  }
}

updateMemory supersedes a memory with a full replacement and returns a new record with a new id. Re-send content, kind, and any sourceThreadIds you want to keep.

Enable attachments#

Pass an AttachmentsConfig to CopilotChat. The built-in input supports the file picker, drag and drop, and paste.

src/app/media-chat.component.ts
import { Component } from "@angular/core";
import {
  CopilotChat,
  type AttachmentsConfig,
} from "@copilotkit/angular";

@Component({
  selector: "app-media-chat",
  imports: [CopilotChat],
  template: `
    <copilot-chat [attachments]="attachments" />
  `,
})
export class MediaChatComponent {
  protected readonly attachments: AttachmentsConfig = {
    enabled: true,
    accept: "image/*,application/pdf",
    maxSize: 10 * 1024 * 1024,
    onUploadFailed: (error) => {
      console.error(error.reason, error.message);
    },
  };
}

Without onUpload, files are read as base64. Supply onUpload to place large files in your own storage and return a URL. The selected model and backend must support each content type you allow.

Build a headless chat#

injectAgentStore gives you the state needed to render your own transcript and composer. Add the user message to the agent, then run that same agent through CopilotKitCore.

src/app/headless-chat.component.ts
import { Component, inject, signal } from "@angular/core";
import { CopilotKit, injectAgentStore } from "@copilotkit/angular";

@Component({
  selector: "app-headless-chat",
  template: `
    <div aria-live="polite">
      @for (message of store().messages(); track message.id) {
        <article [attr.data-role]="message.role">
          {{ message.content }}
        </article>
      }
      @if (store().isRunning()) {
        <p>Agent is working…</p>
      }
    </div>

    <textarea
      aria-label="Message"
      [value]="draft()"
      (input)="updateDraft($event)"
    ></textarea>
    <button
      type="button"
      [disabled]="store().isRunning() || !draft().trim()"
      (click)="send()"
    >
      Send
    </button>
  `,
})
export class HeadlessChatComponent {
  private readonly copilotKit = inject(CopilotKit);
  readonly store = injectAgentStore("default");
  readonly draft = signal("");

  protected updateDraft(event: Event): void {
    this.draft.set((event.target as HTMLTextAreaElement).value);
  }

  protected async send(): Promise<void> {
    const content = this.draft().trim();
    if (!content || this.store().isRunning()) return;

    const agent = this.store().agent;
    agent.addMessage({
      id: crypto.randomUUID(),
      role: "user",
      content,
    });
    this.draft.set("");
    await this.copilotKit.core.runAgent({ agent });
  }
}

Add error handling around runAgent in production and disable repeated sends while isRunning() is true. The store tears down its agent subscription with the owning injector.

The runnable Showcase factors that flow into this signal-first controller:

headless-chat.ts
/** Signal-first controller shared by the two native Angular headless demos. */export abstract class HeadlessChatController {  private readonly copilotKit = inject(CopilotKit);  private readonly destroyRef = inject(DestroyRef);  protected readonly agentStore: ReturnType<typeof injectAgentStore>;  protected readonly inputValue = signal("");  protected readonly error = signal<string | null>(null);  protected readonly messages = computed(    () => this.agentStore().messages() as ShowcaseMessage[],  );  protected readonly isRunning = computed(() => this.agentStore().isRunning());  protected constructor(feature: string) {    const route = inject(ActivatedRoute);    this.agentStore = injectAgentStore(agentIdForRoute(feature, route));  }  protected updateInput(event: Event): void {    this.inputValue.set((event.target as HTMLTextAreaElement).value);  }  protected handleComposerKeydown(event: KeyboardEvent): void {    if (event.key !== "Enter" || event.shiftKey) return;    event.preventDefault();    void this.send();  }  protected async send(override?: string): Promise<void> {    const text = (override ?? this.inputValue()).trim();    if (!text || this.isRunning()) return;    this.error.set(null);    const agent = this.agentStore().agent;    const message = {      id: createMessageId(),      role: "user" as const,      content: text,    };    agent.addMessage(message);    this.inputValue.set("");    try {      await this.copilotKit.core.runAgent({ agent });    } catch (error) {      if (this.destroyRef.destroyed) return;      console.error("[showcase-angular:headless] Agent run failed", error);      this.error.set(        error instanceof Error ? error.message : "The agent run failed.",      );    }  }}

Next steps#