Why Agent Consumption Demands a Client-Side Library

Sending a question to an agent and receiving an answer sounds straightforward. In practice, the client has to interpret a continuous stream of events – text fragments, tool invocations with their arguments and results, user approval prompts, or error notifications.

That is plumbing code, and plumbing code does not belong inside application logic. What the frontend actually needs is a library that absorbs those details so the product-specific code can focus on what makes the application unique.

AG-UI: Freedom of Choice for the Server-Side Stack

The second requirement is flexibility. Whether the backend team builds its agents with LangGraph, LangChain, Mastra, Spring AI, or the Microsoft Agent Framework should be irrelevant from the frontend's perspective.

That is precisely what AG-UI offers: an open, lightweight protocol sitting between agents and user interfaces. The client subscribes to a uniform stream of events – run initiated, tool call, text fragment, run completed – without needing to know which framework produced those events.

Three Standards, One Frontend

AG-UI is not the only standard vying for the frontend's attention. Two complementary specifications are emerging alongside it:

  • A2UI enables an agent to describe user interfaces declaratively, so its replies can be composed of real UI components rather than plain text.
  • MCP Apps extend the Model Context Protocol by letting third-party tools bundle their own visualizations with their capabilities.

These standards are not mutually exclusive – they can be combined: an agent streamed over AG-UI that answers with A2UI surfaces while embedding MCP Apps from third parties. Someone has to weave these pieces into a coherent programming model, and that someone should not be every project team individually.

Standards Alone Are Not Enough

A second gap exists around usability and developer experience. The official SDKs for these standards are a solid starting point but deliberately low-level. They offer building blocks such as event streams or renderers, yet the wiring remains our responsibility – which would reintroduce infrastructure code into the application, the very thing we want to avoid.

CopilotKit steps in here. Built by the same team behind AG-UI, it wraps these standards in a frontend SDK that operates in two modes: a polished chat component for quick wins and a headless mode exposing the raw event stream for more control. With @copilotkit/angular, all of this arrives as an Angular-native package: providers, signals, and components that blend naturally with the rest of the application.

In short: AG-UI, A2UI, and MCP Apps standardize communication with the agent; CopilotKit provides a comfortable way to consume agents over those protocols – effectively the missing link for agentic UI in Angular.

Setting Up a Demo

The demo pairs a regular Angular application with a Mastra-based agent living right next to it. Mastra is a popular TypeScript framework for building agents. Two commands scaffold both sides of the project:

ng new angular-copilot-demo
cd angular-copilot-demo
npx mastra@latest init

The Mastra wizard creates a src/mastra directory with a working example agent and asks along the way about the LLM provider and API key, which it stores in a .env file.

We picked Mastra here because it is TypeScript-first and refreshingly lightweight. For this article the choice hardly matters: the frontend only ever speaks AG-UI. Any agent that understands AG-UI – whether built with LangGraph, CrewAI, Pydantic AI, LlamaIndex, or the Microsoft Agent Framework – would connect in exactly the same way.

The Server-Side Agent

The scaffolded Mastra agent is a weather assistant with a single tool that looks up conditions for a given city – essentially the "hello world" of agentic AI. The weather-agent.ts file brings together everything an agent needs – prompt, model, tools, and memory:

// src/mastra/agents/weather-agent.ts
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { weatherTool } from '../tools/weather-tool';

export const weatherAgent = new Agent({
  id: 'weather-agent',
  name: 'Weather Agent',
  instructions: `You are a helpful weather assistant [...]`,
  model: 'openai/gpt-5.6-terra',
  tools: { weatherTool },
  memory: new Memory(),
});

The instructions field holds the system prompt that defines the agent's role and behavior. For the model, a single identifier combining provider and model name suffices – Mastra's model router handles the rest and reads the corresponding API key from an environment variable.

Thanks to the configured Memory instance, the conversation history stays on the server so the client only has to submit the latest message. The base template generated by mastra init uses a local SQLite database for this purpose.

Using schemas built on the popular Zod library, the registered weatherTool describes its parameters and outputs:

// src/mastra/tools/weather-tool.ts
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

export const weatherTool = createTool({
  id: 'get-weather',
  description: 'Get current weather for a location',
  inputSchema: z.object({
    location: z.string().describe('City name'),
  }),
  outputSchema: z.object({
    temperature: z.number(),
    conditions: z.string(),
    location: z.string(),
    [...]
  }),
  execute: async (inputData) => {
    return await getWeather(inputData.location);
  },
});

