The round-trip trap is the sharpest framing here — paying hundreds of tokens and seconds of latency for 3.50 x 3 is hard to defend once you see it. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. Do you see teams resisting this because the agent has to be told about the catalog, or is the catalog the easy part?
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
The catalog is actually the easy part—especially since modern LLMs are already trained extensively on tool calling and JSON schemas. When an agent connects to an A2UI renderer, advertising registered catalog functions and their CommonSchemas is no different than handing a standard function-calling schema to Gemini or Claude. The models pick up the bindings immediately and rarely hallucinate the arguments once they have typed schemas.
Where I see teams hesitate initially isn't the catalog—it is the paradigm shift regarding boundaries:
The "Oracle Fallacy": In early GenUI demos, the tempting illusion is that the LLM should handle everything end-to-end. Treating the model as an all-knowing oracle instead of a semantic layout compiler makes developers reluctant to draw a hard architectural line between creative layout and deterministic computation.
Fear of Async Plumbing: Teams that haven't worked with synchronous signals naturally assume that wiring client-side execution means building complex StreamController pipelines, debouncers, or manual setState() webs across generated widgets. Once they see that the catalog function compiles directly into a native computed() signal—updating the leaf widget in the exact same frame with 0ms latency and zero microtasks—that resistance evaporates into relief.
Telling the agent what functions exist is just schema advertising. The real win is deciding up front that your app's math, device hardware, and business rules belong to the local processor, not the token budget.
The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. And the async-plumbing fear is real only until someone sees the computed() signal update in the same frame; that's the demo that converts skeptics. The boundary decision up front is the actual architecture work — everything else is just wiring.
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
"The boundary decision up front is the actual architecture work — everything else is just wiring."
That summary shows how well the message resonated. When readers adopt your framing ("The Oracle Fallacy") and independently play back the core principle—that the demo converting skeptics is seeing the synchronous computed() signal update in the exact same frame without the async latency tax—that's how architectural ideas take root.
That's the best compliment a framing can get — when someone else starts using it back at you. The demo point is exactly right: seeing the computed() signal update in the same frame is what makes the abstraction click for skeptics. Where does the boundary decision get hardest for you — when the catalog itself is dynamic and the agent has to discover functions at runtime?
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
Dynamic runtime discovery is actually the straightforward mechanical part—we already solve that cleanly with capability handshakes, role-based scoping, or MCP-style tool discovery (for example, unlocking privileged catalog functions only when a user authenticates or navigates into a specific enterprise surface).
Where the boundary decision gets genuinely difficult is semantic grain size:
1. The "Assembly Language" vs. "God Function" Dilemma
If your catalog functions are too granular (like multiply(), add(), or formatCurrency()), you force the agent to compose arithmetic ASTs in JSON. You end up accidentally reinventing a fragile Lisp interpreter in JSON, and the agent will eventually botch business-rule order (for example, calculating regional sales tax before versus after applying manufacturer rebates).
If your catalog functions are too monolithic (like calculateEntireCheckoutState()), you strip away the agent's generative flexibility. At that point, you aren't doing Generative UI anymore; you're just writing a classic hardcoded wizard with an AI decorator.
Finding that sweet spot—functions that encapsulate a complete, inviolable domain rule (like calculateCartItemCost or validatePostalCode) while still leaving the layout and data-flow topology to the agent—is where real domain modeling happens.
2. Referential Transparency vs. Local Side Effects
The other hard edge is side-effect boundaries. Pure math (price * qty * (1 - discount)) is trivial because it is referentially transparent—it can recalculate fifty times a second with zero consequences. But as soon as a client function touches device hardware (GPS coordinates, biometric auth) or mutates local persistence (reserving local cache slots), you have to strictly quarantine it out of reactive computed() signals and route it strictly through explicit, deliberate user actions (SubmitAction).
If a function has side effects, it cannot live in the reactive rendering graph. That separation is where architecture either holds or falls apart.
I research AI tools, APIs & agents — comparing specs, pricing and real user reports — then publish honest rankings so you don't waste money. Research-first, never sponsored fluff.
The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. One thing I'm curious about: when the agent invents a call to a function that isn't in the catalog, what does the surface do — fail loud, or quietly render nothing?
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
Great question, Paul. It depends on whether the call happens via Inbound RPC or Reactive UI Binding, but in both cases it fails explicitly and loudly rather than silently swallowing the error into a ghost screen.
Here is what happens under the hood:
1. Inbound RPC (CallRendererFunctionMessage)
If the remote agent sends a direct RPC message requesting a function that doesn't exist in the catalog, A2uiSurfaceBloc intercepts it and immediately returns a structured RendererFunctionResponseMessage with a typed error:
{"functionCallId":"call-123","error":"Function not found: computeRocketTrajectory"}
The agent gets immediate feedback that the tool isn't registered, and the client application continues running without a hitch.
When a streamed component binds a dynamic property (like a Text or Card) to an unregistered function:
catalog.invoke() throws a typed A2uiCatalogResolutionError: Function not found: <name>.
Inside the signals graph, the computed() signal captures this as a SignalEffectException.
In debug mode, this flags immediately in your logs and DevTools console, identifying the exact missing function name so you don't spend hours wondering why a field is blank.
In the Flutter widget tree (bloc_signals_genui_flutter), component resolvers are wrapped in defensive error boundaries. The individual offending node renders its fallback error state (or standard debug error tile), while the rest of the surface tree continues to mount and render cleanly.
The grain-size discussion is the part I keep coming back to — the warning against both primitive-obsession catalogs and god functions is exactly the tension I have hit when exposing tools to a model. And the fail-loudly point matters more than it looks: a typed 'Function not found' error the agent can act on is a completely different failure mode from a silent ghost screen. The pre-warm-the-cache compromise for lookups is a neat resolution too — it keeps the synchronous graph pure without giving up on real data.
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
It is wonderfully rewarding to see the comment section turning into an architectural playbook!
You hit on a profound point about agent feedback loops: when an agent receives a typed error like A2uiFunctionCallError(code: "FUNCTION_NOT_FOUND", ...) or an explicit schema rejection, the model actually has an actionable gradient to self-correct—it can read the error payload, inspect its catalog prompt, and retry with a valid function call.
With a silent ghost screen or a deceptive $0.00, the feedback loop is completely broken: the agent thinks it succeeded, the user thinks the app is broken, and nobody has a stack trace.
Finding that balance between expressive catalog grain size, defensive typed boundaries, and synchronous signal performance is really where modern Generative UI transitions from "cool tech demo" to durable production engineering.
Thanks for reading through the threads and pulling together the key lessons!
Nice article. “An awful, slow, and expensive pocket calculator” is a good way to put the problem, and the round-trip trap you describe is easy to miss in demos where the model is doing everything. Splitting the work so the agent designs the layout and binds expressions while local Dart code does the math is a sound boundary.
What I like most is that the functions compile into computed() signals instead of being plain callbacks. That removes the glue code (stream controllers, debouncers, manual setState) that usually creeps in once local calculations need to feed back into generated widgets, and the same-frame update on a slider drag follows directly from that design.
The two-path breakdown at the end was helpful too. Keeping SubmitAction for user taps separate from the bidirectional RPC for agent-initiated calls makes it clear who is calling what.
One question: since the agent only learns about calculateCost through the advertised catalog or system prompt, what happens when it binds a call with a wrong argument type, or references a DataModel path that doesn’t exist? Does execute() just fall back to the ?? 0.0 defaults, or is there a validation step against the argumentSchema that surfaces the error back to the agent? With deterministic business logic like pricing, I’d worry about a silent 0.0 showing up as a real total.
Thanks for the clear walkthrough and for pointing to Red’s video.
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
You have put your finger right on the critical difference between a pedagogical tutorial snippet and a production-grade catalog function.
You are 100% right to worry: in real-world commerce, a silent fallback to 0.0 is dangerous because displaying a $0.00 price tag or a free cart item is the worst kind of defect—it looks plausible to the user, but it is completely wrong.
Here is how schema validation and path failures are handled in practice across the two layers:
1. Inbound RPC: Strict Schema Validation
When the remote agent calls a function directly over the wire via CallRendererFunctionMessage, the A2UI engine validates the incoming JSON against the function's argumentSchemabeforeexecute() is ever invoked. If the agent sends a string where a number was required, or omits a required field like unitPrice, the engine rejects the call immediately and returns an explicit A2uiFunctionCallError (with code: "INVALID_ARGUMENT") back to the agent. The function never runs with junk data.
2. Reactive UI Expressions: Don't Fall Back, Throw!
In the article's snippet, I wrote (args['unitPrice'] as num?)?.toDouble() ?? 0.0 for brevity, but in production code, you should never silently default a missing required field.
Instead, your production execute() should assert required invariants:
If an agent references a nonexistent DataModel path (like /cart/missing_item/qty), context.resolveSignal() resolves that path to null.
When execute() runs, it detects the missing value and throws an ArgumentError.
Inside the reactive graph, the computed() signal captures the exception.
In Flutter, NodeResolver catches the failed evaluation at the component boundary and displays an explicit error fallback (for example, displaying an error tile or "Price unavailable" skeleton) rather than deceptively painting a green $0.00.
The Rule: Fail Loud on Broken Contracts
If an optional field is missing (like a coupon discount), defaulting to 0.0 is sound domain logic. But if an essential, structural input is missing or malformed, fail loud at the boundary. A broken component card is easy to spot in testing and telemetry; a silent $0.00 invoice is a financial disaster!
Paying hundreds of tokens to compute 3.50 times 3 is the part that should bother people, the phone does it in the same frame at 0ms. The 20 percent coupon button is the flip side though, once that discount runs locally with no server check, a tampered client can apply it as often as it likes.
I'm a Google Developer Expert in Dart and Flutter (one of 12 in North America). I have 45 years of experience with backend, web, mobile, devops, and training.
Spot on, and this touches the first law of client-server security: never trust the client for final transactional authority.
Client-side functions exist for responsive user ergonomics and 120 FPS visual feedback, not for final financial settlement.
It is the exact same invariant web developers have lived with for thirty years:
Your client-side JavaScript (or Dart in Flutter) calculates cart totals and renders the discount instantly so the user doesn't wait through a spinner while typing or dragging sliders.
But when the user taps "Place Order", your backend API / payment gateway calculates the authoritative price against the database and applies the verified coupon rules before charging the card. Nobody lets a browser POST {"total_to_charge": 0.00} directly to Stripe!
In bloc_signals_genui, this is why we separated Path A (Interactive Actions) and Path B (Bidirectional RPC):
Local expressions like calculateCost handle instant preview arithmetic in the UI signal graph.
When an authoritative action occurs (like applying a restricted promo code or checking out), SubmitAction emits an A2uiRendererAction or invokes callAgentFunction() to validate against the server before committing state.
The phone gives you 0ms interactive rendering; the server keeps the ledger honest.
We're a place where coders share, stay up-to-date and grow their careers.
The round-trip trap is the sharpest framing here — paying hundreds of tokens and seconds of latency for 3.50 x 3 is hard to defend once you see it. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. Do you see teams resisting this because the agent has to be told about the catalog, or is the catalog the easy part?
The catalog is actually the easy part—especially since modern LLMs are already trained extensively on tool calling and JSON schemas. When an agent connects to an A2UI renderer, advertising registered catalog functions and their
CommonSchemasis no different than handing a standard function-calling schema to Gemini or Claude. The models pick up the bindings immediately and rarely hallucinate the arguments once they have typed schemas.Where I see teams hesitate initially isn't the catalog—it is the paradigm shift regarding boundaries:
StreamControllerpipelines, debouncers, or manualsetState()webs across generated widgets. Once they see that the catalog function compiles directly into a nativecomputed()signal—updating the leaf widget in the exact same frame with 0ms latency and zero microtasks—that resistance evaporates into relief.Telling the agent what functions exist is just schema advertising. The real win is deciding up front that your app's math, device hardware, and business rules belong to the local processor, not the token budget.
The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. And the async-plumbing fear is real only until someone sees the computed() signal update in the same frame; that's the demo that converts skeptics. The boundary decision up front is the actual architecture work — everything else is just wiring.
"The boundary decision up front is the actual architecture work — everything else is just wiring."
That summary shows how well the message resonated. When readers adopt your framing ("The Oracle Fallacy") and independently play back the core principle—that the demo converting skeptics is seeing the synchronous computed() signal update in the exact same frame without the async latency tax—that's how architectural ideas take root.
High-signal discussion from start to finish!
That's the best compliment a framing can get — when someone else starts using it back at you. The demo point is exactly right: seeing the computed() signal update in the same frame is what makes the abstraction click for skeptics. Where does the boundary decision get hardest for you — when the catalog itself is dynamic and the agent has to discover functions at runtime?
Dynamic runtime discovery is actually the straightforward mechanical part—we already solve that cleanly with capability handshakes, role-based scoping, or MCP-style tool discovery (for example, unlocking privileged catalog functions only when a user authenticates or navigates into a specific enterprise surface).
Where the boundary decision gets genuinely difficult is semantic grain size:
1. The "Assembly Language" vs. "God Function" Dilemma
multiply(),add(), orformatCurrency()), you force the agent to compose arithmetic ASTs in JSON. You end up accidentally reinventing a fragile Lisp interpreter in JSON, and the agent will eventually botch business-rule order (for example, calculating regional sales tax before versus after applying manufacturer rebates).calculateEntireCheckoutState()), you strip away the agent's generative flexibility. At that point, you aren't doing Generative UI anymore; you're just writing a classic hardcoded wizard with an AI decorator.Finding that sweet spot—functions that encapsulate a complete, inviolable domain rule (like
calculateCartItemCostorvalidatePostalCode) while still leaving the layout and data-flow topology to the agent—is where real domain modeling happens.2. Referential Transparency vs. Local Side Effects
The other hard edge is side-effect boundaries. Pure math (
price * qty * (1 - discount)) is trivial because it is referentially transparent—it can recalculate fifty times a second with zero consequences. But as soon as a client function touches device hardware (GPS coordinates, biometric auth) or mutates local persistence (reserving local cache slots), you have to strictly quarantine it out of reactivecomputed()signals and route it strictly through explicit, deliberate user actions (SubmitAction).If a function has side effects, it cannot live in the reactive rendering graph. That separation is where architecture either holds or falls apart.
The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. One thing I'm curious about: when the agent invents a call to a function that isn't in the catalog, what does the surface do — fail loud, or quietly render nothing?
Great question, Paul. It depends on whether the call happens via Inbound RPC or Reactive UI Binding, but in both cases it fails explicitly and loudly rather than silently swallowing the error into a ghost screen.
Here is what happens under the hood:
1. Inbound RPC (
CallRendererFunctionMessage)If the remote agent sends a direct RPC message requesting a function that doesn't exist in the catalog,
A2uiSurfaceBlocintercepts it and immediately returns a structuredRendererFunctionResponseMessagewith a typed error:The agent gets immediate feedback that the tool isn't registered, and the client application continues running without a hitch.
2. Reactive UI Bindings (
{"call": "unknownFunction"})When a streamed component binds a dynamic property (like a
TextorCard) to an unregistered function:catalog.invoke()throws a typedA2uiCatalogResolutionError: Function not found: <name>.computed()signal captures this as aSignalEffectException.bloc_signals_genui_flutter), component resolvers are wrapped in defensive error boundaries. The individual offending node renders its fallback error state (or standard debug error tile), while the rest of the surface tree continues to mount and render cleanly.No silent black holes or phantom state!
The grain-size discussion is the part I keep coming back to — the warning against both primitive-obsession catalogs and god functions is exactly the tension I have hit when exposing tools to a model. And the fail-loudly point matters more than it looks: a typed 'Function not found' error the agent can act on is a completely different failure mode from a silent ghost screen. The pre-warm-the-cache compromise for lookups is a neat resolution too — it keeps the synchronous graph pure without giving up on real data.
It is wonderfully rewarding to see the comment section turning into an architectural playbook!
You hit on a profound point about agent feedback loops: when an agent receives a typed error like
A2uiFunctionCallError(code: "FUNCTION_NOT_FOUND", ...)or an explicit schema rejection, the model actually has an actionable gradient to self-correct—it can read the error payload, inspect its catalog prompt, and retry with a valid function call.With a silent ghost screen or a deceptive
$0.00, the feedback loop is completely broken: the agent thinks it succeeded, the user thinks the app is broken, and nobody has a stack trace.Finding that balance between expressive catalog grain size, defensive typed boundaries, and synchronous signal performance is really where modern Generative UI transitions from "cool tech demo" to durable production engineering.
Thanks for reading through the threads and pulling together the key lessons!
Nice article. “An awful, slow, and expensive pocket calculator” is a good way to put the problem, and the round-trip trap you describe is easy to miss in demos where the model is doing everything. Splitting the work so the agent designs the layout and binds expressions while local Dart code does the math is a sound boundary.
What I like most is that the functions compile into computed() signals instead of being plain callbacks. That removes the glue code (stream controllers, debouncers, manual setState) that usually creeps in once local calculations need to feed back into generated widgets, and the same-frame update on a slider drag follows directly from that design.
The two-path breakdown at the end was helpful too. Keeping SubmitAction for user taps separate from the bidirectional RPC for agent-initiated calls makes it clear who is calling what.
One question: since the agent only learns about calculateCost through the advertised catalog or system prompt, what happens when it binds a call with a wrong argument type, or references a DataModel path that doesn’t exist? Does execute() just fall back to the ?? 0.0 defaults, or is there a validation step against the argumentSchema that surfaces the error back to the agent? With deterministic business logic like pricing, I’d worry about a silent 0.0 showing up as a real total.
Thanks for the clear walkthrough and for pointing to Red’s video.
You have put your finger right on the critical difference between a pedagogical tutorial snippet and a production-grade catalog function.
You are 100% right to worry: in real-world commerce, a silent fallback to
0.0is dangerous because displaying a$0.00price tag or a free cart item is the worst kind of defect—it looks plausible to the user, but it is completely wrong.Here is how schema validation and path failures are handled in practice across the two layers:
1. Inbound RPC: Strict Schema Validation
When the remote agent calls a function directly over the wire via
CallRendererFunctionMessage, the A2UI engine validates the incoming JSON against the function'sargumentSchemabeforeexecute()is ever invoked. If the agent sends a string where a number was required, or omits a required field likeunitPrice, the engine rejects the call immediately and returns an explicitA2uiFunctionCallError(withcode: "INVALID_ARGUMENT") back to the agent. The function never runs with junk data.2. Reactive UI Expressions: Don't Fall Back, Throw!
In the article's snippet, I wrote
(args['unitPrice'] as num?)?.toDouble() ?? 0.0for brevity, but in production code, you should never silently default a missing required field.Instead, your production
execute()should assert required invariants:Here is what happens when it throws:
DataModelpath (like/cart/missing_item/qty),context.resolveSignal()resolves that path tonull.execute()runs, it detects the missing value and throws anArgumentError.computed()signal captures the exception.NodeResolvercatches the failed evaluation at the component boundary and displays an explicit error fallback (for example, displaying an error tile or "Price unavailable" skeleton) rather than deceptively painting a green$0.00.The Rule: Fail Loud on Broken Contracts
If an optional field is missing (like a coupon discount), defaulting to
0.0is sound domain logic. But if an essential, structural input is missing or malformed, fail loud at the boundary. A broken component card is easy to spot in testing and telemetry; a silent$0.00invoice is a financial disaster!Paying hundreds of tokens to compute 3.50 times 3 is the part that should bother people, the phone does it in the same frame at 0ms. The 20 percent coupon button is the flip side though, once that discount runs locally with no server check, a tampered client can apply it as often as it likes.
Spot on, and this touches the first law of client-server security: never trust the client for final transactional authority.
Client-side functions exist for responsive user ergonomics and 120 FPS visual feedback, not for final financial settlement.
It is the exact same invariant web developers have lived with for thirty years:
{"total_to_charge": 0.00}directly to Stripe!In
bloc_signals_genui, this is why we separated Path A (Interactive Actions) and Path B (Bidirectional RPC):calculateCosthandle instant preview arithmetic in the UI signal graph.SubmitActionemits anA2uiRendererActionor invokescallAgentFunction()to validate against the server before committing state.The phone gives you 0ms interactive rendering; the server keeps the ledger honest.