Write your own AG-UI agent
Extend AG-UI's AbstractAgent, register it in the Copilot Runtime, and keep its fields across the runtime's per-request clone.
Open your coding agent in your project's folder, or in an empty folder for a new app.This runs in a coding agent on your computer.
The Copilot Runtime's agents map takes AG-UI AbstractAgent instances. The
framework adapters and HttpAgent are subclasses of it, and you can write your
own. Use this when your agent logic lives in the same process as the runtime and
is not built on a framework that CopilotKit already integrates with.
If you only want to call a model with your own code, BuiltInAgent factory
mode is shorter: it emits the run lifecycle events for
you. If your agent is a separate service that already speaks AG-UI over HTTP,
use HttpAgent instead.
Install#
npm install @copilotkit/runtime @ag-ui/client@1.0.1 rxjsYour @ag-ui/client must be the version your @copilotkit/runtime depends on,
so the app has one copy of it. Run npm ls @ag-ui/client: it should show a
single, deduped version. If it shows two, reinstall @ag-ui/client at the
version listed under @copilotkit/runtime.
A minimal agent#
A subclass implements run. It receives the AG-UI RunAgentInput (messages,
tools, context, state, threadId, runId) and returns an RxJS Observable of
AG-UI events. Start with RUN_STARTED, end with RUN_FINISHED, and stream the
reply in between:
import {
AbstractAgent,
EventType,
type BaseEvent,
type RunAgentInput,
} from "@ag-ui/client";
import { Observable } from "rxjs";
export class EchoAgent extends AbstractAgent {
run(input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>((subscriber) => {
const { threadId, runId } = input;
const last = input.messages.at(-1);
const text = typeof last?.content === "string" ? last.content : "";
const messageId = crypto.randomUUID();
subscriber.next({ type: EventType.RUN_STARTED, threadId, runId });
subscriber.next({ type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant" });
subscriber.next({ type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: `You said: ${text}` });
subscriber.next({ type: EventType.TEXT_MESSAGE_END, messageId });
subscriber.next({ type: EventType.RUN_FINISHED, threadId, runId });
subscriber.complete();
});
}
}Tool calls, state updates, and reasoning are more events on the same stream. See the AG-UI event reference.
Register it in the runtime#
Pass an instance in the agents map. The key is the agent ID the frontend uses.
The chat components use default when you do not name an agent.
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { EchoAgent } from "@/lib/echo-agent";
const runtime = new CopilotRuntime({
agents: { default: new EchoAgent() },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handler;
export const POST = handler;
export const PATCH = handler;
export const DELETE = handler;The agent runs inside the route handler's process. There is no separate agent server and no URL to configure. The frontend setup is the same as in the quickstart.
Override clone when the agent has its own fields#
The runtime does not run the instance you registered. For every request it calls
agent.clone() and runs the copy, so per-request changes never touch the
original.
The base AbstractAgent.clone() copies only the fields AbstractAgent itself
defines, such as the agent ID, thread ID, messages, state, subscribers, and
middleware. It does not call your constructor. A field that your subclass sets in
its constructor, such as an API client, a URL, or a config object, is
undefined on the copy.
EchoAgent above has no fields of its own, so the base clone is enough. An
agent that takes a client in its constructor needs to copy it:
import {
AbstractAgent,
EventType,
type BaseEvent,
type RunAgentInput,
} from "@ag-ui/client";
import { Observable } from "rxjs";
export interface SupportClient {
answer(question: string, signal: AbortSignal): Promise<string>;
}
export class SupportAgent extends AbstractAgent {
constructor(private client: SupportClient) {
super();
}
override clone(): SupportAgent {
const copy = super.clone() as SupportAgent;
copy.client = this.client;
return copy;
}
run(input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>((subscriber) => {
const controller = new AbortController();
const { threadId, runId } = input;
const last = input.messages.at(-1);
const question = typeof last?.content === "string" ? last.content : "";
subscriber.next({ type: EventType.RUN_STARTED, threadId, runId });
this.client
.answer(question, controller.signal)
.then((answer) => {
const messageId = crypto.randomUUID();
subscriber.next({ type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant" });
subscriber.next({ type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: answer });
subscriber.next({ type: EventType.TEXT_MESSAGE_END, messageId });
subscriber.next({ type: EventType.RUN_FINISHED, threadId, runId });
subscriber.complete();
})
.catch((error) => subscriber.error(error));
return () => controller.abort();
});
}
}Start from super.clone() and add your fields to it, the same way HttpAgent
copies its url and headers. Do not return new SupportAgent(this.client):
a new instance starts with no middleware, so anything you attached with
agent.use(...) at startup silently stops running.
You only need to copy fields that hold configuration. A field that run resets
at the start of every run holds per-run scratch state, and losing it on the copy
changes nothing.
The function returned inside the Observable is its teardown. It runs when the
run completes, fails, or is unsubscribed, so it is the place to cancel in-flight
work.
The symptom when clone is missing#
The registration succeeds and /info lists the agent, but every run fails as
soon as run touches the missing field. The server logs:
Agent execution failed: TypeError: Cannot read properties of undefined (reading 'answer')With InMemoryAgentRunner, the run's event stream ends with a RUN_ERROR
event that carries the same message and the code INCOMPLETE_STREAM. If the
error names one of your own constructor fields, add a clone override that
copies it.
Remote agents: use HttpAgent#
If the agent runs as its own service and serves AG-UI over HTTP, do not write a
subclass. Register an HttpAgent that points at the service:
import { HttpAgent } from "@ag-ui/client";
import { CopilotRuntime, InMemoryAgentRunner } from "@copilotkit/runtime/v2";
const runtime = new CopilotRuntime({
agents: { default: new HttpAgent({ url: process.env.AGENT_URL! }) },
runner: new InMemoryAgentRunner(),
});HttpAgent already overrides clone. The URL is the agent service's own
endpoint, not the runtime's. See Copilot Runtime.
Related#
- How agents slot into the runtime: the request path every registered agent goes through.
- Use any model router:
BuiltInAgentfactory mode, which handles lifecycle events for you. - AG-UI middleware: attach behavior to any agent with
agent.use(...).