Skip to content

Agent avatar

An AI agent's Bayer mark in a rounded tile, with a breathing indigo ring while it is live.

Status
Stable
Level
Atom
Category
Identity
Adoption
Not used yet
import { AgentAvatar } from "@oration/canon/components/agent-avatar";
packages/canon/src/components/agent-avatar.tsx

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 labelled when 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 live ring is indigo because indigo is spent on live state that carries a label. It is the only color the avatar adds. The mark itself stays in its Bayer palette.

The Label-Beside-Color Rule

The ring alone doesn't say On a call. The component speaks the state to screen readers; sighted people need a status label, a count (2 on calls now) or a waveform beside it.

Anatomy#

On a call
Draft
  1. 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.
  2. 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".
  3. 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.
  4. 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 extra
On a callliveSpoken: On a call
DraftdraftSpoken: Draft
import { 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.

16px
20px
24px
28px
32px
44px
On a call16px
On a call20px
On a call24px
On a call28px
On a call32px
On a call44px
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.

Payment status lineReady to test
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.

Agents on Q4 W-9 outreach
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.

On a call

Payment status line

Published2 on calls now
Version 7 published Sep 24Edited 2 hours ago by Priya Raman+13125550190
import { 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#

States
StateTreatment
IdleThe tile alone. Published agents not on a call.
LiveThe indigo ring, breathing every 2.2s. Spoken as On a call.
Live, reduced motionThe static indigo ring only. useReducedMotion removes the breathing copy.
DraftThe dashed ring. Spoken as Draft.

Behavior#

  • data-state on the root carries idle, live or draft for styling from outside.
  • The rings are absolutely positioned outside the tile, so the avatar's box stays size by size and rows don't shift when a call starts. Leave about 4px around it.
  • The breathing ring animates opacity and transform only, 1.8s on the house curve cubic-bezier(0.23, 1, 0.32, 1), repeating after a 0.4s rest.
  • Without labelled, the state is an sr-only word (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 is role="img" with an aria-label such as Payment status line, on a call, and the sr-only word 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#

On a callPayment status lineOn a call
Do. Say the live state in words beside the ring: a status label, a call count or a waveform.
On a callPayment status line
Don't. Rely on the ring alone in a list. In grayscale, or with motion off, it is a thin outline that says nothing.
W-9 collection
Do. Draw agents with the agent mark, so they read as software at a glance.
W-9 collection
Don't. Draw an agent as initials in a circle. It reads as a teammate, and people will expect it to answer a message.

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 labelled off. The name is read from the text and the state from the sr-only word.
  • Alone, set labelled so the mark is role="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#

Design tokens
TokenUsed for
--primaryLive ring and breathing ring
--foreground at 40%Dashed draft ring
oklch-monoThe 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.

Props of AgentAvatar
PropTypeDefaultDescription
agentRequired{ id: string; name: string }No defaultid seeds the mark; name is used for the label when labelled is set.
sizenumber32Pixels. 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.
labelledbooleanfalseMakes the avatar role="img" named for the agent and state, for use without a visible name.
classNamestringNo defaultOn 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.