Programmatic Control
Drive agent runs directly from code — no chat UI required.
What is this?
Programmatic control is what you reach for when you want to drive an agent run from code rather than from a chat composer — a button, a form, a cron job, a keyboard shortcut, a graph callback. CopilotKit exposes three primitives that cover every triggering pattern:
agent.addMessage(...)— append a message to the conversation without running the agent. Pair withcopilotkit.runAgent({ agent })when you want the appended message to kick off a turn.copilotkit.runAgent({ agent })— the same entry point<CopilotChat />calls under the hood. Orchestrates frontend tools, follow-up runs, and the subscriber lifecycle.agent.subscribe(subscriber)— low-level AG-UI event subscription (onCustomEvent,onRunStartedEvent,onRunFinalized,onRunFailed, …). Pairs withagent.runAgent({ forwardedProps: { command: { resume, interruptEvent } } })to drive interrupt resolution from arbitrary UI.
Every example on this page is pulled from two live cells:
headless-complete (full chat surface, shown here for the message-send
path) and interrupt-headless (button-driven interrupt resolver, shown
here for the subscribe + resume path).
When should I use this?
Use programmatic control when you want to:
- Trigger agent runs from buttons, forms, or other UI elements
- Execute specific tools directly from UI interactions (without an LLM turn)
- Build agent features without a chat window
- Access agent state and results programmatically
- Create fully custom agent-driven workflows
Sending a message from code
The message-send path in headless-complete is the canonical pattern:
append a user message with agent.addMessage, then call
copilotkit.runAgent({ agent }). The same handleStop calls
copilotkit.stopAgent({ agent }) to cancel mid-run. Note the
connectAgent effect at the top — it opens the backend session on
mount so the very first runAgent doesn't race the handshake.
const threadId = useMemo(() => crypto.randomUUID(), []);
const { agent } = useAgent({ agentId: AGENT_ID, threadId });
const { copilotkit } = useCopilotKit();
// Connect the agent on mount so the backend session is live before the first
// send. Mirrors the internal connect effect used by CopilotChat (abort on
// unmount to play nice with React StrictMode).
useEffect(() => {
const ac = new AbortController();
// HttpAgent honors abortController.signal; assign before connect.
if ("abortController" in agent) {
(
agent as unknown as { abortController: AbortController }
).abortController = ac;
}
copilotkit.connectAgent({ agent }).catch(() => {
// connectAgent emits via the subscriber system; swallow here to avoid
// unhandled-rejection noise on unmount.
});
return () => {
ac.abort();
void agent.detachActiveRun().catch(() => {});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent, threadId]);
const [input, setInput] = useState("");
const messages = agent.messages as Message[];
const isRunning = agent.isRunning;
const handleSubmit = useCallback(async () => {
const text = input.trim();
if (!text || isRunning) return;
setInput("");
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: text,
});
try {
await copilotkit.runAgent({ agent });
} catch (err) {
console.error("headless-complete: runAgent failed", err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent, input, isRunning]);
const handleStop = useCallback(() => {
try {
copilotkit.stopAgent({ agent });
} catch (err) {
console.error("headless-complete: stopAgent failed", err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]);copilotkit.runAgent() vs agent.runAgent()
Both methods trigger the agent, but they operate at different levels:
copilotkit.runAgent({ agent })— the recommended default. Orchestrates the full lifecycle: executes frontend tools, handles follow-up runs, and routes errors through the subscriber system.agent.runAgent(options)— low-level method on the agent instance. Sends the request to the runtime but does not execute frontend tools or chain follow-ups. Reach for this only when you need direct control — the canonical example is resuming from an interrupt withforwardedProps.command.
Subscribing to agent events
agent.subscribe(subscriber) returns { unsubscribe }. The subscriber
object accepts every AG-UI lifecycle callback — onCustomEvent,
onRunStartedEvent, onRunFinalized, onRunFailed, and the streaming
deltas. Use it to drive custom progress UI, forward events to
analytics, or — the pattern below — catch LangGraph interrupt(...)
events and resume with a payload.
Resolving a LangGraph interrupt from a button
The interrupt-headless cell demonstrates the full pattern without
useInterrupt or a chat surface. A plain hook subscribes to
on_interrupt custom events, buffers the payload until the run
finalizes (so the UI doesn't flash mid-stream), and exposes a
resolve(response) callback that calls copilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } }) to unblock
the graph:
function useHeadlessInterrupt(agentId: string): {
pending: InterruptEvent | null;
resolve: (response: unknown) => void;
} {
const { copilotkit } = useCopilotKit();
const { agent } = useAgent({ agentId });
const [pending, setPending] = useState<InterruptEvent | null>(null);
useEffect(() => {
let local: InterruptEvent | null = null;
const sub = agent.subscribe({
onCustomEvent: ({ event }) => {
if (event.name === INTERRUPT_EVENT_NAME) {
local = {
name: event.name,
value: (event.value ?? {}) as InterruptPayload,
};
}
},
onRunStartedEvent: () => {
local = null;
setPending(null);
},
onRunFinalized: () => {
if (local) {
setPending(local);
local = null;
}
},
onRunFailed: () => {
local = null;
},
});
return () => sub.unsubscribe();
}, [agent]);
const resolve = useMemo(
() => (response: unknown) => {
const snapshot = pending;
setPending(null);
void copilotkit
.runAgent({
agent,
forwardedProps: {
command: {
resume: response,
interruptEvent: snapshot?.value,
},
},
})
.catch(() => {});
},
[agent, copilotkit, pending],
);
return { pending, resolve };
}The resulting { pending, resolve } tuple is pure data — any UI can
drive it. The cell itself renders a simple button grid, but the same
hook would power a modal, a toast, a sidebar form, or a voice UI.
See also
- Headless UI — the full
useRenderedMessagescomposition that mirrors<CopilotChatMessageView>line-for-line. - Human-in-the-Loop — the
useHumanInTheLoopanduseInterrupthooks with their render-prop contracts, for the "paused mid-chat" pattern this page's headless variant replaces.