Skip to content

Repository files navigation

agui-compose

A Compose Multiplatform client for the AG-UI protocol.

This library rides on the upstream community Kotlin SDK (com.ag-ui.community:kotlin-core / kotlin-client). It is not a second implementation of the protocol. The event types are upstream's, and so are the transport and the SSE parser. What is added here is the layer upstream has no opinion about: a render model that keeps the order an agent produced things in, and the Compose UI that draws it. The one function this library does replace, and why, is in docs/decisions/0001; the UI layer's own three — no design system, no Markdown parser, one fewer target — are in docs/decisions/0002, and what the Material 3 layer decides on your behalf is in docs/decisions/0003. Fitting a Markdown parser under all of it, without a design system to style it from, is docs/decisions/0004. Where the upstream transport meets all of that — and what it decides about Ktor on your behalf — is docs/decisions/0005. Why saying something needs a second entry point, and why upstream's own API leaves no other way in, is docs/decisions/0006. And what a sample application found that no module's own tests could — a dependency neither of two modules names, on which they disagree — is docs/decisions/0007. How a tool the agent calls gets executed here and answered, and why upstream's own handler for that is not used, is docs/decisions/0008. What a run that stopped to ask a human looks like, and why a tool result is not an answer to it, is docs/decisions/0010. What a run has to carry for an agent to draw at all is docs/decisions/0011. How a version gets from a tag to Maven Central, and what stands between the two, is docs/decisions/0012.

Chat is the first surface, not the boundary. AG-UI's 33 events cover streaming text, reasoning, tool calls, human-in-the-loop approval, shared state, generative UI surfaces, run lifecycle, multimodal input and steering — this library is aimed at all of it.

Status: 0.2.0 is on Maven Central, and the API is not stable. A 0.x line: every public signature is covered by a checked-in ABI dump, so a change to one is a visible diff rather than a surprise, but nothing is promised across versions yet.

The API reference for the released version is at https://uny.github.io/agui-compose/.

Installation

dependencies {
    implementation("dev.ynagai.agui:agui-material3:0.2.0")
    implementation("dev.ynagai.agui:agui-agent:0.2.0")
}

That is the usual pair: agui-material3 brings agui-compose, agui-model and the slots, and agui-agent brings agui-core and the upstream client. Add agui-markdown for prose as Markdown, and agui-a2ui-material3 -- which brings agui-a2ui-compose and agui-a2ui -- when the agent draws. Each lower module is published on its own for a host that wants less: agui-core to fold events with no Compose at all, agui-compose to draw with a design system other than Material 3. Coordinates are dev.ynagai.agui:<module>:0.2.0; the Modules table says what each carries, and Targets which platforms.

What a consumer has to be on

Three floors, and they are not the same for every module. Each is what the published 0.2.0 artifacts declare, read from them rather than from this file's intentions. 0.1.0 predates both splits — it declares compileSdk 37 for all nine modules and a Kotlin 2.4 floor — so a consumer who cannot move off it is reading the wrong table.

Floor The four that draw nothing Every module that draws How it fails
compileSdk 24 37 AGP's checkAarMetadata names the module and the version it wants. compileSdk is what you compile against; targetSdk and minSdk need not move.
Compose Multiplatform none 1.12.0 Silently. Gradle takes the highest version, so a lower one you declare is raised without a message; holding it with strictly wins, and the mismatch surfaces at compile or run time instead of at resolution.
Kotlin 2.3 line 2.3 line A 2.4 compiler reads all of it. A 2.2 one reads the JVM and Android artifacts (class-file metadata is read one language release ahead) and refuses the iOS klibs (incompatible ABI version).

