ADR 0008 — Conversational Creative agent in the harness + handoff foundation
- Status: accepted
- Date: 2026-07-24
- Phase: 3b (session 1)
Context
Phase 3a delivered the deterministic harness + the one-shot ToolLoop
(autonomous, runs until it stops) and the human chat is a stateless
api-only echo (SendChatMessageUseCase sends only the current message to
the provider — no history, no harness, no tools; the engine doesn't even
participate). Phase 3b needs a conversational Creative agent that drives
ideation WITH the user, emits business_rule throughout the conversation and
— only after explicit user confirmation — emits product_brief and offers a
handoff to the PO. It also needs the handoff foundation, with the rule
that gates agent activation.
Hard rule from CLAUDE.md: "Agents ALWAYS run inside a Harness" and "the engine NEVER talks to an LLM provider directly". So the Creative agent lives in the engine (harness), and every turn goes through the api (metering + budget).
Decisions
1. Creative agent as a stateful GenServer (not stateless)
An Engine.Agents.CriativoServer (GenServer, restart: :temporary) per
session, supervised by a CriativoSupervisor (DynamicSupervisor), registered
in Engine.Sessions.Registry under the key "criativo:<session_id>" —
mirrors SessionServer. Conversation history lives in memory and is
rehydrated from session_events (chat.message/agent.response) in
init — the event log is the durable source of truth. Started by a user
command (the exception to the activation rule), via the api →
ApiToEngineClient.startAgent → engine POST /internal/sessions/:id/agent/start.
Discarded alternative: rebuilding the history from the event log on every
turn (no live process). We chose the GenServer to have a natural place for
per-session state and lifecycle (consistent with SessionServer).
2. Token-by-token streaming via SSE engine→api and Phoenix rebroadcast
Each user message runs ONE streamed turn: StreamLlmTurnUseCase (an SSE
sibling of the 3a RunLlmTurnUseCase, which remains turn-result for the
ToolLoop) transmits deltas; the CriativoServer consumes the SSE
(EngineApiClient.llm_turn_stream) and rebroadcasts the deltas to the
web over the Phoenix channel session:<id> (agent.delta/agent.done) —
which the web already connected to only for heartbeat. Final persistence
(agent.response + artifacts) arrives via the session-events poll (3s)
that SessionPage already performs. Metering (token_usage) is mandatory and
runs in the api; the engine never talks to the provider directly. This
partially reverts the 3a turn-result decision only for interactive
agents.
3. Handoffs: table with mutable status + immutable events
New table handoffs {from_agent, to_agent, artifact_id, status: offered|accepted|completed|rejected}. Unlike event tables, status is
MUTABLE (it's the current state); each transition also becomes an immutable
session_event handoff.*. The api owns the table — the engine creates the
handoff via POST /internal/sessions/:id/handoffs (it never writes the
api's table directly).
4. Agent activation rule (pure domain)
domain/sessions/agent-activation.ts: an agent can only be activated in a
session with a handoff accepted addressed to it; single exception: the
Creative agent (started by user command — USER_STARTED_AGENTS). Pure and
tested in isolation (mirrors decide.ts/session-state-machine.ts);
ActivateAgentUseCase loads the handoffs and applies the rule before
spinning up the process in the engine.
5. Readiness is a user action; product_brief is server-emitted (domain guardrail)
"Readiness confirmation is a user action (button), not model inference." The
business_rule is emitted by the model via the emit_artifact tool (with
origin — references to the conversation — validated as NON-empty in
ArtifactSchemas). The product_brief is NOT tool-emittable
(known/0 excludes it; EmitArtifact blocks system-emitted types) — it only
comes out when the user clicks "I'm ready to produce"
(ConfirmReadinessUseCase → engine confirm_readiness): at that point the
CriativoServer runs a consolidation turn, emits the product_brief
directly (append_event_returning, capturing the id) and creates the
offered handoff to the PO. This makes "brief only after confirmation" a
domain guarantee, not a prompt guarantee.
6. Artifacts remain typed session_events
No artifact table: business_rule/product_brief are session_events
artifact.<type> with payload validated in the engine (ArtifactSchemas).
The web reads them from the same event poll (rule cards in the side panel;
handoff divider in the chat when handoff.offered appears).
Scope
Only the handoff foundation (table + rule + offered handoff) and the Creative agent. The PO (accepting the handoff, activating the PO, backlog) is a later session of 3b. No Bitbucket/GenericGitProvider; queues remain on Postgres (Oban), no Redis.
Consequences
- The web gains a second chat path (agent vs. human), decided by
agent.activatedin the event log; sessions without a Creative agent stay on the human chat. - The
CriativoServerrehydrates from the event log — survives a restart without losing the thread of the conversation. - Deterministic tests: activation rule (unit),
ArtifactSchemas(mandatory origin), andCriativoServervia directinit/1+handle_call/3with the scripted LLM fake (streaming, product_brief guardrail, readiness→brief+handoff, broadcast, rehydration).