AI Chat Interface skill for Codex

Chat UIs that stream, stop and recover cleanly. Codex builds chat interfaces around a typed message-parts model instead of one growing string, buffers tokens per animation frame so long answers do not thrash React, repairs half-open code fences while streaming, and renders tool calls as ordered cards with approval gates. Stop aborts the request and the upstream model call, partial answers survive reloads, and untrusted output is sanitized.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- ai-chat-interface

Installs to ~/.agents/skills/ai-chat-interface/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $ai-chat-interface, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds production chat UIs for LLM apps in React and similar frameworks - message parts model, token streaming with incremental markdown and code rendering, tool-call cards with pending, running and error states, stop, retry, regenerate and edit-and-resend, scroll anchoring, persistence and accessible live regions. Use when building or fixing a chatbot or assistant UI, when streamed markdown flickers or breaks mid code fence, when the stop button does not actually stop generation, or when tool calls need to be shown in the conversation.

Use it when

  • Building or refactoring a chatbot or assistant front end
  • Streaming responses flicker, jump or re-render the whole transcript
  • Stop, retry, regenerate or edit-and-resend behave inconsistently
  • Tool calls or approvals need to appear in the transcript

Use something else when

  • The project already uses the AI SDK useChat hook end to end: use vercel-ai-sdk
  • The server side of the stream is the problem (buffering proxies, cancellation): use llm-streaming

How to check the result

Throttle the network and stream a long answer with code: rendering stays smooth, Stop cancels the request and the upstream call, and an injected HTML payload in model output never executes.

The mistake it prevents

Calling setState for every token, which re-renders the full message list hundreds of times per second and makes long answers stutter.

~/.agents/skills/ai-chat-interface/SKILL.md

--- name: ai-chat-interface description: Builds production chat UIs for LLM apps in React and similar frameworks - message parts model, token streaming with incremental markdown and code… license: MIT ---

A chat UI is done when a user can send, watch tokens arrive without layout jumps, stop mid-answer and get a clean partial message, retry or edit without losing history, see what tools ran, and reload the page into the same state. Rendering untrusted model output never executes HTML or script.

Before you build

  1. Read AGENTS.md, package.json and the existing component library. Reuse the project’s design tokens, button and textarea components rather than inventing new ones.
  2. Find the transport. If the backend already streams (SSE, fetch stream, WebSocket), use its event shape. If nothing streams yet, build the server side first with llm-streaming. If the project uses the Vercel AI SDK (ai, @ai-sdk/react), use its useChat hook and follow vercel-ai-sdk instead of hand-rolling state.
  3. Decide where conversations persist (server DB, local only) before writing state code. Changing it later rewrites every component.

Message model

Model messages as typed parts, not a single string. Tool calls, reasoning summaries, images and citations all need their own rendering.

type Part =
  | { type: "text"; text: string }
  | { type: "tool-call"; id: string; name: string; args: unknown;
      state: "pending" | "running" | "done" | "error"; result?: unknown; error?: string }
  | { type: "reasoning"; text: string }
  | { type: "image"; url: string; alt: string }
  | { type: "citation"; sourceId: string; title: string; url?: string };

type Message = {
  id: string;                 // client-generated (crypto.randomUUID()) so retries are idempotent
  role: "user" | "assistant";
  parts: Part[];
  status: "streaming" | "complete" | "stopped" | "error";
  parentId?: string;          // enables edit-and-resend branches
  createdAt: number;
};

Rules:

  • Keep status on the message. “stopped” and “error” messages stay in history and render differently; they are not deleted.
  • Generate ids on the client so a retried POST with the same id is deduplicated server-side.

Streaming state

Append deltas to a ref or reducer, and flush to React state at most once per animation frame. Setting state per token re-renders the whole list hundreds of times a second.

const buffer = useRef("");
const frame = useRef<number | null>(null);

function onDelta(chunk: string) {
  buffer.current += chunk;
  if (frame.current == null) {
    frame.current = requestAnimationFrame(() => {
      frame.current = null;
      const text = buffer.current;
      setMessages(ms => ms.map(m => m.id === activeId
        ? { ...m, parts: replaceLastText(m.parts, text) } : m));
    });
  }
}

Memoize each finished message (React.memo keyed on id + status) so only the streaming one re-renders.

Stop, retry, regenerate, edit

ActionClientServer
Stopcontroller.abort() on the fetch; mark message stopped; keep partial textDetect disconnect and cancel the upstream model call (see llm-streaming)
Retry after errorRe-send the same user message id; replace the failed assistant messageIdempotent on message id
RegenerateDrop the last assistant message from the context, request againSame conversation, new assistant id
Edit and resendNew user message with parentId of the edited one’s parent; old branch keptStore branches; build context from the selected path
const controller = new AbortController();
const res = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ conversationId, message }),
  signal: controller.signal,
});
// Stop button:
controller.abort();   // fetch rejects with AbortError: treat as "stopped", not "error"

