skills/react-core/references/agent-access.md
This skill builds on copilotkit/provider-setup. useAgent reads from the
same registry the provider populates from /info.
Two complementary surfaces:
useAgent — imperative access to an agent instance, subscribe to
messages/state/run-status changes.useAgentContext — declarative push of app state to every agent run."use client";
import {
useAgent,
useAgentContext,
UseAgentUpdate,
} from "@copilotkit/react-core/v2";
import { useMemo } from "react";
export function ChatDriver({
route,
userId,
}: {
route: string;
userId: string;
}) {
const { agent } = useAgent({
agentId: "default",
threadId: "main",
updates: [
UseAgentUpdate.OnMessagesChanged,
UseAgentUpdate.OnRunStatusChanged,
],
throttleMs: 100,
});
const context = useMemo(() => ({ route, userId }), [route, userId]);
useAgentContext({ description: "app context", value: context });
return (
<div>
{agent.isRunning ? "…thinking" : "idle"} — {agent.messages.length}{" "}
messages
</div>
);
}
const { agent } = useAgent({ agentId: "default" });
const { copilotkit } = useCopilotKit();
async function ask(text: string) {
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: text });
await copilotkit.runAgent({ agent });
}
const { agent } = useAgent({
agentId: "default",
updates: [UseAgentUpdate.OnRunStatusChanged],
});
const isRunning = agent.isRunning;
useAgent returns { agent, isReady }; isRunning lives on the agent
itself. Subscribing to OnRunStatusChanged forces a re-render when the
value flips, so reading agent.isRunning stays live.
const value = useMemo(
() => ({ cartItems: cart.items, currentRoute: router.pathname }),
[cart.items, router.pathname],
);
useAgentContext({ description: "user cart + route", value });
const { agent } = useAgent({ agentId: "default" });
<button onClick={() => agent.abortRun()}>Stop</button>;
const { agent, isReady } = useAgent({ agentId: "default" });
useEffect(() => {
if (!isReady) return; // provisional stand-in — don't attach yet
const sub = agent.subscribe({ onRunStartedEvent: handleRunStarted });
return () => sub.unsubscribe();
}, [agent, isReady]);
Until the runtime /info sync resolves, agent is a provisional
stand-in. It is a fully-constructed AbstractAgent, so every call on it
is safe — but it is then replaced, and agent changes reference.
Anything keyed to the old instance goes with it.
AbstractAgent.clone() that returns thisWrong:
class MyAgent extends AbstractAgent {
clone() {
return this; // wrong — same instance is reused across threads
}
}
Correct:
class MyAgent extends AbstractAgent {
clone() {
const next = new MyAgent(this.config);
next.state = { ...this.state };
return next;
}
}
useAgent calls source.clone() to build a per-thread clone and throws
clone() must return a new, independent object if the clone is the same
instance. This guards per-thread isolation.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:58-69
agent without guarding on isReadyWrong:
const { agent } = useAgent({ agentId: "default" });
// Correlation map for matching responses back to the row that asked.
const pending = useRef(new Map<string, string>());
useEffect(() => {
pending.current = new Map(); // re-runs when `agent` is swapped
const sub = agent.subscribe({ onRunFinishedEvent: resolvePending });
return () => sub.unsubscribe();
}, [agent]);
Correct:
const { agent, isReady } = useAgent({ agentId: "default" });
// Owned by the component, not by the agent — survives the swap.
const pending = useRef(new Map<string, string>());
useEffect(() => {
if (!isReady) return;
const sub = agent.subscribe({ onRunFinishedEvent: resolvePending });
return () => sub.unsubscribe();
}, [agent, isReady]);
agent changes reference exactly once per mount, when /info resolves and
the provisional stand-in is swapped for the real instance. Any effect with
agent in its dependency array re-runs at that moment — so app state
initialized inside such an effect is silently reset partway through the
first interaction.
This bites hardest with Intelligence configured, because license verification and thread-endpoint discovery lengthen the provisional window past the first user action. In plain SSE mode the window usually closes before anyone can interact, which is why the bug does not reproduce in OSS-only development.
Never put per-component bookkeeping (correlation maps, in-flight request
records, refs) behind an agent dependency. Initialize it in the ref
itself and let the effect only manage the subscription.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:226-290,465-481
agent.messages directlyWrong:
agent.messages.push({ id, role: "user", content: "hi" });
Correct:
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: "hi" });
// or:
agent.setMessages([...agent.messages, newMessage]);
AG-UI fires onMessagesChanged subscribers via addMessage /
setMessages. Direct array mutation bypasses subscribers and the UI never
re-renders.
Source: packages/react-core/src/v2/hooks/use-agent.tsx (throughout)
useAgentContextWrong:
useAgentContext({
description: "user",
value: {
name: "Alice",
lastLogin: new Date(),
onLogout: () => logout(), // dropped silently
},
});
Correct:
useAgentContext({
description: "user",
value: { name: "Alice", lastLogin: new Date().toISOString() },
});
useAgentContext runs the value through JSON.stringify. Functions are
dropped, Date coerces to an ISO string (which the agent has to parse), and
circular references throw.
Source: packages/react-core/src/v2/hooks/use-agent-context.tsx:30-35
Wrong:
useAgent({
agentId: "default",
throttleMs: 300,
// expecting onRunInitialized / onRunFinalized / onRunFailed to also be throttled
});
Correct:
// Only OnMessagesChanged / OnStateChanged / OnRunStatusChanged are throttled.
// Lifecycle callbacks always fire immediately — handle them synchronously.
useAgent({ agentId: "default", throttleMs: 300 });
throttleMs only applies to the three subscribed updates enumerated in
UseAgentUpdate. Lifecycle callbacks bypass the throttler.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:36-48
Wrong:
useAgentContext({ description: "cart", value: { items: cart.items } });
Correct:
const value = useMemo(() => ({ items: cart.items }), [cart.items]);
useAgentContext({ description: "cart", value });
A fresh object literal on every render invalidates the useMemo inside
useAgentContext that serializes the value, causing constant
remove/re-add churn in the core context store.
Source: packages/react-core/src/v2/hooks/use-agent-context.tsx:30-35
useAgentContext or copilotkit.addContext to scope context per agentWrong:
useAgentContext({ agentId: "research", description: "paper list", value });
// or the imperative form:
copilotkit.addContext({
description: "paper list",
value: JSON.stringify(value),
agentId: "research",
});
Correct:
// Context is global — every agent run sees every registered entry.
useAgentContext({ description: "paper list", value });
// When only one agent should key off a value, branch inside its prompt
// or tool logic instead of trying to scope the context entry.
Context is intentionally global and there is no per-agent scoping hook.
useAgentContext has no agentId parameter, and copilotkit.addContext
destructures only { description, value } — any agentId passed is
silently dropped. Treat context as "state of the world" that every agent
sees.
Source: packages/react-core/src/v2/hooks/use-agent-context.tsx (no agentId parameter); packages/core/src/core/context-store.ts:26-31
(agentId, threadId) expecting isolationWrong:
function A() {
const { agent } = useAgent({ agentId: "default", threadId: "t1" });
}
function B() {
const { agent } = useAgent({ agentId: "default", threadId: "t1" });
}
Correct:
function A() {
useAgent({ agentId: "default", threadId: "a" });
}
function B() {
useAgent({ agentId: "default", threadId: "b" });
}
Per-thread clones are cached in a module-level WeakMap keyed by
(registryAgent, threadId). Two consumers of the same (agentId, threadId) observe the same state. Give each surface a distinct threadId
when isolation is intentional.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:78-119