AG-UI Relay
AG-UI is the event protocol between an agent backend and the application a person is using. Poetry is the Rails client of it: your controller runs the agent, folds its events into a transcript, and relays every change into the page as Turbo Streams, while the page's own components answer the agent's tool calls. The AG-UI Relay demo runs the whole loop against a scripted agent; this guide is the recipe behind it.
What it is
An AG-UI run is a stream of events: RUN_STARTED, text and
reasoning deltas, tool calls with their arguments and results, shared
state as snapshots and JSON Patch deltas, activities, and
RUN_FINISHED, which may end on an interrupt the user answers
before the next run. Rails already had the server side twice and no client.
Poetry::Agent::AGUI is that client, in three pieces:
Clientruns an agent endpoint over Net::HTTP and yields each event as it arrives.Transcriptfolds events into chat-shaped messages. Each message'spartsare text, reasoning, tool calls carrying their state, or activities; the transcript also holds the sharedstate, theinterrupts, the tool calls waiting on the browser, andmessages_for_inputfor the next run.Relayturns each change into a Turbo Stream through your own partials: an append into the scroller when a message first appears, a versioned replace after. Every frame carriesdata-versionand the runtime applies only strictly newer frames, so an out-of-order delivery never paints an older state over a newer one.
Run an agent
One ActionController::Live action does the work. It opens an
event stream, builds the run input from the user's message and the tools
the page offers, runs the client, and writes each stream the relay
returns. When the run ends with a tool call the browser must execute, the
relay's closing streams hand it over.
# app/controllers/assistant_controller.rb
class AssistantController < ApplicationController
include ActionController::Live
SCROLLER = "assistant-scroller-messages" # MessageScroller renders "<id>-messages"
def stream
response.headers["Content-Type"] = "text/event-stream"
response.headers["X-Accel-Buffering"] = "no"
transcript = Poetry::Agent::AGUI::Transcript.new(client_tools: frontend_tools.map { |tool| tool["name"] })
relay = Poetry::Agent::AGUI::Relay.new(
transcript: transcript, container: SCROLLER, morph: true,
render: ->(message, version) { render_to_string(partial: "assistant/row", locals: { message:, version: }) },
append_render: ->(message, version) { render_to_string(partial: "assistant/item", locals: { message:, version: }) }
)
input = Poetry::Agent::AGUI::RunInput.build(
thread_id: params[:thread], tools: frontend_tools,
messages: [Poetry::Agent::AGUI::RunInput.user_message(params[:q])]
)
client = Poetry::Agent::AGUI::Client.new(url: ENV.fetch("AGENT_URL"),
headers: { "Authorization" => "Bearer #{ENV.fetch("AGENT_TOKEN")}" })
client.run(input) do |event|
relay.apply(event).each { |html| write(html) }
end
relay.client_tool_streams(continue_url: assistant_continue_path(thread: params[:thread])).each { |html| write(html) }
ensure
response.stream.close
end
private
# The page's Tabs (rendered with webmcp: "sections") as the agent's tools.
def frontend_tools
Poetry::Ui::Tabs::Component.tool_definitions.map do |definition|
Poetry::Agent::AGUI.tool_descriptor("sections", definition)
end
end
def write(html)
response.stream.write(Poetry::Agent::AGUI::TurboStream.sse(html))
end
end
morph: true makes the replaces morph, so anything with local
state inside a row (a surface the agent painted, a tab the reader picked)
survives the row re-rendering around it. The relay needs to know which rows
the page already holds: after rendering history server-side, call
relay.mark_seen(transcript.messages.map(&:id)) so those rows
replace instead of appending twice.
Render the transcript
The page holds a MessageScroller and a stream source pointing at the action
above. Two partials do the rendering: the item wraps a row in a scroller
item on its first appearance, and the row is what every later change
replaces. A row reads message.parts and renders each kind with
the chat components.
<%= poetry_tabs(label: "Plans", webmcp: "sections") do |tabs| %>
<% tabs.with_tab("Overview", value: "overview") { "..." } %>
<% tabs.with_tab("Pricing", value: "pricing") { "..." } %>
<% end %>
<%= poetry_message_scroller(id: "assistant-scroller", class: "h-96 rounded-lg border") do %>
<% @transcript.messages.each do |message| %>
<%= render "assistant/item", message: message, version: message.version %>
<% end %>
<% end %>
<turbo-stream-source src="<%= assistant_stream_path(thread: @thread, q: @question) %>"></turbo-stream-source>
<%# app/views/assistant/_item.html.erb - the first appearance: a scroller item around the row %>
<%= poetry_message_scroller_item(id: "item-#{message.id}") do %>
<%= render "assistant/row", message: message, version: version %>
<% end %>
<%# app/views/assistant/_row.html.erb - every later change replaces this element %>
<div id="row-<%= message.id %>" data-version="<%= version %>">
<%= poetry_message(align: message.role == "user" ? :end : :start) do %>
<% message.parts.each do |part| %>
<% case part[:kind] %>
<% when :text %>
<%= poetry_bubble { part[:text] } %>
<% when :reasoning %>
<p class="text-xs italic text-muted-foreground"><%= part[:text] %></p>
<% when :tool %>
<code><%= part[:name] %></code>
<%= poetry_badge { part[:state] } %> <%# :loading, :awaiting_client, :done, :error %>
<% when :activity %>
<%= render "assistant/activity", part: part %>
<% end %>
<% end %>
<% end %>
</div>
Tool parts carry a state: :loading while arguments stream,
:awaiting_client when the browser must execute the call,
:done with the output, :error
when the agent reported one. Chunked argument deltas parse into
input once complete. Shared state folds into
transcript.state through STATE_SNAPSHOT and
STATE_DELTA patches, so a sidebar can render from it beside
the messages.
Frontend tools
A component's declared tools (the same ones WebMCP
registers with the browser) become the agent's tools without a WebMCP
browser. Poetry::Agent::AGUI.tool_descriptor("sections", definition)
turns a rendered instance's declaration into the descriptor
RunInput advertises, named poetry.sections.set_value
the way the registrar names it. When the agent calls it, the transcript
marks the call as waiting on the browser, and the relay's closing streams
append a bridge element carrying the call and your continue URL. The
poetry--agent--agui-client-tool controller executes the call
through the registrar on the rendered component, then POSTs the result
back as JSON. Your continue endpoint resolves the call and starts the next
run from the transcript.
# POST /assistant/continue - the bridge's { toolCallId, name, content, error } JSON
def continue
body = JSON.parse(request.body.read)
transcript = load_transcript(params[:thread]) # your own persistence of the events so far
transcript.resolve_client_tool(body["toolCallId"], body["content"], error: body["error"])
input = Poetry::Agent::AGUI::RunInput.build(
thread_id: params[:thread], tools: frontend_tools,
messages: transcript.messages_for_input # the assistant's tool calls + your tool results
)
start_run(input) # e.g. enqueue, then hand the page a new stream source
render body: Poetry::Agent::AGUI::TurboStream.replace("assistant-next", next_source_tag),
content_type: "text/vnd.turbo-stream.html"
end
messages_for_input carries the assistant's tool calls and
your tool results in the wire shape the next run expects, so the agent
sees what happened in the browser as an ordinary tool message.
Interrupts
A run can end on an interrupt: an approval the agent needs before it continues. The transcript exposes it, you render the decision as a form or a pair of links, and the next run carries a resume entry answering it.
# The run ended on an interrupt: RUN_FINISHED with outcome.type == "interrupt"
if transcript.interrupted?
interrupt = transcript.interrupts.first # { "id", "reason", "message", "toolCallId", ... }
# render a decision form; on submit, resume:
input = Poetry::Agent::AGUI::RunInput.build(
thread_id: thread, messages: transcript.messages_for_input,
resume: [Poetry::Agent::AGUI::RunInput.resume_entry(interrupt["id"], status: params[:approved] ? "approved" : "rejected")]
)
end
A2UI surfaces in the stream
An agent that paints UI sends it inside the stream as an activity: an
ACTIVITY_SNAPSHOT with activityType
a2ui-surface, whose content carries the A2UI messages. The
activity part reaches your row partial like any other; fold its content
into a session and render each surface as a form whose action posts back
into the run. The action arrives at the agent as
forwardedProps.a2uiAction, the placement the AG-UI middleware
expects. The A2UI Surfaces guide covers the
renderer itself.
<%# app/views/assistant/_activity.html.erb %>
<% if part[:activity_type] == "a2ui-surface" %>
<% session = Poetry::Agent::A2UI::Session.new %>
<% session.apply_activity(part[:content]) %>
<% session.surfaces.each_value do |surface| %>
<%= Poetry::Agent::A2UI::Renderer.new(surface, view: self, action_url: assistant_surface_path).call %>
<% end %>
<% else %>
<code><%= part[:activity_type] %>: <%= part[:content].to_json %></code>
<% end %>
# POST /assistant/surface - the surface form; its action feeds the next run
action = session.action(surface_id: params[:a2ui][:surface], source: params[:a2ui][:action],
values: params[:a2ui][:values].to_h)
if action&.valid?
input = Poetry::Agent::AGUI::RunInput.build(
thread_id: thread, messages: transcript.messages_for_input,
forwarded_props: action.forwarded_props # { "a2uiAction" => { "userAction" => ..., "dataModel" => ... } }
)
end
Deterministic development
Script the agent while you build. The demo's replay is a plain Ruby module
returning event arrays per run, so every stream replays byte-identical and
the integration tests assert on real Turbo Streams with no model and no key.
Transcript#apply_all folds a recorded run in tests, and a
recorded run is also the fastest way to shape the row partial before an
agent exists. Events may arrive camel-cased or snake-cased; the transcript
reads both.
Setup
gem "poetry-agent"
// app/javascript/controllers/index.js
import { registerPoetryControllers } from "@poetry/controllers"
import { registerPoetryAgent } from "@poetry/agent"
registerPoetryControllers(application)
registerPoetryAgent(application) // the client-tool bridge, the versioned replace, the morph guard
That is the whole integration. The runtime registers the client-tool bridge, installs the versioned replace stream action the relay emits, and installs the guard that keeps a surface's local state through morphs. The gem is a client only: the agent runs wherever AG-UI agents run.