These schemas serve dual duty: they generate TypeScript types for the code, and at runtime Mastra derives a JSON schema from the inputSchema, sending it together with the tool's name and description to the model. Based on this information, the model decides on its own when to invoke the tool and which arguments to pass. The outputSchema, however, stays server-side: it validates and types the tool's result.

The execute method contains the tool implementation, which in our case simply delegates to a public weather API via getWeather.

Publishing the Agent over AG-UI

So far, the agent only speaks Mastra. The bridge to AG-UI is the MastraAgent adapter from @ag-ui/mastra: it runs the Mastra agent and converts its streaming events into AG-UI events. A dedicated route exposes this over HTTP – the simplified handler below publishes every registered agent under /ag-ui/:agentId and streams the AG-UI events back as server-sent events:

// src/mastra/server/ag-ui-route.ts
import { registerApiRoute } from '@mastra/core/server';
import { MastraAgent } from '@ag-ui/mastra';
import type { RunAgentInput } from '@ag-ui/core';
import { streamSSE } from 'hono/streaming';
import { concatMap, lastValueFrom } from 'rxjs';

export const agUiRoute = registerApiRoute('/ag-ui/:agentId', {
  method: 'POST',
  handler: async (c) => {
    const mastra = c.get('mastra');
    const agent = mastra.listAgents()[c.req.param('agentId')];
    const input = (await c.req.json()) as RunAgentInput;

    const aguiAgent = new MastraAgent({ agent, resourceId: 'anonymous' });

    return streamSSE(c, async (sse) => {
      const send = (data: unknown): Promise<void> =>
        sse.writeSSE({ data: JSON.stringify(data) });

      await lastValueFrom(aguiAgent.run(input).pipe(concatMap(send)), {
        defaultValue: undefined,
      });
    });
  },
});

The handler parses the RunAgentInput – the payload AG-UI clients send, including the thread ID and new messages – and feeds it to the adapter, whose run method returns an observable of AG-UI events. The rest is pleasantly mundane: since Mastra's server is built on Hono, its streamSSE helper takes care of the SSE headers and framing. concatMap forwards the events one by one through send, and lastValueFrom keeps the callback – and thus the stream – alive until the observable completes.

The central Mastra instance registers the agent and the route and enables CORS via middleware:

// src/mastra/index.ts
import { Mastra } from '@mastra/core/mastra';

import { weatherAgent } from './agents/weather-agent';
import { agUiRoute } from './server/ag-ui-route';

export const mastra = new Mastra({
  agents: { weatherAgent },
  server: {
    apiRoutes: [agUiRoute],
    cors: { origin: '*' },
  },
  [...]
});

For this demo, the permissive origin: '*' is fine; a production app would restrict the allowed origins. That is the entire server side: a scaffolded agent, a single route, one configuration entry.

Even Shorter: The CopilotKit Runtime

For completeness, the server side can get even more compact. CopilotKit includes its own runtime that handles agent publishing – eliminating the need to write the AG-UI route yourself. Registering and serving the Mastra agent boils down to a few lines:

const runtime = new CopilotRuntime({
  agents: { weatherAgent: new MastraAgent({ agent: weatherAgent }) },
});

const app = createCopilotHonoHandler({ runtime, basePath: '/' });

serve({ fetch: app.fetch, port: 4555 }, (info) => {
  console.log(`CopilotKit runtime listening on http://localhost:${info.port}`);
});

I deliberately avoided this shortcut in our demo: my goal is to demonstrate that the Angular client works with any server that speaks AG-UI – regardless of whether CopilotKit is present on the backend. The hand-written route makes that point explicit.

Fresh: Agentic UI with Angular

For those interested not only in connecting an agent but embedding agentic UI into a scalable architecture:
In my book Agentic UI with Angular, I examine the underlying patterns and trade-offs in detail.

Cover des E-Books Agentic UI with Angular

Discover the eBook →

Installing and Configuring CopilotKit for Angular on the Client

Now for the missing link itself: CopilotKit for Angular. Installation happens through npm:

npm i @copilotkit/angular

The provideCopilotKit function in app.config.ts establishes the connection between the application and our agent:

// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideCopilotKit } from '@copilotkit/angular';
import { HttpAgent } from '@ag-ui/client';

const AG_UI_URL = 'http://localhost:4111/ag-ui/weatherAgent';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    [...]
    provideCopilotKit({
      agents: {
        default: new HttpAgent({ url: AG_UI_URL }),
      },
    }),
  ],
};

CopilotKit simply expects an AG-UI-compatible agent. The HttpAgent from the AG-UI client SDK points at our endpoint /ag-ui/weatherAgent – no CopilotKit runtime, no extra middleware in between, and not a single line of Mastra-specific code in the browser. The key default is the client-side name components use to refer to this agent.

