Back to Copilotkit

CopilotKit Agent Access (React)

skills/react-core/references/agent-access.md

1.69.39.1 KB
Original Source

CopilotKit Agent Access (React)

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.

Setup

tsx
"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>
  );
}

Core Patterns

Send a message and stream the response

tsx
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 });
}

Subscribe only to run-status to reduce re-renders

tsx
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.

Share app state with every agent run (global)

tsx
const value = useMemo(
  () => ({ cartItems: cart.items, currentRoute: router.pathname }),
  [cart.items, router.pathname],
);
useAgentContext({ description: "user cart + route", value });

Abort the run

tsx
const { agent } = useAgent({ agentId: "default" });
<button onClick={() => agent.abortRun()}>Stop</button>;

Wait for the real agent before attaching to it

tsx
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.

Common Mistakes

CRITICAL — Custom AbstractAgent.clone() that returns this

Wrong:

tsx
class MyAgent extends AbstractAgent {
  clone() {
    return this; // wrong — same instance is reused across threads
  }
}

Correct:

tsx
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

HIGH — Deriving app state from agent without guarding on isReady

Wrong:

tsx
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:

tsx
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

HIGH — Mutating agent.messages directly

Wrong:

tsx
agent.messages.push({ id, role: "user", content: "hi" });

Correct:

tsx
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)

HIGH — Registering non-serializable values via useAgentContext

Wrong:

tsx
useAgentContext({
  description: "user",
  value: {
    name: "Alice",
    lastLogin: new Date(),
    onLogout: () => logout(), // dropped silently
  },
});

Correct:

tsx
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

MEDIUM — Expecting lifecycle callbacks to be throttled

Wrong:

tsx
useAgent({
  agentId: "default",
  throttleMs: 300,
  // expecting onRunInitialized / onRunFinalized / onRunFailed to also be throttled
});

Correct:

tsx
// 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

MEDIUM — Unstable context value identity

Wrong:

tsx
useAgentContext({ description: "cart", value: { items: cart.items } });

Correct:

tsx
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

MEDIUM — Expecting useAgentContext or copilotkit.addContext to scope context per agent

Wrong:

tsx
useAgentContext({ agentId: "research", description: "paper list", value });
// or the imperative form:
copilotkit.addContext({
  description: "paper list",
  value: JSON.stringify(value),
  agentId: "research",
});

Correct:

tsx
// 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

MEDIUM — Two components using the same (agentId, threadId) expecting isolation

Wrong:

tsx
function A() {
  const { agent } = useAgent({ agentId: "default", threadId: "t1" });
}
function B() {
  const { agent } = useAgent({ agentId: "default", threadId: "t1" });
}

Correct:

tsx
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