Elements

Guardrail notice

A refusal in its own shape, with the nearest thing it can do instead.

I can't help with thatpolicy

This asks for a working attack against infrastructure you don't own. I can help with the defensive side of the same problem.

try instead
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-guardrail-notice"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

An icon, a title, a short policy tag, an explanation, and an optional list of alternatives the reader can pick instead. Unlike a generic failure banner (see /elements/error-state), this is specifically for a policy stop rather than a transport or server error. With a runtime you detect the stop from the message's own status; standalone you decide when to show it.

Getting started

A refusal is not a separate concept from a normal message: it is an assistant message whose status finished as "incomplete" with reason "content-filter". Everything else on the notice, the title, the explanation, and the alternatives, is content your app supplies; the runtime only tells you that the stop happened.

Detect a content filter stop

components/assistant-ui/elements/guardrail-notice.tsx
"use client";

import { useAuiState } from "@assistant-ui/react";

function useIsGuardrailStop() {
  return useAuiState((s) => {
    const status = s.message.status;
    return status?.type === "incomplete" && status.reason === "content-filter";
  });
}

Send an alternative back

import { useAui } from "@assistant-ui/react";
import { GuardrailNotice } from "@/components/assistant-ui/elements/guardrail-notice";

const ALTERNATIVES = ["Explain the defensive side of the same problem"];

function AssistantGuardrail() {
  const isGuardrailStop = useIsGuardrailStop();
  const aui = useAui();
  if (!isGuardrailStop) return null;

  return (
    <GuardrailNotice
      title="I can't help with that"
      explanation="This asks for something I can't assist with directly."
      policy="content-filter"
      alternatives={ALTERNATIVES}
      onPick={(alternative) => aui.thread.append(alternative)}
    />
  );
}

aui.thread.append(text) sends text as a new user message and starts a run with it, the same as if the reader had typed and sent it themselves.

Anatomy

<div data-slot="guardrail-notice">
  <div>{/* shield icon, title, policy tag */}</div>
  <p>{/* explanation, always rendered */}</p>
  <div>{/* "try instead" label + one button per alternative, only when alternatives.length > 0 */}</div>
</div>

The alternatives block is entirely omitted, not just empty, when alternatives is an empty array; the notice then ends after the explanation paragraph. Each alternative renders as a button when onPick is provided and as non-interactive text otherwise.

Examples

Reading the refusal reason

status.reason on an incomplete message is one of a fixed set of strings; "content-filter" is the one that maps to a guardrail stop specifically, distinct from "cancelled", "length", "error", or "other".

const policy = status.type === "incomplete" ? status.reason : undefined;

Restyle the notice

Both lanes take className on the root, which starts as flex w-full max-w-sm flex-col gap-3 rounded-[20px] on the shared paper surface. The shield icon's amber tint and the policy tag's mono styling both come from surfaces.tsx.

<GuardrailNotice className="max-w-none rounded-2xl" /* ... */ />

API reference

Message state

Selector / callTypeDescription
s.message.statusMessageStatus | undefinedtype === "incomplete" with reason === "content-filter" marks a guardrail stop.
aui.thread.append(message)(message: CreateAppendMessage) => voidSends a plain string (or a full message) as a new user turn; use it to send a picked alternative back.