Skip to content

Latest commit

 

History

History
382 lines (282 loc) · 15.3 KB

File metadata and controls

382 lines (282 loc) · 15.3 KB

A2UI Elixir — Roadmap

Core protocol layer, data binding, 18 built-in components, LiveView integration, Local transport, and demo app are complete. See the codebase and README for current functionality.

This document is the implementation plan for remaining work — organized as independent phases that can be built and shipped incrementally.


Architecture: Network Transports

The A2UI v0.9 spec is transport-agnostic. It defines the JSON envelope format and message semantics but does not prescribe specific transport bindings. The spec lists these as viable options:

Transport Type Status
Local Erlang messages Done (A2UI.Transport.Local)
SSE + JSON-RPC HTTP transport Done (A2UI.Plug, A2UI.Plug.SSE, A2UI.Plug.JSONRPC)
A2A Protocol adapter Done (A2UI.A2A, A2UI.Transport.A2A)
WebSocket HTTP transport Phase 3
REST HTTP transport Phase 4
AG-UI Protocol adapter Phase 5
MCP Protocol adapter Phase 6 (future — spec needs more detail)

Connection Abstraction

A2UI.Connection is an opaque handle representing a connected client. The agent receives it in handle_connect/2 and passes it to send_message/2 — the agent never knows which transport is in use.

External Client ←──HTTP/WS──→ Handler Process ←──Erlang msgs──→ A2UI Agent
                               (A2UI.Connection)
  • A2UI.Connection struct: id, transport (module), ref, pid
  • A2UI.Agent.send_message/2 dispatches through transport.deliver_message(ref, msg)
  • A2UI.Transport behaviour includes deliver_message/2 (agent→client direction)
  • Agent tracks connections as %{id => Connection.t()} with Process.monitor
  • Dispatch helpers: A2UI.Transport.send_action/3, .send_error/3, .disconnect/1

Transport Contract (from v0.9 spec)

All transports must fulfill:

  1. Reliable, ordered delivery — messages arrive in generation order
  2. Message framing — clear boundaries between JSON envelopes (newline-delimited JSON, SSE events, or WebSocket frames)
  3. Metadata support — data model sync (a2uiClientDataModel) and capabilities exchange (a2uiClientCapabilities)
  4. Bidirectional capability (optional) — return channel for action messages

Phase 1: SSE + JSON-RPC Transport

Primary network transport for web clients. Server→client via SSE stream, client→server via JSON-RPC 2.0 POST.

Modules

  • A2UI.Plug — Plug router, guarded with Code.ensure_loaded?(Plug)

    • GET /sse → SSE connection endpoint
    • POST /rpc → JSON-RPC endpoint
    • Options: :agent (required), :sse_path, :rpc_path
  • A2UI.Plug.SSE — SSE connection handler

    • On GET: generate unique connection ID, create A2UI.Connection
    • Connects to agent via {:a2ui_connect, %Connection{}}
    • Receives {:a2ui_deliver, msg}Message.to_json/1 → SSE data: event
    • SSE event format: id: <seq>\ndata: <json>\n\n
    • Keep-alive pings every 30s
    • Initial SSE event includes connectionId for client to use in JSON-RPC
  • A2UI.Plug.JSONRPC — JSON-RPC request handler

    • Method a2ui.action: params {connectionId, action} → parse Action → forward to agent via handler pid
    • Method a2ui.error: params {connectionId, error} → parse Error → forward to agent via handler pid
    • Returns JSON-RPC 2.0 success/error responses
    • Looks up handler pid from connection registry
  • A2UI.Plug.ConnectionRegistry — ETS-based registry

    • Maps connection_id → handler_pid
    • Created lazily in A2UI.Plug.init/1
    • Auto-cleanup when handler process exits (via monitor)

Wire Format

Server→client (SSE events):

id: 1
data: {"version":"v0.9","createSurface":{"surfaceId":"main","catalogId":"basic"}}

id: 2
data: {"version":"v0.9","updateComponents":{"surfaceId":"main","components":[...]}}

Client→server (JSON-RPC POST):

{"jsonrpc":"2.0","method":"a2ui.action","params":{"connectionId":"abc123","action":{"name":"submit","surfaceId":"main","sourceComponentId":"btn1","timestamp":"2025-01-01T00:00:00Z","context":{}}},"id":1}

Dependencies

No new required deps. Plug is already transitive via phoenix_live_view.

Tests

  • test/a2ui/plug/sse_test.exs — start agent, open SSE, verify events stream
  • test/a2ui/plug/json_rpc_test.exs — POST actions, verify JSON-RPC responses
  • test/a2ui/plug_test.exs — routing, 404s, method not allowed

Phase 2: A2A Transport Binding

Uses the A2A (Agent-to-Agent) protocol as transport between a LiveView renderer and a remote A2UI agent. Enables the core agentic use case: AI agents rendering UI across process or network boundaries.

The v0.9 spec defines A2A as a first-class transport binding:

  • Each A2UI envelope maps to a single A2A.Part.Data in an A2A message
  • a2uiClientDataModel placed in A2A message metadata when sendDataModel is active
  • a2uiClientCapabilities placed in metadata of every client→server message
  • A2UI sessions map to A2A contextId