The user interface is almost embarrassingly simple. The CopilotChat component brings the message list, input field, and streaming indicator all together:

// src/app/app.ts
import { CopilotChat } from '@copilotkit/angular';

@Component({
  selector: 'app-root',
  imports: [CopilotChat],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main class="chat-host">
  <copilot-chat agentId="default" />
</main>

That's it – a complete, streaming chat backed by a real agent, without a single line of event-handling code in the application.

Running the Demo

Launching the demo requires two terminals in the project root. The .env file generated by the Mastra wizard must contain the LLM key – for OpenAI that would be OPENAI_API_KEY. The first terminal starts the Mastra dev server, which listens on port 4111:

npm run dev

The second terminal starts the Angular dev server on port 4200:

npm start

Now we ask about the weather in Vienna – the answer streams in word by word, and behind the scenes the agent has already geocoded the city and invoked its weather tool:

Die CopilotKit-Chat-Komponente beantwortet eine Wetterfrage mit dem Mastra-Agenten im Rücken

Thanks to the server-side memory, follow-up questions like Is it warmer than in Paris? also work: both queries belong to the same conversation thread, so the agent knows what "it" refers to.

Angular CopilotKit's Headless Mode

The ready-made chat component is the fastest route to a working result. But real applications quickly outgrow it: the chat needs to match the design system, custom widgets should appear mid-conversation – and not everything is a chat anyway. An agent can just as well drive a form, a dashboard, or an entire workflow. CopilotKit's headless mode handles that flexibility: it provides state and behavior while leaving every pixel of rendering to us.

The demo project includes a hand-built chat component that consumes the same agent through the headless API:

// src/app/headless-chat/headless-chat.ts
import { Component, computed, inject } from '@angular/core';
import { CopilotKit, injectAgentStore } from '@copilotkit/angular';
import { randomUUID } from '@copilotkit/shared';

@Component({
  selector: 'app-headless-chat',
  [...]
})
export class HeadlessChat {
  private readonly copilotKit = inject(CopilotKit);
  private readonly store = injectAgentStore('default');

  protected readonly isRunning = computed(() => this.store().isRunning());

  protected readonly visibleMessages = computed(() =>
    this.store()
      .messages()
      .filter((m) => m.role === 'user' || m.role === 'assistant')
      .map((m) => [...])
  );

  protected async send(content: string) {
    const agent = this.store().agent;
    agent.addMessage({ id: randomUUID(), role: 'user', content });
    await this.copilotKit.core.runAgent({ agent });
  }
}

The injectAgentStore function returns a signal-based store for the registered agent: messages() holds the conversation and isRunning() tracks the execution state – both standard Angular signals that plug directly into computed expressions and templates. Sending a message is just as explicit: push the user's message into the agent, then kick off a run with runAgent. The store updates continuously as events arrive, so the UI re-renders accordingly.

The template belongs entirely to us – a simple list and a form, styled however we see fit:

<!-- src/app/headless-chat/headless-chat.html -->
<ol class="log" aria-live="polite">
  @for (message of visibleMessages(); track message.id) {
    <li [attr.data-role]="message.role">
      <span>{{ message.role === 'user' ? 'Sie' : 'Agent' }}</span>
      <p>{{ message.text }}</p>
    </li>
  }
</ol>

<form (submit)="[...]">
  [...]
</form>

What Comes Next

The implementation we have walked through marks only the starting point. Production-grade agentic UIs demand considerably more, including:

  • Client-side tools that let the agent automate tasks within the application while remaining aware of current user context — what is selected, which screen is active, and what the user is working on at any moment.
  • Agent-selected visualizations on the client: rather than replying with a wall of text, the agent should pick dynamically from the components available in the application.
  • A2UI support so the agent can compose its responses as declarative UI in real time.
  • MCP app support to integrate and display third-party tools inside the host application.
  • Human-in-the-loop patterns that go beyond simple approvals, giving users lasting control over what the agent does.

One illustration of where this is headed: a dynamic dashboard whose content is directed by the agent:

Ein dynamisches, vom Agenten gesteuertes Dashboard, das in der Blog-Serie Schritt für Schritt entsteht

CopilotKit covers this territory as well — and my blog series walks through these steps one by one. Among other outcomes, that series produces exactly this dashboard:

To the blog series →

Looking for production-ready agentic UI architectures?

In my workshop, we dive into AG-UI, A2UI, MCP Apps, HITL patterns, and modern Angular architectures for real-world agentic systems.

Workshop: Agentic AI mit Angular – AG-UI, A2UI, MCP Apps & HITL-Patterns

All details →