Elements

Elements · Reasoning

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. Every alternative renders as its own button regardless of whether onPick is provided.

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.