Migrating from flat-props ChatProvider

Move from the legacy flat-props chat component to AgentInterface and its two adapter objects — with a complete old-to-new mapping and the behavior changes to watch for.

The legacy chat component took its backend wiring as a flat bag of props — apiUrl, streamProtocol, processMessage, threadApiUrl, fetchThreadList, loadThread, and a dozen more — directly on the component. AgentInterface replaces all of that with two adapter objects: an llm for producing replies and a storage for persistence. The component's prop surface shrinks to something small and stable, and each adapter is testable in isolation — you can unit-test a ChatLLM or ChatStorage without rendering anything.

The whole migration, in one idea: the flat backend props are removed (not deprecated), and their values move into fetchLLM({ ... }) for the LLM and restStorage({ ... }) for threads.

Everything imports from @openuidev/react-ui: AgentInterface, the fetchLLM / restStorage / defineArtifactRenderer factories, the stream adapters, the message formats, and hooks like useArtifactList.

The whole diff

If you were on the common setup — POST to a route, REST-backed threads — the migration is: build two adapters, pass two props.

Before:

// legacy flat-props chat component
<LegacyChat
  apiUrl="/api/chat"
  streamProtocol="openai"
  messageFormat="openai"
  threadApiUrl="/api/threads"
/>;

After:

import { AgentInterface, fetchLLM, restStorage, openAIAdapter, openAIMessageFormat } from "@openuidev/react-ui";

const llm = fetchLLM({
  url: "/api/chat",
  streamAdapter: openAIAdapter(),
  messageFormat: openAIMessageFormat,
});

const storage = restStorage({
  baseUrl: "/api/threads",
  messageFormat: openAIMessageFormat,
});

<AgentInterface llm={llm} storage={storage} />;

Your route handler doesn't change: fetchLLM POSTs the same { threadId, messages } to url, and restStorage calls the same thread endpoints threadApiUrl did. Everything else on this page is the field-by-field mapping behind those two factory calls.

Old → new at a glance

Legacy flat propNew homeNotes
apiUrlfetchLLM({ url })The LLM endpoint moves into the factory's url.
streamProtocolfetchLLM({ streamAdapter })String enum → a stream-adapter factory call (openAIAdapter()).
messageFormatfetchLLM({ messageFormat }) (and restStorage({ messageFormat }))String enum → a MessageFormat value (openAIMessageFormat).
processMessageChatLLM.sendCustom send logic becomes a send implementation. abortController → the signal you receive.
threadApiUrlrestStorage({ baseUrl })The thread REST root moves into restStorage.
fetchThreadListstorage.thread.listThreadsNow takes an optional cursor; returns { threads, nextCursor? }.
createThreadstorage.thread.createThreadReceives the first UserMessage, returns the new Thread.
updateThreadstorage.thread.updateThreadTakes a full Thread, returns the updated Thread.
deleteThreadstorage.thread.deleteThreadTakes an id.
loadThreadstorage.thread.getMessagesRenamed; takes a threadId, returns Message[].
appRenderersartifactRenderersProp rename; array of renderer configs.
defineAppRendererdefineArtifactRendererkindtype; toolName is string | string[]; parser returns { props, meta }.
useAppListuseArtifactListHook rename; per-thread artifact registry.
Artifact* panel APIsDetailedView*The in-thread panel hooks were renamed around "detailed view."
legacy chat componentAgentInterfaceComponent rename, same package.

LLM props → llm

Four old props collapse into one fetchLLM call. Two things change beyond the renames:

  • streamProtocol was a string; streamAdapter is a factory call. Pick the adapter matching your provider's wire format and call itopenAIAdapter(), not the bare reference. The options are agUIAdapter, openAIAdapter, openAIReadableStreamAdapter, openAIResponsesAdapter, and langGraphAdapter.
  • messageFormat was a string; it's now a value. Use openAIMessageFormat, openAIConversationMessageFormat (the one that pairs with openAIResponsesAdapter()), langGraphMessageFormat, or identityMessageFormat (the default — the canonical Message array, unchanged).

processMessage → a custom ChatLLM

If processMessage did something fetchLLM can't express — a non-fetch transport, a websocket, bespoke request shaping — implement the ChatLLM interface directly instead of calling fetchLLM.

BeforeprocessMessage received an abortController:

<LegacyChat
  streamProtocol="openai"
  processMessage={async (messages, { abortController }) => {
    return fetch("/api/chat", {
      method: "POST",
      body: JSON.stringify({ messages }),
      signal: abortController.signal,
    });
  }}
/>;

Aftersend receives the signal directly, and the parser moves onto the object as streamProtocol:

import { AgentInterface, type ChatLLM, openAIAdapter } from "@openuidev/react-ui";

const llm: ChatLLM = {
  streamProtocol: openAIAdapter(),
  async send({ threadId, messages, signal }) {
    return fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ threadId, messages }),
      signal, // was abortController.signal
    });
  },
};

<AgentInterface llm={llm} />;

The behavior change to internalize: you no longer create or own an AbortController. AgentInterface owns it and hands you the signal — wired to the UI's stop control — in every send call. Just forward it.

Thread props → storage

threadApiUrl plus the per-operation callbacks become a single ChatStorage whose thread member holds five methods.