Client Side — A2UI.Transport.A2A

Implements the A2UI.Transport behaviour. Allows a LiveView to connect to a remote A2UI agent over A2A protocol (HTTP JSON-RPC + SSE streaming).

  • A2UI.Transport.A2A — A2A client transport
    • connect/1 — accepts :url or :client (pre-built A2A.Client), sends initial A2A message, spawns handler process
    • Extracts A2UI envelopes from A2A.Part.Data parts in the response
    • Delivers each as {:a2ui_message, parsed_struct} to the LiveView
    • send_action/3 — sends A2A message with Action as Part.Data
    • send_error/3 — sends A2A message with Error as Part.Data
    • disconnect/1 — cancels the A2A task if still running
    • Manages A2A task lifecycle: continues tasks via task_id on input_required state

Server Side — A2UI.A2A

Wraps an A2UI.Agent so it can be served over A2A protocol. An A2A client (including A2UI.Transport.A2A above) can connect to it.

  • A2UI.A2A — adapter macro
    • use A2UI.A2A, agent: MyApp.UIAgent generates an A2A.Agent that wraps the given A2UI.Agent
    • On handle_message/2: extracts A2UI actions/errors from incoming Part.Data parts → creates A2UI.Connection → forwards to the A2UI agent
    • Collects responses via sync fence → wraps as A2A.Part.Data
    • Returns {:input_required, parts} to keep the task open for more turns
    • First message in a task triggers handle_connect; subsequent messages with the same task_id route to handle_action

A2A ↔ A2UI Message Mapping

A2A Message (client → server)              A2UI Message
─────────────────────────────              ─────────────
Part.Data with action JSON        →        Action struct
Part.Data with error JSON         →        Error struct
metadata.a2uiClientDataModel      →        data model sync
metadata.a2uiClientCapabilities   →        capabilities exchange
contextId                         →        session/connection ID

A2A Message (server → client)              A2UI Message
─────────────────────────────              ─────────────
Part.Data with createSurface      ←        CreateSurface
Part.Data with updateComponents   ←        UpdateComponents
Part.Data with updateDataModel    ←        UpdateDataModel
Part.Data with deleteSurface      ←        DeleteSurface

Wire Format

A2UI envelope embedded as A2A Data part:

{
  "role": "ROLE_AGENT",
  "parts": [
    {"kind": "data", "data": {"version":"v0.9","createSurface":{"surfaceId":"main","catalogId":"basic"}}}
  ],
  "metadata": {}
}

Client action sent as A2A message:

{
  "role": "ROLE_USER",
  "parts": [
    {"kind": "data", "data": {"name":"submit","surfaceId":"main","sourceComponentId":"btn1","timestamp":"...","context":{}}}
  ],
  "metadata": {
    "a2uiClientCapabilities": {"supportedCatalogIds": ["https://a2ui.org/specification/v0_9/basic_catalog.json"]},
    "a2uiClientDataModel": {"surfaces": {"main": {"reservation": {"name": "Alice"}}}}
  }
}

Dependencies

  • {:a2a, "~> 0.2", optional: true} — guarded with Code.ensure_loaded?(A2A.Agent) / Code.ensure_loaded?(A2A.Client)

Tests

  • test/a2ui/transport/a2a_test.exs — client transport: connect, receive messages, send actions, multi-turn
  • test/a2ui/a2a_test.exs — server adapter: connect, action forwarding, error forwarding, cancel, multi-turn

Phase 3: WebSocket Transport (was Phase 2)

Full-duplex communication over a single WebSocket connection. Simpler than SSE + JSON-RPC since both directions use the same connection.

Modules

  • A2UI.Plug.WebSocketWebSock behaviour handler
    • On upgrade: generate connection ID, connect to agent via {:a2ui_connect, self()}
    • Receives WS text frame → Message.from_json/1 → forward to agent as {:a2ui_action, action, metadata} or {:a2ui_error, error, metadata}
    • Receives {:a2ui_message, msg}Message.to_json/1 → send as WS text frame
    • On WS close: {:a2ui_disconnect, self()}

Wire Format

Both directions use the same JSON envelope format as the protocol spec:

{"version":"v0.9","updateComponents":{"surfaceId":"main","components":[...]}}
{"name":"submit","surfaceId":"main","sourceComponentId":"btn1","timestamp":"...","context":{}}

Dependencies

  • {:websock, "~> 0.5", optional: true} — or rely on it being available via Bandit/Phoenix

Tests

  • test/a2ui/plug/websocket_test.exs — start Bandit, connect WS client, verify bidirectional messages

Phase 4: REST Transport (was Phase 3)

Stateless HTTP for simple integrations, webhooks, and non-streaming clients.

Modules

  • A2UI.Plug.REST — stateless HTTP handler
    • POST /action — receive Action JSON, return response messages as JSON array
    • Implementation: spawn temporary proxy process → connect to agent → send action → collect response messages (with configurable timeout) → disconnect → return
    • POST /connect — optional: create surface, return initial messages
    • POST /disconnect — optional: clean up

