Agent
An agent's turn, in components: what it ran, what it was thinking, what it wants permission to do — and what it says while the answer is still arriving.
Import
JSX
import { ToolCallCard, ApprovalCard, Reasoning, StreamingText } from '@box-kite/react/components/agent';One turn, from thought to decision
A tool call, its reasoning and the gate in front of the next step. Press the button and the turn runs — the same four statuses an agent runtime reports, in the order it reports them.
The order is 4182 and the customer is asking for a refund. The refund window is 30 days; the order was placed on the 4th, so it is still open. The amount is 6,400 MDL, which is over the 5,000 threshold, so a person has to say yes.
Input
{
"orderId": 4182
}JSX
<Reasoning duration={1200}>{reasoningText}</Reasoning>
<ToolCallCard name="searchOrders" status="success" input={{ orderId: 4182 }} output={{ total: 6400 }} />
<ApprovalCard
title="Refund order 4182"
description="6,400 MDL back to the customer. This cannot be undone."
input={{ orderId: 4182, amount: 6400 }}
onDecisionChange={(decision) => respond(decision === 'approved')}
/>The status is a word, not a colour
Four states —
pending, running, success, error — and each carries its own label beside the dot. A forced-colors mode throws a tint away and a screen reader never had it, so a state told by fill alone is a state half the readers of a transcript cannot see.Input
{
"query": "refunds",
"limit": 20
}Input
{
"query": "refunds",
"limit": 20
}Input
{
"query": "refunds",
"limit": 20
}Output
[
{
"id": 4182,
"total": 6400
}
]Input
{
"query": "refunds",
"limit": 20
}Error
The search index is rebuilding. Try again in a minute.
JSX
<ToolCallCard name="searchOrders" status="running" input={{ query: 'refunds' }} />They map one for one onto what every runtime already reports under its own spelling — AI SDK's
input-streaming, input-available, output-available and output-error — so wiring a part to a card is a lookup rather than a state machine.JSX
const STATUS = {
'input-streaming': 'pending',
'input-available': 'running',
'output-available': 'success',
'output-error': 'error',
};
{message.parts.map((part) =>
part.type.startsWith('tool-') ? (
<ToolCallCard
key={part.toolCallId}
name={part.type.slice(5)}
status={STATUS[part.state]}
input={part.input}
output={part.output}
error={part.errorText}
/>
) : null,
)}A value is whatever the model produced
A tool's arguments are not data an app wrote — they are JSON a model invented, and it can be circular, hold a
BigInt, or be four megabytes long. All three make JSON.stringify throw or freeze the frame, so the card formats rather than trusts: the text is capped at valueLimit (20,000 characters) with a line saying how much was left, and a value that cannot be serialised is described instead of crashing the transcript around it.The judgement is a model, not a component
AgentUtils.formatValue, formatDuration and statusLabel are framework-free and exported from the same entry, so the same decisions can be made in a server route, a test or a log line.JSX
import { AgentUtils } from '@box-kite/react/components/agent';
const { text, truncated } = AgentUtils.formatValue(part.output, 4000);A card with nothing to show renders no control at all: a header that discloses nothing is a tab stop nobody wants to land on. Pass
collapsible={false} to keep a row of text whatever it holds.The decision is one channel
onDecisionChange(decision, { reason }) is the whole API — the decision says what was chosen and the reason says which button chose it, the shape every other component in the library reports through. It maps straight onto AI SDK 6's needsApproval, CopilotKit's renderAndWaitForResponse and AG-UI's INTERRUPT.Refund order 4182
6,400 MDL back to the customer. This cannot be undone.
Request
{
"orderId": 4182,
"amount": 6400,
"currency": "MDL"
}JSX
<ApprovalCard title="Refund order 4182" input={{ orderId: 4182 }} onDecisionChange={() => {}} />Two things a decision card must not do
It does not take focus. An agent's turn arrives while the reader is somewhere else, and a card that grabs the keyboard is one that gets answered by accident —
autoFocus is the opt-in, and it lands on Reject, which is APG's rule for a decision: the least destructive action. And it does not announce itself; the transcript it is rendered into is what does that. What the card owns is the answer, in a live region that is in the DOM before there is anything in it — a region inserted together with its text is not reliably read out.busy is the round trip: both buttons disabled and aria-busy on the card while the answer is on its way to a server. Once there is a decision the buttons are gone, because a decision that can be pressed twice is not one.Reasoning is an aside
Closed by default, because a chain of thought is an aside and a transcript of them is unreadable.
streaming changes what the header says and shimmers it; whether it also opens is the app's call, since that is a value somebody may be controlling — open={streaming} is the usual answer, and duration is what the header says once it is over.The order is 4182 and the customer is asking for a refund. The refund window is 30 days; the order was placed on the 4th, so it is still open. The amount is 6,400 MDL, which is over the 5,000 threshold, so a person has to say yes.
JSX
<Reasoning duration={4200}>{thought}</Reasoning>It opens in the same one-row grid an
Accordion panel does — a track running 1fr to 0fr, so nothing is measured, nothing is written per instance and the rules are shared with every other disclosure on the page.What it says, as it arrives
StreamingText takes the message so far and fades in the part that was not there a render ago. A message that is already whole — a prerendered page, a transcript read back — paints at once with nothing animating, and one still arriving costs the same at the ten-thousandth token as at the first: the text is one settled string plus the last few runs to reach it.Order 4182 was placed on the 4th of September, so it is still inside the 30-day refund window. The amount is 6,400 MDL, which is over the threshold a tool may refund on its own — so the refund is waiting on a person rather than on me.
JSX
<StreamingText text={message} streaming={status === 'streaming'} />The entrance is
@starting-style rather than a keyframe, so it rides --transitionTime and disappears under prefers-reduced-motion with nothing declared for it; the caret is the pulse preset, which stops itself for the same reason. window is how many runs stay faded at once — 0 turns the entrance off, and a stream fast enough to fill the window inside one transition is the case for raising it.It is not a live region
A region announcing every token reads the message out a word at a time, and again when it finishes. What announces an agent's turn is the transcript it lands in; what the element carries is
aria-busy while more is coming.Markdown, without the stylesheet
A model writes markdown, and a parser is a choice an app has usually already made — so what ships is the half that is ours:
markdownComponents, the components map that react-markdown, streamdown and everything built on that shape already takes, with the engine's classes on it. No stylesheet, no build-tool config and no design tokens to declare.JSX
import Markdown from 'react-markdown';
import { markdownComponents } from '@box-kite/react/components/markdown';
<Box component="markdown">
<Markdown components={markdownComponents}>{message}</Markdown>
</Box>It is a constant, not a factory, and while streaming that is the whole difference: a map built inside render is a new set of component types every token, which React answers by unmounting the message and mounting it again. Override a node by spreading at module scope —
{ ...markdownComponents, h1: MyHeading }.Whether a URL is safe stays the renderer's: by the time a component is called the href has been parsed, so a
javascript: link is refused by urlTransform or defaultUrlTransform. What the map sets is rel="noreferrer", which costs nothing.Where the answer will be
Skeleton is the placeholder: bars where the content goes, with a gloss crossing them. The duration is named in milliseconds, so it sits outside what --transitionTime zeroes and stops itself under prefers-reduced-motion. It renders on a server — no state, no effect, no measurement.Loading orders
JSX
<Skeleton lines={3} label="Loading orders" />With no
label the whole thing is aria-hidden, because a reader told "three empty bars" has been told nothing. A label makes it a role="status" naming what is on its way — put it on the one skeleton standing for a region and leave it off the rest, since a screen of placeholders each announcing itself is a screen nobody can listen to.Styling
Three trees:
toolCall with header, summary, name, description, status (and status.dot), arrow, body, section, label, value and truncated under it; approval with title, description, the same three value nodes, footer, actions, button and decision; and reasoning with trigger, arrow, duration and body. The status is a variant on toolCall.status and the decision one on approval itself, so a card can be re-skinned per state without a prop.Three more for what it says:
streamingText with segment (the run that fades) and caret; skeleton with bar — whose short and circle variants are the last line and the avatar — and gloss; and markdown, whose heading, paragraph, link, list, item, quote, code, codeBlock, rule, image, table, row, cell, inline and checkbox are what a renderer's components map draws with.JSX
Box.components({
toolCall: { styles: { borderRadius: 3, shadow: 'xs' } },
approval: { variants: { approved: { bgColor: 'emerald-100' } } },
});ToolCallCard props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
namerequired | ReactNode | — | The tool's name, as the model called it. It is what the header reads. |
description | ReactNode | — | A line under the name: what this call is for, in the app's own words. |
status | ToolCallStatus | 'pending' | Where the call is. Default pending. |
input | unknown | — | The arguments. A string is shown as it stands, anything else as the JSON a reader can check. |
output | unknown | — | What came back, shown the same way. |
error | unknown | — | What went wrong, shown in place of the output. |
open | boolean | — | Controlled: whether the body is open. |
defaultOpen | boolean | false | Whether it starts open. Default false — a transcript of tool calls is a list, not a wall of JSON. |
onOpenChange | ChangeHandler<boolean, DisclosureReason> | — | Fires when the header is pressed, with the body's new state. |
collapsible | boolean | true | Whether the body can be opened at all. Default true; false leaves the header a plain row. |
inputLabel | ReactNode | 'Input' | The word above the arguments. Default Input. |
outputLabel | ReactNode | 'Output' | The word above what came back. Default Output. |
errorLabel | ReactNode | 'Error' | The word above what went wrong. Default Error. |
valueLimit | number | — | How much of a value is shown before it is cut. Default AgentUtils.VALUE_LIMIT (20,000 characters). |
children | ReactNode | — | Anything else that belongs in the body, under the values. |
ToolCallCard keyboard
| Key | Result |
|---|---|
Tab | Focuses the header, and again leaves it. Nothing inside a closed body is reachable. |
Enter, Space | Opens the body, or closes it. |
ToolCallCard accessibility
- The header is a real
<button>carryingaria-expandedandaria-controlswhen there is a body to open, and a plain row when there is not — a control that discloses nothing is a control nobody wants to land on. aria-busywhile the call is running, so a reader is told the card is not finished rather than reading a half-written result as the answer.- The card announces nothing itself. The transcript it is rendered into is what does that, and a live region inserted together with its content is not reliably announced.
Swept with axe on every release, in this state:
ToolCallCard (every status). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.ToolCallCard style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.toolCalltoolCall.headervariants: interactivetoolCall.summarytoolCall.nametoolCall.descriptiontoolCall.statusvariants: pending, running, success, errortoolCall.status.dotvariants: runningtoolCall.arrowvariants: opentoolCall.trackvariants: closedtoolCall.cliptoolCall.bodytoolCall.sectiontoolCall.labeltoolCall.valuetoolCall.truncatedApprovalCard props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
titlerequired | ReactNode | — | What is about to happen, in one line. It names the card. |
description | ReactNode | — | The consequence, where a title cannot carry it: what this will change, and whether it can be undone. |
input | unknown | — | The call itself, shown the way a ToolCallCard shows its input. |
inputLabel | ReactNode | 'Request' | The word above it. Default Request. |
decision | ApprovalDecision | null | — | Controlled: what has been decided. null is "not yet", and is what shows the two buttons. |
defaultDecision | ApprovalDecision | null | null | What it starts as. Default null. |
onDecisionChange | ChangeHandler<ApprovalDecision | null, ApprovalReason> | — | Fires when a button is pressed, with the decision and which button made it. |
busy | boolean | false | The decision is on its way to a server: both buttons are disabled and the card reports aria-busy. |
approveLabel | ReactNode | 'Approve' | What the approving button says. Default Approve. |
rejectLabel | ReactNode | 'Reject' | What the refusing button says. Default Reject. |
autoFocus | boolean | false | Whether to put focus on the card when it mounts. Default false — an agent's turn arrives while the reader is somewhere else, and taking focus from them is how a decision gets pressed by accident. It lands on the *reject* button, which is APG's rule for a decision: the least destructive action. |
valueLimit | number | — | How much of the input is shown before it is cut. Default AgentUtils.VALUE_LIMIT. |
children | ReactNode | — | Anything else that belongs above the buttons. |
ApprovalCard keyboard
| Key | Result |
|---|---|
Tab | Reaches the two buttons in order; Enter or Space presses one. |
ApprovalCard accessibility
role="group"named by the title, so the two buttons are read as belonging to one decision rather than as loose controls in a transcript.- Focus is not taken on mount unless
autoFocusis set, and then it lands on the least destructive button. A card that grabs the keyboard is one that gets answered by accident. aria-busywhilebusy, which is also when both buttons are disabled.
Swept with axe on every release, in this state:
ApprovalCard (undecided and answered). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.ApprovalCard style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.approvalvariants: approved, rejectedapproval.titleapproval.descriptionapproval.sectionapproval.labelapproval.valueapproval.truncatedapproval.footerapproval.actionsapproval.buttonvariants: approve, rejectapproval.decisionvariants: approved, rejectedReasoning props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | — | The header's words. Default Reasoning, or Thinking… while streaming. |
children | ReactNode | — | The thought itself. |
open | boolean | — | Controlled: whether it is open. |
defaultOpen | boolean | false | Whether it starts open. Default false — a chain of thought is an aside, not the answer. |
onOpenChange | ChangeHandler<boolean, DisclosureReason> | — | Fires when the header is pressed. |
streaming | boolean | false | Still arriving: the header says so and shimmers. Opening it is the app's — pass open={streaming}. |
duration | number | — | How long the thought took, in milliseconds. Rendered beside the label as "Thought for 4s". |
Reasoning keyboard
| Key | Result |
|---|---|
Tab | Focuses the header, and again leaves it. |
Enter, Space | Opens the thought, or closes it. |
Reasoning accessibility
- The header is a
<button>witharia-expandedandaria-controls; the thought itself has no role, because a region wants a name and a disclosure's trigger is not a heading. aria-busywhile it is streaming.
Swept with axe on every release, in this state:
Reasoning. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Reasoning style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.reasoningreasoning.triggervariants: streamingreasoning.arrowvariants: openreasoning.durationreasoning.trackvariants: closedreasoning.clipreasoning.bodyStreamingText props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
textrequired | string | — | The message so far. Append to it — anything else is a different message and settles without fading. |
streaming | boolean | false | Whether more is still coming. The caret shows while it is true, and the element reports aria-busy. |
window | number | — | How many of the runs that arrived most recently stay faded in at once; the rest settle. Default AgentUtils.STREAM_WINDOW (8), and 0 turns the entrance off altogether. A run is settled once this many newer ones have arrived rather than after a time, so a stream fast enough to fill the window inside one --transitionTime cuts the tail of the fade short — raise it if that shows. |
caret | boolean | true | Whether to draw the caret while streaming. Default true. |
StreamingText accessibility
aria-busywhilestreaming. The element is **not** a live region: a region announcing every token would read the message out a word at a time and again when it finished. What announces an agent's turn is the transcript it lands in.
Swept with axe on every release, in this state:
StreamingText. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.StreamingText style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.streamingTextstreamingText.segmentstreamingText.caretSkeleton props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
lines | number | 1 | How many bars to draw. Default 1; past that the last one is short, the way a paragraph's last line is. |
circle | boolean | false | A round placeholder — an avatar, a thumbnail. width sets its size and the height follows. |
label | ReactNode | — | What is loading, for a reader. It makes the skeleton a role="status" with the words in it, so put it on the one skeleton that stands for a region and leave it off the rest — a screen of placeholders each announcing itself is a screen nobody can listen to. |
Skeleton accessibility
aria-hiddenunlesslabelis given, and thenrole="status"naming what is on its way.
Swept with axe on every release, in this state:
Skeleton. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Skeleton style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.skeletonskeleton.barvariants: short, circleskeleton.gloss