DEV Community

Cover image for Stop Asking LLMs to Do Math: Client-Side Functions in Flutter GenUI with bloc_signals

Stop Asking LLMs to Do Math: Client-Side Functions in Flutter GenUI with bloc_signals

Comments spotlight zero-latency computed signals

The Calculator Fallacy in Generative UI

"An LLM is a phenomenal semantic reasoning engine, but it is an awful, slow, and expensive pocket calculator."

If you haven't watched Andrew "Red" Brogdon's walkthrough on the Flutter channel (Client side functions for Flutter GenUI), stop and watch it. In five minutes, Red exposes the fundamental flaw lurking beneath most early Generative UI demos: the round-trip calculation trap.

Suppose an AI agent generates a recipe or shopping cart card for a user. The user drags a slider to scale the recipe from 2 servings to 6 servings, or edits the quantity of an ingredient.

What happens next in naive GenUI systems?

  1. The client captures the input change.
  2. It serializes the state and sends a network payload back to the remote model over Server-Sent Events (SSE) or WebSockets.
  3. The LLM burns hundreds of tokens computing 3.50 * 3, occasionally hallucinates floating-point arithmetic or loses formatting consistency, and responds over a multi-second latency gap.
  4. The client finally repaints the screen.

Making a remote AI call to multiply two numbers on a mobile device equipped with an Apple Silicon or Snapdragon processor capable of billions of operations per second is an architectural anti-pattern.

As Red demonstrated with the Commis kitchen assistant app, the solution is Client-Side Functions: the agent designs the interface and binds expressions, but delegates deterministic execution to local Dart code registered in the client's catalog.

In bloc_signals_genui and bloc_signals_genui_flutter, we have taken this concept to its logical architectural conclusion by pairing Google's official A2UI protocol with synchronous reactive signals.


The Missing Link: Reactivity Without the Async Tax

Running a Dart function locally fixes the latency and token cost of LLM round-trips. But it creates a secondary architectural challenge: how does the UI react when local state changes?

In asynchronous architectures, developers often end up wiring StreamController pipelines, debouncers, or manual setState() callbacks to propagate local math back into generated widgets. Every slider notch pays an event-loop and microtask tax.

Because bloc_signals_genui is built directly on Google's official a2ui_core engine—which natively uses preact_signals—client-side functions are not just callable routines: they are first-class reactive computed() signals.

flowchart TD
    User["User Drags Slider / Edits Field"] -->|"1. Updates Local DataModel (0ms)"| Data["DataModel Signal ('/recipe/servings')"]
    Data -->|"2. Synchronous Dependency Trigger"| Computed["computed(() => calculateCost(...))"]
    Computed -->|"3. Same-Frame Leaf Rebuild (120 FPS)"| Widget["Text Widget ('$14.50')"]

When a user adjusts a quantity, the calculation runs in the exact same frame. There are zero network round-trips, zero microtask hops, and zero UI flickers.


Step-by-Step: Implementing Client-Side Functions

Here is how you implement and wire client-side functions using bloc_signals_genui.

1. Define the Function in Pure Dart

Subclass FunctionImplementation from bloc_signals_genui (re-exported from a2ui_core). Declare the function's name, return type, and typed JSON argument schema using CommonSchemas.dynamicNumber:

import 'package:bloc_signals_genui/bloc_signals_genui.dart';
import 'package:json_schema_builder/json_schema_builder.dart'
    hide ValidationResult;

class CalculateCostFunction extends FunctionImplementation {
  CalculateCostFunction()
      : super(
          name: 'calculateCost',
          returnType: A2uiReturnType.number,
          argumentSchema: Schema.object(
            properties: {
              'unitPrice': CommonSchemas.dynamicNumber,
              'quantity': CommonSchemas.dynamicNumber,
              'discount': CommonSchemas.dynamicNumber,
            },
            required: ['unitPrice', 'quantity'],
          ),
        );

