A Ruby port of Google's A2UI (Agent-to-User Interface) for Rails, using Turbo Streams and DSPy.rb for LLM-driven UI generation.
Status: Early development. APIs will change.
AI-generated health briefing from Garmin data using DSPy.rb signatures
A2UI lets AI agents generate rich, interactive UIs by shipping data and UI descriptions together as structured output, rather than executable code. The client maintains a catalog of trusted components that the agent references by type.
This port maps A2UI concepts to Rails + Turbo:
| A2UI | Rails + Turbo |
|---|---|
| Surface | <turbo-frame> |
surfaceUpdate |
<turbo-stream> |
dataModelUpdate |
Stimulus controller values |
| Component catalog | ViewComponent library |
| JSON adjacency list | Rendered HTML fragments |
┌─────────────────────────────────────────────────────────────────────────────┐
│ A2UI DSPy Pipelines │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CREATE SURFACE │ │
│ │ │ │
│ │ "Create a booking form" ───▶ GenerateUI (ChainOfThought) │ │
│ │ + surface_id │ │ │
│ │ + available_data ┌──────┴──────┐ │ │
│ │ ▼ ▼ │ │
│ │ root_id components[] │ │
│ │ "form-1" [Column, TextField, │ │
│ │ TextField, Button] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ UPDATE SURFACE │ │
│ │ │ │
│ │ "Add phone field" ───▶ UpdateUI (ChainOfThought) │ │
│ │ + current_components │ │ │
│ │ + current_data ┌──────┴──────┐ │ │
│ │ ▼ ▼ │ │
│ │ streams[] new_components[] │ │
│ │ [{action: [TextFieldComponent] │ │
│ │ "after", │ │
│ │ target: │ │
│ │ "email"}] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ HANDLE ACTION │ │
│ │ │ │
│ │ UserAction{name,context} ───▶ HandleAction (ChainOfThought) │ │
│ │ + business_rules │ │ │
│ │ + current_data ┌───────┼───────┐ │ │
│ │ ▼ ▼ ▼ │ │
│ │ response streams data_updates │ │
│ │ _type [] [{path: "/booking", │ │
│ │ :update_ui entries: [...]}] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Signals (Inputs):
request— Natural language describing what to build/changeavailable_data/current_data— JSON data model the UI binds tocurrent_components— Existing component tree for incremental updatesbusiness_rules— Domain constraints for action handling
Decisions (LLM Reasoning via ChainOfThought):
- Component Selection — Which component types fit the request?
- Layout Structure — How to arrange components (Row vs Column, nesting)?
- Data Binding — Which JSON Pointer paths connect to which fields?
- Action Mapping — What context to capture when buttons are clicked?
- Stream Operations — For updates: append, replace, or remove?
Outputs (Structured):
components[]— Flat adjacency list of typed component structsroot_id— Entry point for rendering the treestreams[]— Turbo Stream operations (action + target + content)data_updates[]— Mutations to apply to the data model
The LLM never generates code—only typed data structures that map to trusted ViewComponents.
Add to your Gemfile:
gem 'dspy', '~> 0.34'
gem 'sorbet-runtime'
# Choose your LLM provider:
gem 'dspy-openai' # OpenAI, OpenRouter, Ollama
# gem 'dspy-anthropic' # Claude
# gem 'dspy-gemini' # Gemini# Install A2UI (creates initializer, routes, CSS)
rails generate a2_u_i:install
# Create a new surface
rails generate a2_u_i:surface DashboardCopy the lib/a2ui/ and app/ directories to your Rails app.
Configure DSPy in an initializer:
# config/initializers/dspy.rb
DSPy.configure do |c|
c.lm = DSPy::LM.new('openai/gpt-4o-mini', api_key: ENV['OPENAI_API_KEY'])
endConfigure A2UI (optional):
# config/initializers/a2ui.rb
A2UI.configure do |config|
config.default_model = 'anthropic/claude-sonnet-4-20250514'
config.debug = Rails.env.development?
end# Define typed data models
class BookingData < T::Struct
const :guests, Integer, default: 2
const :date, T.nilable(String)
const :email, T.nilable(String)
end
manager = A2UI::SurfaceManager.new
# Create a surface with typed data
surface = manager.create(
surface_id: 'booking-form',
request: 'Create a booking form with guest count, date picker, and submit button',
data: BookingData.new(guests: 2)
)
# For surfaces without initial data
surface = manager.create(
surface_id: 'welcome',
request: 'Create a welcome message',
data: A2UI::EmptyData.new
)
# Render in your view
render partial: 'a2ui/surface', locals: { surface: surface }# Define typed context for the action
class SubmitBookingContext < T::Struct
const :guests, Integer
const :date, String
end
action = A2UI::UserAction.new(
name: 'submit_booking',
surface_id: 'booking-form',
source_id: 'submit-btn',
context: SubmitBookingContext.new(guests: 3, date: '2025-01-15')
)
result = manager.handle_action(
action: action,
business_rules: 'Maximum 10 guests per booking'
)
# result.response_type => A2UI::ActionResponseType::UpdateUI
# result.streams => [A2UI::StreamOp, ...]
# result.components => [A2UI::Component, ...]result = manager.update(
surface_id: 'booking-form',
request: 'Add a phone number field after the email'
)
# Returns Turbo Stream operations to apply┌─────────────────────────────────────────────────────────────┐
│ Your Rails App │
├─────────────────────────────────────────────────────────────┤
│ │
│ DSPy Signatures DSPy Modules Controllers │
│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ GenerateUI │────────▶│ UIGenerator │─────▶│ Surfaces │ │
│ │ UpdateUI │ │ UIUpdater │ │ Actions │ │
│ │ HandleAction│ │ ActionHndlr │ └───────────┘ │
│ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ A2UI::Components::Renderer ││
│ │ Maps Component structs → ViewComponents ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Turbo Streams / Frames ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
Type-safe interfaces for LLM calls using Sorbet types:
class A2UI::GenerateUI < DSPy::Signature
description 'Generate UI components from natural language.'
input do
const :request, String
const :surface_id, String
const :available_data, String, default: '{}'
end
output do
const :root_id, String
const :components, T::Array[Component] # Union type
const :initial_data, T::Array[DataUpdate], default: []
end
endComponents and values use discriminated unions for type safety:
# Value is either literal or a path reference
A2UI::Value = T.any(A2UI::LiteralValue, A2UI::PathReference)
# Children are either explicit IDs or data-driven
A2UI::Children = T.any(A2UI::ExplicitChildren, A2UI::DataDrivenChildren)
# Component is a union of all component types
A2UI::Component = T.any(
A2UI::TextComponent,
A2UI::ButtonComponent,
A2UI::TextFieldComponent,
A2UI::RowComponent,
A2UI::ColumnComponent,
# ... 13 total
)DSPy automatically handles _type discrimination in LLM responses.
A2UI uses the same type coercion pattern as DSPy.rb for LLM responses:
- Define typed structs for data and action context
- Register schemas when creating surfaces (schemas stay server-side)
- Client sends raw JSON (like LLM responses)
- Server coerces automatically using DSPy's TypeCoercion
This provides compile-time safety and IDE autocomplete without requiring schemas to travel over the wire.
Surface data must be a T::Struct:
class BookingData < T::Struct
const :guests, Integer, default: 2
const :date, T.nilable(String)
end
manager.create(
surface_id: 'booking',
request: 'Create a booking form',
data: BookingData.new(guests: 4)
)
# For surfaces without data
manager.create(
surface_id: 'welcome',
request: 'Create a welcome message',
data: A2UI::EmptyData.new
)Register context types when creating a surface. The server stores schemas internally and coerces incoming JSON automatically:
# Define context types
class SubmitContext < T::Struct
const :guests, Integer
const :date, String
end
class CancelContext < T::Struct
const :reason, T.nilable(String)
end
# Register when creating surface
manager.create(
surface_id: 'booking',
request: 'Create a booking form',
data: BookingData.new,
actions: {
submit: SubmitContext,
cancel: CancelContext
}
)
# Client sends raw JSON, server coerces automatically
action = A2UI::UserAction.new(
name: 'submit',
surface_id: 'booking',
source_id: 'btn',
context: { 'guests' => '3', 'date' => '2025-01-15' }
)
result = manager.handle_action(action: action)
# Context coerced to SubmitContext.new(guests: 3, date: "2025-01-15")For direct coercion (uses DSPy's TypeCoercion):
context = A2UI::TypeCoercion.coerce(
{ 'guests' => '3', 'date' => '2025-01-15' },
SubmitContext
)
# => SubmitContext.new(guests: 3, date: "2025-01-15")Each component type maps to a ViewComponent:
| Struct | ViewComponent | Purpose |
|---|---|---|
TextComponent |
A2UI::Components::Text |
Display text with semantic hints |
ButtonComponent |
A2UI::Components::Button |
Trigger actions |
TextFieldComponent |
A2UI::Components::TextField |
Text input with data binding |
CheckBoxComponent |
A2UI::Components::CheckBox |
Boolean input |
SelectComponent |
A2UI::Components::Select |
Dropdown select |
SliderComponent |
A2UI::Components::Slider |
Range slider input |
RowComponent |
A2UI::Components::Row |
Horizontal flex layout |
ColumnComponent |
A2UI::Components::Column |
Vertical flex layout |
CardComponent |
A2UI::Components::Card |
Container with elevation |
ListComponent |
A2UI::Components::List |
List with data-driven children |
DividerComponent |
A2UI::Components::Divider |
Visual separator |
TabsComponent |
A2UI::Components::Tabs |
Tabbed content |
ModalComponent |
A2UI::Components::Modal |
Modal dialogs |
ImageComponent |
A2UI::Components::Image |
Images with fit modes |
IconComponent |
A2UI::Components::Icon |
Icon display |
Form inputs bind to the data model via JSON Pointer paths:
# Component definition
A2UI::TextFieldComponent.new(
id: 'guest-count',
value: A2UI::PathReference.new(path: '/booking/guests'),
input_type: A2UI::InputType::Number
)
# Renders with Stimulus binding
# <input data-controller="a2ui-binding"
# data-a2ui-binding-path-value="/booking/guests" ...>Buttons dispatch actions with context from the data model:
A2UI::ButtonComponent.new(
id: 'submit',
label: A2UI::LiteralValue.new(value: 'Book Now'),
action: A2UI::Action.new(
name: 'submit_booking',
context: [
A2UI::ContextBinding.new(key: 'booking', path: '/booking')
]
)
)Three controllers handle client-side behavior:
a2ui-data- Manages surface data model (JSON Pointer get/set)a2ui-binding- Two-way binding between inputs and data modela2ui-action- Dispatches user actions to server via fetch
namespace :a2ui do
resources :surfaces, only: [:create, :show, :update, :destroy]
resources :actions, only: [:create]
endbundle exec rspec spec/a2ui/types_spec.rb # Unit tests (no API)
bundle exec rspec spec/a2ui/ # All tests (needs API key + VCR)Integration tests use VCR to record LLM responses:
RSpec.describe A2UI::GenerateUI, :vcr do
it 'generates a booking form' do
generator = A2UI::UIGenerator.new
result = generator.call(
request: 'Create a booking form',
surface_id: 'booking'
)
expect(result.components).not_to be_empty
end
end# Install dependencies
bundle install
# Run tests
bundle exec rspec
# Type check (optional)
bundle exec srb tcFor standalone use without Rails, see the @a2ui/core package:
npm install @a2ui/coreimport { renderSurface, type Surface } from '@a2ui/core';
const surface: Surface = {
id: 'my-surface',
root_id: 'card-1',
components: { /* ... */ },
data: {}
};
document.getElementById('app').innerHTML = renderSurface(surface);See packages/a2ui-js/README.md for full documentation.
- ✅ 15 Component Types — Text, Button, TextField, CheckBox, Select, Slider, Row, Column, Card, List, Divider, Tabs, Modal, Image, Icon
- ✅ Type-Safe Data — Typed
T::Structfor data and action context with DSPy TypeCoercion - ✅ Data-driven Children — Repeat templates from arrays with
DataDrivenChildren - ✅ Evidence Spans — Track LLM reasoning for health predictions and UI decisions
- ✅ Signal Modeling — Detect significant changes in Garmin data and user activity
- ✅ Rails Engine — Mountable engine with configuration
- ✅ Generators —
a2_u_i:installanda2_u_i:surface - ✅ JavaScript Package — Standalone renderer for browser use
- Optimizers for prompt tuning (MIPROv2 for signature optimization)
- Interactive A2UI playground demo app
- Publish to RubyGems and npm
MIT
- Google A2UI - Original specification
- DSPy.rb - Ruby DSPy framework
- Hotwired Turbo - Turbo Streams/Frames