The REST case → restStorage

If your old setup pointed threadApiUrl at a REST backend, the migration is one factory call:

import { restStorage } from "@openuidev/react-ui";

const storage = restStorage({ baseUrl: "/api/threads" }); // was threadApiUrl

<AgentInterface llm={llm} storage={storage} />;

restStorage hits the exact endpoints the old threadApiUrl prop did — GET {baseUrl}/get, POST {baseUrl}/create, GET {baseUrl}/get/{threadId}, PATCH {baseUrl}/update/{id}, DELETE {baseUrl}/delete/{id} — so an existing backend keeps working. Pass messageFormat here too if your backend stores messages in a provider shape.

If you pass no storage at all, AgentInterface uses an internal in-memory store — fine for prototyping, but wiped on reload.

Custom thread callbacks → storage.thread.*

If you supplied individual thread callbacks, each maps to a method on storage.thread:

import { AgentInterface, type ChatStorage } from "@openuidev/react-ui";

const storage: ChatStorage = {
  thread: {
    listThreads: fetchThreadList, // now takes an optional cursor, returns { threads, nextCursor? }
    createThread,                 // receives the first UserMessage, returns the new Thread
    getMessages: loadThread,      // was loadThread — takes threadId, returns Message[]
    updateThread,                 // takes a full Thread, returns the updated Thread
    deleteThread,                 // takes an id
  },
};

<AgentInterface llm={llm} storage={storage} />;

Two renames carry behavior changes: fetchThreadListlistThreads now takes an optional cursor and must return { threads, nextCursor? } (cursor pagination for the sidebar's "load more"); loadThreadgetMessages is the same job, just renamed for symmetry. If your callbacks already match these shapes, hand them over directly; otherwise wrap them.

Renderers: appRenderersartifactRenderers

The "app renderer" concept was renamed to artifact renderer throughout — the prop, the factory, and the parser contract all changed.

import { AgentInterface, defineArtifactRenderer } from "@openuidev/react-ui";

const codeArtifactRenderer = defineArtifactRenderer({
  type: "code_artifact",                // was kind
  toolName: "create_code_artifact",     // now string | string[]
  parser: ({ args, response }, { isStreaming }) => {
    const data = response as CodeArtifact | null;
    if (!data) return null;             // tolerate partial data while streaming
    return {
      props: data,                      // props AND meta now come from one return value
      meta: isStreaming ? null : { id: `code:${data.title}`, version: 1, heading: data.title },
    };
  },
  preview: (props, controls) => <CodeCard {...props} onOpen={controls.open} />,
  actual: (props) => <CodeBlock language={props.language} codeString={props.code} />,
});

<AgentInterface llm={llm} artifactRenderers={[codeArtifactRenderer]} />;

The behavior changes inside the renderer:

  • kindtype. A literal string that links the renderer to its stored artifacts' type.
  • toolName is now string | string[]. Register one renderer for several tools by passing an array. Names are literal only — no RegExp. First registration wins on a duplicate toolName.
  • The parser returns { props, meta } (or null), not props alone. meta is { id, version, heading } | null: return the object to render and register the artifact in the thread (so it appears in the Workspace rail and artifact lists); return null for meta to render without registering (the common move while streaming). Returning null from the parser entirely skips rendering.
  • The parser must tolerate partial data. It's called on every stream update — response is null until the result lands, and args may be a partial JSON string. Guard accordingly.

Hooks and panel APIs

useAppList()useArtifactList(filter?), imported from @openuidev/react-ui. It returns the per-thread artifact registry, optionally filtered by type:

import { useArtifactList } from "@openuidev/react-ui";

const artifacts = useArtifactList();
const codeArtifacts = useArtifactList({ type: ["code_artifact"] });

The in-thread panel hooks that used to be named around "Artifact" are now named around detailed view: useActiveDetailedView(), useDetailedView(viewId), useDetailedViewStore(), useDetailedViewPortalTarget(). If you reached into the old Artifact* panel hooks, swap to these.

"Artifact" now consistently means the durable output (a dashboard, report, app); "detailed view" means the in-thread panel that shows one. The old "app" / "Artifact panel" naming conflated the two.

Migration checklist

  1. Rename the legacy chat component → AgentInterface (same @openuidev/react-ui package).
  2. Build llm = fetchLLM({ url, streamAdapter, messageFormat }) from your old apiUrl / streamProtocol / messageFormat. Call the adapter (openAIAdapter()).
  3. If you had a custom processMessage, implement ChatLLM directly and forward the signal — drop your own AbortController.
  4. Build storage = restStorage({ baseUrl }) from threadApiUrl, or assemble storage.thread.* from your fetchThreadList (→ listThreads), createThread, updateThread, deleteThread, and loadThread (→ getMessages).
  5. Pass llm (required) and storage (optional) to AgentInterface.
  6. Rename appRenderersartifactRenderers and defineAppRendererdefineArtifactRenderer; change kindtype, allow toolName arrays, and return { props, meta } from each parser.
  7. Rename useAppListuseArtifactList and any Artifact* panel hooks → DetailedView*.
  8. Delete every remaining flat backend prop — they're gone. If TypeScript flags an unknown prop, find it in the table above and move it into the right adapter.

On this page