How to architect web applications where AI agents can observe state, discover available actions, and execute them safely.

Purpose

Define an architecture pattern that enables AI agents to:

  1. Understand what the app does
  2. Observe current state
  3. Discover available actions given the state
  4. Execute actions to modify state

Core Insight: Redux + Finite State Machines

Redux captures the mutation pattern (actions create state changes), but doesn’t capture validity (which actions make sense when).

Finite State Machines (FSM) explicitly define:

  • All possible states
  • Valid transitions between states
  • Guards (conditions for transitions)

The combination gives us:

  • Single source of truth (Redux)
  • Predictable mutations (reducers)
  • Explicit valid actions per state (FSM)
  • Discoverable action catalog (for AI)

Architecture Overview

+-------------------------------------------------------------------------+
| AI Agent (Claude) |
| Receives: { state, availableActions, appDescription } |
| Sends: { action, payload } |
+-------------------------------------+-----------------------------------+
|
v
+-------------------------------------------------------------------------+
| Agent Bridge Layer |
| - Serializes state for AI consumption |
| - Computes available actions from current state |
| - Validates incoming actions |
| - Dispatches valid actions to store |
+-------------------------------------+-----------------------------------+
|
+-------------------------+-------------------------+
v v v
+-------------------+ +-------------------+ +-------------------+
| State Machine | | Redux Store | | Action Registry |
| (XState) | | | | (JSON Schemas) |
| | | - UI state | | |
| - Flow states | | - Data state | | - All actions |
| - Valid actions |<--->| - Derived state |<--->| - Input schemas |
| - Guards | | | | - Descriptions |
+-------------------+ +-------------------+ +-------------------+

The Four Requirements

1. App Description (What the App Does)

Static metadata the AI receives once:

interface AppDescription {
name: string;
purpose: string;
capabilities: string[];
// Entity types the app manages
entities: {
name: string;
description: string;
fields: Record<string, FieldSchema>;
}[];
// High-level flows/features
flows: {
name: string;
description: string;
states: string[];
}[];
}

Example:

const appDescription: AppDescription = {
name: "E-Commerce Store",
purpose: "Online shopping platform for browsing and purchasing products",
capabilities: [
"Browse product catalog",
"Search products",
"Manage shopping cart",
"Complete checkout with shipping and payment"
],
entities: [
{
name: "Product",
description: "Item available for purchase",
fields: {
id: { type: "string" },
name: { type: "string" },
price: { type: "number" },
inStock: { type: "boolean" }
}
},
{
name: "CartItem",
description: "Product in shopping cart with quantity",
fields: {
productId: { type: "string" },
quantity: { type: "number" }
}
}
],
flows: [
{
name: "Checkout",
description: "Purchase flow from cart to order confirmation",
states: ["cart", "shipping", "payment", "confirmation"]
}
]
};

2. Observable State (Current State)

What AI sees on every turn:

interface ObservableState {
// Current state machine state(s)
machineState: {
current: string; // e.g., "checkout.shipping"
context: Record<string, any>;
};
// Relevant data state (filtered/summarized)
data: {
user: { id: string; name: string; } | null;
cart: { items: number; total: number; };
currentView: string;
};
// UI state
ui: {
modals: string[]; // Open modals
loading: string[]; // Loading indicators
errors: string[]; // Active errors
focused: string | null; // Current focus
};
}

Key principle: Don’t expose raw Redux state. Serialize a summarized, AI-friendly view that contains what’s relevant for decision-making.

3. Available Actions (State-Dependent)

Computed dynamically from current state:

