Agent avatar
An AI agent's Bayer mark in a rounded tile, with a breathing indigo ring while it is live.
Agents
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";export function Hero() { const agents = [ { agent: { id: "agt_payment_status", name: "Payment status line" }, state: "live", meta: "1,284 calls, 71% contained", status: { tone: "primary", label: "2 on calls now", pulse: true }, }, { agent: { id: "agt_w9_collection", name: "W-9 collection" }, state: "idle", meta: "412 calls, 64% contained", status: { tone: "success", label: "Published", pulse: false }, }, { agent: { id: "agt_vendor_onboarding", name: "Vendor onboarding" }, state: "draft", meta: "Not published yet", status: { tone: "neutral", label: "Draft", pulse: false }, }, ] as const; return ( <div className="flex w-full max-w-md flex-col rounded-xl bg-card p-2 shadow-border"> <p className="px-2 pt-1 pb-2 text-sm font-semibold text-foreground"> Agents </p> {agents.map((row) => ( <button key={row.agent.id} type="button" onClick={() => toast.add({ title: row.agent.name, description: row.meta, }) } className="flex items-center gap-3 rounded-lg px-2 py-2 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > <AgentAvatar agent={row.agent} size={28} state={row.state} /> <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> {row.agent.name} </span> <span className="truncate text-xs text-muted-foreground tabular-nums"> {row.meta} </span> </span> <span className="text-13"> <StatusLabel tone={row.status.tone} pulse={row.status.pulse} > {row.status.label} </StatusLabel> </span> </button> ))} </div> );}Usage#
Agent avatar is an AI agent's face: its Bayer Identicon, seeded by the agent's ID, in a rounded tile rather than a circle, so an agent never passes for a teammate. It appears wherever an agent does work in the Agents Platform: lists, call pills, transcripts, run history and the agent's own header. Two states add a ring: a breathing indigo ring while the agent is on a call, and a dashed ring while it is a draft. The ring is color and motion, so the words for the state still belong beside it.
When to use
- Beside an agent's name in lists, tables, pickers and transcripts: Payment status line, W-9 collection.
- With
state="live"while the agent is on a call, in the live calls list, the call pill and the agent header. - With
state="draft"for agents that have never been published. - With
labelledwhen the mark stands alone, such as a stack of agents on a campaign, so screen readers get the name and state.
When not to use
- For a person on the support team or a supplier contact. Use Avatar
- For a customer, cohort or report with no agent behind it. Use Identicon
- To show that a voice session is listening, thinking or speaking. Use Voice orb
- As the only signal that an agent is live in a dense list. Add the words. Use Status label
The Quiet Indigo Rule
The Label-Beside-Color Rule
Anatomy#
- Tile. A Bayer identicon seeded by
agent.id, with a corner of 10px at 32px that scales with size (never under 4px). It is a rounded square, never a circle. - Live ring. A static Quiet Indigo border around the tile, 1.5px (2px from 40px), set off by a 2px gap (1.5px under 28px). Only when
state="live". - Breathing ring. A copy of the live ring that grows to 1.16x and fades from 50% to nothing, then rests 0.4s. Skipped under reduced motion.
- Draft ring. A dashed 1.5px ring in Graphite Ink at 40%, in the same place, when
state="draft".
Examples#
Idle, live and draft
Idle is the tile alone. Live adds the indigo ring that breathes while motion is allowed. Draft adds a dashed ink ring. Each state is spoken for screen readers.
idleSpoken: Nothing extraliveSpoken: On a calldraftSpoken: Draftimport { AgentAvatar } from "@oration/canon/components/agent-avatar";export function States() { const states = [ { state: "idle", label: "Idle", spoken: "Nothing extra" }, { state: "live", label: "Live", spoken: "On a call" }, { state: "draft", label: "Draft", spoken: "Draft" }, ] as const; return ( <div className="flex flex-wrap items-start justify-center gap-12"> {states.map((item) => ( <div key={item.state} className="flex flex-col items-center gap-3" > <AgentAvatar agent={{ id: "agt_payment_status", name: "Payment status line", }} size={40} state={item.state} /> <span className="flex flex-col items-center"> <code className="font-mono text-xs text-foreground"> {item.state} </code> <span className="text-xs text-muted-foreground"> Spoken: {item.spoken} </span> </span> </div> ))} </div> );}Sizes
The corner scales at 10px per 32px. Under 28px the gap tightens to 1.5px, and from 40px the ring thickens to 2px, so the ring reads at every size.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";export function Sizes() { const sizes = [16, 20, 24, 28, 32, 44]; return ( <div className="flex flex-col items-center gap-6"> {(["idle", "live"] as const).map((state) => ( <div key={state} className="flex flex-wrap items-end gap-6"> {sizes.map((size) => ( <div key={size} className="flex flex-col items-center gap-2" > <AgentAvatar agent={{ id: "agt_w9_collection", name: "W-9 collection", }} size={size} state={state} /> <span className="text-xs text-muted-foreground tabular-nums"> {size}px </span> </div> ))} </div> ))} </div> );}Going live
Switch state as the call starts and ends. The ring sits outside the tile's box, so nothing in the row moves.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { Button } from "@oration/canon/components/button";import * as React from "react";export function StartCall() { const [live, setLive] = React.useState(false); return ( <div className="flex w-full max-w-sm items-center gap-3 rounded-xl bg-card px-4 py-3 shadow-border"> <AgentAvatar agent={{ id: "agt_payment_status", name: "Payment status line", }} size={32} state={live ? "live" : "idle"} /> <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> Payment status line </span> <span role="status" className="text-xs text-muted-foreground"> {live ? "On a test call" : "Ready to test"} </span> </span> <Button type="button" variant="outline" size="sm" onClick={() => setLive((value) => !value)} > {live ? "End call" : "Start test call"} </Button> </div> );}Without a visible name
With labelled, the avatar is role="img" named for the agent and its state, so a button around it is announced as W-9 collection, on a call. A tooltip gives sighted people the name.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";export function Labelled() { const agents = [ { agent: { id: "agt_w9_collection", name: "W-9 collection" }, state: "live", }, { agent: { id: "agt_payment_confirm", name: "Payment run confirmation", }, state: "idle", }, { agent: { id: "agt_vendor_onboarding", name: "Vendor onboarding" }, state: "draft", }, ] as const; return ( <div className="flex items-center gap-3"> <span className="text-13 text-muted-foreground"> Agents on Q4 W-9 outreach </span> <ul className="flex items-center gap-2"> {agents.map((row) => ( <li key={row.agent.id}> <Tooltip> <TooltipTrigger render={ <button type="button" onClick={() => toast.add({ title: `Opened ${row.agent.name}`, }) } className="flex rounded-lg p-1 outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" /> } > <AgentAvatar agent={row.agent} size={24} state={row.state} labelled /> </TooltipTrigger> <TooltipContent>{row.agent.name}</TooltipContent> </Tooltip> </li> ))} </ul> </div> );}In the agent header
A 44px live avatar leads the agent's page, with its publish status and a call count beside the name.
Payment status line
Published2 on calls nowimport { AgentAvatar } from "@oration/canon/components/agent-avatar";import { StatusLabel } from "@oration/canon/components/status-dot";export function AgentHeader() { return ( <div className="flex w-full max-w-xl min-w-0 items-center gap-4"> <AgentAvatar agent={{ id: "agt_payment_status", name: "Payment status line", }} size={44} state="live" /> <div className="min-w-0 flex-1"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <h3 className="truncate text-xl font-semibold tracking-[-0.015em] text-foreground"> Payment status line </h3> <span className="text-13"> <StatusLabel tone="success">Published</StatusLabel> </span> <span className="text-13 text-primary tabular-nums"> 2 on calls now </span> </div> <div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> <span className="tabular-nums"> Version 7 published Sep 24 </span> <span>Edited 2 hours ago by Priya Raman</span> <span className="font-mono text-foreground/80"> +13125550190 </span> </div> </div> </div> );}States#
| State | Treatment |
|---|---|
| Idle | The tile alone. Published agents not on a call. |
| Live | The indigo ring, breathing every 2.2s. Spoken as On a call. |
| Live, reduced motion | The static indigo ring only. useReducedMotion removes the breathing copy. |
| Draft | The dashed ring. Spoken as Draft. |
Behavior#
data-stateon the root carriesidle,liveordraftfor styling from outside.- The rings are absolutely positioned outside the tile, so the avatar's box stays
sizebysizeand rows don't shift when a call starts. Leave about 4px around it. - The breathing ring animates
opacityandtransformonly, 1.8s on the house curvecubic-bezier(0.23, 1, 0.32, 1), repeating after a 0.4s rest. - Without
labelled, the state is ansr-onlyword (On a call, Draft) inside the avatar and the tile is hidden, so the visible name beside it names the agent. - With
labelled, the root isrole="img"with anaria-labelsuch as Payment status line, on a call, and thesr-onlyword is dropped. - The avatar isn't interactive. Wrap it and the name in one link or button to open the agent.
Do and don't#
Content#
- Agent names are what the agent does, in sentence case: Payment status line, W-9 collection, Vendor onboarding.
- The spoken states are fixed: On a call for live and Draft for draft. Visible labels use the same words.
- Counts beside a live agent are specific: 2 on calls now, not Active.
Accessibility#
- Beside a visible name, leave
labelledoff. The name is read from the text and the state from thesr-onlyword. - Alone, set
labelledso the mark isrole="img"with the agent's name and state. - The breathing ring stops under
prefers-reduced-motion; the static ring stays so the state is still drawn. - The indigo ring is 1.5 to 2px against the plane with a gap, which holds up at every size from 16px. It isn't the only cue: the state is always spoken.
Design tokens#
| Token | Used for |
|---|---|
--primary | Live ring and breathing ring |
--foreground at 40% | Dashed draft ring |
oklch-mono | The Bayer mark's two colors, from the agent's ID. Not a theme token. |
cubic-bezier(0.23, 1, 0.32, 1) | The breathing ring's curve, 1.8s plus a 0.4s rest |
API reference#
AgentAvatar
An agent's mark with its state ring. Also exports the type AgentAvatarState.
| Prop | Type | Default | Description |
|---|---|---|---|
agentRequired | { id: string; name: string } | No default | id seeds the mark; name is used for the label when labelled is set. |
size | number | 32 | Pixels. The corner is size * 10 / 32; ring width and gap step up at 28 and 40px. |
state | "idle" | "live" | "draft" | "idle" | Adds the live or draft ring and its spoken word. |
labelled | boolean | false | Makes the avatar role="img" named for the agent and state, for use without a visible name. |
className | string | No default | On the root span. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
A second AgentAvatar lives in apps/web/src/components/records/avatars.tsx: a sparkle icon on a tag tint, used through ActorAvatar across the CRM. Agents look different in the CRM and in the Agents Platform.
Without labelled, the sr-only state word is inside the avatar, so it is read before the name: On a call Payment status line.
The breathing loop (1.8s plus 0.4s) runs outside the spring tokens and the 320ms ceiling in the motion grammar. It is ambient, like the status label's pulse, but it isn't a Canon token.
Rows in the Home live calls list write the caller's name but not the agent's, and don't set labelled, so the agent is identified by its mark alone and screen readers hear only On a call.