Let your agent interact with and update your application's UI.
"""Shared LlmAgent factories used across multiple demos.`build_simple_chat_agent` produces a plain Gemini chat agent with no backendtools — appropriate for any demo whose only customisation is on the frontend(prebuilt-sidebar, prebuilt-popup, chat-slots, chat-customization-css,headless-simple, headless-complete, voice, frontend-tools, agentic-chat).`build_thinking_chat_agent` uses Gemini 3.1 Flash-Lite with the thinking_configexposed so reasoning is streamed back as `thought` parts; the v2 React corerenders these via CopilotChatReasoningMessage.`get_model` returns a `Gemini` instance configured with the aimock proxyendpoint when `GOOGLE_GEMINI_BASE_URL` is set, or the default model stringotherwise. All agent modules should call `get_model()` instead ofhard-coding `"gemini-3.1-flash-lite"` so Railway deployments route throughaimock.`stop_on_terminal_text` is the canonical after_model_callback shared by everyregistered LlmAgent. Gemini 3.1 Flash-Lite does not naturally end its agenticloop after a successful tool call — it keeps re-issuing the same tool. Thecallback inspects each non-partial model response and, when it containstext with no pending function_call, sets `_invocation_context.end_invocation= True` so ADK terminates the loop. Without this guard every backend orfrontend tool in this package fires infinitely."""from __future__ import annotationsimport loggingimport osfrom typing import Optional, Unionfrom google.adk.agents import LlmAgentfrom google.adk.agents.callback_context import CallbackContextfrom google.adk.models.google_llm import Geminifrom google.adk.models.llm_response import LlmResponsefrom google.genai import typesfrom ag_ui_adk import AGUIToolsetfrom agents._header_forwarding import install_httpx_hooklogger = logging.getLogger(__name__)DEFAULT_MODEL = "gemini-3.1-flash-lite"def stop_on_terminal_text( callback_context: CallbackContext, llm_response: LlmResponse) -> Optional[LlmResponse]: """Terminate the ADK agentic loop on a final text-only model turn. Lifted from the (orphaned) `simple_after_model_modifier` in `agents/main.py`, with the SalesPipelineAgent name-gate removed so it applies to every registered agent. Guards: 1. Skip partial streaming events — never end on a mid-stream chunk (belt-and-suspenders with `ADK_DISABLE_PROGRESSIVE_SSE_STREAMING=1` in `entrypoint.sh`). 2. Only terminate when the final non-partial response contains TEXT and NO pending function_call — mixed text+function_call responses (a known Gemini Flash quirk) must NOT terminate. 3. `_invocation_context` is an ADK private attribute; if it disappears in a future ADK release, log-and-degrade rather than crash the callback (which would stall the request). Without this guard, Gemini calls the same tool indefinitely after a successful tool result because no native termination condition fires. """ content = llm_response.content if not content or not content.parts: if llm_response.error_message: logger.warning( "stop_on_terminal_text: Gemini returned error_message for agent=%s: %s", callback_context.agent_name, llm_response.error_message, ) return None if getattr(llm_response, "partial", False): return None # Under thinking mode (`include_thoughts=True`), Gemini emits a turn # as TWO separate non-partial chunks: # 1. text-only chunk: thought + reply text, `finish_reason=None` # 2. function_call-only chunk: `finish_reason=FUNCTION_CALL` # The callback fires on both. Without the finish_reason guard below, # chunk 1's text-without-function-call shape causes premature # termination — the function call in chunk 2 still streams but the # agentic loop is already marked `end_invocation=True`, so the # post-tool-result re-invocation that would chain to the next tool # never happens (tool-rendering-reasoning-chain AAPL→MSFT regression). # Only terminate when Gemini signals the turn is genuinely done with # `finish_reason=STOP` (no further chunks coming). FUNCTION_CALL and # None mean "more chunks are inbound" — defer. finish_reason = getattr(llm_response, "finish_reason", None) finish_reason_name = ( getattr(finish_reason, "name", None) if finish_reason is not None else None ) if finish_reason_name != "STOP" and finish_reason != "STOP": return None has_text = any(getattr(part, "text", None) for part in content.parts) has_function_call = any( getattr(part, "function_call", None) for part in content.parts ) if content.role != "model" or not has_text or has_function_call: return None invocation_context = getattr(callback_context, "_invocation_context", None) if invocation_context is None: logger.debug( "stop_on_terminal_text: callback_context has no " "_invocation_context attribute; skipping end_invocation." ) return None try: invocation_context.end_invocation = True except AttributeError: logger.debug( "stop_on_terminal_text: _invocation_context lacks " "end_invocation; ADK private-API shape may have drifted." ) return Nonedef get_model(model: str = DEFAULT_MODEL) -> Union[str, Gemini]: """Return a model suitable for LlmAgent's `model=` parameter. When `GOOGLE_GEMINI_BASE_URL` is set (Railway aimock proxy), returns a `Gemini` instance with its `base_url` pointed at the proxy. Otherwise returns the plain model string so the ADK resolves the default endpoint. """ base_url = os.environ.get("GOOGLE_GEMINI_BASE_URL") if base_url: gemini = Gemini(model=model, base_url=base_url) # Walk Gemini's ``._client`` chain and attach the request hook so # inbound x-* headers (e.g. ``x-aimock-context``) ride along on # outbound calls to the aimock proxy. install_httpx_hook(gemini) return gemini return modeldef get_a2ui_model(model: str = DEFAULT_MODEL) -> Gemini: """Return a concrete ``Gemini`` BaseLlm for the A2UI sub-agent. The middleware's ``get_a2ui_tool({"model": ...})`` invokes the model directly (forced ``render_a2ui`` call), so it needs a model *object*, not the bare string ``get_model`` may return for ``LlmAgent.model=``. This mirrors ``get_model``'s aimock-proxy wiring (base_url + x-header hook) so the sub-agent's Gemini calls route through the same proxy as the primary agent and match the same aimock fixtures. (The auto-inject path got this object for free from the agent's ``canonical_model``; backend-owned wiring must resolve it explicitly.) """ resolved = get_model(model) if isinstance(resolved, Gemini): return resolved # No proxy: build a plain Gemini against the default endpoint. return Gemini(model=model)def build_simple_chat_agent( *, name: str, instruction: str, model: str = DEFAULT_MODEL,) -> LlmAgent: return LlmAgent( name=name, model=get_model(model), instruction=instruction, tools=[AGUIToolset()], after_model_callback=stop_on_terminal_text, )def build_thinking_chat_agent( *, name: str, instruction: str, model: str = DEFAULT_MODEL,) -> LlmAgent: """LlmAgent with Gemini thinking enabled. `include_thoughts=True` makes Gemini emit `thought=True` parts alongside final answer parts; ADK forwards these through ag-ui as reasoning chunks so v2's CopilotChatReasoningMessage / useRenderReasoning can show them. `thinking_budget=-1` lets the model decide how much to think. """ return LlmAgent( name=name, model=get_model(model), instruction=instruction, tools=[AGUIToolset()], generate_content_config=types.GenerateContentConfig( thinking_config=types.ThinkingConfig( include_thoughts=True, thinking_budget=-1, ), ), after_model_callback=stop_on_terminal_text, )
See this in Inspector
Open Inspector on localhost. Go to Agents, then Frontend Tools.
Your tool and its schema are listed.
Frontend tools let your agent define and invoke client-side functions that run entirely in the user's browser. Because the handler executes on the frontend, it has direct access to component state, browser APIs, and any third-party UI library the page already uses. That's how an agent can "reach into" the app: update React state, trigger animations, read localStorage, pop a toast, or steer the user's view.
This page covers the "agent drives the UI" shape of frontend tools. The same primitive also powers Generative UI and Human-in-the-loop; see those pages for interaction patterns.
AGUIToolset() exposes CopilotKit's frontend tools to the model.
Add it to your LlmAgent's tools= list. Use an ADK-supported model
available to your project.
The callback below preserves the Gemini termination safeguard: it stops on
final text with a STOP finish reason, while leaving partial responses and
pending tool calls alone. It is defined here in full, not imported from
ag-ui-adk or a showcase-only module.
from ag_ui_adk import AGUIToolsetfrom google.adk.agents import LlmAgentfrom google.adk.agents.callback_context import CallbackContextfrom google.adk.models.llm_response import LlmResponsedef stop_on_terminal_text( callback_context: CallbackContext, llm_response: LlmResponse) -> None: content = llm_response.content if llm_response.partial or not content or content.role != "model": return finish_reason = llm_response.finish_reason if getattr(finish_reason, "name", finish_reason) != "STOP": return parts = content.parts or [] if not any(part.text for part in parts) or any(part.function_call for part in parts): return # ADK's invocation context is private; tolerate SDK changes. invocation = getattr(callback_context, "_invocation_context", None) if invocation is not None: try: invocation.end_invocation = True except AttributeError: passagent = LlmAgent( name="assistant", model="gemini-3.1-flash-lite", instruction="Help the user and call the available frontend tools when appropriate.", tools=[AGUIToolset()], after_model_callback=stop_on_terminal_text,)
Register a frontend tool with useFrontendTool. Give it a name, a Zod schema for parameters, and a handler. The agent can then call it like any other tool and your frontend runs it in the browser.
page.tsx
import React, { useState } from "react";import { CopilotKit, CopilotSidebar, useFrontendTool,} from "@copilotkit/react-core/v2";import { z } from "zod";import { Background, DEFAULT_BACKGROUND } from "./background";import { useFrontendToolsSuggestions } from "./suggestions";export default function FrontendToolsDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="frontend_tools"> <Chat /> </CopilotKit> );}function Chat() { const [background, setBackground] = useState<string>(DEFAULT_BACKGROUND); useFrontendTool({ name: "change_background", description: "Change the page background. Accepts any valid CSS background value — colors, linear or radial gradients, etc.", parameters: z.object({ background: z .string() .describe("The CSS background value. Prefer gradients."), }), handler: async ({ background }) => { setBackground(background); return { status: "success" }; }, });
The handler receives the parsed, type-safe parameters and can do anything
the browser can: update state, call an API, touch the DOM. Its return value
is sent back to the agent as the tool result so the model can reason about
what happened.
useFrontendTool registers one tool per call, so it cannot be called in a loop
over a list whose length changes between renders. When the set of tools comes
from state, from props, or from a backend response, use
useFrontendTools instead. It takes an
array and runs a single effect over it, so the array can be empty on one render
and hold twenty entries on the next.
Tools that leave the array are unregistered, tools that join it are registered,
and a re-render that produces an equal list does not re-register anything. A
description built from your data stays current on its own. The second argument
is for values a handler closes over, such as navigate above.