Components
AgentMessage
A whole assistant message: reasoning, streaming markdown, tool calls, approvals and sources.
Example
I’ll check the route handler and confirm the limiter API first.
Interactive, and the same on the components page. Try the keyboard below on it.
Installation
npm i signoff-ui aiimport { AgentMessage } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';npx shadcn@latest add https://signoff-ui.vercel.app/r/agent-message.jsonimport { AgentMessage } from '@/components/signoff-ui/agent-message';The styles are once per app; Getting started has the Tailwind v4 and plain CSS options.
Usage
const { messages, status, addToolApprovalResponse } = useChat();
const last = messages.findLast((m) => m.role === 'assistant');
{last && (
<AgentMessage
message={last}
streaming={status === 'streaming'}
tools={{ run_command: { label: 'Run command', risk: 'high' } }}
onToolApproval={addToolApprovalResponse}
/>
)}How it works
A whole assistant UIMessage, part by part: streaming-safe markdown, collapsible reasoning that summarizes how long the model thought, consecutive tool parts grouped into one timeline with inline approval cards, files, data-* parts through renderData, and sources. renderTool lets you take over any tool part, as the quickstart does for review_changes with a DiffReview. What renderTool and renderData return is your content: with styles.css, the kit's rules don't reach into it, and components you render there are styled as usual.
Images in text and reasoning don't load unless they're allowed: a URL in model output can carry data out of the conversation as soon as the browser fetches it (, so by default an image renders as a link with its alt text, or as plain text inside the link it belongs to (a badge). allowedImageHosts takes:
- host names:
'images.example.com'matches any port,'localhost:3000'only that port; 'self': relative URLs such as/, which load from your own origin. Protocol-relative URLs (logo.png /) are not relative, and absolute URLs to your own site need their host listed. Relative requests carry your cookies, so allow/ host/ x 'self'only if GET requests to your origin have no side effects;'*': every image.
const IMAGE_HOSTS = ['images.example.com', 'self'];
<AgentMessage message={last} allowedImageHosts={IMAGE_HOSTS} />;The list is compared by value, so an inline array works as well: it does not re-render the finished text on every streamed delta. Image file parts follow the same list, except that data: and blob: URLs, which need no request, always preview.
API reference
Generated from the types the package ships, so it matches the version you install.
AgentMessage
Renders an assistant UIMessage part by part: streaming markdown, collapsible
reasoning, consecutive tool calls grouped into a timeline (with inline approval
cards), files, and the message's sources with linked inline citations.
Props
messagerequiredPick<UIMessage, 'id' | 'role' | 'parts'>streamingbooleanDefaultfalseThe message is still being generated. Text parts with an explicit
statetake precedence.activebooleanDefaulttrueWhether the run can still make progress, including while it waits on the user. Pass
falseonce it has ended (stopped, failed, or an older message): tool calls that never settled read "Stopped" instead of running forever, and text left streaming loses its caret. SeeToolCallTimeline'sactive.toolsRecord<string, ToolMeta>renderTool(part: ToolPart) => ReactNode | undefinedRender a tool part yourself. Return
undefinedto use the default timeline, ornullto render nothing. Custom-rendered parts split the timeline. What you return is your app's content: it sits in adata-signoff-slotelement, andstyles.cssleaves it to your CSS (components you render in it are styled as usual).onToolApproval(response: ToolApprovalResponse) => void | PromiseLike<void>Enables inline approval cards. Pass
useChat().addToolApprovalResponse.approvalPropsPartial<Omit<ApprovalCardProps, 'toolName' | 'status' | 'onApprove' | 'onDeny'>>Props forwarded to every approval card, e.g.
{ autoFocus: true }.approvalPolicyApprovalPolicyApproval rules from
useApprovalPolicy: cards offer once, this session and always, a rule's decisions are made without asking, and two or more waiting approvals get "Approve all".onToolInputEdit(toolCallId: string, input: unknown) => voidLets a person edit a call's arguments before approving it, and applies the edit. With AI SDK 7:
(id, input) => setMessages((m) => setToolInput(m, id, input)); withuseAgUiAgent, itseditInput.renderData(part: DataPart) => ReactNodeRender
data-*parts. They are skipped when omitted. Your content, likerenderTool's.showSourcesbooleanDefaulttruesourcesVariant'chips' | 'cards'Default'chips'allowedImageHostsreadonly string[]Where images in text, reasoning and image file parts may load from: host names,
'self'for relative URLs, or'*'for every image. Default: none, so other images render as links. File parts withdata:andblob:URLs always preview. SeeMarkdownProps.allowedImageHosts.timingsToolTimingsExternally measured tool timings. Measured client-side when omitted.
labelsSignoffLabelsInputWords to use instead of the English defaults, for the message and all it renders: see
SignoffLabelsProvider.
Other props go to the root <article>: className (merged with tailwind-merge), id, aria-*, data-* and event handlers.
ToolMeta
Per-tool presentation. Every field is optional.
labelstringDisplay label. Defaults to the humanized tool name.
iconReactNodesummary(input: unknown, part: ToolPart) => ReactNodeOne-line summary shown next to the label. Defaults to the most descriptive input field. Not called before any input has arrived; while the input streams it can be partial.
renderOutput(output: unknown, part: ToolPart) => ReactNodeReplace the default JSON output view. Your content, like
AgentMessage'srenderTool.riskRiskLevel | ((input: unknown) => RiskLevel)Risk shown on approval requests for this tool.
ToolApprovalResponse
idrequiredstringapprovedrequiredbooleanreasonstring
Accessibility
| Keys | Action |
|---|---|
| ↑↓ | Move between tool calls in a timeline |
| YN | Approve or deny, with focus in an approval card |
- The message is an
<article>witharia-busywhile it streams, so screen readers wait for the text to settle. - Each part keeps its own behavior: the tool timeline, approval cards, reasoning and sources below all apply.
- Images in model output don't load unless
allowedImageHostsallows them; a blocked image is a link with its alt text, read as "Image:". - Citation markers such as
[2]become links named "Source 2" to the matching source.
Theming
Every color, radius and font is a CSS variable, so the overrides in Theming apply, on :root or scoped to any element. These hooks are read from the component's markup.
- Slots (data-slot)
- signoff-agent-message
- State attributes
- data-roleon
signoff-agent-messagethe message’s role: assistant, user or system - Tokens it uses
- --signoff-border--signoff-fg--signoff-fg-subtle--signoff-font-sans--signoff-ring--signoff-surface--signoff-surface-2
/* Only this component, and only inside .settings-panel */
.settings-panel [data-slot='signoff-agent-message'] {
--signoff-radius: 4px;
}