Custom artifacts

Add a custom artifact type beyond Cloud's built-in slides and reports: produce the data, write a renderer, register it.

OpenUI Cloud ships built-in artifact types (slides, reports) already wired to render. When your agent produces something they don't cover, like a code snippet or a custom app, you add your own type: a tool returns the data, a renderer turns it into UI, and the artifactRenderers prop registers it. The same renderer draws the artifact whether it just streamed in or was loaded from storage.

This guide builds one custom artifact end to end: a code snippet the agent generates. For the concept, see Artifacts. For every renderer field and edge case, see defineArtifactRenderer.

1. Produce the artifact

An artifact reaches the UI as the result of a tool call. Your agent calls a create_code_artifact tool, and its arguments are the artifact's data. The renderer below matches that tool by name and draws the result, the same way on OpenUI Cloud or your own backend.

Keep that shape identical to whatever you persist, so one renderer covers both the freshly-streamed and the loaded-from-storage cases. For this example the data is:

interface CodeArtifact {
  language: string;
  title: string;
  code: string;
}

2. Write the renderer

A renderer is keyed to an artifact type, not to a tool. A tool call is just one way the data arrives. You describe it with defineArtifactRenderer: a parser that reads the raw envelope into typed props, a preview (the inline card in the chat), and an actual (the full view in a side panel or page).

The rule that matters most: the parser must never throw. It runs on every stream update, including before the result exists, so read response as the source of truth, fall back to a tolerant parse of args for an early preview, and bail with null until you have enough to draw.

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

const codeArtifactRenderer = defineArtifactRenderer({
  type: "code_artifact",
  toolName: "create_code_artifact",

  parser: ({ args, response }, { isStreaming }) => {
    // Storage path: args is undefined, response is the persisted content.
    // Tool-call path: response is null until the result lands; args streams
    // in first as a partial JSON string.
    const data = (response as CodeArtifact | null) ?? tryParse(args);
    if (!data?.title) return null; // not enough to draw yet

    return {
      props: data,
      // Don't register in the workspace until the result is final.
      meta: isStreaming
        ? null
        : { id: `code:${data.title}`, version: 1, heading: data.title },
    };
  },

  preview: (props, controls) => (
    <button
      type="button"
      onClick={controls.open}
      data-active={controls.isActive}
      className="code-card"
    >
      <span className="code-card__title">{props.title}</span>
      <span className="code-card__lang">
        {props.language}
        {controls.isStreaming ? " · building…" : ""}
      </span>
    </button>
  ),

  actual: (props) => (
    <div className="code-artifact">
      <CodeBlock language={props.language || "text"} codeString={props.code} />
    </div>
  ),
});

// Best-effort parse of a possibly-partial JSON arg string.
function tryParse(args: unknown): CodeArtifact | null {
  if (typeof args !== "string") return null;
  try {
    return JSON.parse(args) as CodeArtifact;
  } catch {
    return null; // still mid-stream
  }
}

A few things to notice:

  • preview vs actual: preview is the compact card inline in the message; keep it small and give it an affordance that calls controls.open(). actual is the full view in the detailed-view panel or the artifact page. Here it uses the built-in CodeBlock component.
  • meta: return meta: null while streaming so the artifact joins the workspace only once complete; return { id, version, heading } on the final pass to register it. Keep id stable across re-runs of the same artifact.
  • controls.isStreaming: the same component instance is reused across the streaming-to-complete transition, so this is a state swap, not a remount.

3. Register it

Hand your renderer to <AgentInterface> through the artifactRenderers prop. Pass as many as you like:

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

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

The interface indexes each renderer by toolName (the tool-call path) and by type (the storage path), so a create_code_artifact call renders as an inline card that opens a full panel, and any code_artifact loaded from storage renders through the same code. On a duplicate toolName or type, the first registration wins, so put your custom renderers before any built-in defaults.

On this page