Agent Handoff
An agent-to-agent handoff marker — from/to avatars, a track that marches while the transfer is in flight, the reason control moved, chips for the context passed along, and who holds the run; compact divider and detailed card variants.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/agent-handoff.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AgentHandoff" component (lucide-react for
the two glyphs, no other dependency): the marker a multi-agent run drops into its
log when one agent hands the run to another.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children; spread the rest onto the root and publish data-status / data-variant
so a host can style rows by state.
- from / to: Party = { id?, name, role?, avatar?, initials?, tone?: 1..5 }.
- status?: "pending" | "complete" | "failed" (default "complete").
- variant?: "divider" | "card" (default "divider").
- reason?: string — why control moved.
- context?: { label: string; value?: string }[] — the manifest of what travelled
with the run (thread window, tool results, budget, deadline).
- maxContext?: number (default 3, 0 = no cap) — chips shown before the expander.
- at?: Date | number | string; formatTime?: (date: Date) => string.
- labels?: Partial<Labels>, where Labels carries every string plus two functions,
more(count) and returned(name), so counts and names interpolate in any language.
- announce?: boolean (default false).
- actions?: ReactNode — card-only footer slot; the consumer wires the handlers,
the component never invents one.
Behavior — the control token (the whole point)
- Exactly one party holds the run at a time, and the holder is DERIVED from
status, never passed in: "complete" hands it to the receiver, "pending" and
"failed" leave it with the sender. Render the holder with a ring around its
avatar and an sr-only "(holds the run)" after its name.
- The party that does not hold it yet — the receiver while pending, and the
receiver of a failed handoff — gets a DASHED, dimmed avatar. Never encode that
difference in colour alone.
- "failed" is not "cancelled": keep both parties, swap the arrow for a return
glyph, paint the track and the status word with the destructive token, and add
one muted line saying the sender keeps the run.
Behavior — the transfer track
- One flex line between the parties: a hairline plus an arrowhead.
- pending: the dash pattern marches from sender to receiver. Draw it as a
repeating-linear-gradient whose background-size equals exactly one dash period
and animate background-position by that same period, so the loop is seamless at
any measured width — no JS, no ResizeObserver, no width math.
- complete: one solid linear-gradient from the sender's accent to the receiver's,
which is what keeps the direction readable with no motion at all.
- failed: the same dashes, static, in the destructive token.
Behavior — the one-shot arrival lock
- The pending → complete edge plays a single expanding ring on the receiver's
avatar. Implement it by bumping a numeric key and putting that key on the ring
element: a CSS animation fires when its element is INSERTED and can never be
replayed by a re-render, so a parent re-rendering 60x/s cannot retrigger it.
- Detect the edge with a render-phase state adjustment (compare the previous
status with the incoming one and bump inside render), not an effect — an effect
leaves the first painted frame one status behind.
- Mounting straight into "complete" must NOT pulse: the key starts at 0 and the
ring only renders above 0. There is nothing to celebrate about history.
Behavior — context chips
- Render the manifest as a <ul> with an accessible name; each chip is a label plus
an optional monospace value.
- Past maxContext, collapse the tail behind a real <button type="button"> with
aria-expanded and a focus-visible ring, labelled "+N more" / "Show less".
Nothing is ever silently truncated.
- Reset the expanded flag whenever the manifest's CONTENT changes (a signature of
the labels and values, not just the count): a virtualised log recycles this
instance for a different handoff whose chips are usually the same kinds with
different values, and inherited expansion would expose another run's chips.
Reset in render, so the first frame is collapsed.
Behavior — identity, avatars, time
- Accent per party: use `tone` when given, else hash `id ?? name` with FNV-1a into
--chart-1..5. Integer-only and deterministic, so server and client agree and one
agent keeps the same colour across every handoff in a log.
- Avatar: put the remote <img> ON TOP of the initials so a dead or slow URL
degrades to initials, never to a broken-image glyph. Also re-check
`complete && naturalWidth === 0` in a ref callback: a cached or already-failed
image finishes before React attaches onError on every hydrated page.
- Initials: first glyph of the first and last word (or the first two of a single
word), split with Array.from so an emoji or astral glyph is not cut in half.
- Timestamp: a string prop is printed verbatim (the caller already formatted it);
a Date/number renders <time dateTime={ISO}> — identical on both sides of
hydration — with the human-readable local time filled in only after mount,
because the server cannot know the reader's zone. An unparseable value renders
nothing at all rather than leaving a dangling separator in the meta line.
Behavior — accessibility
- The arrow is aria-hidden; between the two names put an sr-only connective
("handed off to" / "is handing off to" / "failed to hand off to") so the row
reads as one sentence.
- announce mounts a permanent sr-only role="status" aria-live="polite" region
carrying that sentence plus the reason and, on failure, where control landed.
Default it OFF: the marker's own text is already in the reading order, and one
region per row in a long log talks over itself.
- The only focusable element is the chip expander; everything else is text.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground, border, bg-border,
bg-muted/50, text-muted-foreground, text-destructive + border-destructive/40,
ring for focus, and var(--chart-1..5) for the per-agent accents (mixed through
color-mix(in oklab, …) for the avatar fill, its border and the control ring).
No hex, no rgb(), no oklch() literals.
- divider: hairlines left and right of a centred from → to row, then a meta line
(status word when not complete · reason · time) with the chips underneath. The
reason is line-clamped to one line so the marker stays one row tall at any
width; the full text stays in the title attribute.
- card: a status pill + time header, then a wrapping parties row — give each
party ONE arbitrary shorthand (flex-[1_1_9rem]) rather than `flex-1 basis-36`,
because those are two utilities writing the same longhand and the stylesheet
order, not the class string, decides the winner — so the row degrades to
stacked lines instead of squeezing the avatars; then the reason, the chips, the
"keeps the run" line and the actions slot.
- Every name and role truncates with min-w-0 + truncate and keeps a title;
nothing in this component may widen the row that hosts it.
- Motion: the marching track and the arrowhead nudge are
motion-reduce:[animation:none] (the dashes and the direction stay legible), and
the arrival ring is motion-reduce:hidden because it is pure decoration. Ship the
keyframes in a React 19 hoisted <style href precedence> so dozens of markers on
one page dedupe instead of each injecting a tag.
Customization levers
- Ownership signal: the ring + sr-only note is the minimum. Swap it for a badge
("owner"), or drop it when your runs are strictly sequential and nobody ever
reads a handoff out of order.
- Track physics: the dash period (0.5rem) and duty cycle (0.1875rem) set the
texture, the 0.55s loop sets the speed. Slow it to ~1.2s for a calmer log, or
replace the whole track with a solid line plus a pulsing arrowhead.
- Density: divider vs card is the coarse knob; inside the card, drop the status
pill, the role line or the chips independently — each is one JSX branch.
- Chip cap: maxContext={0} shows the whole manifest, 2–3 keeps a dense log
scannable. The chip itself is one class constant, shared with the expander.
- Palette: tones are --chart-1..5 by convention; pin `tone` per agent to match an
existing legend, or map roles (planner / tool / critic) to fixed indices.
- Failure copy: labels.returned(name) is where "keeps the run" becomes "stays with
the orchestrator" or "escalated to a human", and labels.more(count) localises
the expander.
- Timestamps: pass a preformatted string to reuse the relative-time formatter you
already ship ("3 min ago"), or a Date plus formatTime for absolute clocks.Concepts
- Control token — the run is owned by exactly one agent at a time and the owner is derived from
statusrather than passed in: a transfer still in flight, or one that failed, leaves the sender in charge — so the marker answers "who do I chase right now" without the caller having to say it twice. - In-flight vs settled — "pending" is a real state, not a spinner: the track marches, the receiver's avatar stays dashed and dimmed, and the meta line names the phase, so a stalled delegation looks like a stalled delegation instead of a finished one.
- One-shot arrival lock — the arrival ring is keyed, and a CSS animation only plays when its element is inserted; the pulse therefore fires exactly on the pending → complete edge, and never on a re-render, a remount into
complete, or a parent list re-key. - Context manifest — what travelled with the run (thread window, tool results, budget, deadline) is data, not prose: chips make it scannable, and anything past the cap hides behind a labelled, keyboard-reachable expander instead of being silently cut.
- Derived identity accent — each agent's colour comes from hashing its id into
--chart-1..5, so the same agent keeps the same accent across every handoff in a log without a palette registry, while the host theme still owns the actual hues. - Hydration-safe stamp — the machine-readable
datetimeis UTC and identical on both sides of hydration, while the human-readable local time lands one commit later, because the reader's time zone is a fact the server never has.
Tool Catalog
A searchable registry of the tools an agent may call — source facets with live counts, per-tool allow switches, parameter tables that open themselves when the query lands inside a signature, and four data states.
Agent Roster
A multi-agent fleet panel — per-agent state dot, model chip, current-task line and capacity bar, attention-first sorting, a real listbox keyboard path, and four data states.