Disable the send button while a response streams, or queue the next message; never run two assistant streams into the same conversation at once.

Rendering markdown while it streams

  • Use a markdown renderer that tolerates incomplete input. react-markdown with remark-gfm works; Streamdown (streamdown on npm) is built for streaming and closes unterminated fences and emphasis for you.
  • If you use plain react-markdown, patch the display string before rendering: if the count of ”```” fences is odd, append a closing fence so the half-written code block renders as code instead of turning the rest of the message into a paragraph.
  • Never enable raw HTML from model output. If rehype-raw is required, put rehype-sanitize after it. Links get rel="noopener noreferrer" and target="_blank"; block javascript: URLs.
  • Syntax highlighting is expensive. Highlight code blocks only after the message completes, or debounce to every 250 ms during streaming.
  • Add a copy button per code block that copies the raw source, not the highlighted HTML.
  • Render math (KaTeX) and Mermaid only if the product needs them; both are heavy.

Tool calls in the transcript

  • Show a compact card per tool call: name, a one-line summary of arguments, a spinner while running, a collapsed result, and a visible error state. Raw JSON goes behind a disclosure.
  • Tools that need approval (sending email, payments, deleting data) render Approve and Deny buttons and block the loop until the user decides. Never auto-approve them in UI code.
  • Keep tool cards in order with text parts. Do not move them to a sidebar; users lose the causal order.

Scroll behaviour

  • Stick to the bottom only while the user is already near it (within ~80 px). If they scroll up to read, stop auto-scrolling and show a “Jump to latest” button.
  • Use overflow-anchor: auto on the scroll container and a sentinel element at the end; scroll with sentinel.scrollIntoView({ block: "end" }) inside the same rAF flush.
  • Reserve space for the streaming message (min-height) to reduce layout shift.

Input box

  • Enter sends, Shift+Enter inserts a newline; respect IME composition (if (e.nativeEvent.isComposing) return;) or CJK users send half-typed words.
  • Auto-grow the textarea up to a max height, then scroll inside it.
  • Keep the draft per conversation (sessionStorage) so a reload or navigation does not lose it.
  • Disable send for whitespace-only input; show a character or token limit before the server rejects it.

Accessibility

  • The message list is role="log" with aria-live="polite". Announce the completed assistant message, not every token; announce “Response stopped” or the error text.
  • Stop, retry and copy buttons have accessible names. Focus returns to the input after send.
  • Respect prefers-reduced-motion for typing cursors and auto-scroll smoothing.

Persistence and errors

  • Persist the user message before calling the model, and the assistant message as it completes (or as “stopped” with partial text). A refresh during streaming should show the partial message, not a missing turn.
  • Map errors to actionable messages: 429 → “Busy, retrying in Ns” with backoff; context too long → offer to start a new chat or summarize; content refused → show the refusal text as an assistant message, not a red error.

Workflow

  • Read AGENTS.md, the component library and the existing chat API contract
  • Define the Message / Part types shared with the server
  • Implement send with optimistic user message and client ids
  • Stream into a buffered reducer with rAF flushing
  • Add stop (AbortController) and confirm the server cancels upstream
  • Render markdown safely with fence repair; highlight on completion
  • Add tool-call cards with approval gates for side-effecting tools
  • Scroll anchoring, input behaviour, accessibility
  • Persistence and reload test

Verification

  • Network throttled to “Slow 3G”: tokens still appear smoothly; no layout jump when a code block opens and closes.
  • Click Stop mid-answer: the request shows as cancelled in DevTools, server logs show the upstream call cancelled, the partial message stays with a “stopped” marker.
  • Paste model output containing <img src=x onerror=alert(1)> and a javascript: link: nothing executes.
  • React Profiler during a long stream: only the streaming message commits per frame.
  • Reload mid-stream and after completion: history matches what was shown.
  • Keyboard only: send, stop, copy and retry are reachable; a screen reader announces the finished answer once.
  • If Playwright exists in the repo, add an e2e test that mocks the stream endpoint and asserts stop and retry behaviour.

Gotchas

  • AbortError is not a failure. Treating it as an error shows a red banner every time a user presses Stop.
  • Closing the browser tab does not stop billing unless the server propagates cancellation.
  • Markdown parsers re-parse the full string per update. For very long answers, split the finished prefix (complete paragraphs) into memoized blocks and only re-parse the tail.
  • Rendering the model’s tool arguments as HTML is an injection path; treat them as text.
  • Keys by array index break when a regenerate replaces a message; key by message id.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "AI Chat Interface"
  short_description: "Chat UIs that stream, stop and recover cleanly"
  default_prompt: "$ai-chat-interface Build a chat page for our assistant with streaming answers, a stop button and regenerate"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem