Send an untagged message to the coworker it is for - #122
Merged
Merged
Conversation
Typing without naming anyone reached the default coworker; a specialist needed an @. That makes the person the router, which is the friction that reads as dated next to assistants that just take what you say and act. Now an untagged message is routed to the coworker whose purpose matches it. A channel is pinned to one coworker before its first turn, so the choice happens at the one seam where an untagged message picks a coworker, before the channel is created. A new POST /api/route reads the roster for the person asking (so it can only ever pick a coworker they may already reach), asks the deployment's own model to choose against each coworker's own description, validates the answer is a coworker on that roster, records a channel.routed row, and returns it. The model call is the deployment's existing model and key, not a second thing to configure. Named, not silent, which the composer already believed: the channel header is the coworker it went to, and the audit row carries the reason and the candidates but never the message, which the payload redaction would drop anyway. @ is unchanged and wins: an addressed message skips routing entirely, no model call, no inference recorded. And every uncertain path lands on the same default the composer always used and says so rather than misroute or drop: no roster, one coworker, model unreachable, unparseable answer, an id not on the roster, or low confidence. The classifier is a pure function with the model call injected, so those failure paths are a plain test rather than a mock of a network.
davidmckayv
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
August 21, 2026 23:52
The lint gate now fails on warnings (#120), and roster[0]! tripped noNonNullAssertion. Guard on the resolved value instead: find the first public coworker or fall back to the first, and 409 when there is none, which also reads straighter than asserting a length check the linter cannot see.
Merged
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Typing without an
@reached the default coworker; to get a specialist you had to@them. That makes the person do the routing, which is the friction an outside user flagged: assistants that "just work" take what you say and act. Now an untagged message is routed to the coworker whose purpose matches it.How. A channel is pinned to one coworker before its first turn, so the choice happens at the one seam where an untagged message picks a coworker (
index.tsx,draft.agentId ?? fallback), before the channel is created. A new governedPOST /api/route:profileStore.list(actor)), so it can only ever pick a coworker they may already reach;roleDescription;channel.routedaudit row, and returns it.Named, not silent — which the composer already believed ("a message that silently reaches somebody you did not choose costs trust"). The channel header is the coworker it went to; the audit row carries the reason and the candidates but never the message (payload redaction drops it anyway).
@is unchanged and wins. An addressed message skips routing entirely: no model call, no inference recorded.Every uncertain path fails safe to the same default the composer always used, and says so, rather than misroute or drop: no roster, one coworker, model unreachable, unparseable answer, an id not on the roster, or confidence below threshold.
Where it runs
agent.invoked.Boundary and audit
channel.routed) → act (start). A returned id not on the roster is never acted on.channel.routedrecords who, why,viaMention,fallback, and the candidate ids. No message text.Changelog
Unreleased.Proof
Driven in Chrome on a running stack, verified by the audit trail and the rendered app, not the model's words:
@: typed "review this wire transfer for fraud and sanctions risk" → landed with Risk Analyst (header + a risk-flavored answer). Audit:channel.routed → risk-analyst,fallback:false,viaMention:false, candidates[general-assistant, knowledge, risk-analyst], no message body.@override: picked General Assistant from the@menu, sent the same risk text → landed on General Assistant, and no newchannel.routedrow (routing was not called).Failsafe is a red-green unit suite on the pure classifier (
server/tests/routing-classify.test.ts, 7 tests): specialist match, model unreachable, unparseable, off-roster id refused, low confidence deferred, fenced JSON parsed, single-coworker roster.bun run typecheckexit 0,biome lint/formatclean, full suite 1086 pass.Follow-up (not in this PR)
The routing reason is recorded and returned but not yet shown in the transcript; the header names the coworker. A one-line "Routed to Risk Analyst because…" notice is a small follow-up.