ADR 0109 — Manual handoff to an agent of choice reuses offer+accept, never a third handoff state
- Status: Accepted
- Date: 2026-08-23
- Context: closes the backlog item "Manual handoff to an agent of
choice" (
docs/explanation/backlog.md), whose activation criterion was already written there: "when an agent OUTSIDE the fixed handoff pipeline needs to be reached by human decision — today that's exactly the Staff's case (ADR 0088), reachable only via the internal route" - Direct precedent: ADR 0038
(handoff target rule —
assertHandoffTargetAllowed), ADR 0088 (the Staff, the real case that motivated this ADR), ADR 0087 (UX Designer, found addressable by the same investigation)
Context
CreateHandoffUseCase is the only place in the system that writes
toAgent (ADR 0038) — but its only caller was
POST internal/sessions/:sessionId/handoffs, an engine-only route. Every
handoff in the product today is created by an AGENT calling that internal
route on its own initiative (Criativo offering to the PO, the Arquiteto
offering to Infra/Dev Lead, etc.). There was no path for a HUMAN, from the
session screen, to address a handoff to an agent outside that fixed chain.
ADR 0088 named the gap precisely while building the Staff: "acionável
MANUALMENTE" meant the domain mechanics (activation, target validation)
already allowed it, but "the generic 'manual handoff to an agent of
choice' UI remains in the backlog." The Staff's engine plumbing
(StaffSupervisor, StaffServer, the "staff" clause in
agent_command_controller.ex) was ready and untested by any real user
path — the only way to reach it was a raw HTTP call to the internal route.
Investigation before writing any code found a second agent in the same
situation: ux-designer (ADR 0087) also has a fully-wired message/2
clause in the engine (agent_command_controller.ex, confirmed by reading
the file — the clause mirrors the Staff's exactly), and is equally
unreachable from SessionPage.tsx, which never added it to
AGENTES_DE_CHAT. Both close together.
Decision
No third handoff state. A manual handoff is written by the SAME
CreateHandoffUseCase.execute that an agent's own offer_handoff already
uses — it is born offered, exactly like an automatic one, and the
EXISTING accept flow (AcceptHandoffUseCase, the handoffs/:handoffId/accept
route, SessionPage.tsx's offeredHandoff/handleAcceptHandoff) picks it
up with zero changes. The only new thing is a second, human-facing WRITE
path. Two reasons this was chosen over inventing a distinct
manual_handoff concept:
- The read side never needed to know who decided.
handoff.offeredalready hasactoron the event envelope — a manual handoff just writesactor: {kind: 'user', id: userId}instead of the default{kind: 'agent', id: fromAgent}thatCreateHandoffUseCaseused to hardcode.CreateHandoffInputgained an optionalactor?: Actorfield for exactly this — backward compatible, the internal caller doesn't pass it and keeps the old behavior. - Duplicating the accept/activation logic for a second handoff "kind"
would be the exact anti-pattern ADR 0086 already rejected once (a
second suspension mechanism instead of reusing the existing one). The
accept card in
SessionPage.tsxdoesn't care HOW a handoff was offered — only that it exists and targets an agent the screen knows how to converse with (AGENTES_DE_CHAT).
addressableAgents() — a catalog stricter than assertHandoffTargetAllowed.
The existing target rule (ADR 0038) only REJECTS a subagent; it has no
opinion about whether toAgent is a real, known agent at all — an agent
citing "abc" as a target would pass it. That's fine for an agent, which
only ever cites agents it already knows about from its own instructions.
It is NOT fine for a human picking from a dropdown, where a stale client
or a hand-crafted request could send anything. addressableAgents()
(apps/api/src/domain/agents/agent-areas.ts) is the closed set — area
leads (AGENT_AREAS.map(a => a.lead)) ∪ SOLO_CONVERSATIONAL_AGENTS
(criativo, po, arquiteto, ux-designer, staff) — and
RequestManualHandoffUseCase checks membership in it BEFORE ever calling
CreateHandoffUseCase.
SOLO_CONVERSATIONAL_AGENTS is a new, hand-written list in the api's
domain — not derived from apps/web/src/lib/agents.ts (that roster also
includes gate agents like qa-automacao/secops and the Psicólogo/
Anamnese, none of which are addressable by handoff) and not generated by
gerar:areas (which only ever covered AGENT_AREAS). The web side gets
its own manually-mirrored copy in agents.ts, in the same file whose
comment already says it "survived for color, icon, and the addressing
RULE" — this is exactly that rule. The two lists are NOT cross-checked by
a test today (that would need the same generator mechanism the Fase 18
areas list has); a drift there produces, at worst, a stale option in a
<select> that the backend's RequestManualHandoffUseCase still refuses
with 400 — never a wrongly-written handoff.
fromAgent for a manual handoff is derived, not asked. The picker
only asks for toAgent — asking the user to also name a fromAgent would
be asking them to explain the engine's internal bookkeeping.
RequestManualHandoffUseCase reads the session's agent.activated
events (SessionEventRepository.listByTypeInSession) and takes the most
recent one, the same "most recent by seq wins" rule activeAgent in
SessionPage.tsx already uses (achado 9-fix) — no session-wide "current
agent" query existed server-side before this, so this ADR introduces the
first one, scoped to this one use case. A session with no agent activated
yet falls back to the sentinel "usuario" — never a guessed agent name,
the same "no diagnosis by elimination" rule RN-059 already established
for turn failures.
ux-designer and staff both entered AGENTES_DE_CHAT. Verified by
reading agent_command_controller.ex before writing any web code: both
have a message/2 clause identical in shape to po/dev-lead/
arquiteto's — no engine change was needed for either. infra/qa
remain excluded, unchanged: they're area leads without kickoff/1 and
without a message clause, the same documented reason AGENTES_DE_CHAT's
comment already gave for excluding Infra.
Consequences
For
- The Staff (ADR 0088) and UX Designer (ADR 0087) become reachable from the Session screen for the first time — both had complete engine plumbing sitting unused behind an internal-only route.
- Zero new handoff semantics:
handoffstable,handoff.offeredevent shape, and the accept/activation path are all untouched. A manual handoff and an automatic one are indistinguishable to every existing reader except foractor.kind. - The addressable catalog is closed and typed, not a free-text field —
RequestManualHandoffUseCaserefuses an unknown slug the same way it refuses a subagent, both with 400 before anything is written.
Against, declared
- No test cross-checks
SOLO_CONVERSATIONAL_AGENTSbetween api and web. A new solo conversational agent added to the api's list without updating the web's manual mirror won't fail CI — it will just not show up as an option in the picker until someone notices. Extendinggerar:areas(Fase 18) to cover this list too is the fix, left for when a second drift actually happens (same "don't refactor what's done" discipline CLAUDE.md asks for). - The picker offers no confirmation/preview of what the target agent
will do with the handoff. It is a plain
<select>+ button, styled deliberately smaller than the composer (.manualHandoffRow, ADR intent: "this is redirection, not conversation"). infra/qa/dev-<modulo>remain unreachable from this picker.addressableAgents()does include their leads (infra,qa,dev-lead) — they ARE addressable by the backend — butAGENTES_DE_CHAT(the web's separate "who can this screen actually converse with" list) doesn't includeinfra/qa, so a handoff to them from this picker would create anofferedhandoff the accept card can't render. This ADR does not fix that — it is the same limitation ADR 0088 already declared for the Staff before this feature, now narrowed to the two leads without amessageclause.
Alternatives considered
A dedicated POST .../handoffs/manual route, separate from a generic
POST .../handoffs. Rejected: GET .../handoffs already owns the plain
handoffs path with no verb suffix, and Nest routes by HTTP method, not
just path — POST .../handoffs reads as "create a handoff" without
needing a qualifier, and the accept path (.../handoffs/:handoffId/accept)
already establishes the pattern of a verb suffix only where a stateful
transition happens.
Extending CreateHandoffInternalDto/the internal route to accept a
userId and calling it directly from the web. Rejected: the internal
route is engine-service-classified in docs/security-surface.md,
authenticated by the shared service token, not a user's JWT — routing a
human-initiated request through it would blur the classification that
route-surface.spec.ts enforces, and would skip RequireRole('developer')
entirely.
References
apps/api/src/domain/agents/agent-areas.ts—addressableAgents,SOLO_CONVERSATIONAL_AGENTSapps/api/src/application/use-cases/agents/request-manual-handoff.use-case.tsapps/api/src/application/use-cases/agents/create-handoff.use-case.ts—actor?: Actoradditionapps/api/src/interfaces/http/agents/agents.controller.ts—POST .../handoffsapps/web/src/lib/agents.ts—addressableAgents,SOLO_CONVERSATIONAL_AGENTSapps/web/src/routes/SessionPage.tsx—AGENTES_DE_CHAT,.manualHandoffRowdocs/business-rules.md— RN-440docs/explanation/backlog.md— the closed "Manual handoff to an agent of choice" row