What the app owns, what the agent owns
OpenCouch runs a hybrid architecture around the OpenAI Agents SDK. The application owns lifecycle and ordering: which specialist runs, when memory writes are allowed, when the crisis gate fires, when a session ends. The agents own judgment within their slot: which tool to call, which response style fits this turn, how to word the reply.
We don't reject ReAct — we constrain where it runs. Each specialist is a real SDK agent with attached tools and reasons over them at inference time. What the agent never gets to choose is the outer shape of the turn: it can't skip safety classification, hand off to a different specialist, or decide that a memory write should happen mid-response.
The split
The dividing line is before vs inside the specialist. Everything
that decides which specialist runs (or whether one runs at all)
lives in the application. Everything that happens while a specialist
runs is the agent's call, scoped to the tools the runtime attached.
In text, that application layer is the lightweight TextTurnGraph: it
turns crisis state, triage output, grounded lookup intent, memory-control
state, and guided-exercise lifecycle into one route plan before any
specialist executor runs.
App-owned
Lifecycle and ordering. Deterministic. Cannot be skipped or reordered by the model.
- Safety classificationRuns before any specialist is built
- Specialist selectionTherapeutic / Crisis / Guided Exercise
- Memory mutation gatingWhen writes are allowed, what is held
- Exercise stateConsent, current step, exit semantics
- Persistence & auditThread locks, crisis log, finalization
Agent-owned
Judgment within the assigned specialist slot. ReAct loop over attached tools.
- Which tool to callMemory, grounded lookup, response-style, exercise tools
- Response style choicesupportive · reflective · clarifying · psychoeducation · technique · closing
- Response wordingDrafting the actual prose for the user
- In-slot reasoningWhen to look up a hotline, when to record exercise progress
The agent runs inside a slot the app defined. The outer ring decides who runs and when; the inner ring decides what to do once selected.
The boundary isn't ideological. It's wherever a wrong choice would be worse than the LLM's judgment is good enough to justify — which is almost always at the ordering layer in a mental health product.
What stays app-owned
These responsibilities live in agent/runtime/ and the orchestration
helpers around it. The runtime calls into the SDK to execute a chosen
specialist; it never asks the SDK "which specialist should I be?"
Safety classification. Crisis assessment runs before any
specialist is built. There is no serving path that reaches ordinary
response generation without going through the gate. Implementation:
agent.guardrails for the classifier, with the runtime branching on
its output in agent/runtime/openai_text_runtime.py.
Specialist selection. The runtime picks the specialist agent
deterministically — THERAPEUTIC_AGENT_NAME, CRISIS_AGENT_NAME, or
GUIDED_EXERCISE_AGENT_NAME — based on triage output, crisis level,
and active exercise state. See the explicit selected_agent= calls in
OpenAITextRuntime (agent/runtime/openai_text_runtime.py).
Specialist handoff_description fields exist on the agent definitions
but they aren't used as a model-driven routing mechanism; the app
makes the call.
Memory mutation gating. Whether a turn can write memory, what
candidates are held, and how session-end consolidation runs is
orchestrated by agent.runtime.memory_context and
agent.runtime.session. The therapeutic agent has memory tools
attached, but the runtime decides when those tools are allowed to
mutate state through prompt context and pending-action gates.
Guided-exercise state. Consent, current step, exit semantics, and
completion all live in state["exercise_state"]. The guided-exercise
agent receives that state in its prompt and is instructed not to
invent steps or change the active exercise. See the runtime's
_load_and_prepare_guided_exercise flow and the agent instructions in
agent/specialists/guided_exercise.py.
Persistence, audit, and session lifecycle. Thread locks, app-owned
state snapshots, active-session windows, crisis-audit writes, and
session finalization are all PersistentAgentRuntime concerns. None
of them are choices the SDK runner can defer or skip.
What stays agent-owned
These are SDK agents in the standard sense: an Agent definition
with attached tools, instructions, and a model. Once selected, each
specialist reasons over its slot.
Therapeutic agent (agent/specialists/therapeutic.py). Inside a
safe turn it chooses among:
show_saved_memory/show_memory_status(when the user asks)load_therapeutic_response_skillwith a style argument (supportive,reflective,clarifying,psychoeducation,technique,closing)answer_grounded_lookup(when the user asks for current or source-backed information)list_guided_exercise_skills(when considering offering an exercise)
The agent ReActs over these tools at inference time — that's genuinely the LLM's call, shaped by instructions but not enumerated by the runtime.
Crisis agent (agent/specialists/crisis.py). On a turn already
classified as level 2/3, the crisis specialist owns the wording and
chooses between:
lookup_crisis_resourcesto find region-appropriate hotlinesget_crisis_support_templatefor deterministic safety scaffolding
The classification was app-owned; the response shape is the agent's.
Guided-exercise agent (agent/specialists/guided_exercise.py).
Given runtime-provided exercise state, the agent chooses when to call:
load_guided_exercise_skillfor the next step's guidancerecord_guided_exercise_progresswhen the user's reply changes state (complete, partial, hold, stuck, exit, unsafe)
The agent never picks the exercise or invents steps; the runtime validates progress and computes the next step.
Triage agent (agent/specialists/triage.py). The clearest example
of the hybrid: triage is itself an LLM-judgment step (returning a
structured dispatch decision) but the runtime is the one that
invokes triage and acts on its output. The agent decides the
route; the app decides what to do with the route.
Why split it that way
Each boundary exists because of a specific failure mode that's unacceptable in this domain — and a specific kind of decision that genuinely benefits from LLM judgment.
Crisis classification is app-owned because the only acceptable outcome is that it runs before any specialist. The failure mode is "a user in crisis gets a therapeutic reply instead of crisis routing and resources." Prompt hardening can reduce that risk; an architectural invariant eliminates it.
Specialist selection is app-owned because the routing decision is small, structured, and has user-visible cost when wrong. Handing "which specialist owns this turn?" to the model adds variance for no real upside — the runtime already has the triage output, the crisis level, and the exercise state.
Memory mutation is app-owned at the gate because the failure mode is silent corruption: a write that lands on the wrong owner, runs before the response is finalized, or never runs at all because the model didn't think to call the tool. The runtime owns when a write is allowed; the agent still owns whether to call the tool within that gate.
Guided-exercise state is app-owned because exercises have an explicit protocol (steps, consent, completion) that the agent shouldn't be free to reshape mid-flow. The agent provides wording; the runtime tracks the protocol.
Tool selection inside a specialist is agent-owned because the LLM genuinely benefits from context. Does this user want grounded facts or emotional reflection right now? Should the response open with acknowledgment or move straight to a clarifying question? These are judgment calls that depend on the conversation, not invariants.
Response wording is agent-owned for the same reason. The runtime
can choose the style scaffold (load_therapeutic_response_skill); it
shouldn't be writing the prose.
What this isn't
This isn't a claim that LLMs are untrustworthy or that determinism is universally better. ReAct loops genuinely shine when the task is open-ended, the tool sequence is unpredictable, and the cost of a wrong action is low — web search, code search, exploratory research.
In a mental health support agent, the outer shape of a turn isn't exploratory. The sequence is known, safety ordering is critical, and the cost of a wrong action (missed crisis, corrupted memory, drifted exercise) is high. The inner shape — which tool to call, how to phrase support — is contextual and is where the LLM earns its keep.
The hybrid puts each decision at the layer where it lives best. The app owns what would be unacceptable to let drift. The agent owns what would be wasteful to enumerate.
Where to read the code
- Runtime orchestration:
agent/runtime/openai_text_runtime.py(theOpenAITextRuntimeclass) - Specialist agents:
agent/specialists/{therapeutic,crisis,guided_exercise,triage}.py - Tool registries (what each agent can reach):
agent/tools/{memory,grounded,crisis,therapeutic,guided_exercise}.py - State and persistence:
agent/state.py,agent/runtime/session/ - Top-level summary:
apps/backend/agent/README.md