---
title: Assistant Message
description: An AI assistant turn with roles, streaming, model chip, reasoning disclosure, and readable announcements
visibility: guest
draft: false
---

# Assistant Message

An **AI turn**. [Message](/components/message) is the human chat bubble — this is
its assistant-side counterpart: roles, a streaming body, a model chip, a
reasoning disclosure, and — the part almost every AI chat UI gets wrong —
announcements a screen-reader user can actually follow.

Drop it inside a [Conversation](/components/conversation) and stream into it.

## Basic Usage

:::preview{title="An assistant turn"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::assistant-message model="atlas-2">
        The export API allows 60 requests per minute per token. A 429 always includes a `Retry-After` header, in seconds.
    </x-wirekit::assistant-message>
</div>
:::

:::source{language="blade"}
<x-wirekit::assistant-message model="atlas-2">
    The export API allows 60 requests per minute per token. A 429 always includes a `Retry-After` header, in seconds.
</x-wirekit::assistant-message>
:::

## Roles

`assistant` and `user` are the two sides of the turn; `system` is a
configuration note — centered, muted, and **no avatar**, because a system turn
is not a person.

:::preview{title="The three roles"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::stack gap="md">
        <x-wirekit::assistant-message role="user">Summarize the rate limits.</x-wirekit::assistant-message>
        <x-wirekit::assistant-message role="assistant" model="atlas-2">Sixty requests a minute, per token.</x-wirekit::assistant-message>
        <x-wirekit::assistant-message role="system">Switched to the long-context model.</x-wirekit::assistant-message>
    </x-wirekit::stack>
</div>
:::

:::source{language="blade"}
<x-wirekit::stack gap="md">
    <x-wirekit::assistant-message role="user">Summarize the rate limits.</x-wirekit::assistant-message>
    <x-wirekit::assistant-message role="assistant" model="atlas-2">Sixty requests a minute, per token.</x-wirekit::assistant-message>
    <x-wirekit::assistant-message role="system">Switched to the long-context model.</x-wirekit::assistant-message>
</x-wirekit::stack>
:::

## States

When a turn **is** a state — an error answer, a caution, a confirmation —
`intent` tints the bubble. `danger`, `warning`, `success` and `info` mix the
state color into the surface and border at low alpha; the body text stays
regular, so the tint reads as a marker, never as low-contrast colored text.

:::preview{title="State-tinted turns"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::stack gap="md">
        <x-wirekit::assistant-message model="atlas-2" intent="danger">I could not reach the export API — it returned a 503. Nothing was changed.</x-wirekit::assistant-message>
        <x-wirekit::assistant-message model="atlas-2" intent="warning">This will delete 1,204 rows and cannot be undone. Confirm to continue.</x-wirekit::assistant-message>
        <x-wirekit::assistant-message model="atlas-2" intent="success">Done — all 412 rows imported cleanly.</x-wirekit::assistant-message>
    </x-wirekit::stack>
</div>
:::

:::source{language="blade"}
<x-wirekit::stack gap="md">
    <x-wirekit::assistant-message model="atlas-2" intent="danger">I could not reach the export API — it returned a 503. Nothing was changed.</x-wirekit::assistant-message>
    <x-wirekit::assistant-message model="atlas-2" intent="warning">This will delete 1,204 rows and cannot be undone. Confirm to continue.</x-wirekit::assistant-message>
    <x-wirekit::assistant-message model="atlas-2" intent="success">Done — all 412 rows imported cleanly.</x-wirekit::assistant-message>
</x-wirekit::stack>
:::

## Streaming and announcements

This is the important part.

The obvious approach — putting `aria-live` on the streaming body — makes a
screen reader **re-read the entire growing answer on every token**. It is
unusable, and it is what most AI chat interfaces ship.

Assistant Message keeps the body **silent** (`aria-live="off"`, `aria-busy`
while streaming) and mirrors **complete sentences** into a separate, always-
present live region. The reader hears the answer as prose, one finished sentence
at a time — never a stuttering re-read.

| `announce` | Behavior |
| --- | --- |
| `sentence` *(default)* | Flush each finished sentence as it lands |
| `all` | Flush everything once, when streaming stops (call `flush()`) |
| `off` | Never announce — you narrate it yourself |

```blade
{{-- 1. wire:stream appends tokens into the body; the announcer stays readable --}}
<x-wirekit::assistant-message :streaming="$streaming" model="atlas-2">
    <span wire:stream="answer">{{ $answer }}</span>
</x-wirekit::assistant-message>
```

```php
// 2. Flip $streaming around the streamed call
public bool $streaming = false;

public function ask(): void
{
    $this->streaming = true;
    $this->stream(to: 'answer', content: $token, replace: false);
    $this->streaming = false;
}
```

For `announce="all"`, call `flush()` on the Alpine scope when the stream ends:

```blade
{{-- 3. Announce the whole answer once, instead of sentence by sentence --}}
<x-wirekit::assistant-message announce="all" x-on:stream-finished.window="flush()">
    <span wire:stream="answer">{{ $answer }}</span>
</x-wirekit::assistant-message>
```

## Reasoning

The `reasoning` slot is collapsed behind a disclosure — the answer is the point,
the thinking is opt-in.

:::preview{title="An answer with its reasoning tucked away"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::assistant-message model="atlas-2">
        <x-slot:reasoning>
            Checked the rate-limit middleware, then the API docs, then confirmed against the 429 response headers.
        </x-slot:reasoning>
        Sixty requests a minute, per token — and the 429 tells you exactly how long to wait.
    </x-wirekit::assistant-message>
</div>
:::

:::source{language="blade"}
<x-wirekit::assistant-message model="atlas-2">
    <x-slot:reasoning>
        Checked the rate-limit middleware, then the API docs, then confirmed against the 429 response headers.
    </x-slot:reasoning>
    Sixty requests a minute, per token — and the 429 tells you exactly how long to wait.
</x-wirekit::assistant-message>
:::

## Metadata and actions

`footer` takes ambient chips (latency, tokens, cost); `actions` takes controls.

:::preview{title="Footer chips and actions"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::assistant-message model="atlas-2">
        Done — the import finished with 412 rows.
        <x-slot:footer>1.2s · 340 tokens</x-slot:footer>
        <x-slot:actions>
            <x-wirekit::clipboard-button icon-only value="Done — the import finished with 412 rows." aria-label="Copy answer" />
        </x-slot:actions>
    </x-wirekit::assistant-message>
</div>
:::

:::source{language="blade"}
<x-wirekit::assistant-message model="atlas-2">
    Done — the import finished with 412 rows.
    <x-slot:footer>1.2s · 340 tokens</x-slot:footer>
    <x-slot:actions>
        <x-wirekit::clipboard-button icon-only value="Done — the import finished with 412 rows." aria-label="Copy answer" />
    </x-slot:actions>
</x-wirekit::assistant-message>
:::

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `role` | string | `'assistant'` | `assistant`, `user`, `system` |
| `name` | string\|null | `null` | Speaker name; defaults to the role's wording |
| `avatar` | string\|null | `null` | Avatar image for the speaker |
| `model` | string\|null | `null` | Model chip — assistant turns only |
| `intent` | string | `'neutral'` | State tint on the bubble: `neutral`, `info`, `success`, `warning`, `danger` |
| `streaming` | bool | `false` | Marks the turn `aria-busy` while tokens land |
| `announce` | string | `'sentence'` | `sentence`, `all`, `off` |
| `scope` | string\|null | `null` | Scoped personalization name |

## Slots

| Slot | Description |
| --- | --- |
| default | The answer body (rendered as prose) |
| `reasoning` | Collapsed disclosure above the answer |
| `footer` | Ambient chips — latency, tokens, cost |
| `actions` | Controls — copy, regenerate, rate |

## Accessibility

- **The body is never a live region.** `aria-live="off"` on the streaming body is
  deliberate — a live region there re-reads the whole answer per token.
- A **separate, always-present** announcer (`role="status"`, `aria-live="polite"`)
  receives complete sentences. It is rendered up front, because a live region
  created at the same moment as its text is inert to assistive technology.
- `streaming` sets `aria-busy="true"` — assistive technology knows the turn is
  still being written.
- Each turn is an `<article>` named by its speaker, so a screen reader can jump
  turn by turn.
- A `system` turn has no avatar — it is not a person.
- Icon-only actions need their own `aria-label`.

## Keyboard Interaction

| Key | Action |
| --- | --- |
| <kbd>Tab</kbd> | Focus the reasoning disclosure, then the actions |
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Toggle the reasoning disclosure |

## Pitfalls

- **Do not add your own `aria-live` to the body.** That re-introduces exactly the
  stuttering re-read this component avoids.
- **Do not leave `streaming` true.** A turn stuck `aria-busy` tells assistive
  technology the answer never finished.
- **Use `flush()` with `announce="all"`.** Without it, nothing is announced.

## Design Tokens

| Element | Token |
| --- | --- |
| Assistant / system surface | `--color-wk-bg-elevated`, `--color-wk-bg-muted` |
| User surface | `--color-wk-accent` (10% mix over elevated) |
| Border | `--color-wk-border-subtle`, `--border-wk-width` |
| Radius | `--radius-wk-lg` |
| Speaker text | `--text-wk-sm`, `--font-wk-heading-weight` |
| Footer chips | `--text-wk-xs`, `--color-wk-text-muted` |

## Further Reading

- [WAI-ARIA: `aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy)
- [WAI-ARIA: `status` role](https://www.w3.org/TR/wai-aria-1.2/#status)
- [MDN: ARIA live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions)