Limitations

  • No streaming — client gets a batch of response messages, not a live stream
  • Timing-dependent: proxy process waits for a configurable window (e.g. 100ms) to collect agent responses
  • Not suitable for long-running agent interactions
  • Good for: webhooks, simple form submissions, testing, one-shot queries

Tests

  • test/a2ui/plug/rest_test.exs — POST action, verify response contains expected messages

Phase 5: AG-UI Protocol Adapter (was Phase 4)

Maps A2UI protocol to CopilotKit's AG-UI event-based protocol. Enables A2UI agents to serve AG-UI compatible frontends.

AG-UI uses a single POST / endpoint. The client sends RunAgentInput JSON, the server responds with an SSE stream of typed events. AG-UI defines 16 event types organized into lifecycle, text, tool, state, and custom categories.

Modules

  • A2UI.AGUI.Plug — AG-UI endpoint

    • POST / — receives RunAgentInput, starts SSE event stream
    • Spawns handler process (proxy pid) → connects to agent
    • Wraps A2UI message flow in AG-UI lifecycle: RUN_STARTED → events → RUN_FINISHED
  • A2UI.AGUI.EventMapper — A2UI → AG-UI event translation

    • CreateSurfaceSTATE_SNAPSHOT (surface as initial state)
    • UpdateComponentsSTATE_DELTA (RFC 6902 JSON Patch for component changes)
    • UpdateDataModelSTATE_DELTA (JSON Patch for data model changes)
    • DeleteSurfaceSTATE_DELTA (remove surface key)
    • Custom events for A2UI-specific semantics that don't map cleanly
  • A2UI.AGUI.InputMapper — AG-UI → A2UI input translation

    • RunAgentInput.messages → A2UI Action messages
    • RunAgentInput.state → A2UI data model (surface state sync)
    • RunAgentInput.tools → A2UI capabilities metadata
  • A2UI.AGUI.Encoder — SSE event encoding

    • Encodes AG-UI events as SSE: event: <type>\ndata: <json>\n\n
    • Event types: RUN_STARTED, RUN_FINISHED, RUN_ERROR, STATE_SNAPSHOT, STATE_DELTA, CUSTOM, etc.

AG-UI Event Mapping Detail

A2UI Agent Flow                    AG-UI Event Stream
──────────────                     ──────────────────
connect                         → RUN_STARTED {threadId, runId}
CreateSurface{surfaceId, ...}   → STATE_SNAPSHOT {snapshot: {surfaces: {<id>: ...}}}
UpdateComponents{surfaceId, ..} → STATE_DELTA {delta: [{op:"replace", path:"/surfaces/<id>/components", value:...}]}
UpdateDataModel{surfaceId, ..}  → STATE_DELTA {delta: [{op:"replace", path:"/surfaces/<id>/dataModel/<path>", value:...}]}
DeleteSurface{surfaceId}        → STATE_DELTA {delta: [{op:"remove", path:"/surfaces/<id>"}]}
disconnect                      → RUN_FINISHED {threadId, runId}
error                           → RUN_ERROR {message, code}

Wire Format

Request:

POST /
Content-Type: application/json
Accept: text/event-stream

{"threadId":"t1","runId":"r1","state":{},"messages":[{"id":"m1","role":"user","content":"..."}],"tools":[],"context":[],"forwardedProps":{}}

Response (SSE):

event: RUN_STARTED
data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}

event: STATE_SNAPSHOT
data: {"type":"STATE_SNAPSHOT","snapshot":{"surfaces":{"main":{"catalogId":"basic","components":{},"dataModel":{}}}}}

event: STATE_DELTA
data: {"type":"STATE_DELTA","delta":[{"op":"replace","path":"/surfaces/main/components","value":{...}}]}

event: RUN_FINISHED
data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}

Dependencies

No external deps — AG-UI is SSE-based, uses the same Plug chunked response pattern as Phase 1. No Elixir AG-UI SDK exists, so we implement from spec.

Tests

  • test/a2ui/agui/event_mapper_test.exs — unit tests for A2UI → AG-UI mapping
  • test/a2ui/agui/input_mapper_test.exs — unit tests for AG-UI → A2UI mapping
  • test/a2ui/agui/plug_test.exs — integration: POST RunAgentInput, verify SSE events

Phase 6: MCP Adapter (Future, was Phase 5)

Map A2UI to Model Context Protocol for AI agents that use MCP tool calls.

The v0.9 spec mentions MCP as "delivered as tool outputs or resource subscriptions" but provides no detailed binding. This phase is deferred until the spec provides more guidance.

Likely Shape

  • Separate package: a2ui_mcp
  • A2UI messages as MCP tool results with structured content
  • MCP tool calls → A2UI actions
  • MCP resource subscriptions → A2UI surface streams
  • Depends on an MCP Elixir library (e.g. mcp)

Out of Scope

These are not planned for this library:

  • Agent logicA2UI.Agent provides connection/lifecycle scaffolding; business logic uses GenServer, langchain, etc.
  • Custom component catalogs — renderers can be extended via A2UI.ComponentRenderer behaviour, but catalog hosting is not this library's concern