{"slug": "one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend", "title": "One Event Stream, Two UI Protocols: Wiring Solon AI to Any Frontend", "summary": "The Solon AI project released solon-ai-ui, a translation module that converts Solon AI's internal Flux<ChatEvent> stream into frontend-facing event streams for two UI protocols: the Vercel AI SDK UI Message Stream Protocol v1 (via solon-ai-ui-aisdk) and AG-UI (via solon-ai-ui-agui). The adapters depend only on solon-ai-core and resolve agent events reflectively, so the agent stack is not dragged into apps that only need to stream a chat model, and unknown events are routed to a custom/data-* bucket rather than dropped.", "body_md": "Your Java agent streams tokens. Your frontend speaks a protocol. Between them sits a translation layer that nobody wants to write — and that everybody writes badly.\n\nIt is the same story every time: text deltas, reasoning deltas, tool-call arguments, tool results, citations, errors, aborts. Six or seven channels, all multiplexed onto one SSE connection, all needing stable IDs so the UI can stitch deltas back into messages. Get the ID keying wrong and two parallel tool calls collide. Forget to close a block on error and the frontend hangs forever waiting for an `end` that never comes. Cancel the request and — if you forgot one line — the model keeps generating, and you keep paying.\n\nSolon AI has a module whose only job is this translation layer: **`solon-ai-ui`**.\n\nRepo: [https://github.com/opensolon/solon-ai](https://github.com/opensolon/solon-ai) (module: `solon-ai-ui`)\n\nIt ships two adapters:\n\n| Artifact | Protocol it speaks | Pairs with | \n|---|---|---|\n| `solon-ai-ui-aisdk` | Vercel AI SDK — UI Message Stream Protocol v1 | `@ai-sdk/react` ,`@ai-sdk/vue`` useChat` | \n| `solon-ai-ui-agui` | AG-UI | AG-UI compatible component libraries | \n\nBoth convert Solon AI's internal `Flux<ChatEvent>` into a frontend-facing event stream. Neither is a rewrite of your agent — they are thin adapters, and the way they stay thin is the most interesting part of the design.\n\nThe obvious implementation would be: `ui` depends on `agent`, and maps `AgentEvent` to UI events directly. Simple. And wrong — it would drag the entire agent stack into every app that just wants to stream a chat model, and every agent event added upstream would become a breaking change downstream.\n\nSo the adapters depend only on `solon-ai-core`. Agent events arrive as `Object`, and the adapter figures them out reflectively:\n\n`getChatEvent()`, the adapter delegates to the core event state machine. The three delta events — the ones carrying actual text, reasoning, and tool arguments — all take this path.`ToolCallStartEvent`, `ToolCallEndEvent`, `RunEndEvent` / `SimpleEndEvent` / `TeamEndEvent`.` custom` / `data-*` bucket instead of being dropped.\nNothing is silently discarded. That is the rule the whole module is built around.\n\nThe AI SDK adapter converts `chatModel.prompt(prompt).stream()` into a `Flux<SseEvent>` that is a drop-in for `useChat`.\n\n```\n@Controller\npublic class AiChatController {\n    @Inject\n    ChatModel chatModel;\n\n    private final AiSdkStreamWrapper wrapper = AiSdkStreamWrapper.of();\n\n    @Produces(MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)\n    @Mapping(\"/ai/chat/stream\")\n    public Flux<SseEvent> stream(String prompt, Context ctx) {\n        // required by the AI SDK protocol\n        ctx.headerSet(\"x-vercel-ai-ui-message-stream\", \"v1\");\n\n        return wrapper.toAiSdkStream(chatModel.prompt(prompt).stream());\n    }\n}\n```\n\nThe protocol is a **parts** model — roughly twenty part types, each a small JSON frame — and the wrapper emits them in a fixed order:\n\n```\nstart → (message-metadata) → start-step\n  → (reasoning-start → reasoning-delta* → reasoning-end)\n  → (tool-input-start → tool-input-delta* → tool-input-available → tool-output-available)\n  → (source-url* / source-document*)\n  → (text-start → text-delta* → text-end)\n  → (file* / data-*)\n→ finish-step → … → finish → [DONE]\n```\n\nA single-turn reply is one step. A tool call that re-prompts the model produces multiple steps, and the `start-step` / `finish-step` pair is what lets `useChat` reassemble a multi-step assistant turn correctly.\n\nThere is also a blocking counterpart: `toAiSdkStream(ChatResponse)` wraps a `call()` result into the same frame sequence, so a non-streaming endpoint can still feed a streaming client.\n\nThe AG-UI adapter targets a different event vocabulary — `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`, `TOOL_CALL_ARGS`, `REASONING_MESSAGE_CONTENT`, `STEP_FINISHED`, and so on.\n\n```\nAgUiStreamWrapper wrapper = AgUiStreamWrapper.of(\"thread-1\", \"run-1\");\nFlux<Event> stream = wrapper.toAgUiStream(chatModel.prompt(prompt).stream());\n```\n\nTwo details are worth calling out.\n\n**The reasoning rename is handled for you.** AG-UI's modern vocabulary is `REASONING_*`; the older `THINKING_*` events are marked `@Deprecated` in the enum, with each old constant pointing at its replacement. Solon AI's core still calls its events `THINKING_*`, so the adapter maps them onto the modern `REASONING_START` / `REASONING_MESSAGE_CONTENT` / `REASONING_END` family — including the message-level boundaries, not just the outer block.\n\n**Interruption is a first-class outcome, not an error.** When the core emits `ABORT`, the AG-UI adapter closes any open content block, then emits a `RUN_FINISHED` whose outcome type is `interrupt`. A user pressing \"stop\" is a normal ending with a name — not a fake failure.\n\nEvents AG-UI has no standard representation for — server-side tools, media, safety, usage, custom payloads — are preserved as `CUSTOM` rather than mapped onto something semantically wrong. For backwards compatibility the payload is written to both the standard `name`/` value` fields and the legacy `rawEvent` field, so older clients keep working while standard clients move forward.\n\nThere is also typed support for state sync: `StateDeltaEvent` carries RFC 6902 JSON Patch operations.\n\n```\nStateDeltaEvent delta = new StateDeltaEvent()\n        .add(JsonPatchOperation.replace(\"/progress\", 50))\n        .add(JsonPatchOperation.add(\"/message\", \"working...\"));\n```\n\nProtocol mapping is the easy 20%. These are the rest.\n\n**1. Stable IDs across deltas.** Deltas arrive in fragments, so each block needs an ID minted once and reused for its `start` / `delta` / `end` frames. Both adapters key the ID map on `responseId + step + itemId`, falling back to `index`. That key is what keeps two concurrent tool calls, or two reasoning channels, from borrowing each other's IDs. The AI SDK adapter also lets you swap the ID source entirely — UUID by default, snowflake or anything else via `AiSdkIdGenerator`, with prefixed helpers (` msg_`, `txt_`, `rsn_`, `call_`, `src_`).\n\n**2. Cancellation has to propagate upstream.** Both wrappers call `sink.onDispose(upstream)`. When the browser disconnects, the subscription to the model is released — the request doesn't keep running in the background burning tokens after nobody is listening.\n\n**3. Failures must not be dressed up as success.** If the stream errors mid-flight, the wrapper first closes any open text/reasoning blocks (otherwise the client waits forever for an `end`), then emits the error part with `finishReason` set to `error` — not the default `stop`. The error text is taken from the terminal `ERROR` event when one was emitted, because that carries more context than the bare `Throwable`. If the error path had to synthesize the `end` frames itself, it reuses the same closing logic as the success path rather than inventing new frames.\n\n**4. No orphan tool output.** Some providers deliver a tool result without ever sending a `tool-input-*` frame. A strict client will drop an output that references an input it never saw. So the adapter idempotently emits the missing `tool-input-start` / `tool-input-available` pair first, then the output. Same guard applies to agent tool events arriving from a replay or resume path.\n\n**5. Content that isn't the answer must not look like the answer.** In a multi-agent run, a supervisor's internal routing chatter can arrive on the same stream. Both adapters force those events into a `custom` / `data-*` bucket — never into the assistant text or reasoning channels, so internal deliberation can't leak into what the user sees as the reply. Similarly, agent turns are namespaced by run and reason ID in the AI SDK adapter, so two turns can't accidentally reuse a closed part ID.\n\nOne more, from the AI SDK adapter's javadoc, worth knowing before you file a bug: the core's default event filter blocks `RAW` and `HEARTBEAT`, so unmodeled raw frames never reach the wrapper by default. If you want them passed through, opt in explicitly when building the stream:\n\n```\nchatModel.prompt(prompt).eventFilter(ChatEventFilter.all()).stream()\n```\n\n| If your frontend… | Use | \n|---|---|\n| already uses `useChat` from`@ai-sdk/react` or`@ai-sdk/vue` , or any AI-Elements component library | `solon-ai-ui-aisdk` | \n| targets AG-UI / is protocol-first and wants run/step semantics, or you want typed JSON-Patch state sync | `solon-ai-ui-agui` | \n| just wants plain SSE text and parses it by hand | neither — `streamText()` is enough | \n\nThe two are not exclusive. `ChatEvent` is Solon AI's internal, provider-agnostic model; each adapter is an outbound projection of it. If you ever genuinely need both, you're translating one core stream two ways, not maintaining two agents.\n\n```\n<dependency>\n    <groupId>org.noear</groupId>\n    <artifactId>solon-ai-ui-aisdk</artifactId>\n</dependency>\n```\n\nVersions are managed by the Solon AI BOM, so no `<version>` is needed. The adapter follows the Solon AI 4.1 line.\n\nThe interesting thing about a translation layer is that the best one is invisible. You wire two lines, the UI renders text, reasoning, tool calls and citations in order, and you never think about it again — until the day a tool call hangs the frontend, and you have to go find out whose job it was to close the block.\n\nThis module's answer to that question is: ours.\n\n*All behavior described above was read from the `solon-ai-ui` source in the [opensolon/solon-ai](https://github.com/opensolon/solon-ai) repository.*", "url": "https://wpnews.pro/news/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend", "canonical_source": "https://dev.to/solonjava/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend-318h", "published_at": "2026-10-06 04:03:37+00:00", "updated_at": "2026-10-06 04:17:52.276852+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "agent-protocols"], "entities": ["Solon AI", "solon-ai-ui", "Vercel AI SDK", "AG-UI", "solon-ai-core", "useChat", "opensolon"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend", "markdown": "https://wpnews.pro/news/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend.md", "text": "https://wpnews.pro/news/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend.txt", "jsonld": "https://wpnews.pro/news/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend.jsonld"}}