How Agent-Native Communicates with the UI Using Shared SQL State

Agent-Native uses a single SQL table called application_state as a shared data layer, where both the LLM agent and the UI read and write JSON values through helper functions, keeping both sides synchronized via lightweight polling.

The BuilderIO/agent-native framework treats the frontend and the AI agent as peers that interact through a transactional database rather than websockets or direct function calls. This architecture enables persistent, scoped state that survives page reloads while remaining observable by both the backend logic and the React components.

The Shared SQL State Architecture

At the core of this communication pattern is a dedicated SQL table that acts as a bidirectional message bus.

The application_state Table Schema

The framework automatically creates an application_state table defined in packages/core/src/db/schema.ts. This table stores:

  • key – A unique identifier for the state slice (e.g., "navigation", "compose-window")
  • value – A JSON blob containing the actual state data
  • session_id – Scopes the data to the current user session
  • updated_at – A timestamp used for incremental polling
  • request_source – Tags writes as either "agent" or "ui" to prevent race conditions

Because the state lives in SQL, it is transactional, durable, and queryable by both the agent and the UI without requiring additional infrastructure.

Helper Functions for State Management

Both the agent and the UI import the same API from @agent-native/core/application-state:

  • readAppState(key) – Retrieves the current JSON value for a given key
  • writeAppState(key, value) – Upserts a JSON value into the table
  • deleteAppState(key) – Removes the state entry

These functions issue Nitro API calls to the internal route /_agent-native/application-state/:key, ensuring a consistent interface regardless of which side is calling.

How the Agent Writes to Shared State

When the LLM agent needs to trigger a UI change—such as navigating to a new view—it writes to the shared table using the helper functions.

Reading the current navigation state:

// templates/plan/actions/view-screen.ts
import { readAppState } from "@agent-native/core/application-state";

const navigation = await readAppState("navigation");   // ← reads the SQL row

Writing a navigation command:

// templates/plan/actions/navigate.ts
import { writeAppState } from "@agent-native/core/application-state";

await writeAppState("navigate", { command: "go", view: args.view });

When the agent calls writeAppState, the server implementation in packages/core/src/resources/script-helpers.ts inserts a row with request_source: "agent", ensuring the UI can distinguish agent-initiated updates from its own local changes.

How the UI Reads and Subscribes to State

The frontend uses thin React hooks that fetch the initial state and then maintain a live subscription through polling.

Fetching Initial State

On component mount, the UI fetches the current value directly from the application-state endpoint:

// templates/plan/app/hooks/use-navigation-state.ts
fetch("/_agent-native/application-state/navigation")
  .then(r => r.json())
  .then(setNav);

Real-time Polling Mechanism

To receive updates without websockets, the UI polls the dedicated poll endpoint every few hundred milliseconds. The implementation in templates/plan/app/hooks/use-navigation-state.ts demonstrates this pattern:

// Poll for updates
useEffect(() => {
  const id = setInterval(() => {
    fetch("/_agent-native/poll?tables=application_state")
      .then(r => r.json())
      .then(changes => {
        const updated = changes.find(c => c.key === "navigation");
        if (updated) setNav(updated.value);
      });
  }, 500);
  return () => clearInterval(id);
}, []);

This polling strategy ensures that any write from the agent appears in the UI within milliseconds, while remaining resilient to network interruptions.

Server-Side Implementation

The synchronization relies on two key server-side components implemented in the core package.

The Poll Endpoint

Located in packages/core/src/server/poll.ts, the poll endpoint queries for rows with an updated_at timestamp newer than the client's last known value:

// packages/core/src/server/poll.ts
export async function poll({ db, tables, after }) {
  // … fetch max timestamps …
  if (tables.includes("application_state")) {
    const rows = await db
      .select()
      .from(application_state)
      .where(gt(application_state.updated_at, after));
    return rows;
  }
  // …
}

This allows the UI to receive only the deltas that have changed since its last poll, making the mechanism efficient even with frequent polling.

Handling Write Operations

The writeAppState function in packages/core/src/resources/script-helpers.ts handles the database insertion with conflict resolution:

// Simplified from script-helpers.ts
export async function writeAppState(key: string, value: any, ctx) {
  await db
    .insert(application_state)
    .values({
      key,
      value: JSON.stringify(value),
      session_id: ctx.sessionId,
      request_source: ctx.requestSource ?? "ui",
    })
    .onConflictDoUpdate({ 
      target: application_state.key, 
      set: { value, updated_at: sql`now()` } 
    });
}

Preventing Race Conditions with Request Source Tagging

Every write to application_state is tagged with a request_source field identifying the origin as either "agent" or "ui". This tagging prevents feedback loops where a UI write would trigger a poll update that immediately overwrites the local state, or vice versa.

When the UI polls the /_agent-native/poll endpoint, it can filter out changes that originated from its own request_source, ensuring that updates are only applied when they come from the opposite side. This guarantees that the agent and UI maintain a consistent view of the shared state without collision or double-processing.

Summary

  • Agent-Native uses a single SQL table application_state as the source of truth for transient UI state.
  • Both the agent and UI use identical helper functions (readAppState, writeAppState) that map to Nitro API endpoints.
  • The UI synchronizes via polling the /_agent-native/poll endpoint, which returns only changed rows based on updated_at timestamps.
  • Every write is tagged with request_source to prevent race conditions between the agent and the frontend.
  • Because state lives in SQL, it is transactional, survives page reloads, and requires no websocket infrastructure.

Frequently Asked Questions

How does Agent-Native avoid using websockets for real-time updates?

Agent-Native implements a lightweight polling mechanism where the UI calls the /_agent-native/poll endpoint every few hundred milliseconds. The server returns only rows from application_state with an updated_at timestamp newer than the client's last poll. This approach eliminates the need for persistent websocket connections while maintaining near real-time synchronization between the agent and the UI.

What prevents the UI from overwriting its own state when polling?

Each write to the application_state table includes a request_source column set to either "agent" or "ui". When the UI polls for updates, it can identify and ignore rows where request_source matches its own origin, preventing local writes from immediately bouncing back through the polling mechanism and causing race conditions.

Can multiple browser tabs share the same application state?

The application_state table is scoped by session_id, meaning state is tied to the user's session rather than a specific browser tab. However, because all tabs sharing the same session ID would read from the same SQL rows, they would naturally synchronize through the same polling mechanism, keeping the UI consistent across tabs.

Where is the application_state table schema defined in the codebase?

The table schema is defined in packages/core/src/db/schema.ts within the core package. This schema establishes the columns (key, value, session_id, updated_at, request_source) that enable the shared state pattern, and the framework automatically creates this table during the database migration process.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →