One substrate, any agent — the chat surface that isn't Atlas
PRAMAAN is about to grow more than one agent: Atlas on the boards, a
scoped specialist on a matter, a ledger agent on the register. This Story
builds the agent-substrate UI primitives — the presence
pill, the chat panel, the composer, and the dock that orchestrates them —
as agent-agnostic components in
src/components/agents/, matching the locked Claude-Design ACP
treatment. Atlas is the demo descriptor, never hardcoded into the
substrate. Swap the descriptor → a different agent's bar, panel, and
composer, with zero UI rewrite.
🛰️ Satellite — why the substrate must not know Atlas's name
The stakes: if the first agent surface is built around Atlas — "Atlas" in the type names, "can help you start a matter" in the markup, one reasoning ladder baked in — then the second agent is a copy-paste fork, and the third is three forks drifting apart. The whole product bet is many agents across many surfaces; a per-agent UI is the thing that makes that bet unaffordable.
The naive fix — "we'll generalize it later" — detonates on the
first concrete decision. A thread that renders agent prose with a bespoke
<div> per message can't grow a new message kind without
touching every call site; a composer with a hardcoded model dropdown leaks
the cost lever onto counsel's surface; a reasoning control with an "Auto"
option hands the judgement back to the machine. The real design pushes
every agent-specific fact into one descriptor and makes
the thread a discriminated union so a new message kind is
additive, not a rewrite. The substrate renders structure; the
descriptor supplies identity; the caller supplies data.
✈️ Airplane — five primitives and a descriptor
One analogy: the descriptor is a name badge + capability card you clip onto a generic chat shell. The shell never changes; the badge decides who is wearing it.
| Primitive | Role | Driven by |
|---|---|---|
| AgentDescriptor | The single source of identity + abilities (key, name, avatarSrc, accent, presenceKey, capabilities, bio). | — |
| AgentPresenceBar (APB) | The always-visible pill: avatar + accent name + status dot + open affordance. | descriptor + rail status |
| AgentChatPanel (ACP) | White surface, dark identity header, thread of typed item kinds. | descriptor + AgentThreadItem[] |
| AgentComposer | Auto-growing input · paperclip attach (no "+") · reasoning pill · Send. | descriptor capabilities |
| AgentDock | Orchestrator: controlled open, swaps APB ↔ ACP, mounts the composer. | caller |
--pill-bg,
--presence-green/amber/red, --shadow-atlas-*) or a
token-backed utility. No hex/rgb/hsl literal anywhere in
src/components/agents/ (verified by the AGENTS.md grep).
🔬 Microscopic — the pieces, and why each is shaped the way it is
The descriptor — identity is data, not code
agent-descriptor.ts is the whole reason the substrate is
agnostic. accent is a token reference
("var(--brand-primary)" for Atlas) used for the name colour and
avatar ring — identity, never status. presenceKey is an i18n
key, not a literal, so the "can help…" line follows the page and the locale
(the en+hi doctrine survives first ship). capabilities.reasoningLevels
is what the reasoning pill renders — different agents expose different sets.
The thread — a discriminated union, so kinds are additive
agent-thread-item.ts defines AgentThreadItem as a
union of eight kinds: agentText (plain sans, no bubble) ·
counselNote (mono, faint warm-tint block) · toolAction
(quiet inline note) · navMarker · handoff ·
timeMarker ("thought Ns") · proposal (the one card) ·
thinking. The renderer is a single switch — adding a
ninth kind is one new case, and every existing kind is
untouched. The mockup's atlasText is renamed
agentText on purpose: a union member named after one agent would
bake Atlas into the substrate type, contradicting the entire Story.
agentText item carries a ReactNode, so a table is
rendered by the app's Table primitive (markdown/MDX → components)
inside it — the panel never builds bespoke HTML tables. The fixtures prove
this with a real <Table> result.
The reasoning pill — "how hard," tier hints only, no Auto
reasoning-pill.tsx renders exactly the levels the active
descriptor resolves to (Low / Medium / High / Max / Ultra Max for
Atlas), each with a lucide signal-bar icon and a plain tier hint
("Fastest", "Deeper analysis"). There is no Auto and no model,
provider, or price on the surface — the only extra copy allowed is a
tier hint. The level union is a local STUB; when the functions contract is
codegen'd into src/gen it collapses onto the generated enum
(HR-22). The comment says so in the file so the seam is impossible to miss.
The composer — decluttered to a paperclip
agent-composer.tsx is the locked Claude-Design treatment: an
auto-growing textarea over one control row — a paperclip
that opens a file picker (v1: attach files only, no "+" menu), the
reasoning pill, and a round Send. Staged files show as removable chips. It
is controlled and presentational; the Dock owns transport. (This is the new
agnostic composer; the richer ADR-129 posture composer in
agent-controls/ is a separate, earlier surface.)
The APB and the avatar — identity at a glance
agent-presence-bar.tsx is the front door: a cream pill with the
agent name in its accent colour, a presence suffix, and a status dot driven
by the rail. On thinking/error the suffix swaps to
localized chrome; on ready it shows the per-page presence data.
agent-avatar.tsx generalizes the Atlas avatar: it renders the
descriptor's image grayscale (AI doctrine) and falls back to an abstract
token-built disc when no image is set, with the presence-token status dot
and an optional accent ring.
The Dock — one orchestrator, one visible at a time
agent-dock.tsx holds the controlled open and swaps
the APB (closed) for the ACP (open) — never both. It resolves the open/close
chrome labels and wires APB → open, ACP → close, mounting the composer into
the ACP footer slot.
Verified in Storybook
Every primitive ships a co-located *.stories.tsx with the
required states: APB (ready / thinking / error + a different descriptor),
ACP (empty / streaming / populated / error), Composer (empty /
with-attachment / reasoning-open), the reasoning pill (closed / open /
limited levels), the avatar (every status + ring + fallback), and the Dock
(closed → APB, open → ACP). Components resolve their own chrome via
next-intl; the Storybook preview already provides the
NextIntlClientProvider, so the stories render deterministically
without Clerk or a gateway.
status
+ streaming thread), the layout-level mount + portal, and
drag-to-resize are stubbed — status,
items, and the composer arrive as props. Wiring
onSend → the agent invoke contract, resolving the generated
reasoning-level enum, and the route registry that resolves a descriptor per
surface are follow-up children of Epic-1646 (the contract +
rail children). Per the Story Process, the Project #2 board was not
touched.
What shipped
src/components/agents/agent-descriptor.ts— the descriptor + reasoning-level + status types (level enum is a labelled STUB pendingsrc/gen).agent-thread-item.ts— the 8-kind discriminated union.agent-avatar.tsx,agent-presence-bar.tsx,reasoning-pill.tsx,agent-composer.tsx,agent-chat-panel.tsx,agent-dock.tsx— the primitives, each with co-located stories.agents.fixtures.tsx— the Atlas demo descriptor, a second (Ledger) descriptor proving the swap, and threads exercising every kind.Agentsnamespace inmessages/en.json+messages/hi.json(every key in both locales).- lucide icons (no emoji); tokens only (no hex/rgb/hsl in
agents/); a11y labels on every control; reuse ofButton/PrimaryButton/Table/DropdownMenuprimitives.
Files changed
18 file(s), +1502 / -8 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +49 | -4 |
| messages/hi.json | Config | +49 | -4 |
| src/components/agents/agent-avatar.stories.tsx | Component | +26 | -0 |
| src/components/agents/agent-avatar.tsx | Component | +72 | -0 |
| src/components/agents/agent-chat-panel.stories.tsx | Component | +70 | -0 |
| src/components/agents/agent-chat-panel.tsx | Component | +203 | -0 |
| src/components/agents/agent-composer.stories.tsx | Component | +90 | -0 |
| src/components/agents/agent-composer.tsx | Component | +154 | -0 |
| src/components/agents/agent-descriptor.ts | Component | +81 | -0 |
| src/components/agents/agent-dock.stories.tsx | Component | +63 | -0 |
| src/components/agents/agent-dock.tsx | Component | +87 | -0 |
| src/components/agents/agent-presence-bar.stories.tsx | Component | +42 | -0 |
| src/components/agents/agent-presence-bar.tsx | Component | +102 | -0 |
| src/components/agents/agent-thread-item.ts | Component | +94 | -0 |
| src/components/agents/agents.fixtures.tsx | Component | +152 | -0 |
| src/components/agents/index.ts | Component | +19 | -0 |
| src/components/agents/reasoning-pill.stories.tsx | Component | +36 | -0 |
| src/components/agents/reasoning-pill.tsx | Component | +113 | -0 |