TL;DR
- The model picks the screen; the browser is what actually opens it. A screen lookup tool returns candidates filtered by the user’s permissions, and the model chooses the identifier of the target screen from them.
- The screen navigation tool is registered on the server, but the server executor does not navigate. It only echoes the arguments it received together with
received:true; every line of code that opens a screen lives in the browser. The client-tool declaration mechanism AG-UI provides is not used. Managing tool declarations on the server and getting the browser’s execution result back are separate decisions. - The browser looks at the tool name and navigates at
TOOL_CALL_END. It does not wait for the server’s acknowledgement. The execution-scope field from the first generation is gone; a list of tool names in the front end decides where a tool runs, and the server does not know that list. - “I have moved you there” does not confirm that the screen actually changed. The browser’s execution result never goes back to the model, so the only evidence the model receives is the server’s own acknowledgement. When navigation fails for lack of permission, the user sees a permission alert while the bot believes it succeeded.
This chapter follows how a chat request opens a work screen in the second-generation bot, the one moved onto Google ADK Java. The stack is Spring Boot + React; the agent framework is google-adk 1.7.1 and the front end’s AG-UI SDK is @ag-ui/client 0.0.52. The server-side AG-UI conversion was written in-house, without a public library. Later on, the role and the upkeep of that converter are assessed separately from the way tools are managed.
The screen feature was added in May 2026. This chapter is written against the code as of September 7, 2026. The work identifiers, the in-house tool and field names in the examples are made-up aliases for explanation; the public API and event field names of ADK and AG-UI are used as they are.
A list screen opens behind the chat panel
Type “open the work request list” to the bot in the side panel, and the chat stays where it is while the content area behind it turns into the work request list. No page reload. A moment later the bot answers, “I have moved you to the work request list.” The user request and the bot’s reply here are examples of the same shape, not quotes from real logs.
That one line passes through three places before it becomes a screen action. The model picks the identifier of the screen to open, the server emits that call as events, and the browser sees those events and calls the app’s navigation function. This chapter follows how a single screen identifier travels from the model’s output to the browser’s navigation function. I will call the identifier of the work request list screen MNU-3120, and one request handled on that list R-1042. At the end we also look at what the bot’s “I have moved you there” is actually based on.
Turning a screen name into an identifier
Users usually do not know the exact name of a screen. They say “the request list”, or “the place where you see things like R-1042”. So the model generally takes two steps.
- It calls the screen lookup tool, which runs on the server. The tool queries a managed search service for screen candidates and returns them filtered down to the screens the logged-in user may see. Screens outside the user’s permissions never make it into the candidate list.
- It picks the identifier of one candidate and calls the screen navigation tool.
In the first step, “work request list” becomes MNU-3120. That identifier is the value this chapter follows to the end. The execution history in the development environment also contains failures of this lookup step; the cause has not been pinned down yet. When the lookup fails, the model has no identifier to navigate to, so it cannot call the navigation tool. But the lookup tool returns its failure as a value, so the model knows about it and can continue into an answer. The stretch where results do not come back to the model starts at the next step.
What the screen tool registered on the server does
The screen navigation tool runs in the browser, but it is a tool registered on the server. The AG-UI standard offers a client-tool execution mode: the browser sends its own tool declarations in the tools field of the run request and returns execution results as tool messages. This implementation has no tool-declaration field in its run request body, and instead of the run input object the front end’s AG-UI SDK would build, it sends its own request body — agent code, session identifier, the current screen context and the new message. The tool list handed to the model is built entirely by the server. The strengths of this structure and the problems that remain are assessed in the next section.
The declarations are stored as JSON in a tool catalog in the database. When the server creates a session, it restores that JSON into ADK function declaration objects and, using the executor key stored on the same row, looks up the Spring bean and wires it in. If the bean cannot be found, it does not log a warning and move on; it throws immediately. That is the promise chapter 2 asked for: “when registration is off, it must end in a failure, not a warning.” Among these tools the screen navigation tool is an internal mandatory tool, named in runtime code, so it is always visible to the model regardless of the per-agent tool mapping.
// Conceptual example (not the real declaration). Names and fields are made up for explanation.
{
"name": "navigateToScreen",
"description": "A browser-executed tool that requests navigation to the given screen. The server does not navigate; the browser performs the actual navigation using the screen identifier. When the screen is unknown, search for candidates with the screen lookup tool first.",
"parameters": {
"type": "object",
"required": ["screenId"],
"properties": {
"screenId": { "type": "string", "description": "Identifier of the screen to open. Navigation is based on this value, ahead of any path or URL." },
"screenName": { "type": "string", "description": "Screen name to show the user." },
"routeHint": { "type": "string", "description": "Screen route metadata. The actual navigation key is the identifier." },
"orgScope": { "type": "string", "description": "Organization boundary (usage scope) the navigation was requested in." },
"payload": { "type": "object", "description": "Optional state values to pass to the target screen." }
}
}
}The only required argument is screenId. There are fields for a screen route and an address, but as the description says, they are not used for navigation. Even if the model invents a plausible-looking path string, navigation never uses it; it has to go through the identifier, so the target is limited to screens that actually exist. What the screen navigation tool calls is the browser’s shared navigation function, and the way a screen opens is fixed.
The server executor is short. There is not a single line that opens a screen.
// Conceptual example (not the real code)
@Component("screenAction_navigateToScreen")
public class NavigateToScreen implements ScreenActionExecutor {
@Override
public Object execute(Map<String, Object> args, UserContext user) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("received", true); // only marks that the server received the request
result.put("requestKind", "SCREEN");
putIfPresent(args, result, "screenId");
putIfPresent(args, result, "screenName");
putIfPresent(args, result, "orgScope");
putIfPresent(args, result, "payload");
return result; // no validation, no lookup, no navigation
}
}It picks the arguments that have values, attaches a received marker and returns them — that is all. No database query, no external call. It does not even check that the identifier exists. The lookup tool in the earlier step did the target lookup, and the permission to show the screen is handled by that lookup tool’s filter and by the browser-side check described below. In between, the server executor is nothing but a relay that echoes the arguments. What chapter 2’s table called the “server-mediated bridge” — the server returning the arguments with a received marker while the screen performs the action — has become the only mode for screen tools in the second generation.
What was gathered on the server and what stayed in the browser
The AG-UI documentation describes a layout where server-side tools live in the agent configuration and client tools are declared in the run request’s tools. This implementation registers screen tools in the server catalog too, and keeps in the browser only a list of the names to execute. Looking at the code again now, I see three separate decisions: how tools are managed, where the conversation history lives, and whether execution results are returned. What follows is an assessment of the current structure; it does not mean all these alternatives were compared at the time.
Managing tool declarations on the server lets you build the definitions shown to the model in one place. The server also checks that the declarations in the database catalog connect to Spring executors. That does not mean the actual browser executors are verified, though. The browser’s name list and its argument-handling code are separate, and nothing checks that they agree with the server. Registration errors inside the server are caught; the two sides drifting apart across deployments is not. A design in which the server validates client-sent tool declarations against an allow list and a schema is also possible, so central management does not require giving up the standard mechanism.
Managing conversation history on the server is a separate choice from tool declarations. This implementation keeps the history in a store that implements the ADK session service, and the front end sends the user and session identifiers, allow-listed screen context values, the new message and so on. Since the whole history is not resent, transfer volume is lower and the scope of screen context is narrower. But accepting AG-UI’s messages field does not force you to keep the history in two places; the server store can remain the reference against which new messages are identified and validated. The custom request format fits the current app, but a generic front end built for servers that accept the standard input cannot be attached as is.
If the server does not wait for the browser’s result, it can finish the run on the acknowledgement alone. In return, the model cannot know whether the navigation actually succeeded. Whether that trade-off is right depends on whether the navigation result changes the next decision. Result return and run resumption can be implemented only for the screen tools that need a result, leaving the ADK internal loop that handles server tools untouched. Managing tools on the server and getting the browser’s result back are compatible. Server-centered tool management does not mean results cannot come back.
- Row 1: Tool catalog, Browser tools declaration
- Row 2: Server restore declarations · wire executors
- Row 3: Model
- Row 4: Server conversion layer ADK events → AG-UI events
- Row 5: Browser executor match by name list · navigate, Execution result back to the server none
- Tool catalog → Server restore declarations · wire executors: at session creation
- Browser tools declaration → Server restore declarations · wire executors: not used
- Server restore declarations · wire executors → Model: function declarations
- Model → Server conversion layer ADK events → AG-UI events: functionCall
- Server conversion layer ADK events → AG-UI events → Browser executor match by name list · navigate: TOOL_CALL_START · ARGS · END
- Browser executor match by name list · navigate → Execution result back to the server none
If the browser’s name list and the server’s catalog drift apart, nothing checks it. This is what chapter 2 called a case that leaves no signal — a problem that does not show itself when it happens. Having the server announce where a tool runs would reduce the duplicated name list, but you would still have to confirm that the front end in question has an executor and can handle the arguments. Even with a single front end, this can happen when the server and the front end are deployed at different times.
The role and upkeep of the Java conversion layer
The server-side layer that turns ADK events into AG-UI events was written by hand. In the AG-UI repository, checked on September 8, 2026, the Google ADK integration directory holds Python and TypeScript adapters, and the Java SDK documentation offers the shared event types and an HTTP client for connecting to a remote server. Within that check I found no server adapter that converts ADK Java events into AG-UI events. That explains why Java conversion code had to be written; it is not a record of a library comparison at adoption time.
Writing the converter yourself and not using even the public SDK’s shared types are separate decisions, though. This implementation manages the event types in its own code as well. It can be compared with the alternative of pinning the SDK version, reusing its types and writing only the ADK mapping by hand. Either way, the event fields, order and call identifiers the front end expects have to be checked, and an in-house enum is no substitute for a compatibility check.
The converter maps a function call to start, args and end events, and a function response to a result event. When a call has no identifier, it substitutes the event identifier and a sequence number; when the response has no identifier either, it pairs calls and responses with the same name in order. If the results of parallel calls to the same tool arrive out of order, they can be paired wrongly, so ordering guarantees or a separate matching key have to be confirmed. The execution result for this case has not been checked yet. The public Python ADK adapter also patches call IDs, so this is not a problem unique to in-house code. The value of a hand-written converter is that you can add the corrections you need; the responsibility for verifying and maintaining those corrections comes with it.
The moment a tool call becomes a browser action
The execution-scope field that separated server tools from screen tools in the first generation did not survive into the second-generation tool definition; the column and the field are gone from both the back end and the front end. Instead, the tool names the browser handles itself are written in a list in the front-end code. navigateToScreen is on that list, so once all the arguments of a call by that name have arrived, they are handed to the screen executor. The server does not know this list, and nothing checks that the names on both sides match.
What reaches the browser are AG-UI events. The server’s conversion layer takes the function call out of a single ADK event and produces TOOL_CALL_START, TOOL_CALL_ARGS and TOOL_CALL_END as one bundle, and when the server executor returns a value it emits a separate TOOL_CALL_RESULT. No custom event was created as a signal to open a screen; the standard tool-call events are used as they are.
// Conceptual example. All values are fake, and only AG-UI standard fields are kept.
{ "type": "TOOL_CALL_START", "toolCallId": "call-7f3a", "toolCallName": "navigateToScreen" }
{ "type": "TOOL_CALL_ARGS", "toolCallId": "call-7f3a",
"delta": "{\"screenId\":\"MNU-3120\",\"screenName\":\"Work request list\",\"orgScope\":\"ORG-01\"}" }
{ "type": "TOOL_CALL_END", "toolCallId": "call-7f3a" }
// -- The browser navigates here. The rest arrives afterwards. --
{ "type": "TOOL_CALL_RESULT", "messageId": "msg-50", "toolCallId": "call-7f3a", "role": "tool",
"content": "{\"received\":true,\"requestKind\":\"SCREEN\",\"screenId\":\"MNU-3120\",\"screenName\":\"Work request list\",\"orgScope\":\"ORG-01\"}" }
{ "type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-51", "delta": "I have moved you to the work request list." }Nothing in the event payload says “this is a tool the browser executes”. The decision rests on the tool name alone. Any name not on the list is treated as server-tool activity and only shown on the progress timeline. Tools on the list fall into two kinds, depending on which screen carries out the execution.
| Executed by | Role | When it runs |
|---|---|---|
| Tools the chat screen executes itself | The chat screen handles everything from parsing the arguments to executing. The screen navigation tool is one of these | TOOL_CALL_END |
| Tools delegated to a work screen | The chat screen knows only the name; argument validation and applying the result belong to that work screen | Waits until TOOL_CALL_RESULT |
For delegated tools, the browser first checks the result event for pending approval, rejection or a server error before deciding whether to execute them. The screen navigation tool is currently not subject to approval and the server executor returns only an acknowledgement, so the browser is set to execute right at TOOL_CALL_END, where the argument stream ends. That is the current execution point; it does not mean no screen tool ever needs to wait for a result.
- functionCall navigateToScreen — The model calls the screen navigation tool with MNU-3120, the identifier it picked from the lookup result.
- TOOL_CALL_START — The conversion layer first sends the call identifier call-7f3a and the tool name. The name is on the list, so the browser puts it in the pending map.
- TOOL_CALL_ARGS — The argument JSON sent by the server accumulates in the browser’s argument buffer.
- TOOL_CALL_END — Argument transfer done. The browser calls the executor here. It does not wait for the server’s response.
- Look up MNU-3120 in the user’s screen tree → navigate — If the identifier is in the tree, in-app navigation. If not, access denied.
- TOOL_CALL_RESULT — The received:true returned by the server executor arrives. The browser already finished executing at END, so this result produces no side effect. The order of the server executor and the browser navigation is not judged from this sequence alone.
- Function response re-injected — The ADK runner hands the acknowledgement to the model. This is the only result the model knows.
- TEXT_MESSAGE_CONTENT — The model writes a sentence to the effect of “I have moved you there”.
Step 0/8
Step through the flow with the buttons.
React uses the same events for three things: executing screen tools, updating the chat bubbles, and updating the timeline and approval cards. The screen opens in the first branch only, so even when navigation fails, the conversation display continues. Reduced to the key branches of that first path, it looks like this.
// Conceptual example (not the real code). Only the key event branches are kept.
// Defenses against duplicates and delays — the executed-call set, replay blocking on reconnect — are omitted.
const CLIENT_TOOLS = ['navigateToScreen']; // names the browser executes itself — the server does not know this list
const pending = useRef(new Map()); // call identifier -> { name, argsBuffer, done }
function onEvent(event) {
if (event.type === 'RUN_STARTED' || event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') {
pending.current.clear(); // no side effects outside the run boundary
return;
}
if (event.type === 'TOOL_CALL_START' && CLIENT_TOOLS.includes(event.toolCallName)) {
pending.current.set(event.toolCallId, { name: event.toolCallName, argsBuffer: '', done: false });
return;
}
if (event.type === 'TOOL_CALL_ARGS') {
const slot = pending.current.get(event.toolCallId);
if (slot) slot.argsBuffer += event.delta; // stitch the chunks together
return;
}
if (event.type !== 'TOOL_CALL_END') return; // RESULT is not awaited
const slot = pending.current.get(event.toolCallId);
if (!slot || slot.done) return; // ignore END for a call already removed from the pending map
slot.done = true;
pending.current.delete(event.toolCallId);
const outcome = runClientTool(slot.name, safeParse(slot.argsBuffer));
if (!outcome.ok) markActivityFailed(event.toolCallId, outcome.message);
// Success or failure, nothing is sent to the server.
}Executing the same navigation twice could switch the screen twice, so the code has several layers of protection against duplicate calls and late events after a run ends. Each call identifier executes once and is removed from the pending map immediately on execution; when a new run starts, every candidate from the previous run is discarded; and an end event that arrives late after the run has finished produces no side effect. On the path where another window reconnects to a run in progress and replays its events, only messages and activity indicators are restored — navigation is not executed. Restoring messages and re-triggering navigation are kept apart. I confirmed this defensive code, but not the actual behavior under reconnect and delay conditions.
The browser checks again whether the screen can be opened
There is one front-end executor for screen tools. After validating the arguments it branches, by opening mode, into replacing the current screen, opening a new tab, or navigating — and the screen navigation tool always takes the last branch. The side panel stays in place in every case. Navigation calls the app’s shared navigation function, which looks up the identifier it was given in the user’s screen tree fetched at login. If found, it navigates to that screen’s address, passing along the parent screen’s hierarchy information and the state values it received; if not, it treats the request as access denied. Whatever the model wrote in routeHint, the actual navigation path is decided here.
Permission to show a screen is checked in two places: the server’s lookup tool filters candidates by the user’s permissions, and the browser’s navigation function checks the screen tree again. The lookup tool’s filter is applied before the search results are stored as a tool response event, so screens outside the user’s permissions do not remain in the stored history either.
Navigation is allowed only when the bot was opened as a side panel. The full-screen bot has no content area to replace, and a chat modal floating above a work screen covers what the user was looking at, so the screen behind it must not change. In both cases the executor blocks the call before validating the arguments and returns a message to the effect of “please navigate there yourself along the route you were shown”. The tool remains visible to the model.
One distinction matters here. What is checked twice is the permission to show the screen, and the browser checks it through the screen-tree lookup described above. What the executor also looks at while validating arguments is whether the requested usage scope matches the one the user logged in with — an organization-boundary check, not a per-record permission check. Whether R-1042 can be read after arriving at MNU-3120 is judged nowhere on the bot’s path; access to that data is checked when the target screen queries it. The path that opens a screen does not guarantee access to the data, and whether this placement was intended from the start is not certain in hindsight.
The evidence behind “I have moved you there” is the acknowledgement
The executor counts the call as a success once it has handed over the navigation request, and no further. For navigation, that is the point where the navigation function returned; whether the screen actually changed and whether the data loaded are not checked. The new-tab branch does not look at the return value of the window-open function, so if the browser blocks the popup, nothing on the executor’s side notices.
How failures are handled inside the navigation function also matters. When the shared navigation function cannot find the identifier in the screen tree, it shows an alert itself and does not rethrow. The executor never sees an exception and returns success. The user sees an access-permission alert, while the execution timeline records the navigation as successful.
None of these outcomes go back to the server or to the model. There is no API through which the browser sends its execution result, and the next request carries no tool result message either. The server processes the screen tool immediately with its own executor, turns the acknowledgement into a function response, hands it to the model; the model uses it to continue the answer, and the run ends normally. There is no stretch where anything pauses to wait for the browser. The only mechanism this implementation has for pausing and resuming a run is ADK’s approval gate, and screen tools are not subject to it.
So the basis for “I have moved you to the work request list” is the received:true the server itself produced. The model does not know whether the browser navigated, was blocked, or had its popup suppressed. The model’s completion sentence alone therefore cannot confirm that the screen actually changed.
Two things have to be separated here: not sending the result to the model, and the browser marking a failure as success. The first can stay as it is while the navigation function reports failures to its caller, which lets the timeline show them. The wording of a model that has not checked the actual screen can also be limited to “I have requested the navigation”. Both can be improved before any result return is attached.
After that comes the question of whether the model needs the navigation result to decide its next action. If it does, the result can be returned so that a failure turns into route guidance or stops follow-up tool calls. Attaching that to this implementation’s request and stream design requires result submission and run resumption, plus handling of closed tabs and delayed responses. The existing path for submitting approval decisions can serve as a reference, but a person’s approval and a browser’s execution result are different things, so it needs its own design, including call-identifier matching and duplicate-submission prevention. What counts as success — request handed over, screen switched, or data displayed — has to be decided too. Managing tools and history on the server is compatible with getting back the results the next decision needs.
References
- Google ADK Java —
com.google.adk:google-adk(opens in a new tab). This chapter’s reference version is v1.7.1 (opens in a new tab) (GitHub release 2026-08-03). - AG-UI — the public protocol (opens in a new tab). Tool-call events and the fields of
ToolCallResult: events documentation (opens in a new tab); the client-tool declaration channel (RunAgentInput.tools): tools documentation (opens in a new tab) — checked 2026-09-07. The event examples in this chapter keep only the standard fields; the list of tool names the browser treats as its own is defined by the application. - AG-UI JS SDK —
@ag-ui/client(opens in a new tab) 0.0.52 (published to npm 2026-04-08). - AG-UI public implementations — the Python and TypeScript setups in
integrations/adk-middleware(opens in a new tab) and the shared types and HTTP client scope of the Java SDK (opens in a new tab), checked 2026-09-08. This means no ADK Java server adapter was found within that scope; it does not prove that no public library exists or explain the choice made at adoption time. Version 0.5.1 in the Python adapter changelog (opens in a new tab) records the call-ID patching.
| Item | Reference for this chapter |
|---|---|
Agent framework (google-adk) |
1.7.1 |
Model SDK (google-genai) |
1.65.0 |
Front-end AG-UI SDK (@ag-ui/client · @ag-ui/core) |
0.0.52 |
| Server AG-UI conversion layer | In-house implementation |
| Code checked as of | 2026-09-07 |
| Screen feature introduced | 2026-05 — not the adoption date of the versions above |