Angular apps can now run any agent, with the streaming, tool calls, and shared state already handled.
Today we're releasing Angular support for CopilotKit, an open source client that brings any AG-UI agent into your Angular app. It's built with Angular's own patterns, standalone components, dependency injection and signals.
You get the building blocks for agent-native apps in Angular: pre-built chat components or a fully headless setup, generative UI, shared state, human-in-the-loop, multimodal attachments, threads and more.
Use the CLI to scaffold a full starter Angular app with a Google ADK agent.
npx copilotkit@latest init --framework adk-angular
Let's see how to set everything up, then go through each of the pieces and give your agent the context. Quickstart docs are on docs.copilotkit.ai/angular.
Rainer Hahnekamp (Angular GDE, NgRx core) and Murat Sari helped build the integration and are now taking on its ongoing maintenance.
How everything fits together
Everything runs on Agent-User Interaction Protocol (AG-UI), the open protocol that connects agents to user-facing apps. It streams an agent's entire lifecycle as events, the messages, the tool calls, the state changes, which is what keeps your Angular app and the agent in sync.
That matters because the agent becomes a choice you can change. The runtime can point at a BuiltInAgent, LangGraph, Google ADK, Mastra, Pydantic AI, Claude Agents SDK or any framework that speaks AG-UI and your Angular code doesn't change.
Here's the architecture.
┌──────────────────────────┐ ┌──────────────────────────┐
│ ANGULAR APP │ │ COPILOT RUNTIME (Node) │
│ │ │ │
│ provideCopilotKit() │ ─────► │ holds your model keys │
│ <copilot-chat /> │ AG-UI │ connects to your agent │
│ tools · context · state │ ◄───── │ streams events back │
└──────────────────────────┘ └──────────────┬───────────┘
│
▼
┌───────────────────────────┐
│ YOUR AGENT + MODEL │
│ LangGraph · ADK · Mastra │
│ OpenAI or a local model │
└───────────────────────────┘
Your Angular app is the browser half. The runtime is a small Node server that keeps your model credentials off the client. Everything below the AG-UI line is swappable, including running the model locally.
Set up CopilotKit in your Angular app
The CLI scaffolds all three parts for you, including a runtime server with a Google ADK agent, already wired with generative UI and threads.
npx copilotkit@latest init --framework adk-angular --name my-angular-adk-app
Let's also do it manually, since it's just four steps and it shows you where each piece goes.
Install the client along with Angular's CDK. Make sure the CDK version matches your Angular major version.
npm install @copilotkit/angular @angular/cdk
Angular configures services through providers, so that's where CopilotKit goes. Point it at a Copilot Runtime endpoint, which is where your agent runs.
// src/app/app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from "@angular/core";
import { provideCopilotKit, provideCopilotChatConfiguration } from "@copilotkit/angular";
export const AGENT_ID = "default";
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideCopilotKit({
runtimeUrl: "http://localhost:8200/api/copilotkit",
}),
// owns the active thread that the threads drawer drives
provideCopilotChatConfiguration({ agentId: AGENT_ID }),
],
};
Now import the stylesheet. It comes with everything the chat needs, so you don't have to write any CSS.
/* src/styles.css */
@import "@copilotkit/angular/styles.css";
You can drop in the ready-made chat, compose it from smaller pieces, or go fully headless and build your own UI.
// src/app/app.ts
import { Component } from "@angular/core";
import {
CopilotChat,
CopilotThreadsDrawer,
CopilotChatMessageView,
CopilotChatInput,
} from "@copilotkit/angular";
import { AGENT_ID } from "./app.config";
@Component({
selector: "app-root",
imports: [CopilotChat, CopilotThreadsDrawer, CopilotChatMessageView, CopilotChatInput],
template: `
<!-- pre-built chat component -->
<copilot-chat [agentId]="AGENT_ID" />
<!-- or compose the pieces into your own panel -->
<aside class="copilot-panel">
<copilot-threads-drawer />
<copilot-chat-message-view />
<copilot-chat-input />
</aside>
`,
})
export class App {
protected readonly AGENT_ID = AGENT_ID;
}
For a fully custom UI, injectAgentStore() gives you the agent's messages and run state as signals, and you render everything yourself. I've covered this in-depth in the shared state section.
The last piece is the runtime.
// server.ts
import "dotenv/config";
import { createServer } from "node:http";
import { BuiltInAgent, CopilotRuntime } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
model: "openai:gpt-5.5",
prompt: "You are a helpful assistant inside an Angular app.",
}),
},
});
createServer(
createCopilotNodeListener({ runtime, basePath: "/api/copilotkit", cors: true }),
).listen(8200, () => {
console.log("Copilot Runtime on http://localhost:8200/api/copilotkit");
});
The agent is named default on purpose, since that's the AGENT_ID the chat is pointed at.
That's the whole setup. It streams responses, renders tool calls, and keeps conversation state. Read more on the docs.copilotkit.ai/angular.
Generative UI, native in Angular
Generative UI lets the agent build interfaces in real time. That ranges from the agent picking components you already wrote, to assembling them from a catalog you define, to generating a whole surface on its own.
If you are interested, read more on the Generative UI Spectrum blog that covers all the approaches with the tradeoffs.
The simplest version is the first one, where you write the components and the agent decides which one to render and what data to pass. Start with a normal standalone component that reads the tool call as a signal input.
// src/app/weather-card.ts
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { z } from "zod";
import type { AngularToolCall, ToolRenderer } from "@copilotkit/angular";
export const weatherArgs = z.object({ location: z.string() });
type WeatherArgs = z.infer<typeof weatherArgs>;
@Component({
selector: "app-weather-card",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="wc">
<h3>{{ location() }}</h3>
<div class="temp">70°</div>
<p>Clear skies</p>
</div>
`,
styles: [
`
.wc { border-radius: 12px; padding: 16px; color: #fff; background: #6366f1; width: fit-content; }
.wc h3 { margin: 0; text-transform: capitalize; }
.temp { font-size: 30px; font-weight: 700; }
`,
],
})
export class WeatherCard implements ToolRenderer<WeatherArgs> {
readonly toolCall = input.required<AngularToolCall<WeatherArgs>>();
protected readonly location = computed(
() => this.toolCall().args?.location ?? "Weather",
);
}
Now register it as the renderer for a tool the agent calls. The cleanest way is declaratively, in the provider config.
// src/app/app.config.ts
import { provideCopilotKit } from "@copilotkit/angular";
import { WeatherCard, weatherArgs } from "./weather-card";
provideCopilotKit({
runtimeUrl: "http://localhost:8200/api/copilotkit",
// when the agent calls get_weather, render WeatherCard with its args
renderToolCalls: [
{ name: "get_weather", args: weatherArgs, component: WeatherCard },
],
});
You can also register it from inside a component with registerRenderToolCall({ name, args, component }), which is handy when the renderer only makes sense on one page.
For example: ask about the weather in Tokyo and the card appears in the chat. The agent decides when to render it, and your app decides how it looks.
You can also use A2UI (Agent-to-UI), Google's schema-driven spec for agent-authored interfaces. The agent describes a surface with a schema, and Angular renders it as real components, so it can go beyond the components you registered ahead of time.
Murat Sari published a tutorial on building an A2UI app in Angular on a local 12B model, which is worth a read if you want to see it end to end.
Multimodal attachments
Real users don't just type. Let them send images, audio, video, and documents to the agent alongside their message.
It's one input on <copilot-chat>. Pass an AttachmentsConfig and the composer gets an attach button, drag and drop, previews, and validation.
// src/app/app.ts
import { Component } from "@angular/core";
import { CopilotChat, type AttachmentsConfig } from "@copilotkit/angular";
import { AGENT_ID } from "./app.config";
@Component({
selector: "app-root",
imports: [CopilotChat],
template: `<copilot-chat [agentId]="AGENT_ID" [attachments]="attachments" />`,
})
export class App {
protected readonly AGENT_ID = AGENT_ID;
protected readonly attachments: AttachmentsConfig = {
enabled: true,
accept: "image/*", // MIME filter, defaults to all files
maxSize: 10 * 1024 * 1024, // bytes, defaults to 20MB
};
}
With just { enabled: true }, files are encoded inline and sent along with the message, which is fine while you're building. In production, you don't want a 10MB base64 blob in every request, so hand large files to your own storage with onUpload and give the agent a URL instead.
protected readonly attachments: AttachmentsConfig = {
enabled: true,
accept: "image/*,application/pdf",
maxSize: 20 * 1024 * 1024,
// upload to your bucket, hand the agent a URL instead of base64
onUpload: async (file) => {
const url = await uploadToStorage(file); // your S3, GCS, whatever
return { type: "url", value: url, mimeType: file.type };
},
onUploadFailed: (error) => console.error(error.reason, error.message),
};
The attachment travels with the message content, so a vision-capable model reads the image the user sent and can act on it through your tools.
Human-in-the-loop: pause for approval
Some critical actions shouldn't run on their own like deleting a record or sending an email, so it's important to configure human-in-the-loop. It pauses the agent mid-run and waits for your approval before it continues.
The component reads the paused tool call, shows the controls, and calls respond() to unblock the agent. Whatever you pass to respond() becomes the tool result, so the approve and cancel paths send different messages.
// src/app/delete-confirm.ts
import { Component, inject, input } from "@angular/core";
import type { HumanInTheLoopToolCall } from "@copilotkit/angular";
import { TaskStore } from "./task-store";
@Component({
selector: "app-delete-confirm",
standalone: true,
template: `
@if (toolCall().status === "complete") {
<div class="resolved">{{ toolCall().result }}</div>
} @else {
<div class="confirm">
<p>Delete "{{ toolCall().args.title }}"?</p>
<button class="danger" (click)="approve()">Delete</button>
<button (click)="cancel()">Keep it</button>
</div>
}
`,
})
export class DeleteConfirm {
private store = inject(TaskStore);
readonly toolCall =
input.required<HumanInTheLoopToolCall<{ id: string; title: string }>>();
approve(): void {
const { id, title } = this.toolCall().args as { id: string; title: string };
this.store.remove(id);
this.toolCall().respond?.(`Deleted "${title}".`);
}
cancel(): void {
this.toolCall().respond?.("User kept the task.");
}
}
Register it as a human-in-the-loop tool. There's no handler here, unlike a frontend tool, because the component is the handler. The outcome depends on what the person clicks.
// inside a component's constructor (injection context)
import { registerHumanInTheLoop } from "@copilotkit/angular";
import { z } from "zod";
import { DeleteConfirm } from "./delete-confirm";
registerHumanInTheLoop({
name: "delete_task",
description: "Delete a task. Requires user confirmation.",
parameters: z.object({ id: z.string(), title: z.string() }),
component: DeleteConfirm,
});
Now, if you ask the agent to delete something, the agent will stop the run and ask for your confirmation before proceeding.
Shared state
State flows between your app and the agent in two ways, and the difference is who owns it. Either the agent owns the state and syncs it both directions, or your app owns it and exposes it for the agent to read.
The snippets use zod for schemas, so install it alongside the client with npm install zod.
Agent state: the agent owns it, both sides write
The agent owns a piece of state, your UI reads it, and your UI can write back to it.
injectAgentStore gives you that state as a signal, so components re-render when the agent changes something, and setState sends your changes the other way.
setState replaces the whole state object instead of merging, so spread what's already there and change only the key you need.
// src/app/proverbs.ts
import { Component, computed } from "@angular/core";
import { injectAgentStore } from "@copilotkit/angular";
import { AGENT_ID } from "./app.config";
interface AgentState {
proverbs?: string[];
}
@Component({
selector: "app-proverbs",
standalone: true,
template: `
@for (proverb of proverbs(); track $index) {
<div class="proverb">
<span>{{ proverb }}</span>
<button (click)="remove($index)">✕</button>
</div>
}
`,
})
export class Proverbs {
readonly #store = injectAgentStore(AGENT_ID);
// READ: the agent's state as a signal
protected readonly proverbs = computed<string[]>(
() => (this.#store().state() as AgentState | undefined)?.proverbs ?? [],
);
// WRITE: spread the existing state, setState replaces the whole object
protected remove(index: number): void {
const state = (this.#store().state() as AgentState | undefined) ?? {};
this.#store().agent.setState({
...state,
proverbs: this.proverbs().filter((_, i) => i !== index),
});
}
}
App context: your app owns it, the agent reads
If you only need the agent to see what's on screen, use connectAgentContext.
You pass a function that reads your state, so it works with whatever you already use, plain signals, a service, or a store like NgRx.
// src/app/tasks.ts
import { Component, computed, inject } from "@angular/core";
import { connectAgentContext } from "@copilotkit/angular";
import { TaskStore } from "./task-store";
@Component({ selector: "app-tasks", standalone: true, template: `...` })
export class Tasks {
private store = inject(TaskStore);
constructor() {
connectAgentContext(
computed(() => ({
description: "The user's current task list.",
value: JSON.stringify(this.store.tasks()),
})),
);
}
}
Now the agent can see the tasks and reference one by its ID. But it can only read, so let's add a frontend tool that lets it act on the context.
Frontend tools: how the agent takes action
To let the agent take action based on that context, we need to add a frontend tool. You give it a Zod schema so the arguments are typed, and a handler that runs in the browser.
// inside a component's constructor
import { registerFrontendTool } from "@copilotkit/angular";
import { z } from "zod";
registerFrontendTool({
name: "add_task",
description: "Add a task to the list.",
parameters: z.object({ title: z.string() }),
handler: async ({ title }) => this.store.add(title),
});
Now the user can say "add a task to record the demo video" and the agent calls add_task, your handler adds it to the store, and the dashboard updates.
That's the whole loop. The agent sees your state, changes it through tools, renders UI for the result, and pauses for a person when it matters.
Available today
@copilotkit/angular is open source in the CopilotKit GitHub Repo, running on Angular 19, 20, and 21.
Rainer Hahnekamp and Murat Sari helped build the integration, with Soverius AI now taking on its ongoing maintenance in collaboration with CopilotKit.
- Overview
- Angular docs and quickstart
- Angular reference
- Angular + Google ADK cookbook
- @copilotkit/angular on npm
Angular 22 is next, along with more docs and examples as the SDK closes the remaining gaps with React. If something's missing, open an issue on the CopilotKit repo and we'll pick it up.
Connect with me on GitHub, Twitter, LinkedIn. thanks for reading!
Follow CopilotKit on Twitter. If you get stuck, reach out to the team on the CopilotKit and AG-UI communities.























