Skip to content

Send an untagged message to the coworker it is for - #122

Merged
davidmckayv merged 2 commits into
mainfrom
feat/infer-the-coworker
Aug 21, 2026
Merged

davidmckayv merged 2 commits into
mainfrom
feat/infer-the-coworker

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

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 governed POST /api/route:

  • reads the roster for the person asking (profileStore.list(actor)), so it can only ever pick a coworker they may already reach;
  • asks the deployment's own model (its existing model + key, no new config) to choose against each coworker's roleDescription;
  • validates the answer is a coworker on that roster, records a channel.routed audit 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

  • New state that outlives a request? None. The router is a pure function plus one per-request model call; no cache, map, or timer.
  • Second replica? Identical. One permission-filtered roster read + one model call + one audit row per untagged send, same profile as agent.invoked.
  • Serialised / fanned out / new listener? No.

Boundary and audit

  • The decision goes through the governed shape: resolve (roster for the actor) → decide (model, validated against the roster) → audit (channel.routed) → act (start). A returned id not on the roster is never acted on.
  • New event channel.routed records who, why, viaMention, fallback, and the candidate ids. No message text.
  • Nothing new trusted from the client: only the message text is sent; the roster and the choice are server-side.

Changelog

  • Entry under Unreleased.

Proof

Driven in Chrome on a running stack, verified by the audit trail and the rendered app, not the model's words:

  • No @: 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 new channel.routed row (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 typecheck exit 0, biome lint/format clean, 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.

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.
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.
@davidmckayv
davidmckayv merged commit 42af1b6 into main Aug 21, 2026
6 checks passed
@davidmckayv
davidmckayv deleted the feat/infer-the-coworker branch August 21, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant