One agent per workspace, and routing by delegation
There is no agent picker. A workspace has one lead, the specialists are a delegation roster derived from the org chart, and an @agent or @team tag routes a single turn without changing who you are talking to.

Status: shipped. vocion-core #332 is running. Merged onto the release branch release/2026-09-15-rail (7db4f7f6) and deployed to a deployment we operate; migrations 0094–0099 are applied. The pull request is still landing on main individually behind that release branch.
The first version of the chat surface had a dropdown. You picked an agent, then you typed. It was the obvious design and it was wrong for two reasons: it asked the reader to know the org chart before asking a question, and it made "which agent am I talking to" a piece of durable UI state that had to be persisted, restored, and reasoned about on every page.
The rail has no picker. A workspace has one lead. You talk to the workspace.
The roster is derived, not configured
Specialists are not a second list a person maintains, and not a hand-written subagents array. services/agents/delegationRoster.ts derives who a lead can hand work to from the registry, from three sources in order, de-duplicated by slug:
- Registered children — agents naming this agent as their parent.
- Team leads — the workspace lead also gets every team's lead, with the team named in each entry for provenance. "How is the quarter going" consults every team.
- Team members — the workspace lead gets every team's members as well, and a team lead gets its own team's members.
The third one is not redundancy. Subagents cannot delegate further, so a lead that could only reach team leads could never reach the specialist who actually drafts the thing. Members carry their team's name in their description, which is what lets a lead route by team as well as by role.
export type DelegateEntry = {
slug: string;
name: string;
description: string;
systemPrompt: string;
/** Where the entry came from — surfaced in the PR/debug, not the prompt. */
source: 'child' | 'team-lead' | 'team-member';
};
export type DelegationRoster = {
delegates: DelegateEntry[];
/** Display names of teams with no lead — the caller renders "no lead yet". */
leadlessTeams: string[];
isWorkspaceLead: boolean;
};
leadlessTeams is the small honest touch. A team with no lead is returned by name so the caller can say "no lead yet" for that team, rather than being silently dropped from the roster and looking like it does not exist.
The fold over those rows is pure, so the ordering and de-duplication rules are unit-testable without a database; deriveDelegationRoster loads the rows and calls it.
An answer that came from a specialist is attributed to it in the trace — the actor on a trace_node carries kind: 'lead' | 'specialist' — but it arrives in the same conversation, from the same surface.
That inverts the usual failure. With a picker, asking the wrong agent gets you a confident wrong answer or a refusal. With a lead and a roster, asking the "wrong" thing gets delegated, and the delegation shows up as a nested row in the timeline rather than as a dead end.
Tagging routes one turn
Sometimes you do know who you want. The composer supports @ tags, and they route a single turn — they do not change who the conversation belongs to.
Resolution is a small pure function (packages/core/src/features/dashboard/chat/routing.ts):
export function routeTurn(refs: ContextRef[], agents: AgentOption[]): AgentOption | null {
for (const ref of refs) {
if (ref.type === 'agent') {
const hit = agents.find(a => a.slug === ref.id);
if (hit) { return hit; }
}
if (ref.type === 'team' && ref.routeTo) {
const hit = agents.find(a => a.slug === ref.routeTo);
if (hit) { return hit; }
}
}
return null;
}
A null return is the normal case: the turn goes to the lead. Tagging a team routes to that team's lead via routeTo, which is the same resolution the org chart uses — one definition of "who speaks for this team", not two.
The popover is built from the surface's own agent list plus teams and missions fetched once and filtered client-side, capped at twelve results. Picking one drops the typed @query text — the chip carries it now.
The tags are more than a router. They are also context: the route notes them under the message, so a turn tagged @release-manager tells the model who the reader wanted even when the lead ends up answering.
Tags are validated on the server
Anything a client can put in a request body is something a client can lie about. packages/core/src/services/chat/pageContext.ts reads context refs on the way in: it caps each field's length, caps the number of refs, and silently drops malformed entries rather than erroring. A tag naming an agent that does not exist in this workspace resolves to nothing and the turn goes to the lead — the same outcome as not tagging at all.
This is the general shape of the surface's trust model: the client decides what to display, the server decides what is true. The same route ignores a client-supplied transcript when the request names a persisted conversation.
What a new conversation means
Removing the picker made a second question load-bearing: when you open the rail, are you continuing yesterday's thread or starting a new one?
The reported bug was landing on yesterday's thread. packages/core/src/features/dashboard/chat/resumeRule.ts reduces it to three and only three reasons to resume:
- this browser has an active session for that agent (a
sessionStoragekey, per agent slug), - the URL carries
?conversation=<id>, - the reader explicitly picked a thread from history.
Everything else is a new chat. History is a popover with search, not a default.
The surface is a third column
The last piece of "one agent per workspace" is "one surface per page". requestAgentSurface() is the single entry function, and a route list (OWN_DOCK_ROUTES) names the pages that render their own conversation so a page can never carry two.
Geometry persists in two tiers. localStorage is the fast synchronous path for this browser — vocion_chat_rail_width, bounded below at 320px and above at half the viewport. The server row (chat_widget_state.rail_width, rail_open) is what a second device reads, and the client only adopts it when this browser has never expressed a preference of its own. Width is written on pointer release, so a drag is one write rather than hundreds. Below 1200px the rail renders as a sheet instead.
Where this lives in the repo
Read off release/2026-09-15-rail at 7db4f7f6, the commit that shipped. #332 is merging to main individually behind it.
- Delegation roster:
packages/core/src/services/agents/delegationRoster.ts - Turn routing:
packages/core/src/features/dashboard/chat/routing.ts - Tag lookup:
packages/core/src/features/dashboard/chat/tagSearch.ts, composerChatComposer.tsx - Server-side validation of refs:
packages/core/src/services/chat/pageContext.ts - Resume rule:
packages/core/src/features/dashboard/chat/resumeRule.ts - Rail geometry and persistence:
packages/core/src/features/dashboard/chat/railState.ts,ChatDock.tsx,packages/core/src/services/ChatWidgetStateService.ts - The rule set, including §9.10 "one workspace agent — routing is delegation":
docs/agent-chat-surface.md
Related: One conversation surface, and what streams through it and Teams: the org chart is the interface.