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 with copilotkit.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 with copilotkit.runAgent({ agent, 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, which opens the backend session on mount so the very first runAgent doesn't race the handshake.

chat.tsx
  const { agent } = useAgent({ agentId });  const { copilotkit } = useCopilotKit();  const {    attachments,    fileInputRef,    containerRef,    handleFileUpload,    handleDragOver,    handleDragLeave,    handleDrop,    dragOver,    removeAttachment,    consumeAttachments,  } = useAttachmentsConfig();  const [input, setInput] = useState("");  const messages = agent.messages;  const { listRef, bottomRef, stickRef } = useAutoScroll(    messages,    agent.isRunning,  );  // Send pipeline: consume any ready attachments at submit time, build  // the multimodal `content` array if needed, then dispatch the run.  const sendText = useCallback(    (text: string) => {      const trimmed = text.trim();      // Consume queued uploads first so they get sent even if the user      // didn't type any text alongside them.      const ready = consumeAttachments();      if (!trimmed && ready.length === 0) return;      if (agent.isRunning) return;      stickRef.current = true;      const content = buildContent(trimmed, ready);      agent.addMessage({        id: crypto.randomUUID(),        role: "user",        content,      });      void copilotkit        .runAgent({ agent })        .catch((err) =>          console.error("[headless-complete] runAgent failed", err),        );    },    [agent, copilotkit, consumeAttachments],  );  const handleSend = useCallback(() => {    sendText(input);    setInput("");  }, [input, sendText]);  const handleSuggestion = useCallback(    (text: string) => {      sendText(text);    },    [sendText],  );  const handleReset = useCallback(() => {    if (agent.isRunning) {      try {        agent.abortRun();      } catch {        // no-op: some transports don't support abort      }    }    agent.setMessages([]);    setInput("");    stickRef.current = true;  }, [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. (For the interrupt-resume case, use copilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } }) — see the snippet below — so the subscriber lifecycle still wraps the resumed run.)

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 catch LangGraph interrupt(...) events and resume with a payload (the pattern below).

Resolving an MS Agent tool call from a button#

On Microsoft Agent Framework there's no native interrupt primitive — the demo uses useFrontendTool with a Promise-based handler instead. The handler stages its resolve callback and pending payload via React state, the app surface renders the picker outside the chat, and the user's pick resolves the Promise that the agent's tool call is awaiting. Same UX, different mechanism — the agent never knows it's talking to a button grid instead of a chat picker:

Not supported on AWS Strands (Python)
AWS Strands (Python) doesn't support Human in the Loop: Headless Interrupts. See the framework grid for which integrations support this feature.

The resulting { pending, resolveActive } pair is pure data; any UI can drive it. The cell itself renders a simple button grid, but the same pattern would power a modal, a toast, a sidebar form, or a voice UI.

See also#

  • Headless UI — the full useRenderedMessages composition that mirrors <CopilotChatMessageView> line-for-line.
  • Human-in-the-Loop — the useHumanInTheLoop and useInterrupt hooks with their render-prop contracts, for the "paused mid-chat" pattern this page's headless variant replaces.