agui-model, agui-core, agui-agent and agui-a2ui are the four: none carries Compose, and every AAR they depend on asks for 24 or less — including a2ui-core 0.2.0, which lowered its own floor (uny/a2ui-compose#88) so that this one could follow.

Both floors follow Compose Multiplatform, and that is the whole rule. A drawing module's compileSdk is the minCompileSdk the current stable Compose declares (37 at 1.12.0); the Kotlin floor is the line the current stable Compose is built on, which its klib manifests give as 2.3.20 — so 2.3 it is. Below that buys nobody, because a consumer of Compose is already there; above it locks out consumers Compose itself admits. Both move when Compose moves, and not on their own schedule. The reasoning, and the same rule in the two sibling repositories, is on #20.

What that leaves for a consumer: a project on Kotlin 2.3 or newer, with compileSdk 37 for the UI or 24 for the transport. A KSP processor is not a constraint either way — KSP 2.3.x runs on a Kotlin 2.4 project, so Room, Dagger or Moshi do not pin anyone here.

Modules

Module What it is Version
agui-model The render model: UiMessage as an ordered list of parts, each with its own streaming state. No UI framework, no protocol types. 0.2.0
agui-core Folds an AG-UI event stream into that model, including the ACTIVITY_* events the upstream reducer does not handle. 0.2.0
agui-compose Draws a UiTranscript. Compose runtime and foundation only — no design system, no Markdown parser, one overridable slot per part kind. 0.2.0
agui-material3 Fills every one of those slots with Material 3: bubbles, a reasoning disclosure, tool-call and attachment surfaces, an approval card. The first layer that is meant to be looked at. 0.2.0
agui-markdown Draws prose as GitHub Flavored Markdown through the text renderer slot, re-parsing only the unsettled tail while a run is still arriving. Depends on agui-compose and intellij-markdown; the rendering layer is its own, and no design system. 0.2.0
agui-agent Runs an upstream AbstractAgent and keeps its transcript: one render model per thread, fed by every run, observable as a StateFlow. Brings the upstream client — and the Ktor engine it chose per platform. 0.2.0
agui-a2ui Reads A2UI out of a transcript — from an a2ui-surface activity, a streamed render_a2ui call, or a tool result carrying a2ui_operations — and turns upstream's v0.9 envelopes into the v1.0 messages a2ui-compose parses. Decides which carrier draws a surface that arrived in several. No Compose. 0.2.0
agui-a2ui-compose Keeps an A2uiRenderer up to date with a transcript and fills the activity and toolCall slots with its surfaces. No design system. 0.2.0
agui-a2ui-material3 The basic catalog's Material 3 renderers and a Material 3 "building UI" state, so a Material 3 transcript draws A2UI with one call. 0.2.0
agui-replay A Ktor server that replays upstream's recorded A2UI traffic over SSE — the sample's server, and the trace-driven tests' fixtures. JVM only. See Replaying upstream. never
agui-sample A desktop window that talks to a real AG-UI server: the whole stack above, assembled the way an application would. See The sample. never

The agui-provider-* adapters come next.

Targets

agui-model, agui-core and agui-agent: androidTarget, jvm, iosArm64, iosSimulatorArm64, iosX64 — the five the upstream SDK publishes. From the next release their Android variant compiles against API 24, not the 37 the rest of the repository needs; see what a consumer has to be on.

agui-compose, and every module that draws: the same set minus iosX64. Compose Multiplatform 1.12.0 does not publish an ios_x64 variant of foundation, ui or runtime, so no Compose module can reach an Intel simulator regardless of how it is configured. The lower three modules keep the fifth target rather than being trimmed to match — folding an event stream needs no Compose, and a consumer on an Intel simulator can still do it and render the result with something else.

agui-model, agui-compose, agui-material3 and agui-markdown also publish js and wasmJs. None of them names an upstream type, so a browser — a WebView inside a native app, say — can draw a transcript that was folded somewhere else. The Compose tests run under Kotlin/Wasm; under Kotlin/JS the renderers compile but are not drawn by a test, because Compose's test harness cannot start there. See docs/decisions/0015.

The protocol modules have no browser target, and nothing has a Kotlin/Native macOS target. Upstream publishes neither, and its STATE_DELTA dependency has no wasmJs or macosArm64 variant either. So agui-core, agui-agent and the A2UI modules cannot run in a browser yet, and folding an event stream there is not possible today. Compose Multiplatform on the desktop — macOS included — is the jvm target and is supported. The full reasoning, and what would change the answer, is in docs/decisions/0001 and #29.

Why the render model is not the SDK's transcript

Upstream's AgentState holds an assistant message as one content string plus a separate toolCalls list, and puts reasoning in a channel of its own. An agent that speaks, calls a tool, and speaks again produces a state from which the order of those three is not recoverable — and the order is what a chat UI draws.

agui-model keeps it:

UiMessage(
    id = "a1",
    role = UiRole.ASSISTANT,
    parts = listOf(
        ReasoningPart(text = "weighing it up", streaming = false, /* … */),
        TextPart(text = "Let me look.", /* … */),
        ToolCallPart(name = "search", status = ToolCallStatus.COMPLETE, result = "3 hits", /* … */),
        TextPart(text = "Found three.", /* … */),
    ),
)

Each part carries its own streaming state, because a tool call can still be running under text that has already finished.

Usage

import dev.ynagai.agui.core.foldToTranscript

agent.run(input)                    // Flow<BaseEvent>, from the upstream SDK
    .foldToTranscript()             // Flow<UiTranscript>
    .collect { transcript -> render(transcript) }

One transcript per event, so a renderer sees every frame. A renderer that wants fewer can conflate() — which it could not do if this had already dropped them.

That is one run. A conversation is many, and foldToTranscript starts from empty on every collection — so for a thread, hold an AgentSession around the upstream agent instead:

import com.agui.client.agent.HttpAgent
import com.agui.client.agent.HttpAgentConfig
import dev.ynagai.agui.agent.AgentSession

val session = AgentSession(HttpAgent(HttpAgentConfig(url = "https://…/agui")))

session.transcript                  // StateFlow<UiTranscript>, grows with every run
session.send("What changed today?") // the user's turn, then the run it starts
// or, with no new turn to send -- a retry:
session.run()                       // runs from the history the agent already holds

send is how a client says something, and it is not a convenience over run: upstream's RunAgentParameters carries no messages and AbstractAgent.setMessages is protected, so a run started from parameters alone can only ever send the history the agent already holds. send puts the turn on screen before the first event of the run arrives — it waits for a run already in flight, so "before the answer" is a promise about the answer to this turn, not about the clock — sends it with that history, and leaves it on screen if the run fails. Pass a UserMessage instead of a String to supply the id yourself, or to send files alongside the text.

Retry a failed turn with run, not with a second send. The turn is already the agent's history by the time the run fails, so run asks it again; send would append it a second time, and the server would be asked twice.

The transcript is the report: a run that fails — RUN_ERROR from the agent, or a stream that broke the protocol and was rejected by upstream's verifier — ends in RunState.Failed rather than an exception thrown into whatever coroutine a button launched it from. Cancelling the coroutine is recorded the same way, then propagates. Runs on one session take turns. The session does not own the agent's lifetime — dispose() it yourself, once — and it does not choose the HTTP engine: upstream fixes that per platform (CIO on the JVM, ktor-client-android on Android, Darwin on iOS), and the only way to substitute one is the HttpClient parameter on HttpAgent.

Executing a tool here

A frontend tool is one the agent calls and the client runs. Hand the session a ToolRegistry from upstream's kotlin-tools — it arrives with agui-agent — and the tools in it are declared on every run, executed when called, and answered:

import com.agui.tools.AbstractToolExecutor
import com.agui.tools.ToolExecutionContext
import com.agui.tools.ToolExecutionResult
import com.agui.tools.toolRegistry

class ChangeBackground : AbstractToolExecutor(Tool(name = "change_background", …)) {
    override suspend fun executeInternal(context: ToolExecutionContext): ToolExecutionResult {
        paint(context.toolCall.function.arguments)   // whatever the tool does
        return ToolExecutionResult.success(buildJsonObject { put("changed", true) })
    }
}

val session = AgentSession(agent, tools = toolRegistry(ChangeBackground()))
session.send("tool")   // returns when the agent has been told the result and has answered it

The protocol has one channel for a tool's result: the next run's input. So a run that called a tool is answered by a run of its own — the same tools, context and forwarded properties, the thread's history with the result placed after its call — and send or run suspends until a run ends without calling one. The call is drawn AWAITING_RESULT while the tool executes and COMPLETE once it has a result, in the transcript, before the answering run starts. A tool the registry does not hold is left alone: a backend tool's events fold exactly as they do with no registry, and a run that stops to ask -- an interrupt outcome -- is not answered by a tool result at all; see the next section.

RunState.Finished is the run's verdict, not the turn's — a transcript reads it while a tool is still executing and again between a run and the run that answers it. Gate "the agent is done" on the suspend call returning. A result whose run failed, or was cancelled, is kept and sent ahead of the next turn rather than dropped; the reasoning, and why upstream's own ClientToolResponseHandler is not what sends it, is in docs/decisions/0008.

Answering what a run stopped to ask

A run that needs something only a human can give -- an approval, a choice -- ends with RUN_FINISHED whose outcome is an interrupt, and the protocol lets no run start on the thread until every interrupt of that run has been answered or abandoned in the next input's resume list. The transcript carries the questions on the run, and the session answers them:

val ended = session.send("Transfer 100 to Alice") as RunState.Finished
ended.interrupts                // what the run stopped to ask: prompt, schema, the call it concerns
session.resume(
    ended.interrupts.map { UiResumeEntry.resolved(it, buildJsonObject { put("approved", JsonPrimitive(true)) }) },
)                               // one entry per interrupt; the run that carries them, then its answer

resume throws before the run starts if the entries leave an interrupt uncovered, name one the thread is not waiting on, or name one twice -- the reference client's rules, and omitting an interrupt is not abandoning it (UiResumeEntry.cancelled is). send and run throw while the thread is interrupted, for the same reason: a run the server would refuse is not a run to start. A resume whose run fails is still owed -- the thread keeps waiting, session.pendingInterrupts (a StateFlow) still names the questions after the transcript's RunState.Failed has stopped naming them, and either run(), which carries the answers the failed run carried, or a second resume retries it. Whether an interrupt has expired is not judged here: expiresAt is carried for you to read, and a producer that will not take a late answer fails the run. The reasoning, and why a tool result is not an answer to an interrupt, is in docs/decisions/0010.

A tool call the interrupt names is drawn AWAITING_APPROVAL until the next run starts. The question itself is drawn by AguiInterrupts(interrupts, onResume), placed wherever the application wants it -- it is the thread's status, not a message, so AguiTranscript does not draw it -- and agui-material3 fills the slot with a card whose Approve resolves with {"approved": true} when the schema names that property and Decline abandons.

Drawing it

import dev.ynagai.agui.compose.AguiTranscript

AguiTranscript(transcript)

agui-compose sits below any design system: the defaults draw structure and no colour, spacing or shape. Each kind of part goes through a slot in AguiComponents, replaced one at a time by copy:

val components = remember {
    AguiComponents().copy(
        toolCall = { part, modifier -> MyToolCallCard(part, modifier) },
    )
}

CompositionLocalProvider(LocalAguiComponents provides components) { AguiTranscript(transcript) }

remember the table, rather than building it in the provides. Both locals here are staticCompositionLocalOf, which does not track reads: a value it does not consider equal to the last one recomposes the whole subtree under it, skipping disabled. A slot lambda that captures anything — a click handler, a theme value, view-model state — makes a fresh unequal table on every recomposition of the composable holding the provider, so every visible part redraws on every frame of a streaming response. Measured on a three-message transcript: a capturing override re-runs all three part slots per recomposition, a remembered one re-runs none.

Markdown is not a dependency of this library. TEXT_MESSAGE_CONTENT carries a string and the protocol says nothing about its syntax, so which flavour to parse — and whether to parse at all — is the application's call. The default renderer draws the text literally; a richer one is fitted through LocalAguiTextRenderer, and serves both prose parts at once:

val renderer = remember {
    AguiTextRenderer { text, streaming, modifier ->
        Markdown(text, modifier)   // any renderer you like
    }
}

CompositionLocalProvider(LocalAguiTextRenderer provides renderer) { AguiTranscript(transcript) }

The renderer is handed the whole run as it currently stands rather than the latest delta, so re-parsing on every recomposition is correct; an incremental parser is an optimisation, not a requirement. The streaming flag is there so a parser can tell a run still arriving from a finished one.

agui-markdown is that renderer, written once:

val renderer = remember { MarkdownAguiTextRenderer() }

It parses incrementally while streaming is true — the settled part of the document stays parsed and only the tail is re-read — and parses the finished text complete when the run ends. Its defaults draw black text at Compose's default size, because the module sits below any design system and has nothing ambient to read; under Material 3, hand it the ambient values:

import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle

val renderer = remember {
    MarkdownAguiTextRenderer(
        colors = { markdownAguiColors(text = LocalContentColor.current) },
        typography = { markdownAguiTypography(base = LocalTextStyle.current) },
    )
}

Both locals come from material3, which agui-markdown does not depend on — you already have it if you are under ProvideMaterial3Agui, and you need to declare it if you are not.

Those are @Composable lambdas rather than values so that one remember with no keys still follows a theme change — reconstructing the renderer to pick one up would re-parse every visible run on every frame. The reasoning, and why this is a separate module from agui-material3, is in docs/decisions/0004.

Making it look like something

agui-material3 is that slot table, filled in:

import dev.ynagai.agui.material3.ProvideMaterial3Agui

MaterialTheme {
    Surface {
        ProvideMaterial3Agui {
            AguiTranscript(transcript)
        }
    }
}

User messages become end-aligned tonal bubbles, assistant answers stay full width, reasoning collapses into a disclosure when its stream ends, and tool calls and attachments get surfaces of their own. MaterialTheme stays yours — this provides no theme of its own, so your colour scheme and type scale are what it draws with.

The Surface is not decoration. MaterialTheme sets a colour scheme but does not provide LocalContentColor, which is left at Material 3's own default of black; a Surface is what derives it from the background it paints. Without one, a dark theme draws this transcript's prose black on a dark ground. Any Scaffold or Surface you already have counts — but the two-composable version of this snippet is a trap in dark mode, so it is written with three.

It provides two composition locals, and that is why the function exists: the slot table, and a text renderer that draws through Material 3's Text instead of BasicText. Providing only the first would leave prose ignoring your theme entirely.

Overriding is the same copy as above, and fitting a Markdown renderer is the same LocalAguiTextRenderer — the prose slots here draw through it rather than calling Text themselves, so a renderer you fit keeps every Material 3 frame around it:

ProvideMaterial3Agui(
    textRenderer = remember {
        MarkdownAguiTextRenderer(
            colors = { markdownAguiColors(text = LocalContentColor.current) },
            typography = { markdownAguiTypography(base = LocalTextStyle.current) },
        )
    },
    components = remember { Material3AguiComponents().copy(file = ::MyAttachmentTile) },
) {
    AguiTranscript(transcript)
}

What it deliberately does not do: parse Markdown (that is agui-markdown), load images for attachments, or render an ACTIVITY_SNAPSHOT payload (that is agui-a2ui). Each of those is a dependency with an opinion, and each is one copy away for an application that wants it. The reasoning is in docs/decisions/0003.

Drawing A2UI

A2UI is how an agent describes a user interface as JSON for the client to draw with its own widgets. Over AG-UI it arrives in three carriers, and the same surface commonly arrives in all three during one run: an ACTIVITY_SNAPSHOT with activityType: "a2ui-surface" that upstream's a2ui-middleware synthesises and repaints progressively; the streamed arguments of a render_a2ui tool call; and a tool result whose content holds a2ui_operations. agui-a2ui reads all three off the transcript, decides which one draws a surface that came in several (activity over result over arguments, so nothing is drawn twice), and hands a2ui-compose the messages — deleting a surface before a cumulative snapshot recreates it, since a2ui-core refuses to create one that exists.

import dev.ynagai.a2ui.compose.A2uiRenderer
import dev.ynagai.a2ui.compose.A2uiRendererConfig
import dev.ynagai.a2ui.compose.BasicCatalog
import dev.ynagai.agui.a2ui.compose.rememberA2uiHost
import dev.ynagai.agui.a2ui.material3.withMaterial3A2ui

val renderer = remember { A2uiRenderer(A2uiRendererConfig.Default.withCatalogs(listOf(BasicCatalog.definition))) }
val host = rememberA2uiHost(transcript, renderer)
val components = remember(host) { Material3AguiComponents().withMaterial3A2ui(host) }

ProvideMaterial3Agui(components = components) { AguiTranscript(transcript) }

That draws the basic catalog. A catalog is the renderer's trust boundary — the agent may name only what the client holds — so an agent that generates its own components needs their CatalogDefinition in the renderer and their ComponentRenderers in the registry (Material3Components.Basic.with(...)); the sample's DojoCatalog is one of those, for upstream's dojo. An action a user takes on a surface reaches onMessage as a2ui-core's ActionMessage; toForwardedProps() and toUserText() are the two shapes upstream reads it in, and sending it is yours.

Versions. Upstream emits A2UI v0.9 envelopes; a2ui-compose implements v1.0 and nothing older. For the four messages upstream sends the wire difference is mechanical — the version string, a dropped theme, a renamed attachDataModel, a value that became required — and agui-a2ui rewrites the JSON before a2ui-core parses it, checked against upstream's recorded traffic rather than against the evolution guide's word. One thing is not mechanical: upstream's basic catalog id is …/v0_9/basic_catalog.json, which is not even the v0.9 specification's, and A2uiTranslation maps upstream's spellings -- that URL, the v0.9 specification's, and a bare basic -- to the v1.0 basic catalog and passes any other id through. The reasoning is in docs/decisions/0009.

Asking for a surface. None of the above arrives unless the run asks. Upstream's agents inject their generate_a2ui tool only when the run's forwardedProps carries injectA2UITool, and put the client's components in front of the model only when its context carries the component schema — both things @ag-ui/a2ui-middleware adds to every request when it sits in front of the agent, which nothing does here. A2uiRequest builds both, plus the render tool's usage guide the middleware sends beside the schema, for the catalog the renderer holds; send it on every run:

import com.agui.client.agent.RunAgentParameters
import dev.ynagai.a2ui.compose.BasicCatalog
import dev.ynagai.agui.a2ui.A2uiRequest

val request = A2uiRequest(BasicCatalog.definition)
val parameters = RunAgentParameters(context = request.context(), forwardedProps = request.forwardedProps())
session.send("Show me the hotels", parameters)

Without it, upstream's adapters never inject their tool: the agent answers in text and the stream says nothing about why. An agent that calls render_a2ui itself, through a RenderA2UiTool the client registered, needs no flag to draw. The reasoning, and what was and was not measured against a live server, is in docs/decisions/0011.

RenderA2UiTool is the render_a2ui tool as a frontend tool, for an agent that calls it directly and waits on the answer. It is not registered by anything here, on purpose: upstream's LangGraph adapter calls render_a2ui from a subagent and closes it on the server, and a client that answered that call would be answering a question it was not asked. Register it for the agents that ask.

The sample

agui-sample is one desktop window: an endpoint to point at, a transcript, and a line to type into. It is the whole stack above assembled once — AgentSession over upstream's HttpAgent, drawn by AguiTranscript under ProvideMaterial3Agui, with agui-markdown fitted through the text-renderer slot — and nothing in it is stubbed.

./gradlew :agui-sample:run

The endpoint field starts empty, and the sample ships with no server. There is no hosted endpoint to point it at that would not tie this repository to somebody else's uptime, so what it talks to is a server you run. The smallest one is upstream's own, and it needs no API key:

git clone https://github.com/ag-ui-protocol/ag-ui
cd ag-ui/integrations/server-starter/python/examples
uv run dev

That listens on http://localhost:8000/ (PORT moves it) and answers every turn with a fixed Hello world!. The reply is canned; everything under it is not — a real HTTP request, a real SSE stream of AG-UI events, upstream's parser and verifier, and this library's reducer and renderers. It is what the sample was verified against.

The sample also executes one frontend tool, change_background, and paints whatever gradient it is sent. To see that go round, upstream's all-features starter is the server, and it needs no key either — but it does need a full (not sparse) checkout, because its lockfile points at the Python SDK by path; a shallow clone is fine:

cd ag-ui/integrations/server-starter-all-features/python/examples
uv run dev

Point the sample at http://localhost:8000/agentic_chat and type tool: the server calls the tool, the window changes colour, the result goes back in a second run, and the server answers background changed ✓. Type anything else for a plain reply.

For an agent that actually thinks, any of the other integrations serves the same protocol on the same shape of endpoint; those are LLM-backed and want a provider key of their own. The sample does not care which — it is a URL.

To see a run stop and ask, the server has to be one that raises an approval, and none of upstream's examples turns that on: docs/live/approval is a Strands server with one gated transfer tool, and wants a key for the model behind it. Point the sample at it and type Transfer 100 to Alice: the call is drawn as awaiting approval, the card asks, and Approve runs the tool in the next run. The same server is what agui-agent's LiveApprovalTest measures when AGUI_LIVE_APPROVAL_URL names it; without the variable the test is skipped, and CI skips it.

To see an agent draw because the client asked, the server has to be one that supplies nothing of its own, and upstream's A2UI examples all stamp a catalog id and hand the sub-agent a guide from the server side: docs/live/a2ui is a Strands server with a plain agent and no A2UI config, so the A2uiRequest the sample sends is the only reason a surface arrives. Point the sample at it and type Compare three hotels in Kyoto; the README beside it says what should come back, and what each other outcome means.

One dependency clash you do not have to resolve. Upstream's kotlin-client and kotlin-tools 0.4.1 are compiled against kotlinx-datetime 0.6.2; Compose Material 3 1.9.0 brings 0.7.1, where Clock and Instant moved to kotlin.time and the old classes are gone. Taking agui-material3 and agui-agent together used to compile and then die on the first event upstream timestamps, with NoClassDefFoundError: kotlinx/datetime/Clock$System. agui-agent now declares the coordinate that carries both binary surfaces, so this is handled — it is written down in docs/decisions/0007 because it is the kind of thing that comes back, and because it is what the sample found on its first run.

Replaying upstream

A2UI needs an agent that generates UI, and every upstream agent that does is LLM-backed and wants a key — and, as of this writing, a Python runtime beside it. Rather than ask for either, this repository ships the traffic those agents produced: five event streams recorded by upstream's own end-to-end suite off its LangGraph agents, through its a2ui-middleware, converted to JSON with no other change (agui-replay/src/main/resources/traces/). agui-replay serves them the way an AG-UI server would, one route per recording:

./gradlew :agui-replay:run

Point the sample at http://localhost:8000/advanced-hotel-comparison and type anything: the server streams the recording — the render_a2ui arguments as they were generated, the middleware's building activity, the cumulative surface snapshots, the outer tool's result — and the window draws three hotel cards from the dojo's catalog, which the sample registers because a catalog is the renderer's trust boundary and the agent cannot add to it. GET / lists the routes; fixed-multiple-surfaces answers two turns. What you typed does not reach anything: the request's thread and run ids are honoured and the rest of it is not, because a recording cannot answer a question it was not asked.

The same recordings are what agui-a2ui's trace-driven tests fold, and what the sample's end-to-end test plays over a real socket into a real window. So "verified against a real server" here means verified against what a real server said, byte for byte, minus the model that said it.

What the sample deliberately does not do yet: build for Android or iOS. It is one target and one screen, and the module is laid out so that the second target is a source set rather than a rewrite.

Building

./gradlew build

Requires JDK 21. That floor is inherited, not chosen: upstream's kotlin-core-jvm is Java 21 bytecode, so a JDK 17 build cannot load the types this library is built on.

The Apple targets need macOS. On Linux they are skipped rather than failed, so a green Linux build says less than it appears to -- which is why CI runs on macOS.

Releasing

A v* tag is a release: .github/workflows/cd.yml reads the version from it, publishes every module locally and signs it, resolves that publish from a separate consumer build (smoke-test/) on every target, and publishes and releases to Maven Central, failing if Central's validation rejects the deployment. The job runs under a release environment, so once that environment exists with a required reviewer the run waits on a human before it touches a secret; until then the line gates nothing. release-dry-run.yml rehearses the same path from the Actions tab with a throwaway key and no upload; run it before the first tag, and after any change to a module's targets or publishing block.

Licence

Apache 2.0. See LICENSE and NOTICE.

About

Compose Multiplatform client for the AG-UI protocol — streaming text, tool calls, reasoning, human-in-the-loop and A2UI surfaces on Android, iOS, desktop and web.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages