A feed of what an agent's guardrails did: each check with its rule, action (blocked, redacted, flagged or allowed) and target, counts that double as filters, and rows that open to show why and the text that matched.
Guardrail activity
6 checks shown
The refund was $340, which is over the limit an agent may issue alone. A person was asked to approve it.
issue_refund({ amount: 340.00, order: "A-9914" })
import { GuardrailFeed, type GuardEvent } from "@/components/ballmac/guardrail-feed"
const events: GuardEvent[] = [
{ id: "1", time: "2:14:08 pm", rule: "No customer emails in replies", action: "redacted", target: "Draft reply to ticket 4821", reason: "The draft quoted the customer's email address. It was replaced before sending.", match: "…reach out to maria.lopez@example.com for…" },
{ id: "2", time: "2:13:52 pm", rule: "Refunds above $200 need a person", action: "blocked", target: "Tool call: issue_refund", reason: "The refund was $340, which is over the limit an agent may issue alone. A person was asked to approve it.", match: "issue_refund({ amount: 340.00, order: \"A-9914\" })" },
{ id: "3", time: "2:12:10 pm", rule: "Stay on topic", action: "allowed", target: "Draft reply to ticket 4820" },
{ id: "4", time: "2:10:47 pm", rule: "No legal advice", action: "flagged", target: "Draft reply to ticket 4817", reason: "The draft said what the customer is legally entitled to. It was sent for a person to review.", match: "…you are legally entitled to a full refund…" },
{ id: "5", time: "2:09:31 pm", rule: "Stay on topic", action: "allowed", target: "Draft reply to ticket 4815" },
{ id: "6", time: "2:08:02 pm", rule: "No customer emails in replies", action: "allowed", target: "Draft reply to ticket 4812" },
]
export default function GuardrailFeedDemo() {
return (
<div className="w-full max-w-2xl">
<GuardrailFeed events={events} defaultOpenId="2" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/guardrail-feedInstall 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/guardrail-feed.tsx// Ballmac UI: Guardrail Feed. https://ui.ballmac.com/components/guardrail-feed "use client" import * as React from "react" import { Ban, Check, ChevronDown, EyeOff, Flag, type LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type GuardAction = "allowed" | "blocked" | "redacted" | "flagged" type GuardEvent = { id: string /** When it happened, already formatted, such as "2:14:08 pm". */ time: string /** The rule that fired, for example "No customer emails in replies". */ rule: string action: GuardAction /** What was checked, for example "Draft reply to ticket 4821". */ target: string /** Why the rule fired, in a sentence. */ reason?: string /** The part that matched, shown in the opened row. */ match?: string } const ACTIONS: Record<GuardAction, { icon: LucideIcon; fallback: string; tone: string }> = { blocked: { icon: Ban, fallback: "Blocked", tone: "border-destructive/50 text-destructive" }, redacted: { icon: EyeOff, fallback: "Redacted", tone: "border-chart-3/60 text-foreground" }, flagged: { icon: Flag, fallback: "Flagged", tone: "border-chart-5/60 text-foreground" }, allowed: { icon: Check, fallback: "Allowed", tone: "text-muted-foreground" }, } const ORDER: GuardAction[] = ["blocked", "redacted", "flagged", "allowed"] type GuardrailFeedProps = Omit<React.ComponentProps<"section">, "title"> & { /** Checks in the order to show them, newest first. */ events: GuardEvent[] /** Heading. */ title?: string /** The id of the row that is open at first. */ defaultOpenId?: string } /** * A feed of what an agent's guardrails did: every check with the rule, the action and what was checked, counts by action that double as filters, and * rows that open to show why and the part that matched. Actions are words and icons, never colour alone. */ function GuardrailFeed({ events, title, defaultOpenId, className, ...props }: GuardrailFeedProps) { const msg = useMessages() const uid = React.useId() title ??= msg("guardrail-feed.title", "Guardrail activity") const [filter, setFilter] = React.useState<GuardAction | "all">("all") const [open, setOpen] = React.useState<string | null>(defaultOpenId ?? null) const count = (a: GuardAction) => events.filter((e) => e.action === a).length const shown = filter === "all" ? events : events.filter((e) => e.action === filter) return ( <section data-slot="guardrail-feed" aria-labelledby={`${uid}-t`} className={cn("grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-3"> <h3 id={`${uid}-t`} className="text-sm font-medium text-foreground">{title}</h3> <div role="group" aria-label={msg("guardrail-feed.filter", "Filter by action")} className="flex max-w-full flex-wrap gap-1.5"> {(["all", ...ORDER] as const).map((k) => { const n = k === "all" ? events.length : count(k) return ( <button key={k} type="button" aria-pressed={filter === k} disabled={k !== "all" && n === 0} onClick={() => setFilter(k)} className={cn("h-8 rounded-full border px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none", filter === k ? "border-foreground bg-foreground text-background" : "hover:bg-accent")} > {k === "all" ? msg("guardrail-feed.all", "All") : msg(`guardrail-feed.${k}`, ACTIONS[k].fallback)} <span className={cn("tabular-nums", filter === k ? "opacity-70" : "text-muted-foreground")}>{n}</span> </button> ) })} </div> </div> <p aria-live="polite" className="text-xs text-muted-foreground tabular-nums"> {msg("guardrail-feed.count", "{count} checks shown", { count: shown.length })} </p> {shown.length === 0 ? ( <p className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">{msg("guardrail-feed.empty", "Nothing here yet.")}</p> ) : ( <ul className="divide-y overflow-hidden rounded-lg border"> {shown.map((e) => { const a = ACTIONS[e.action] const Icon = a.icon const isOpen = open === e.id const hasMore = Boolean(e.reason || e.match) return ( <li key={e.id} data-action={e.action}> <button type="button" disabled={!hasMore} aria-expanded={hasMore ? isOpen : undefined} aria-controls={hasMore ? `${uid}-${e.id}` : undefined} onClick={() => setOpen(isOpen ? null : e.id)} className="flex w-full items-center gap-3 px-3 py-2.5 text-start text-[13px] outline-none transition-colors hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset disabled:cursor-default disabled:hover:bg-transparent motion-reduce:transition-none" > <span className={cn("inline-flex w-[5.5rem] shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium", a.tone)}> <Icon aria-hidden="true" className="size-3" /> {msg(`guardrail-feed.${e.action}`, a.fallback)} </span> <span className="min-w-0 flex-1"> <span className="block font-medium text-foreground sm:truncate">{e.rule}</span> <span className="block text-xs text-muted-foreground sm:truncate">{e.target}</span> <time className="mt-0.5 block font-mono text-[11px] text-muted-foreground tabular-nums sm:hidden" dir="ltr">{e.time}</time> </span> <time className="hidden shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums sm:block" dir="ltr">{e.time}</time> {hasMore && <ChevronDown aria-hidden="true" className={cn("size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none", isOpen && "rotate-180")} />} </button> {hasMore && isOpen && ( <div id={`${uid}-${e.id}`} className="grid min-w-0 gap-2 border-t bg-muted/30 px-3 py-3 text-[13px]"> {e.reason && <p className="leading-relaxed text-foreground">{e.reason}</p>} {e.match && ( <p className="rounded-md border bg-background px-3 py-2 font-mono text-xs [overflow-wrap:anywhere] text-muted-foreground" dir="ltr"> {e.match} </p> )} </div> )} </li> ) })} </ul> )} </section> ) } export { GuardrailFeed, type GuardAction, type GuardEvent, type GuardrailFeedProps }Update the import paths to match your project setup.
Usage
import { GuardrailFeed } from "@/components/ballmac/guardrail-feed"The full example is in the Code tab above.
Examples
Nothing blocked
Checks on today's replies
2 checks shown
import { GuardrailFeed } from "@/components/ballmac/guardrail-feed"
export default function GuardrailFeedQuiet() {
return (
<div className="w-full max-w-2xl">
<GuardrailFeed
title="Checks on today's replies"
events={[
{ id: "a", time: "9:02:11 am", rule: "Stay on topic", action: "allowed", target: "Draft reply to ticket 5001" },
{ id: "b", time: "9:04:40 am", rule: "No legal advice", action: "allowed", target: "Draft reply to ticket 5002" },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
events*Checks in the order to show them, newest first. | GuardEvent[] | — |
titleHeading. | string | — |
defaultOpenIdThe id of the row that is open at first. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab, Enter / Space | Filters are toggle buttons; a row's button opens and closes its detail (aria-expanded) |
| Screen readers | Every action is written as a word with an icon; the count of shown checks is a polite live region |
| Disabled filters | An action with no events is disabled instead of hidden, so the set of filters never moves |
Use with AI
events is [{ id, time, rule, action: allowed | blocked | redacted | flagged, target, reason?, match? }] newest first. Counts by action are filter buttons; a row with reason or match opens to show them. defaultOpenId opens one row at first. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Guardrail Feed (@ballmac/guardrail-feed) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing people what an agent's safety rules caught
- An audit view of policy checks on an assistant's output or tool use
Not for
- A full audit log of a whole product; use the audit block
- The live stream of an agent's steps; use trace or log-stream
Registry JSON: https://ui.ballmac.com/r/guardrail-feed.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Permission Grid
A grid of everything an agent could do, each row set to Allow, Ask or Deny with native radios, risk written out, a warning when a high-risk action is allowed, grouped rows and one-click presets.
Approval Card
A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.
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.
Log Stream
A live log viewer with level filters and counts, search with highlighted matches, wrap, pause with a new-lines counter, follow-the-tail scrolling and copy.
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.