Redirecting an agent while it works: say what to change and when (now, after this step or at the end), and watch each instruction go from queued to applied or dropped. Take back an instruction that has not applied yet.
Steer the agent
Working on: Reading the pricing page (step 4 of 9)
Skip the competitor pages and focus on our own pricing.
AppliedNow2:10 pm
Also check whether annual billing is cheaper.
QueuedAfter this step2:12 pm
Let it finish the step, then change course.
"use client"
import * as React from "react"
import { AgentSteering, type SteeringMode, type SteeringNote } from "@/components/ballmac/agent-steering"
const start: SteeringNote[] = [
{ id: "1", text: "Skip the competitor pages and focus on our own pricing.", mode: "now", state: "applied", at: "2:10 pm" },
{ id: "2", text: "Also check whether annual billing is cheaper.", mode: "after-step", state: "queued", at: "2:12 pm" },
]
export default function AgentSteeringDemo() {
const [notes, setNotes] = React.useState(start)
return (
<div className="w-full max-w-xl">
<AgentSteering
notes={notes}
step="Reading the pricing page (step 4 of 9)"
onSend={async (text: string, mode: SteeringMode) => {
await new Promise((resolve) => setTimeout(resolve, 500))
const id = String(Date.now())
setNotes((n) => [...n, { id, text, mode, state: "queued", at: "now" }])
setTimeout(() => setNotes((n) => n.map((x) => (x.id === id ? { ...x, state: "applied" } : x))), 2500)
}}
onRemove={async (id: string) => {
await new Promise((resolve) => setTimeout(resolve, 300))
setNotes((n) => n.filter((x) => x.id !== id))
}}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/agent-steeringInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/agent-steering.tsx// Ballmac UI: Agent Steering. https://ui.ballmac.com/components/agent-steering "use client" import * as React from "react" import { Ban, Check, Hourglass, Loader2, SendHorizontal, X } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type SteeringMode = "now" | "after-step" | "end" type SteeringState = "queued" | "applied" | "dropped" type SteeringNote = { id: string text: string mode: SteeringMode state: SteeringState /** When it was sent, already formatted, such as "2:14 pm". */ at?: string } type AgentSteeringProps = Omit<React.ComponentProps<"section">, "title" | "defaultValue"> & { /** What has been said to the agent so far, newest last. */ notes: SteeringNote[] /** Whether the agent is working. Instructions can only be sent while it is. */ running?: boolean /** What it is doing right now, such as "Reading the pricing page (step 4 of 9)". */ step?: string /** The timing that is chosen to begin with. */ defaultMode?: SteeringMode /** Called with the instruction and when it should apply. Reject and the text stays with the reason. */ onSend?: (text: string, mode: SteeringMode) => void | Promise<void> /** Called to take back an instruction that has not been applied yet. Reject and it stays with the reason. */ onRemove?: (id: string) => void | Promise<void> } const MODES: { id: SteeringMode; label: string; hint: string }[] = [ { id: "now", label: "Now", hint: "Stop what it is doing and change course." }, { id: "after-step", label: "After this step", hint: "Let it finish the step, then change course." }, { id: "end", label: "At the end", hint: "Let it finish, then do this as well." }, ] const STATE = { queued: { icon: Hourglass, label: "Queued" }, applied: { icon: Check, label: "Applied" }, dropped: { icon: Ban, label: "Dropped" } } as const /** * Steering an agent while it works: say what to change and when (now, after the step it is on, or at the end), and see each instruction move from queued to * applied, or dropped. Instructions that have not been applied can be taken back. Nothing is sent when the agent is not running, an empty instruction is * refused, and a failure keeps the text and says why. Ctrl or Command with Enter sends. */ function AgentSteering({ notes, running = true, step, defaultMode = "after-step", onSend, onRemove, className, ...props }: AgentSteeringProps) { const msg = useMessages() const id = React.useId() const [text, setText] = React.useState("") const [mode, setMode] = React.useState<SteeringMode>(defaultMode) const [busy, setBusy] = React.useState(false) const [error, setError] = React.useState<string | null>(null) const [removing, setRemoving] = React.useState<string | null>(null) const modeText = (m: SteeringMode) => msg(`agent-steering.mode.${m}`, MODES.find((x) => x.id === m)!.label) async function send() { const t = text.trim() if (!t) { setError(msg("agent-steering.empty", "Write what the agent should change first.")) return } setBusy(true) setError(null) try { await onSend?.(t, mode) setText("") } catch (e) { setError(e instanceof Error ? e.message : msg("agent-steering.failed", "It was not sent. Your instruction is still here.")) } finally { setBusy(false) } } async function remove(n: SteeringNote) { setRemoving(n.id) setError(null) try { await onRemove?.(n.id) } catch (e) { setError(e instanceof Error ? e.message : msg("agent-steering.removeFailed", "It could not be taken back.")) } finally { setRemoving(null) } } return ( <section data-slot="agent-steering" aria-labelledby={`${id}-t`} className={cn("bg-card text-card-foreground grid gap-4 rounded-xl border p-4 sm:p-5", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1"> <h3 id={`${id}-t`} className="text-sm font-semibold">{msg("agent-steering.title", "Steer the agent")}</h3> <p aria-live="polite" className="flex items-center gap-1.5 text-xs text-muted-foreground"> {running ? <Loader2 aria-hidden="true" className="size-3.5 animate-spin motion-reduce:animate-none" /> : null} {running ? (step ? msg("agent-steering.working", "Working on: {step}", { step }) : msg("agent-steering.running", "Working")) : msg("agent-steering.idle", "Not running")} </p> </div> {notes.length > 0 && ( <ul aria-label={msg("agent-steering.sent", "Instructions sent")} className="grid gap-2"> {notes.map((n) => { const S = STATE[n.state] return ( <li key={n.id} data-state={n.state} className={cn("flex items-start gap-3 rounded-lg border px-3 py-2.5 text-sm", n.state === "dropped" && "text-muted-foreground")}> <S.icon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> <div className="min-w-0 flex-1"> <p className={cn("leading-snug text-pretty", n.state === "dropped" && "line-through")}>{n.text}</p> <p className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground"> <span className="font-medium text-foreground">{msg(`agent-steering.state.${n.state}`, S.label)}</span> <span>{modeText(n.mode)}</span> {n.at && <span className="tabular-nums">{n.at}</span>} </p> </div> {n.state === "queued" && onRemove && ( <button type="button" disabled={removing === n.id} aria-label={msg("agent-steering.remove", "Take back: {text}", { text: n.text })} onClick={() => void remove(n)} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 motion-reduce:transition-none"> <X aria-hidden="true" className="size-4" /> </button> )} </li> ) })} </ul> )} <div className="grid gap-3"> <label htmlFor={`${id}-text`} className="sr-only">{msg("agent-steering.label", "Instruction for the agent")}</label> <textarea id={`${id}-text`} rows={2} value={text} disabled={!running} onChange={(e) => { setText(e.target.value); setError(null) }} onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void send() } }} aria-invalid={!!error} aria-describedby={error ? `${id}-err` : undefined} placeholder={running ? msg("agent-steering.placeholder", "Tell it what to change, for example “Skip the competitor pages”") : msg("agent-steering.startFirst", "Start a run to steer it.")} className="min-h-16 w-full resize-y rounded-lg border bg-background px-3 py-2.5 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60 aria-[invalid=true]:border-destructive" /> <div className="flex flex-wrap items-center justify-between gap-3"> <fieldset disabled={!running} className="min-w-0"> <legend className="sr-only">{msg("agent-steering.when", "When should it apply?")}</legend> <div className="bg-muted grid grid-flow-col gap-1 rounded-lg p-1 text-xs font-medium"> {MODES.map((m) => ( <label key={m.id} title={m.hint} className="relative cursor-pointer"> <input type="radio" name={`${id}-mode`} value={m.id} checked={mode === m.id} onChange={() => setMode(m.id)} className="peer sr-only" /> <span className="block rounded-md px-2.5 py-1.5 text-center text-muted-foreground transition-colors peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 motion-reduce:transition-none">{modeText(m.id)}</span> </label> ))} </div> </fieldset> <button type="button" disabled={!running || busy} onClick={() => void send()} className="inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"> {busy ? <Loader2 aria-hidden="true" className="size-4 animate-spin" /> : <SendHorizontal aria-hidden="true" className="size-4 rtl:-scale-x-100" />} {msg("agent-steering.send", "Send")} </button> </div> <p className="text-xs text-muted-foreground">{MODES.find((m) => m.id === mode)!.hint}</p> <p id={`${id}-err`} role={error ? "alert" : undefined} className="min-h-4 text-sm font-medium text-destructive">{error}</p> </div> </section> ) } export { AgentSteering, type AgentSteeringProps, type SteeringMode, type SteeringNote, type SteeringState }Update the import paths to match your project setup.
Usage
import { AgentSteering } from "@/components/ballmac/agent-steering"The full example is in the Code tab above.
Examples
The agent is not running
Steer the agent
Not running
Skip the competitor pages.
AppliedNow2:10 pm
Write it as a table.
DroppedAt the end2:14 pm
Let it finish the step, then change course.
"use client"
import { AgentSteering } from "@/components/ballmac/agent-steering"
export default function AgentSteeringIdle() {
return (
<div className="w-full max-w-xl">
<AgentSteering
running={false}
notes={[
{ id: "1", text: "Skip the competitor pages.", mode: "now", state: "applied", at: "2:10 pm" },
{ id: "2", text: "Write it as a table.", mode: "end", state: "dropped", at: "2:14 pm" },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
notes*What has been said to the agent so far, newest last. | SteeringNote[] | — |
runningWhether the agent is working. Instructions can only be sent while it is. | boolean | true |
stepWhat it is doing right now, such as "Reading the pricing page (step 4 of 9)". | string | — |
defaultModeThe timing that is chosen to begin with. | SteeringMode | "after-step" |
onSendCalled with the instruction and when it should apply. Reject and the text stays with the reason. | (text: string, mode: SteeringMode) => void | Promise<void> | — |
onRemoveCalled to take back an instruction that has not been applied yet. Reject and it stays with the reason. | (id: string) => void | Promise<void> | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Timing | A native radio group (Now, After this step, At the end); each has a title that says what it does |
| States | Queued, Applied and Dropped are words with icons beside each instruction |
| Errors | An empty instruction, a failed send and a failed take-back are alerts; the text stays in the box |
| Take back | A button named 'Take back: <text>', only on queued instructions |
| Keyboard | Ctrl or Command with Enter sends |
Use with AI
notes is [{ id, text, mode: now | after-step | end, state: queued | applied | dropped, at? }] and the parent owns it (required). running (default true) and step say what it is doing; instructions can only be sent while it runs. onSend(text, mode) and onRemove(id) may be async: reject and the card keeps the text and says why. defaultMode (default after-step). Ctrl or Command with Enter sends. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Agent Steering (@ballmac/agent-steering) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Letting a person change an agent's course without stopping it
- Showing the instructions that are queued, applied or dropped
Not for
- A first prompt before a run starts; use ai-chat or a prompt box
- Approving a risky action; use approval-card
- Setting how much it may do alone; use autonomy-dial
Registry JSON: https://ui.ballmac.com/r/agent-steering.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.
Plan Tracker
An agent's plan against what happened: every step with its state in words, expected and actual time, a bar of the whole plan, an honest note when steps were added or skipped, and how far a step ran over.
Autonomy Dial
A stepped dial for how much an agent may do on its own, from suggest-only to full auto, with what it does alone and what it stops to ask about written out for every step.
Agent Lanes
A swimlane timeline for multi-agent runs: one lane per agent, steps coloured by what they are doing, dashed handoffs between lanes, a Now line and a detail panel, all reachable with the arrow keys.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.