  @override
  Object? execute(
    Map<String, Object?> args,
    DataContext context, [
    CancellationSignal? cancellationSignal,
  ]) {
    final unitPrice = (args['unitPrice'] as num?)?.toDouble() ?? 0.0;
    final quantity = (args['quantity'] as num?)?.toDouble() ?? 0.0;
    final discount = (args['discount'] as num?)?.toDouble() ?? 0.0;

    return (unitPrice * quantity) * (1.0 - discount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that execute() receives a DataContext. That means your function can read or scope additional local surface state if needed, completely decoupled from Flutter widgets.


2. Register the Function in Your Catalog

Add your function to StandardCatalog using .copyWith(...) and pass it to your A2uiSurfaceBloc:

final catalog = StandardCatalog().copyWith(
  functions: [
    CalculateCostFunction(),
    ...BasicCatalog.v0_9().functions.values,
  ],
);

final surfaceBloc = A2uiSurfaceBloc(
  catalogs: [catalog],
);
Enter fullscreen mode Exit fullscreen mode

When your app advertises its capabilities or system prompt to the agent, the agent is informed of calculateCost and its required parameters.


3. The Agent Streams the Reactive Binding

When the AI agent streams down an A2UI JSON component, it doesn't hardcode a static string. Instead, it binds the text property of a Text component to the function call, referencing paths in the local DataModel:

{
  "id": "cost_display",
  "component": "Text",
  "text": {
    "call": "calculateCost",
    "args": {
      "unitPrice": 4.50,
      "quantity": { "path": "/cart/items/0/qty" },
      "discount": 0.10
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Zero-Latency Execution Under the Hood

When bloc_signals_genui and bloc_signals_genui_flutter resolve this component, DataContext.resolveSignal() from a2ui_core evaluates the dynamic function call:

// Conceptual resolution inside DataContext.resolveSignal():
ReadonlySignal<Object?> resolveSignal(Object? value) {
  if (value is Map && value.containsKey('call')) {
    final callName = value['call'] as String;
    final rawArgs = value['args'] as Map<String, dynamic>? ?? {};

    // 1. Recursively resolve arguments into signals
    final argSignals = rawArgs.map(
      (key, val) => MapEntry(key, resolveSignal(val)),
    );

    // 2. Wrap catalog execution in a synchronous computed() signal
    return computed(() {
      final evaluatedArgs = {
        for (final entry in argSignals.entries)
          entry.key: entry.value.value,
      };
      return catalog.invoke(callName, evaluatedArgs, this);
    });
  }
  // Literal values or data bindings ({'path': ...}) resolve directly
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Notice what happens:

  1. Every argument pointing to a DataModel path (for example /cart/items/0/qty) resolves to an underlying reactive signal.
  2. The entire function call compiles into a native computed() signal.
  3. Whenever the user alters the quantity in a TextField or Slider, bloc.add(UpdateFormField('/cart/items/0/qty', newValue)) mutates the DataModel synchronously.
  4. The computed() signal marks itself dirty and recalculates immediately in the exact same frame (0ms latency, zero microtasks).
  5. The Flutter widget (NodeResolver / A2uiSurfaceView) performs a surgical leaf rebuild at 120 FPS.

5. Interactive Actions vs. Bidirectional RPC

In addition to dynamic display properties, client-side functions integrate seamlessly with user actions and remote agent coordination. It is crucial to understand the two distinct operational paths:

Path A: User Interaction via SubmitAction

When a user taps an interactive component (such as a Button), the client dispatches a SubmitAction event to A2uiSurfaceBloc. The action can either trigger a local client-side function (for example applying a local coupon code or validating form inputs) or emit an A2uiRendererAction outbound to the remote agent:

{
  "id": "apply_coupon_btn",
  "component": "Button",
  "text": "Apply 20% Discount",
  "action": {
    "functionCall": {
      "call": "applyDiscountCode",
      "args": { "code": "SAVE20" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Path B: Bidirectional RPC (CallRendererFunctionMessage & callAgentFunction)

Generative UI is not a one-way street. A2uiSurfaceBloc provides full bidirectional RPC across the client-agent boundary:

  1. Inbound RPC (CallRendererFunctionMessage): The remote agent can actively invoke registered client-side functions on demand. If the agent needs to compute a verified total using client business rules, read device GPS coordinates, or query local SQLite storage, it sends a CallRendererFunctionMessage. The client executes the catalog function locally and responds with a typed RendererFunctionResponseMessage.
  2. Outbound RPC (callAgentFunction): The renderer can invoke remote tool capabilities on the agent via bloc.callAgentFunction(), resolving asynchronously when the matching AgentFunctionResponseMessage returns.

Architectural Boundaries for Modern GenUI

Client-side functions draw a clean, resilient line across your Generative UI architecture:

Responsibility Handled By Latency & Reliability
Generative Layout & Reasoning Remote LLM (Gemini, Claude, GPT) Async streaming over network (SSE)
Deterministic Business Logic & Math Local Dart Functions (FunctionImplementation) 0ms synchronous execution on-device
Reactivity & State Synchronization Signals Graph (BlocSignal + a2ui_core) Same-frame UI update without microtasks
Hardware & Native Platform APIs Bidirectional RPC (CallRendererFunctionMessage) Local platform channels & secure sandbox

By adopting Andrew Brogdon's client-side functions pattern and grounding it in a synchronous signal engine, you get the best of both worlds: the creative flexibility of Generative UI with the speed, accuracy, and reliability of native Flutter.


Explore bloc_signals_genui and bloc_signals_genui_flutter on pub.dev, and check out the interactive samples at blocsignal.dev.

Top comments (44)

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

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?

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

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:

  1. 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.
  2. 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.

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

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.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

"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!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

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?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

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.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The grain-size framing is the part I'll carry forward — 'a complete, inviolable domain rule' is a much better test for a catalog function than 'does it save a round trip.' And the side-effect point quietly corrects my computed() enthusiasm: the signal trick only works for pure functions, and the moment a function touches hardware or persistence it has to leave the reactive graph. Where do you draw that line for functions that only read local state, like validatePostalCode against a cached table — pure enough for computed(), or does any I/O at all push it to SubmitAction?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

The boundary comes down to a simple, physical rule of thumb: is it a synchronous in-memory read, or does it cross an async boundary (disk, network, or IPC)?

1. In-Memory Tables: 100% Safe for computed()

If validatePostalCode queries an in-memory lookup table—like a Set<String>, Trie, or hash map pre-loaded in memory or cached in repository state—it is completely synchronous, deterministic, and executes in sub-microsecond time (O(1)).

It has zero side effects and never leaves the CPU cache. That is completely pure enough for computed(). The user types the fifth digit of a ZIP code, the signal evaluates synchronously, and the validation checkmark or error text repaints in the exact same frame at 120 FPS.

2. Actual I/O (Async Futures): Must Leave the Synchronous Graph

The moment "cached table" means an async call—like querying an on-disk SQLite database, reading file storage, or invoking an async platform channel (Future<bool>)—it must not live directly inside a synchronous computed() signal.

Dart signals propagate synchronously in the UI isolate. If a function returns a Future, you are forced to introduce microtask hops, debouncers, or async state wrappers (AsyncSignal<T>), which reintroduces the very frame-budget jitter and UI flicker you set out to avoid.

The Architectural Compromise: Pre-warm the Cache

In production architectures, we solve this by separating cache population from catalog evaluation:

  • The state container or repository asynchronously pre-warms the cache into memory (or updates it out-of-band via events).
  • The catalog function itself remains a pure, synchronous read against that in-memory state via DataContext.

If a lookup genuinely requires on-demand async I/O that cannot be cached in advance (for example, verifying an address against a live postal API), that is no longer a rendering-time expression—it is an explicit intent. You route it through SubmitAction, let the BLoC handle the loading state, and write the verified result back into the DataModel when the future resolves.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That's the rule of thumb I'll steal — synchronous in-memory read versus async boundary is a test anyone on the team can apply without arguing about purity. The pre-warm compromise is the real answer to my catalog question: the catalog stays a pure read, and the hard part moves to keeping the cache fresh. How do you handle staleness there — TTL on the pre-warmed table, or event-driven refresh when the source changes?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts • • Edited

The classic adage is undefeated here: there are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one errors.

In practice, I strongly favor event-driven refresh over passive TTL, with a pragmatic stale-while-revalidate hybrid when network calls are unavoidable. Here is why:

1. The Trouble with Pure TTL in UI Systems

A passive TTL (for example, expiring an entry after five minutes) is almost always a guessing game:

  • If your timer is too long, the user sees outdated business data right when they are making a decision.
  • If it is too short, you burn network calls and risk cache misses right when the user is interacting with the screen.
  • Worst of all, TTLs expire on wall-clock time rather than user cadence. Having an in-memory cache invalidate mid-drag or mid-keystroke can introduce unexpected layout shifts.

2. Event-Driven Refresh Fits Unidirectional Architecture

Because bloc_signals is already built on unidirectional data flow, event-driven updates are natural:

  • When the external world changes (a WebSocket push, an SSE event, or an explicit server response after an action), an event is dispatched to the repository (like TaxRatesUpdated or InventorySnapshotReceived).
  • The repository updates its in-memory signal.
  • Because the catalog function reads that signal via DataContext, any dependent computed() signal marks itself dirty and recalculates synchronously in that exact same frame. The UI repaints seamlessly without spinners or polling loops.

3. Stale-While-Revalidate for Semi-Static Domain Data

For domain data that changes occasionally (like exchange rates or shipping rate tiers):

  1. Immediate synchronous hit: The catalog function reads the current in-memory cache instantly (0ms), keeping the UI responsive at 120 FPS.
  2. Background verification: If a version tag or soft timestamp indicates the data is aging, a background task fetches the fresh payload out-of-band.
  3. Atomic swap: When the fresh payload arrives, an event writes the new data structure into the state container in one atomic transition, updating the surface without blocking user input.

And for truly static reference data (like standard postal code formats, unit conversions, or ISO country codes)? Version-stamp it with the app release or bundle it in the asset catalog. The fastest, freshest cache is the one that never has to invalidate at all!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The mid-drag invalidation point is the one that sells it — a TTL expiring mid-keystroke reads as a UI glitch, not a cache bug, so nobody debugs the right layer. Event-driven fits the unidirectional flow so cleanly that the TTL starts to look like a workaround for not having events. Do you version-stamp the catalog itself, or does the event carry the version tag?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

"TTL starts to look like a workaround for not having events"—that is an absolute gem of a phrase, and I am stealing that one!

Regarding version tags: we strictly separate the Catalog's semantic version from the Event's data version.

Here is why coupling them causes pain, and how we split the responsibilities:

1. The Catalog Version is the Schema Contract

In Google's A2UI protocol and bloc_signals_genui, the catalog is versioned semantically (BasicCatalog.v0_9(), v1_0(), or your app's AppCatalog.v2()).

This represents the structural capability contract advertised to the agent during session handshake:

  • What components exist (Card, Text, Slider)
  • What functions exist and their argument schemas (calculateCost, validatePostalCode)

You do not want to bump the catalog version when runtime data changes (like updating tax rates or zip code tables). Bumping the catalog version mid-flight forces the agent to renegotiate its tool schemas, discard in-flight prompts, or re-parse its system instructions.

2. The Event Carries the Monotonic Data Version

Runtime data updates belong entirely in the event stream:

  • The backend or repository emits an event carrying a monotonic sequence number, timestamp, or ETag: PostalCodeDirectoryUpdated(version: 42, entries: ...) or TaxRatesRefreshed(etag: "3a8f9b", rates: ...).
  • The repository stores this in an in-memory snapshot with that version tag.
  • When an action or validation occurs, the function reads the current in-memory snapshot synchronously via DataContext.

If you ever need auditability (for example, proving which tax table was used when the user saw a quote), the function can simply attach that data version tag to the result object.

The Summary Rule:

  • Version the catalog when the types, names, or argument schemas change (compile-time / protocol boundary).
  • Version the event when the underlying domain data changes (runtime / state boundary).

That keeps the agent's schema static and predictable while letting your domain data mutate at whatever velocity the business requires!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That's a clean split — the catalog version is the contract, the event version is the data, and coupling them would tax every data change with a renegotiation. The part that convinces me is the mid-flight cost: bumping the catalog forces the agent to discard in-flight work for what is really just a data update. Same principle as the TTL point, really: don't let a data problem wear a schema costume.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

"Don't let a data problem wear a schema costume."

Print that out and tape it to the monitor of every engineer building agentic tools. That sums up the entire architectural lesson in eight words.

Terrific discussion—thanks for digging so deeply into the real mechanics!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That line deserves the tape treatment — the schema costume is exactly what happens when the catalog describes the shape but not the meaning. I'm curious where you see it most: teams over-validating inputs that are wrong at the source, or the catalog itself carrying the wrong model?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Hands down, it is the catalog carrying the wrong model—specifically, the classic trap of Primitive Obsession.

When teams first build an agent catalog, they almost always port over their raw database DTOs or loose JSON dictionaries. You see schemas full of String status, double discount, and Map<String, dynamic> metadata.

The schema describes the physical shape of the bytes, but completely strips the semantic meaning:

  • Because status is just an open string, the model can happily stream "pending_but_cancelled".
  • Because discount is just a raw float, the model can emit 1.50 (150% discount) or -0.20.

The Retaliatory Band-Aid: Over-Validating

Once the LLM inevitably emits one of those nonsensical combinations, teams panic. But instead of fixing the catalog model, they react by piling on a mountain of defensive validation checks, regex assertions, and runtime exception traps.

They try to cure an anemic domain model with procedural bureaucracy.

The Cure: Make Invalid States Unrepresentable

The enduring lesson from typed functional programming (Alexis King’s famous "Parse, don't validate") applies double to LLM catalogs:

  1. Use Closed Enums Over Open Strings: If an order can only be draft, submitted, or settled, declare an enum in the JSON schema. The LLM's constrained decoding grammar won't even generate an invalid token.
  2. Bounded Value Objects Over Raw Floats: If discounts only come in approved promo tiers (5%, 10%, 20%), expose the allowed promo codes or tier enum in the schema rather than accepting an unbounded double and then scolding the user when they type 500%.

When the catalog carries the true domain model, 90% of the input-validation glue code simply evaporates. If the agent can only express states that are mathematically and legally valid in your domain, you never have to play whack-a-mole with edge-case checkers!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That reframes my question nicely — the catalog isn't the easy part, it's where the anemic model hides. 'Parse, don't validate' fits the version split we talked about too: if the catalog carries closed enums and bounded tiers, a data change stays a data change instead of forcing a catalog renegotiation. Do you see teams actually adopting that, or does the DTO-port habit survive because the catalog is usually generated from the backend?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

You identified the exact gravity well that keeps the bad habit alive: automated backend code generation.

For the past decade, teams have spent millions building slick OpenAPI/Swagger, Protobuf, and GraphQL generation pipelines. It is seductively easy to point a code generator at a Swagger spec and spit out fifty DTO classes with zero human effort.

Teams proudly say, "Look, our agent catalog is auto-generated from our backend API!"

What they have actually built is an automated pipe that blasts anemic primitive types directly into the LLM prompt. The generator knows what a JSON string and float are; it has zero concept of domain invariants.

How Teams Actually Break the Habit

Teams only abandon the raw DTO-port reflex after they hit what I call the "Validator Tipping Point":

  1. The model starts hallucinating invalid field combinations that technically match the OpenAPI schema (like taxRate: -0.05 or conflicting status strings).
  2. The team responds by writing dozens of defensive regex guards and post-hoc validators.
  3. The maintenance cost of the validation glue code eventually exceeds the cost of writing proper domain models.

The Breakthrough: The Catalog as an Anti-Corruption Layer

The teams that successfully adopt bounded catalogs treat the catalog the way Eric Evans described an Anti-Corruption Layer (ACL) in Domain-Driven Design:

  • Your raw backend DTOs can remain whatever messy, backwards-compatible wire format they need to be.
  • But the Agent Catalog is a curated semantic firewall. It deliberately narrows, translates, and groups those backend DTOs into closed enums, bounded value types, and intention-revealing functions before the model ever sees them.

It takes an organizational mindset shift: moving from "the catalog is just an export of our REST endpoints" to "the catalog is a high-level domain interface designed specifically for cognitive reasoning." Once a team makes that jump, they never go back to raw DTO dumps.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The ACL framing is the piece that makes this click — a catalog designed for cognitive reasoning, not exported from the wire format. And the validator tipping point is a genuinely useful diagnostic: when the validation glue costs more than the domain model, the catalog was wrong, not the model. Do you see the bounded types as hand-curated per domain, or is there a repeatable way to derive them from the invariants teams already have?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

You don't have to invent them from a blank sheet of paper—and you definitely shouldn't hand-craft an arbitrary bespoke schema for the AI.

In every mature codebase, those invariants already exist. They're just buried in places developers forget to look when building AI tools.

Here is the repeatable 3-step extraction recipe I use to derive bounded catalog types from invariants the team already owns:

1. Mine the Domain Types, Not the Wire DTOs

Look at how your core business logic represents the domain internally, away from the network edge:

  • In modern Dart, TypeScript, or Kotlin, senior developers don't use raw strings in core logic; they use sealed classes, algebraic data types, and enhanced enums.
  • If your internal domain uses sealed class OrderState { Draft, Submitted, Fulfilled }, your catalog schema should project that exact enum—not the loose string column from your legacy REST serializer.
  • If you have an internal value object like Quantity with an assertion (value >= 1 && value <= 99), that immediately translates to Schema.integer(minimum: 1, maximum: 99).

2. Inspect the Database CHECK Constraints

Before an API is written, the database engineer usually locked down the actual business physics:

  • Look at your PostgreSQL, Prisma, or SQLite schemas.
  • Look for CHECK (discount >= 0.0 AND discount <= 0.50), CHECK (status IN ('draft', 'active')), and foreign key lookup tables.
  • The database schema is an honest, audited record of what the business actually permits. Projecting those database constraints into Schema.number(minimum: 0.0, maximum: 0.50) takes five minutes and permanently eliminates hallucinated boundary bugs.

3. Mine the Unit Test Assertions

Your unit test suite is literally a catalog of invariants that someone was once paid to defend:

  • Search your tests for throwsArgumentError or asserts: expect(() => applyCoupon("INVALID"), throws...) or expect(order.calculateTax(), ...).
  • Every boundary test in your suite tells you an exact edge case where a raw primitive broke the system in the past.

The Summary Recipe

Don't ask "What schema should we make up for the agent?"

Ask: "What is the intersection of our domain enums, database constraints, and test assertions?"

When you mechanically compile those existing boundaries into your catalog schemas, the agent isn't learning an artificial grammar—it is conforming to the exact same engineering physics your backend and mobile app already enforce!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The DB constraints step is the one I'd trust most — domain types can drift from what's actually enforced, but a CHECK constraint is the business physics as shipped. One tension in the intersection idea: what happens when the three sources disagree — say the domain enum allows a state the database rejects, or a test asserts a boundary the DB never enforces? Does one source win, or is the disagreement itself the finding, a bug in one of the three?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

The disagreement itself is the finding. Never silently pick a winner and move on!

When your domain types, database constraints, and unit tests disagree, mining them for the agent catalog just paid for itself tenfold: you have uncovered a latent production bug before an LLM ever touched it.

Every type of mismatch reveals a specific, real architectural failure:

  1. Domain Enum Allows What the DB Rejects: You have a migration drift bug. Your UI or application layer is cheerfully offering a state (for example, OrderStatus.archived) that triggers an unhandled SQL check_violation 500 error the moment a user hits Save.
  2. Test Asserts What the DB Never Enforces: Your test suite is defending a polite fiction. Any background script, webhook worker, or legacy client that writes directly to the database can silently violate the business rule your tests swore was impossible.
  3. DB Allows What the Domain Type Rejects: You have legacy data ghosts. Historical rows sitting in production tables will throw FormatException or crash modern clients on deserialization.

How We Handle Drift in Practice

When those three sources clash, the workflow splits into two immediate actions:

  1. Immediate Containment (The Strictest Intersection): Until the team reconciles the bug, the agent catalog must adopt the strictest intersection across all three layers. You never want an AI agent generating UI components or proposing parameters that pass one layer only to crash another.
  2. Root Resolution: Open a bug immediately to bring the three layers back into alignment—either ship the missing CHECK constraint, run the cleanup migration, or prune the zombie enum value.

Treating schema drift as a first-class bug rather than quietly overriding it turns the agent catalog onboarding process into one of the best static audits a codebase can ever go through!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The strictest-intersection rule is the part I'd steal — containment that can't silently pass one layer and crash another is exactly what an agent catalog needs while the bug gets fixed. And you're right that the disagreement itself is the finding: a catalog that surfaces migration drift before any LLM touches the code has already earned its keep. Do the teams you see actually open that reconciliation bug, or does the strictest intersection quietly become the permanent answer?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

In most teams without a mechanical forcing function, the strictest intersection becomes the permanent answer.

As the old adage goes, nothing in software is more permanent than a temporary workaround. Once the agent stops throwing 500 errors against the database, the immediate pain vanishes. Everyone marks the ticket done, moves on to the next sprint, and six months later nobody remembers why the agent catalog refuses to acknowledge an enum variant that clearly exists in the UI code.

Why Containment Hardens Into Debt

Containment solves the production crash, which removes the urgency to reconcile the underlying drift. Without pain, backlogs rot.

How Disciplined Teams Force the Reconciliation

The teams that manage to close that loop do it by trading an uncontrolled production crash for a controlled, visible test debt:

  1. Tag Every Intersection Narrowing With a Tracking Issue: Never narrow a catalog schema in silence. Bind the restriction directly to an open issue in code (// Bounded to intersection pending #412: Postgres CHECK drift).
  2. Make Schema Drift Loud in CI: Instead of treating the three-source check as a one-time catalog onboarding exercise, automate it as a CI test. Compare your domain enum variants against your database schema exports on every pull request.
  3. Allowlist Only by Open Ticket: If you must ship immediately using the strictest intersection, add an explicit allowlist entry tied to that tracking issue in your CI check. The moment someone closes the ticket—or adds a fourth stray value—the build turns red.

If your containment workaround feels painless, nobody will ever clean up the drift. Putting a small, annoying, ticket-bound lint check in CI keeps the reconciliation honest!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The 'temporary workaround' line is the whole answer — once the 500s stop, the pain that would have forced reconciliation is gone. Of the three, the CI drift check is the one I'd bet on, because it converts the debt from invisible to annoying on every PR. Which of the three do you see teams actually keep alive past the first sprint — or does even the CI check get allowlisted into silence?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

A plain allowlist file almost always rots into a silent graveyard. A developer hits a deadline on a Friday afternoon, appends their new field to allowlist.json to get a green checkmark, and moves on. Two sprints later, the allowlist has forty entries and protects nothing.

And // TODO(#412) comments? Those survive purely as digital archaeology.

What survives past the first sprint is a Ratchet Gate—specifically, a CI drift check built around two mechanical rules:

1. Freeze Legacy Debt, Block New Drift Zero-Tolerance

Teams abandon CI checks when a check screams about forty historical sins on every unrelated PR. The build turns into background noise, and people disable it.

The version that survives splits old from new:

  • Legacy exceptions sit in a frozen, finite manifest where each entry requires an explicit tracking issue ID.
  • New drift is blocked with zero tolerance. If a pull request introduces an enum value without a matching database migration, or adds a database constraint without updating the domain type in that exact PR, the build fails immediately.

Because the check only yells at the author who created the new mismatch, nobody resents it and nobody turns it off.

2. The Allowlist Is a One-Way Ratchet (With a Deadman's Switch)

To prevent the legacy manifest from expanding:

  • Code review rule / CI assertion: Adding an item to the manifest triggers a dedicated warning or requires an explicit sign-off token (or an expiration milestone).
  • Automatic cleanup: When an allowlist item is reconciled, removing it shrinks the list permanently—you can never bump the baseline count back up. Even better, a nightly check or CI step queries the tracking issue status: the moment issue #412 closes, leaving #412 in the allowlist fails the build until someone deletes the stale entry.

When you combine a zero-new-drift gate with a ratchet-only allowlist, the CI check pays for itself on every PR without turning into noisy spam—and teams keep it turned on for years!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The ratchet framing answers it — the trick is the check only yells at whoever created the new mismatch, so nobody has a reason to turn it off. The deadman's switch is the part I'd steal: an allowlist entry that outlives its tracking issue failing the build turns cleanup from a chore into a gate. Have you seen teams actually wire the issue-status query into CI, or does that part usually stay a manual review rule?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Most teams avoid putting a live issue-tracker API call directly inside blocking per-PR checks, because two things go wrong immediately:

  • External flakiness breaks unrelated builds: A rate limit or transient outage on Jira, Linear, or GitHub Issues turns every engineer's CI red even though the codebase didn't change.
  • Closing an issue ambushes open PRs: If a tech-debt ticket gets marked Done at 2:00 PM, every unrelated feature branch in flight starts failing CI five minutes later—which breaks the whole point of only yelling at the person responsible.

In practice, the cleanest production setups split the deadman's switch into two deterministic pieces instead:

1. A hermetic "no stale exemptions" check in per-PR CI

Every PR runs an offline check verifying that every allowlist entry still matches an actual existing mismatch in the repo. That makes cleanup self-enforcing with zero network calls: the PR that finally fixes the underlying issue cannot merge until the developer also deletes the now-stale allowlist line.

2. An async nightly reconciler instead of a blocking PR gate

If you want issue-status reconciliation too, put the API query in a scheduled nightly job rather than on the PR critical path. When an allowlist entry points to a closed ticket, the nightly job opens a tiny auto-generated cleanup issue or PR targeting the entry. That keeps the pressure on without punishing unrelated PRs or coupling git push to an external SaaS uptime SLA.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The ambush point is the one that settles it — a ticket marked Done at 2pm breaking unrelated branches five minutes later is a worse failure mode than the drift the check was meant to prevent. The split is clean: the hermetic check makes cleanup self-enforcing on the PR that fixes the issue, and the nightly job keeps the pressure without coupling git push to Jira's uptime. Do the nightly cleanup PRs actually get merged in practice, or do they quietly become a second queue nobody reads?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Unowned bot PRs almost always turn into a second Dependabot graveyard—everybody assumes somebody else will click Approve, and thirty notifications later the whole team mutes the bot channel.

The teams whose cleanup PRs get merged treat bot PR design like human attention economics and enforce three rules:

1. Auto-Merge Trivial Deletions When CI Passes

If the actual domain fix has already landed on main, and the nightly job is strictly deleting a now-obsolete allowlist entry, enable automatic merge once the hermetic test suite turns green. Requiring a busy engineer to spend cognitive load reviewing a 1-line allowlist deletion is pure ceremony. If all tests pass without the exemption, the code proves the exemption is dead.

2. When Compliance Requires a Human, Assign the Exact Issue Closer

In regulated shops where branch protection requires a human reviewer, never dump bot PRs into a generic team channel. Have the script read the issue event metadata and assign the PR directly to the person who closed the ticket (or authored the fixing commit). When a 1-line cleanup PR lands in your personal review queue twenty-four hours after you solved the bug, the context is fresh and you click Approve in ten seconds.

3. One Rolling PR, Never Twenty Individual Pings

If five items cleared that day, fold them into one single consolidated hygiene PR rather than firing five separate PR notifications. Engineers will gladly clear one small, clean batch PR during morning coffee; they will mercilessly ignore a flood of twenty fragmented ones.

Combine targeted ownership with green-CI auto-merge (where allowed), and the cleanup queue stays at zero without nagging anybody!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The assign-to-the-issue-closer rule is the one I'd steal — a cleanup PR landing in your own queue a day after you fixed the bug is a ten-second approve, not a chore. Has the auto-merge of trivial deletions ever backfired on you — a case where the hermetic suite was green but the deletion still broke something the tests didn't cover?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Yes—and when it backfires, it is almost always because of anything living outside the hermetic test boundary.

Hermetic suites only prove that current code inside the repository agrees with itself. They cannot see three real-world ghosts:

  1. N-1 / N-2 Mobile Clients in the Wild: An app release from four months ago is still running on thousands of users' phones, sending an old status string or calling a deprecated function signature that your latest code stopped generating.
  2. Out-of-Band Pipelines: A BI reporting job, legacy webhook consumer, or nightly analytics script still expects the old column or enum value.
  3. Remote Config & Cached Flags: A JSON payload sitting in Firebase Remote Config or a Redis cache still references an older identifier.

The Distinction That Prevents Backfires

To make auto-merge safe, you draw a sharp line between Static Hygiene and Wire Compatibility:

  • Static Hygiene (Safe to Auto-Merge): Removing an obsolete entry from a CI drift manifest after the code and database have already been brought into alignment. That deletion changes zero runtime behavior; it strictly updates the static check's baseline.
  • Runtime / Wire Deletions (Never Auto-Merge on Green Tests Alone): Removing a backward-compatibility branch, an API enum fallback, or a deprecated RPC handler. Green hermetic tests prove zero safety for live external callers. Those deletions require telemetry clearance—verifying zero hits in production logs or OpenTelemetry spans across a full client upgrade cycle—plus human sign-off.

Auto-merge works cleanly when the diff only removes static scaffolding. The moment a deletion alters what crosses a network or disk boundary, a human and production telemetry belong in the loop!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The three ghosts are the whole argument — a hermetic suite can only ever prove the repo agrees with itself. The telemetry-clearance rule is the part I'd steal: zero hits across a full client upgrade cycle is a real bar, not a vibe. How long is a full upgrade cycle in practice on your mobile surface — weeks, or does the long tail of old clients make it effectively never?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Without a deliberate floor, the mobile long tail is effectively forever—someone will always have a phone in a drawer running an eighteen-month-old binary.

That is why production mobile teams pair Telemetry Clearance with a scheduled Minimum Supported Version Gate:

  • Track active version distribution until 98–99% of daily sessions have crossed onto the newer build (usually two to six weeks depending on app-store auto-update rollout).
  • Once telemetry confirms the remaining tail is strictly dormant or negligible, bump the minimum-version floor so the old binary prompts a clean upgrade dialog instead of calling deprecated wire endpoints.
  • Only after the floor shifts and telemetry hits zero for that legacy path do you delete the server compatibility adapter.

And with that minimum-version floor, we have walked the entire lifecycle—from a 0ms synchronous computed() signal in Flutter all the way out to schema drift ratchets and mobile deprecation in the wild!

Thanks for pushing this thread into a full production blueprint.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The 98-99% threshold is the honest part — most teams delete the adapter at 'mostly migrated' and then rediscover the tail in their error logs. The floor is what turns deprecation from a hope into a schedule, and it pairs neatly with the computed() point: in both cases the client declares what it can do locally instead of asking something else to figure it out. Good thread — this lifecycle arc is the part I'd want in the article itself.

Collapse
 
paullovelace profile image
Paul Lovelace •

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?

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

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"
}
Enter fullscreen mode Exit fullscreen mode

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 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.

No silent black holes or phantom state!

Collapse
 
onelifevibe profile image
OneLifeVibe •

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.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

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!

Collapse
 
dpietro profile image
Denise Pietro •

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.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

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 argumentSchema before execute() 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:

@override
Object? execute(Map<String, Object?> args, DataContext context, [CancellationSignal? signal]) {
  final rawPrice = args['unitPrice'] as num?;
  if (rawPrice == null) {
    throw ArgumentError.notNull('unitPrice');
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Here is what happens when it throws:

  • 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!

Collapse
 
aifrontierpost profile image
AI Frontier Post •

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.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

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):

  1. Local expressions like calculateCost handle instant preview arithmetic in the UI signal graph.
  2. 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.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.