interface AvailableAction {
name: string;
description: string;
inputSchema: JSONSchema; // For payload validation
reason?: string; // Why this action is available
effects?: string[]; // What this action will do
}
function getAvailableActions(state: ObservableState): AvailableAction[] {
const actions: AvailableAction[] = [];
// Always available (global actions)
actions.push({
name: 'navigate',
description: 'Navigate to a different page',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Route path like /dashboard' }
},
required: ['path']
}
});
// State-dependent actions
if (state.machineState.current === 'checkout.cart') {
if (state.data.cart.items > 0) {
actions.push({
name: 'proceed_to_shipping',
description: 'Continue to shipping information',
inputSchema: { type: 'object', properties: {} },
reason: 'Cart has items',
effects: ['Will transition to shipping form']
});
}
actions.push({
name: 'update_cart_item',
description: 'Change quantity of an item in cart',
inputSchema: {
type: 'object',
properties: {
itemId: { type: 'string' },
quantity: { type: 'number', minimum: 0 }
},
required: ['itemId', 'quantity']
},
effects: ['Updates cart total', 'quantity=0 removes item']
});
}
if (state.machineState.current === 'checkout.shipping') {
actions.push({
name: 'submit_shipping',
description: 'Submit shipping address and continue',
inputSchema: {
type: 'object',
properties: {
address: { type: 'string' },
city: { type: 'string' },
state: { type: 'string' },
zip: { type: 'string' }
},
required: ['address', 'city', 'state', 'zip']
},
effects: ['Validates address', 'Transitions to payment']
});
actions.push({
name: 'go_back_to_cart',
description: 'Return to cart to modify items',
inputSchema: { type: 'object' },
effects: ['Returns to cart view']
});
}
return actions;
}

Why this matters for AI:

  • AI knows exactly what it CAN do (no guessing)
  • JSON schemas enable structured payload generation
  • Reasons/effects help AI make informed decisions

4. Action Execution

interface ActionResult {
success: boolean;
newState: ObservableState;
newAvailableActions: AvailableAction[];
changes?: {
type: 'state_transition' | 'data_update' | 'ui_change';
description: string;
}[];
error?: string;
}
async function executeAction(
actionName: string,
payload: any
): Promise<ActionResult> {
const oldState = getCurrentState();
const available = getAvailableActions(oldState);
// 1. Validate action is available
const action = available.find(a => a.name === actionName);
if (!action) {
return {
success: false,
error: `Action "${actionName}" not available in current state`,
newState: oldState,
newAvailableActions: available
};
}
// 2. Validate payload against schema
const validation = validateSchema(payload, action.inputSchema);
if (!validation.valid) {
return {
success: false,
error: `Invalid payload: ${validation.errors.join(', ')}`,
newState: oldState,
newAvailableActions: available
};
}
// 3. Execute: dispatch to Redux + State Machine
try {
store.dispatch({ type: actionName, payload });
stateMachine.send({ type: actionName, ...payload });
// 4. Wait for async effects
await waitForEffects();
// 5. Compute new state
const newState = getCurrentState();
return {
success: true,
newState,
newAvailableActions: getAvailableActions(newState),
changes: computeChanges(oldState, newState)
};
} catch (err) {
return {
success: false,
error: err.message,
newState: getCurrentState(),
newAvailableActions: getAvailableActions(getCurrentState())
};
}
}

AI Tool Interface

What Claude receives as tools:

const tools = [
{
name: "get_app_info",
description: "Get description of what this app does and its capabilities",
input_schema: { type: "object", properties: {} }
},
{
name: "get_current_state",
description: "Get current state and list of available actions",
input_schema: { type: "object", properties: {} }
},
{
name: "execute_action",
description: "Execute an action from the available actions list",
input_schema: {
type: "object",
properties: {
action: {
type: "string",
description: "Action name (must be in available actions)"
},
payload: {
type: "object",
description: "Action payload matching the action's input schema"
}
},
required: ["action"]
}
}
];

Example Interaction Flow

