Skip to main content

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:

  1. The read side never needed to know who decided. handoff.offered already has actor on the event envelope — a manual handoff just writes actor: {kind: 'user', id: userId} instead of the default {kind: 'agent', id: fromAgent} that CreateHandoffUseCase used to hardcode. CreateHandoffInput gained an optional actor?: Actor field for exactly this — backward compatible, the internal caller doesn't pass it and keeps the old behavior.
  2. 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.tsx doesn'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: handoffs table, handoff.offered event shape, and the accept/activation path are all untouched. A manual handoff and an automatic one are indistinguishable to every existing reader except for actor.kind.
  • The addressable catalog is closed and typed, not a free-text field — RequestManualHandoffUseCase refuses 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_AGENTS between 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. Extending gerar: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 — but AGENTES_DE_CHAT (the web's separate "who can this screen actually converse with" list) doesn't include infra/qa, so a handoff to them from this picker would create an offered handoff 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 a message clause.

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_AGENTS
  • apps/api/src/application/use-cases/agents/request-manual-handoff.use-case.ts
  • apps/api/src/application/use-cases/agents/create-handoff.use-case.ts — actor?: Actor addition
  • apps/api/src/interfaces/http/agents/agents.controller.ts — POST .../handoffs
  • apps/web/src/lib/agents.ts — addressableAgents, SOLO_CONVERSATIONAL_AGENTS
  • apps/web/src/routes/SessionPage.tsx — AGENTES_DE_CHAT, .manualHandoffRow
  • docs/business-rules.md — RN-440
  • docs/explanation/backlog.md — the closed "Manual handoff to an agent of choice" row