A user sends a message, sees the assistant start replying, and refreshes the page. What should they see when they come back?
That small interaction reaches well beyond the message component. The browser needs to recover the right conversation, find out whether the work is still running, and display whatever happened while it was disconnected. Sending the prompt again could start the work twice. Keeping the old spinner forever would be wrong too.
This is the kind of problem behind our choice of assistant-ui for Assign. We started with a custom interface built from shadcn elements, considered ChatKit, and selected assistant-ui as the presentation layer. The migration is in progress. This article explains the choice and the boundaries we want to preserve; it isn’t an announcement that the replacement has shipped.
Starting with the components we already had
Our custom UI started from the same building blocks as the rest of the application: inputs, buttons, menus, and other shadcn-based controls. That gave us direct control over the composer and kept the conversation visually consistent with its surroundings.
It’s a reasonable starting point. You can shape the input around your product, place actions where they belong, and render a useful first conversation without adopting another UI system. The shadcn model gives you component source to work with, which suits an interface you expect to customize.
The ongoing work lives between those components. As text streams in, the transcript has to decide whether to follow it or preserve the reader’s position. A message may contain a structured result alongside prose. The composer has to preserve a draft while something else changes on the page. An input method can make Enter mean “confirm this character,” which is very different from “send this message.”
We wanted to keep our styling and product-specific interactions while adopting a coherent set of chat primitives. A library would still leave us with integration work, but it could give that work a more consistent shape.
What we considered with ChatKit
ChatKit deserved a close look because it brings a substantial chat interface, including widgets and customization options. It also supports a custom server integration. OpenAI’s advanced integration guide describes running that server on your own infrastructure, so an existing backend is not, by itself, a reason to rule ChatKit out.
For us, the deciding issue was control over the rendered interface. We wanted source we could inspect and build into our own application, with direct access to the composition of the page. The ChatKit JavaScript repository documents React bindings and loading the runtime from OpenAI’s CDN. Those bindings did not give us the complete locally buildable renderer we were looking for.
We deferred ChatKit on that basis. That is a fit decision: a remotely delivered interface can be useful when its customization and delivery model match the application. Our requirement was to own the renderer’s build and integrate it directly into the existing UI. A custom backend and a locally controlled frontend are separate questions, and evaluating one does not settle the other.
Why assistant-ui fit
assistant-ui’s primitives provide unstyled building blocks for threads, composers, and messages. We can compose those parts using Assign’s existing visual language. Choosing the library doesn’t require choosing its example application’s appearance.
The other useful piece is ExternalStoreRuntime. It accepts messages and callbacks from an existing state owner. That lets us translate our conversation data for rendering while keeping persistence and synchronization in the application.
That combination fits the change we want to make: adopt chat presentation infrastructure while retaining the application contracts behind it. We still have to decide which controls to expose. A library’s support for editing, regeneration, or branching doesn’t mean those operations belong in our product. Each enabled action needs a defined backend behavior.
The path from the composer to execution
The selected integration follows this path:
assistant-ui
↓
Assign data adapter
↓
Core
↓
Knowledge managed-session runner
↓
OpenAI Agents API
The arrows show how a user intent reaches execution. Results come back through the application before they become visible messages.
At the top, assistant-ui handles presentation. It renders the conversation and exposes interactions such as submitting a message. Its view needs enough state to show what is happening, but it doesn’t decide whether a user may perform a business operation.
The Assign data adapter translates between application data and the UI library. It maps message identities, content, and status into the shapes the renderer expects, and connects UI actions to the application’s existing commands. This is also where we keep a library-specific representation from spreading through the rest of the system.
Core remains the application authority. It owns permissions and canonical conversation state. A click in the chat interface is a request that Core must handle under the same rules as any other application action.
The Knowledge managed-session runner connects that admitted work to execution. The browser reaches it through Core. Changing the transcript renderer should not create another agent loop or a second route around application authorization.
The OpenAI Agents API supplies the managed execution layer. OpenAI’s overview describes managed sessions, orchestration, context compaction, and recovery, with the application providing tools and choosing the execution environment. Those provider capabilities sit behind our application boundary; they don’t determine the shape of the browser’s transcript.
What the adapter has to get right
Return to the refresh example. Consider a synthetic task: a user asks an assistant to update a project note, then reloads the page before seeing the result.
The new page should read the existing conversation and its current execution state. If the update completed, it should show the recorded result. If work continues, it should reattach to progress. Reconstructing the screen must not submit the original request again.
This gives the adapter a concrete responsibility: rebuilding a view is a read operation. Sending a message is a separate command. Mixing those paths can make a component remount trigger real work.
Message identity matters for the same reason. If every incoming chunk becomes a new message, the UI can duplicate content, lose an action’s target, or move the reader’s scroll position. The adapter needs to preserve the relationship between an existing message and its later revisions.
A disconnected stream also needs careful treatment. It tells the browser that its connection ended. It doesn’t establish that the task succeeded, failed, or stopped. The UI has to reconcile with the application’s recorded state before presenting a final outcome.
These are useful review questions for any chat integration. They apply whether the renderer is custom, ChatKit, or assistant-ui.
What choosing a library doesn’t finish
Long conversations still need explicit engineering. assistant-ui’s virtualization guide says it does not ship a virtualized thread component and shows a composition using TanStack Virtual. We need to preserve our history and scrolling behavior as we integrate the primitives, then measure the result.
We also need to verify the awkward interactions: refresh during a response, Stop while the connection is recovering, a file added to an existing draft, and keyboard input while messages arrive. Prepared event sequences let us repeat those cases without depending on a model to produce the same response twice. They verify UI behavior, not model quality.
We aren’t claiming that assistant-ui has made Assign faster or that it removes the cost of maintaining an assistant interface. The choice gives us a specific integration boundary to test. Once a recorded conversation can survive a renderer remount without losing its identity or starting new work, we have evidence that the UI and execution layers are staying separate. It gives the next UI change a concrete recovery test to preserve.