User: "Add the blue shirt to my cart and checkout"
AI: [calls get_current_state]
-> {
machineState: { current: "browsing" },
data: { cart: { items: 0, total: 0 } },
availableActions: ["search", "view_product", "add_to_cart", "navigate"]
}
AI: [calls execute_action { action: "add_to_cart", payload: { productId: "blue-shirt-123" } }]
-> {
success: true,
changes: [{ type: "data_update", description: "Added 1 item to cart" }],
newState: { data: { cart: { items: 1, total: 29.99 } } }
}
AI: [calls execute_action { action: "navigate", payload: { path: "/checkout" } }]
-> {
success: true,
machineState: { current: "checkout.cart" },
availableActions: ["proceed_to_shipping", "update_cart_item", "remove_item"]
}
AI: [calls execute_action { action: "proceed_to_shipping", payload: {} }]
-> {
success: true,
machineState: { current: "checkout.shipping" },
availableActions: ["submit_shipping", "go_back_to_cart"]
}
AI: "I've added the blue shirt ($29.99) to your cart and started checkout.
I'm now at the shipping step. Would you like me to fill in your
shipping address, or would you like to enter it yourself?"

Architecture Patterns Comparison

ApproachProsCons
Redux onlySimple, familiarActions always “available”, ad-hoc validation
XState/FSM onlyExplicit transitionsComplex for data-heavy apps
Redux + FSM hybridBest of bothTwo systems to synchronize
Actor ModelDistributed, message-basedHarder to observe globally

Recommendation: Redux + FSM hybrid for most cases.

Security Considerations

Action Allowlisting

Only expose safe actions. Never expose:

  • Arbitrary code execution
  • Direct DOM manipulation
  • Unrestricted API calls
  • Admin operations without confirmation

User Confirmation for Sensitive Actions

const sensitiveActions = ['delete_account', 'place_order', 'send_payment'];
async function executeWithConfirmation(action: string, payload: any) {
if (sensitiveActions.includes(action)) {
const confirmed = await showConfirmDialog(
`AI wants to: ${action}`,
`Payload: ${JSON.stringify(payload)}`
);
if (!confirmed) {
return { success: false, error: 'User cancelled' };
}
}
return executeAction(action, payload);
}

Rate Limiting

const rateLimiter = new RateLimiter({
maxCalls: 10,
windowMs: 1000
});
async function executeWithRateLimit(action: string, payload: any) {
if (!rateLimiter.allow()) {
return {
success: false,
error: 'Rate limited',
retryAfterMs: rateLimiter.retryAfter()
};
}
return executeAction(action, payload);
}

Implementation with XState

Example XState machine for checkout flow:

import { createMachine, assign } from 'xstate';
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
context: {
items: [],
shipping: null,
payment: null
},
states: {
cart: {
on: {
UPDATE_ITEM: {
actions: assign({
items: (ctx, event) => updateItem(ctx.items, event.itemId, event.quantity)
})
},
PROCEED_TO_SHIPPING: {
target: 'shipping',
guard: 'hasItems'
}
}
},
shipping: {
on: {
SUBMIT_SHIPPING: {
target: 'payment',
actions: assign({ shipping: (_, event) => event.address }),
guard: 'validAddress'
},
GO_BACK_TO_CART: 'cart'
}
},
payment: {
on: {
SUBMIT_PAYMENT: {
target: 'confirmation',
actions: assign({ payment: (_, event) => event.paymentMethod }),
guard: 'validPayment'
},
GO_BACK_TO_SHIPPING: 'shipping'
}
},
confirmation: {
type: 'final'
}
}
}, {
guards: {
hasItems: (ctx) => ctx.items.length > 0,
validAddress: (_, event) => validateAddress(event.address),
validPayment: (_, event) => validatePayment(event.paymentMethod)
}
});

Benefits of This Architecture

  1. Discoverability - AI always knows what actions are possible
  2. Safety - Invalid actions rejected before execution
  3. Transparency - AI understands why actions are/aren’t available
  4. Determinism - Same state + action = same result
  5. Debuggability - Full action history, state snapshots
  6. Human oversight - Confirmation for sensitive actions
  7. Testability - State machines are highly testable

Sources

  1. XState Documentation
  2. Redux Style Guide
  3. State Machines in UI - David Khourshid
  4. Finite State Machines in Game AI

Created: 2025-12-13