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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ai-chat-interface Installs to ~/.agents/skills/ai-chat-interface/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ai-chat-interface The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ai-chat-interface Installs to .agents/skills/ai-chat-interface/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ai-chat-interface.zipunzip -o ai-chat-interface.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ai-chat-interface PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md,package.jsonand the existing component library. Reuse the project’s design tokens, button and textarea components rather than inventing new ones. - 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 itsuseChathook and follow vercel-ai-sdk instead of hand-rolling state. - 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
statuson 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
| Action | Client | Server |
|---|---|---|
| Stop | controller.abort() on the fetch; mark message stopped; keep partial text | Detect disconnect and cancel the upstream model call (see llm-streaming) |
| Retry after error | Re-send the same user message id; replace the failed assistant message | Idempotent on message id |
| Regenerate | Drop the last assistant message from the context, request again | Same conversation, new assistant id |
| Edit and resend | New user message with parentId of the edited one’s parent; old branch kept | Store 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-markdownwithremark-gfmworks; Streamdown (streamdownon 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-rawis required, putrehype-sanitizeafter it. Links getrel="noopener noreferrer"andtarget="_blank"; blockjavascript: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: autoon the scroll container and a sentinel element at the end; scroll withsentinel.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"witharia-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-motionfor 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/Parttypes 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 ajavascript: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
AbortErroris 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.yaml
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.