Making Thread the primary agent interaction primitive
Date: 2026-08-23
Commits measured: 8664b6064343 on openagents/main for this repository,
with the uncommitted docs/taxonomy.md Threads section in the same working
tree; 56eaa4243210 in the openagents monorepo for packages/openagents-cli
(package version 0.2.1 in that tree); bf2aee99c5 in the openai/codex
reference clone for the app-server protocol.
Question: What has to be true for a Thread to be the primary agent
interaction primitive in the web /chat surface and in the openagents coder
CLI? What already holds a thread, what refuses to, and what is the smallest
honest first slice?
Method: direct reading of every migration under priv/repo/migrations/
that creates a table whose name ends in _runs, _jobs, _sessions,
_events, or _steps; the Ecto schema and context for each; INVARIANTS.md;
lib/openagents_web/router.ex and lib/openagents_web/api_route_authority.ex;
and the CLI sources in the openagents monorepo. Nullability claims come from
migration text, never from an Ecto schema. Claims neither repository can settle
are in section 8 with the command that settles them.
0. Summary
One thing is genuinely one-per-user, and it is smaller than it looks. Two unique indexes make a person's conversation singular. Nothing else in the database says one-per-user. Everything else that looks singular is singular because it hangs off that conversation.
The defect is a coupling wall, not the conversation itself. Three tables
declare conversation_id as null: false: work_jobs, inference_grants,
and box_runs. inference_grants is the one that decides the outcome, because
it is the only way a client gets model authority without a provider key. A
grant cannot exist without a conversation, so an execution outside the
canonical conversation cannot buy a token today. That is why the CLI's only
working path was POST /api/v3/chat/turns, and it is why coder prompts landed
where the person did not put them.
The consequence is worse than a shared row. AccountTurns.provider_history/2
replays every completed run in the conversation into the next request, with no
limit (lib/openagents/chat/account_turns.ex:444). A coder turn in one checkout
therefore becomes context for the next question you ask in the browser, and the
reverse. The two surfaces also contend for one slot: the partial unique index
account_chat_runs_one_streaming_per_conversation admits exactly one streaming
run per conversation, so the second openagents coder refuses with
turn_in_progress.
One existing record already has the shape and no conversation. scv_runs
carries no conversation_id at all, scopes by driver_account_id, holds
driver_thread_id and driver_turn_id, and owns an append-only event log in
scv_run_events. Nothing in production reaches it. The owner has ruled that it
cannot be the primary record, so this audit treats it as proof of shape and as
machinery worth borrowing, not as the answer.
Recommended option: add a threads table beside conversations, and make
inference_grants.conversation_id nullable alongside a new nullable
thread_id. It is additive. It touches no invariant's meaning: DATA-002 keeps
saying exactly what it says today, because a thread is not a conversation.
The one migration that is not purely additive is the grant column, and it is a
DROP NOT NULL with an added CHECK — the same shape as the migration that
already made inference_grants.machine_id nullable for coding jobs
(priv/repo/migrations/20260819080000_allow_machineless_inference_grants.exs).
Smallest honest first slice: a threads table, a thread-scoped grant, and
POST /api/v3/threads behind chat:account. That is enough for
openagents coder to stop writing into the conversation. The /chat surface
can keep its single conversation until threads have proven themselves.
1. What one-per-user actually is
1.1 Two unique indexes, not one
DATA-002 (INVARIANTS.md:425) says "Database uniqueness enforces one internal
storage owner per local user and one conversation per owner." That is two
indexes in one migration, priv/repo/migrations/20260816214200_create_sarah_conversations.exs:
create unique_index(:visitors, [:browser_key_hash])
create unique_index(:visitors, [:user_id]) # line 13
...
create unique_index(:conversations, [:visitor_id]) # line 24
visitors_user_id_indexforbids a second visitor row for oneusersrow.conversations_visitor_id_indexforbids a second conversation for one visitor.
Composed, they forbid a second conversation per authenticated user. Neither index mentions users and conversations together, so there is no index named "one conversation per user" — the property is emergent from the pair. Relaxing it means dropping one of them, and dropping either is a data-model change rather than a policy edit.
A third constraint keeps browser rows and account rows from merging:
visitors_identity_source_check requires exactly one of browser_key_hash and
user_id to be set
(priv/repo/migrations/20260820160000_reconcile_visitor_identity_constraint.exs:16).
The proof is test/openagents/accounts_test.exs:36, "one account owns one
canonical conversation across browser sessions", which asserts that two calls
to ensure_conversation/1 return the same id and that a second account gets a
different one.
1.2 The single resolver
OpenAgents.Conversations.ensure_conversation/1 is the only constructor
(lib/openagents/conversations.ex:36 for the account clause, :44 for the
legacy browser-key clause). It inserts with
on_conflict: :nothing, conflict_target: [:visitor_id] and then reads the row
back, so a race produces one conversation and one greeting rather than two
(lib/openagents/conversations.ex:1079).
Every production caller in lib/:
| Caller | Line |
|---|---|
OpenAgents.Chat.AccountTurns.submit/3 |
lib/openagents/chat/account_turns.ex:32 |
OpenAgentsWeb.ChatLive.mount/3 |
lib/openagents_web/live/chat_live.ex:74 |
OpenAgentsWeb.ChatConsoleLive.mount/3 |
lib/openagents_web/live/chat_console_live.ex:82 |
OpenAgentsWeb.MemoryLive.mount/3 |
lib/openagents_web/live/memory_live.ex:31 |
OpenAgentsWeb.VoiceCallController |
lib/openagents_web/controllers/voice_call_controller.ex:19, :78, :97 |
OpenAgentsWeb.ComputerAgentJobsController.create/2 |
lib/openagents_web/controllers/computer_agent_jobs_controller.ex:17 |
Mix.Tasks.Openagents.BackfillVisitors |
lib/mix/tasks/openagents.backfill_visitors.ex:29 |
Seven entry points, one row. There is no path that takes a conversation
identifier from a client and creates something: get_conversation_for_user/2
(lib/openagents/conversations.ex:87) casts the UUID and joins through the
visitor, so a foreign id returns nil rather than another person's
conversation. That is IDENTITY-002 (INVARIANTS.md:200), and it stays true
under every option in section 6.
1.3 TURN-001 is about turns, and it has a namesake
TURN-001 (INVARIANTS.md:739) says at most one active turn per conversation,
where active is queued or streaming, and names its arbiter:
create unique_index(:turns, [:conversation_id],
where: "status IN ('queued', 'streaming')",
name: :turns_one_active_per_conversation_index
)
(priv/repo/migrations/20260816214200_create_sarah_conversations.exs:74).
That index does not govern the /chat surface or the CLI. The turns table
is written from exactly one place, OpenAgentsWeb.ChatLive
(lib/openagents_web/live/chat_live.ex:425 calling
Conversations.create_turn/2), which serves /sarah
(lib/openagents_web/router.ex:209). No API route creates a turns row.
/chat and the CLI use a second, younger lane with its own near-identical
index:
create unique_index(:account_chat_runs, [:conversation_id],
where: "status = 'streaming'",
name: :account_chat_runs_one_streaming_per_conversation
)
(priv/repo/migrations/20260822234211_create_account_chat_runs_and_events.exs:24).
So POST /api/v3/chat/turns does not create a turn. It creates an
account_chat_runs row (lib/openagents/chat/account_turns.ex:153), and the
refusal you see when two coder sessions collide comes from that index, mapped to
:turn_in_progress at lib/openagents/chat/account_turns.ex:177 and rendered
as HTTP 409 at
lib/openagents_web/controllers/chat_turn_controller.ex:31. The name is the
first thing to fix in any surface that says thread.
DATA-003 (INVARIANTS.md:440) bounds Conversations.list_messages/2 and is
untouched by any option here, because it governs the messages table, which the
account chat lane does not write.
2. The coupling wall
2.1 Three tables refuse a null conversation
| Table | Migration | Column declaration |
|---|---|---|
work_jobs |
priv/repo/migrations/20260818003358_create_work_jobs.exs:8 |
references(:conversations, ...), null: false |
inference_grants |
priv/repo/migrations/20260818234500_create_inference_grants.exs:20 |
references(:conversations, ...), null: false |
box_runs |
priv/repo/migrations/20260823133243_create_box_runs.exs:8 |
references(:conversations, ...), null: false |
conversation_boxes |
priv/repo/migrations/20260823034851_create_conversation_boxes.exs:8 |
references(:conversations, ...), null: false |
voice_sessions |
priv/repo/migrations/20260816214735_create_voice_runtime.exs:8 |
references(:conversations, ...), null: false |
account_chat_runs |
priv/repo/migrations/20260822234211_create_account_chat_runs_and_events.exs:8 |
references(:conversations, ...), null: false |
Two tables carry a nullable conversation_id:
forge_assignments, added when assignments were generalized from a Box to a Computer:add :conversation_id, references(:conversations, type: :binary_id, on_delete: :restrict)(priv/repo/migrations/20260823165145_generalize_assignments_for_computers.exs:11), with nonull: false.repository_publicationsandpull_requestscarry a bare:binary_idconversation pointer with no foreign key (priv/repo/migrations/20260823010819_create_repository_publications.exs:13,priv/repo/migrations/20260823021021_add_workspace_publication_to_pull_requests.exs:12).
work_jobs goes further than a NOT NULL. A trigger function,
enforce_work_job_scope, refuses any row whose conversation does not belong to
its owner_visitor_id
(priv/repo/migrations/20260820085203_harden_async_runtime_boundaries.exs:74),
and enforce_work_job_transition makes conversation_id immutable after
insert (priv/repo/migrations/20260818003358_create_work_jobs.exs:63).
2.2 Model authority is the binding constraint
OpenAgents.Inference mints the only credential a coding agent can hold. The
plaintext exists once, at mint, and the provider key never leaves the server
(lib/openagents/inference.ex:35; RELEASE-002, INVARIANTS.md:2131). The proxy
that redeems it is POST /api/inference/proxy
(lib/openagents_web/router.ex:391), which authenticates a grant bearer and
pins the model from the grant so a request body cannot select another
(lib/openagents_web/controllers/inference_proxy_controller.ex:40).
The grant's conversation_id is NOT NULL, required in the changeset
(lib/openagents/inference/grant.ex:63), and frozen by a trigger that raises if
any update changes it
(priv/repo/migrations/20260819080000_allow_machineless_inference_grants.exs:25).
Three call sites mint, and every one is server-initiated:
| Site | Line | machine_id |
|---|---|---|
OpenAgents.Work.Coding.on_start/1 |
lib/openagents/work/coding.ex:64 |
nil (:67) |
OpenAgents.Work.Scv.on_start/1 |
lib/openagents/work/scv.ex:133 |
nil (:136) |
OpenAgents.Work.DelegationServer |
lib/openagents/work/delegation_server.ex:179 |
the paired computer |
The reported machine_id: nil is real, and it is precedent. It exists
because a coding job has no paired machine, and the migration that permitted it
says so in its own moduledoc: "A coding job (#122) meters its own inference
usage into the same grant ledger as probe delegations, but it has no paired
machine"
(priv/repo/migrations/20260819080000_allow_machineless_inference_grants.exs:3).
That migration is the exact template a thread-scoped grant should follow: drop
one NOT NULL, and tighten the immutability trigger to be null-safe so the
column cannot be acquired later.
"Generation-fenced by conversation" is a design intent, not a running
behavior. Inference.revoke_active_for_conversation/1
(lib/openagents/inference.ex:148) exists and is documented as "generation
fence on a new turn/delegation", but it has no caller in lib/. Its only
reference outside its own module is
test/openagents/inference_test.exs:124. The fence today is therefore the
conversation_id column plus the immutability trigger, not an active
revocation sweep. Nothing caps concurrent active grants per account either:
Inference.mint/1 inserts unconditionally, and the budgets are per-grant —
inference_grant_max_calls: 64, inference_grant_max_total_tokens: 2_000_000,
inference_grant_ttl_seconds: 900 (config/config.exs:262 to :265).
2.3 Why the CLI landed in the conversation
packages/openagents-cli/src/coder-ox.ts in the openagents monorepo declares
its two paths at lines 21 and 22:
const SUBMIT_PATH = "/api/v3/chat/turns";
const EVENTS_PATH = "/api/v3/chat/events";
Its header comment states the consequence plainly: "A coder session therefore
shares the account's conversation rather than opening its own" (line 12). That
was the only route that could reach a model with a user token, because
POST /api/v3/coder/sessions — the grant-minting route that
docs/2026-08-23-openagents-coder-cli-spec.md section 6.2 specifies — does not
exist. A grep of lib/openagents_web/router.ex for coder returns nothing.
ChatTurnController.create/2 takes only message and reasoning
(lib/openagents_web/controllers/chat_turn_controller.ex:12). There is no
conversation parameter to pass, so the caller could not have chosen otherwise.
2.4 The three costs of sharing one conversation
- Shared provider context, unbounded.
AccountTurns.provider_history/2selects everycompletedrun for the conversation, ordered, with nolimit(lib/openagents/chat/account_turns.ex:444), and prepends it to the next request. Terminal coder work becomes browser context and the reverse, and the payload grows for the life of the account. - One streaming slot. The partial unique index means a second concurrent
coder invocation, or a coder running while you type in
/chat, is refused. - Shared transcript.
ChatConsoleLive.mount/3rendersAccountTurns.list_messages/1(lib/openagents_web/live/chat_console_live.ex:83), which reads the sameaccount_chat_runsrows (lib/openagents/chat/account_turns.ex:482). Coder prompts are visible in the web console.GET /api/v3/chat/eventsis likewise unbounded (lib/openagents/chat/account_turns.ex:472), and the CLI polls it every 250 ms (packages/openagents-cli/src/coder-ox.ts:24), so each poll re-downloads the account's entire event history.
A fourth cost is quieter: account_chat_runs and account_chat_events are
not in the DATA-004 export. OpenAgents.DataRights.export/3 returns
messages, profile memory, voice sessions, and tool steps
(lib/openagents/data_rights.ex:69), and names no chat run. Deletion still
reaches them through the conversation cascade, but the export ledger does not.
Any thread record should be added to that export when it lands, or DATA-004
inherits the same gap.
3. What already exists that is thread-shaped
scv_runs is the only durable execution record in the repository with no
conversation. Its single migration is
priv/repo/migrations/20260821082652_create_scv_runs.exs; no later migration
alters it.
What it scopes by. driver_account_id, null: false, referencing
scv_driver_accounts with on_delete: :restrict (line 8). That account is
owned by a user: operator_id, null: false, referencing users
(priv/repo/migrations/20260820161342_create_scv_codex_driver_accounts.exs:7).
Ownership therefore reaches a person without passing through a conversation.
What it already carries that a thread needs.
| Column | Line | Why a thread wants it |
|---|---|---|
driver_thread_id, driver_turn_id |
:24, :25 |
the executor's own identifiers, held opaquely |
objective |
:16 |
the bounded goal, capped at 32 KB by :scv_runs_objective_bound_check |
model, reasoning_effort, permission_profile |
:18, :19, :17 |
the admitted execution shape, each with a check constraint |
status |
:20 |
running, succeeded, failed, cancelled, uncertain |
owner_node, generation, lease_expires_at |
:21, :22, :23 |
the cluster fence that survives a node moving |
report, report_digest |
:26, :27 |
the terminal receipt, digest-verified |
event_count, usage, resources |
:28, :29, :30 |
metering without reading the transcript |
repository_revision |
:15 |
pinned to an exact 40-hex commit by :scv_runs_repository_revision_check |
What enforces one at a time.
create unique_index(:scv_runs, [:driver_account_id],
where: "status = 'running'",
name: :scv_runs_one_active_account_index
)
(line 41), plus unique_index(:scv_runs, [:driver_account_id, :generation])
(line 46) as the monotonic fence.
Its event log. scv_run_events (line 74) is append-only —
timestamps(type: :utc_datetime_usec, updated_at: false) — with
run_id cascading on delete, a pinned schema string enforced by
:scv_run_events_schema_check (schema = 'openagents.scv.event.v1', line 85),
and a 16 KB payload ceiling (line 89).
Its model authority is its own. An scv_runs execution authenticates as a
managed ChatGPT account through OpenAgents.SCV.Executor.CodexAppServer, not
through an inference grant. This is the existence proof that model authority
without a conversation is already reachable in this codebase — through a
different credential family.
Nothing reaches it. OpenAgents.SCV.CodexRuns.start/5
(lib/openagents/scv/codex_runs.ex:13) is the only wrapper over
Executions.claim/4, and
rg -n 'CodexRuns' lib/ | grep -v 'lib/openagents/scv/'
returns nothing. Its only callers are test/openagents/scv/codex_runs_test.exs.
The lane is also feature-flagged off by default (config/config.exs:62).
scv_runs.issue_id (line 12) was nullable, indexed, cast, and set by no
caller at all. This audit confirmed it, and #152 then dropped the column and
its index; forge_assignments is the one record that binds an issue to an
attempt.
What a threads table should borrow. The generation fence, the lease, the
owner_node column, the terminal report plus digest, the bounded-objective
check constraint, the partial unique index on live status, and the pinned event
schema with a payload ceiling. What it should not borrow is the coupling to a
driver account: a thread's owner is the account, and its executor is a
property, not its identity.
Two names to keep straight. scv_runs is the Codex app-server lane. A
work_jobs row of kind scv is a different system with the same word,
admitted by OpenAgents.SCV.Deployments.start/2, conversation-bound, and
operator-only under SCV-001 (INVARIANTS.md:1301). docs/taxonomy.md already
warns about this; the audit repeats it because the two records answer the
coupling question in opposite directions.
4. Every other candidate
4.1 work_jobs
One table, five kinds — deep_work, delegation, coding, scv,
continual_learning — validated in Elixir only
(lib/openagents/work/job.ex:18), with no database check constraint on
kind. Adding a sixth kind is a one-line change with no migration.
- Scoped by conversation and owner, both
null: false, both immutable, and cross-checked by theenforce_work_job_scopetrigger described in 2.1. - Transcript.
work_job_stepsis a real ordered tool-step log with digests, a lifecycle-shape check constraint, and an immutability trigger (priv/repo/migrations/20260818003358_create_work_jobs.exs:107). It records tool calls, not assistant prose. Prose accumulates in onereportcolumn, which a check constraint requires to be non-empty on every terminal status (line 53). - Concurrent siblings. Yes. There is no partial unique index on
work_jobs; concurrency is bounded by budget, not by row uniqueness. - Lifecycle.
queued,running,completed,failed,interrupted,budget_exhausted, pluscancelledadded later. Transitions are enforced by theenforce_work_job_transitiontrigger, and terminal rows are immutable.
The disqualifier. Work.finish_job/3 inserts an assistant Message into
the job's conversation on every terminal path, inside the same transaction
that writes the terminal row (lib/openagents/work.ex:527). WORK-001 states
this as a contract: "On terminal state the bounded report becomes a durable
assistant conversation message linked to the job" (INVARIANTS.md:1217). A
work job cannot host a thread without writing into the conversation, which is
the behavior this audit exists to stop.
4.2 computer_agent_jobs
There is no such table. OpenAgents.ComputerAgentJobs is a context module
that builds a work_jobs row of kind delegation
(lib/openagents/computer_agent_jobs.ex:1). Its HTTP entry point calls
Conversations.ensure_conversation(user) directly
(lib/openagents_web/controllers/computer_agent_jobs_controller.ex:17), so it
converges on the canonical conversation like everything else. The
work_jobs_delegation_identity check constraint additionally requires
machine_id IS NOT NULL for this kind
(priv/repo/migrations/20260820085203_harden_async_runtime_boundaries.exs:68),
so it cannot host a machine-less local session.
4.3 forge_assignments
- Scoped by issue and repository, both
null: false,on_delete: :restrict(priv/repo/migrations/20260823141005_create_box_assignments_and_scoped_credentials.exs:11,:14). Itsconversation_idis nullable (2.1), and itstarget_kindisboxorcomputer. - Transcript. None of its own. It points at the substrate that has one:
run_idtobox_runs, andwork_job_id, added bypriv/repo/migrations/20260823180051_link_forge_assignments_to_work_jobs.exs:13. - Concurrent siblings. Constrained hard:
forge_assignments_one_active_issue_index,forge_assignments_one_active_box_index, andforge_assignments_one_active_machine_indexeach admit one live row per issue, per box, and per computer. - Lifecycle.
admitted,running,completed,failed,cancelled.
It is the right record for "who is working this issue", and the wrong one for "what did the agent and I say to each other". It is issue-shaped, and a thread does not require an issue.
4.4 box_runs and conversation_boxes
box_runs is one shell command and its output, not an agent exchange. Its
conversation_id is NOT NULL, its conversation_box_id is NOT NULL
(priv/repo/migrations/20260823133243_create_box_runs.exs:8, :12), and
box_runs_one_active_per_box_index admits one live run per Box.
There is no box_run_events table: the transcript is a single mutable output
column bounded to a 24 KB tail
(lib/openagents/tools/box_output.ex), so earlier output is overwritten rather
than retained. conversation_boxes is the Box ledger, also conversation-bound
and NOT NULL.
Concurrency for a person is capped at four active Boxes by
maximum_active_boxes_per_owner (config/config.exs:112), with a
per-conversation cap of two (:111) and a global cap of twenty (:113).
4.5 Voice sessions
voice_sessions.conversation_id is NOT NULL
(priv/repo/migrations/20260816214735_create_voice_runtime.exs:8), with
voice_sessions_one_active_per_conversation_index admitting one live
generation and unique_index(:voice_sessions, [:conversation_id, :generation])
providing the fence (lines 34–38). Its transcript projects into messages
(VOICE-009, INVARIANTS.md:1748). This is the closest existing analogue to a
correctly built generation fence, and it is the strongest argument for keeping
the word "session" reserved for transport: this record already owns it.
4.6 continual_learning_jobs
No conversation_id. Scoped by buyer_ref and buyer_class as text, with an
optional work_job_id pointing at the conversation-bound job that drives it
(priv/repo/migrations/20260823074000_create_continual_learning_jobs.exs:28).
Its durable children are continual_learning_checkpoints,
continual_learning_artifacts, and continual_learning_receipts. It is a
training job, not an interaction; it has no turns.
4.7 shadow_program_runs
Bound to turn_receipt_id, null: false
(priv/repo/migrations/20260816230000_create_shadow_program_runs.exs:8). It is
a candidate-versus-baseline comparison artifact under PROGRAM-002
(INVARIANTS.md:135), with no live effect. Not a session of any kind.
4.8 stack_check_runs
Scoped by repository_id, stack_id, and pull_request_id, all null: false
(priv/repo/migrations/20260823122038_create_stack_check_runs.exs:8). It is
continuous integration for a pull-request stack. No transcript, no turns.
4.9 OpenAgents.Cluster.Sessions
Worth naming because of the word: this is a facade over a Raft registry that
publishes "who owns this session" for the Work servers
(lib/openagents/cluster/sessions.ex:1). It is in-flight state only, and it
returns {:ok, :local} when the cluster is not formed. It stores nothing
durable and is not a candidate.
4.10 The inventory in one table
| Record | Scoped by | conversation_id |
Nullable | Own event log | Concurrent siblings |
|---|---|---|---|---|---|
turns |
conversation | yes | no | turn_tool_steps, turn_provider_steps |
no — turns_one_active_per_conversation_index |
account_chat_runs |
conversation | yes | no | account_chat_events |
no — one streaming per conversation |
work_jobs |
conversation + owner | yes | no | work_job_steps |
yes |
box_runs |
conversation + box | yes | no | no — one output column |
one per Box |
conversation_boxes |
conversation | yes | no | box_reconciliation_events |
2 per conversation, 4 per owner |
voice_sessions |
conversation | yes | no | voice_events, voice_transcript_items |
no — one live generation |
forge_assignments |
issue + repository | yes | yes | no — points at substrate | one per issue, box, or computer |
scv_runs |
driver account | no | — | scv_run_events |
one per driver account |
continual_learning_jobs |
buyer ref | no | — | checkpoints, receipts | yes |
shadow_program_runs |
turn receipt | no | — | no | yes |
stack_check_runs |
repository + stack + pull request | no | — | no | yes |
Read the third and fourth columns together: every record that can hold an agent exchange today requires a conversation, and the one record that does not holds no exchange the product can reach.
5. The two surfaces
5.1 The web /chat surface
/chat is OpenAgentsWeb.ChatConsoleLive (lib/openagents_web/router.ex:263),
which is distinct from /sarah, the OpenAgentsWeb.ChatLive surface at
lib/openagents_web/router.ex:209. They share a conversation row and share
nothing else: /sarah writes turns and messages; /chat writes
account_chat_runs and account_chat_events and never touches messages.
To let a person hold several threads in /chat, four things change.
ChatConsoleLivegains a thread in its assigns.mount/3currently resolves the conversation and immediately loads every message for it (lib/openagents_web/live/chat_console_live.ex:82). It would resolve or create a thread instead, andhandle_params/3would carry a thread id.AccountTurnschanges its scope key. Six functions take a%User{}and resolve the conversation internally —submit/3(:25),cancel/1(:56),list_events/1(:78),list_messages/1(:85),active?/1(:92), and the privateprovider_history/2(:444). Each would take a thread.provider_history/2also needs a bound while it is being touched; its absence today is a live defect independent of threads.- The
chat:accountAPI gains thread-addressed routes.GET /api/v3/chat/eventsandPOST /api/v3/chat/turnsare registered inlib/openagents_web/router.ex:523and:524and classified inlib/openagents_web/api_route_authority.ex:184. Adding routes there is cheap but not free: FORGEAPI-001 (INVARIANTS.md:2977) makesOpenAgentsWeb.ApiRouteAuthoritymandatory, so a route without a principal, a family, and an error contract fails CI.lib/openagents_web/route_authority.ex:520also carries a per-path policy for/api/v3/chat/turnsthat a new path needs a sibling of. - UI-001 needs a decision, not an edit. UI-001 (
INVARIANTS.md:1946) says the interface "contains no conversation list" and that the sidebar "may never list or switch conversations." A thread list is not a conversation list, and the distinction is exactly what the taxonomy change establishes. Still, the invariant text should say so explicitly before a thread switcher ships, or the next reader will read the switcher as a violation.
Does Sarah's conversation become thread zero? The evidence says no, and the
cost of saying yes is high. The conversation is load-bearing for five memory
planes: MEMORY-001 binds recall to "one canonical conversation resolved from the
authenticated local user" (INVARIANTS.md:452), and profile memory, preferences,
experience memory, and graph memory all cascade from the visitor root through
it (DATA-004, INVARIANTS.md:1903). Folding it into a thread table means every
one of those planes needs a new scope predicate, and MEMORY-004 makes those
predicates database-level (INVARIANTS.md:509). Keeping the conversation as a
distinct thing beside threads costs one nullable pointer if a thread ever wants
to cite it.
The honest framing is the one the taxonomy now uses: a conversation is a persistent relationship with one persona; a thread is a bounded piece of work. They are different records because they have different lifetimes.
5.2 The openagents coder CLI
What it does today, at 56eaa4243210:
openagents coderis wired inpackages/openagents-cli/src/cli.ts:1135, with--plain,--offline, and--reasoningflags.- Its reply source is
OxAlphaReplySourceinpackages/openagents-cli/src/coder-ox.ts, which posts to/api/v3/chat/turns(line 21) and polls/api/v3/chat/events(line 22) every 250 ms (line 24), giving up after 300 seconds (line 26). - It terminates on the two event kinds the server writes,
response_completedandresponse_failed(lib/openagents/chat/account_turns.ex:379,:391). - Authentication is
resolveApiSessioninpackages/openagents-cli/src/session.ts, the same call every other command makes, so the token is whateveropenagents auth loginstored. The scope it needs ischat:account, admitted by the check constraint atpriv/repo/migrations/20260823000143_allow_chat_account_api_token_scope.exs:8.
What it needs to hold a thread:
- A route that mints a thread-scoped grant to a user token. This is the
single blocking item, and
docs/2026-08-23-openagents-coder-cli-spec.mdsection 6.2 already specifies its shape. Everything else the CLI needs already exists:POST /api/inference/proxyaccepts a grant bearer, and theproberuntime already readsPROBE_INFERENCE_GRANTandPROBE_INFERENCE_URLfrom its environment. - A thread identifier in the local transcript. The CLI writes its own
append-only JSON Lines file for
--resume; that file keys on a thread id rather than on nothing. - Nothing else. In particular the CLI should stop calling
/api/v3/chat/turnsentirely rather than gaining a thread parameter there. That route serves Sarah's conversation, and it should keep serving only that.
The spec document is worth reading against this audit, because it reached the
same conclusion from the client side and stated it as a rule: "Do not create a
second work record" and "a coder turn is not a durable server record"
(docs/2026-08-23-openagents-coder-cli-spec.md, section 4.3). This audit
disagrees on one point, and the disagreement is the reason it exists. The spec
proposed anchoring a coder session on an inference_grants row, on the grounds
that the grant already carries owner, model, budget, call count, usage, and
terminal status. That works only while the grant is fenced by the account's one
conversation — which is precisely the coupling that put coder prompts in the
browser. A grant is a budget, not a transcript, and it expires in 15 minutes by
default. A thread outlives its grants.
6. The options, with true cost
6.1 Relax DATA-002 to allow several conversations per user
Migration. Drop conversations_visitor_id_index. Existing rows survive
untouched, so the data migration is empty.
What breaks. More than the index.
Conversations.get_conversation_for_user/1(lib/openagents/conversations.ex:77) callsRepo.one/1on a query that would now return several rows, and raises when it does. Ten call sites use the arity-1 form and each becomes ambiguous:lib/openagents/staging_cleanup.ex:369,lib/openagents/box/fleet.ex:79,lib/openagents/chat/account_turns.ex:57,:79,:86,:93,lib/openagents_web/controllers/data_controller.ex:88,lib/openagents_web/controllers/voice_telemetry_controller.ex:11,lib/openagents_web/controllers/voice_recording_controller.ex:70, andlib/openagents_web/controllers/memory_export_controller.ex:9.ensure_conversation/1loses its meaning. Itson_conflictclause targets[:visitor_id](lib/openagents/conversations.ex:1079), which is the index being dropped.- DATA-004's export and delete take one conversation as an argument
(
lib/openagents/data_rights.ex:42) and would need to iterate. - MEMORY-001 is stated as "recall is confined to the current account conversation" and would have to define which one.
- UI-001 forbids a conversation list outright.
Invariants touched. DATA-002 (rewritten), MEMORY-001 (rescoped), UI-001
(rewritten), DATA-004 (rescoped), IDENTITY-003 (which says one user "resolves
the same canonical OpenAgents owner"). Proofs that change:
test/openagents/accounts_test.exs, test/openagents/conversations_test.exs,
test/openagents/memory/lexical_recall_test.exs, and
test/openagents_web/controllers/data_controller_test.exs.
Verdict: breaking, and it buys the wrong thing. It makes Sarah plural, which nobody asked for, and it still leaves a coder session indistinguishable from a chat.
6.2 Add a threads table beside conversations — recommended
Migration. One additive create table(:threads, ...), one additive
create table(:thread_events, ...), and one ALTER TABLE inference_grants ALTER COLUMN conversation_id DROP NOT NULL with an added nullable thread_id
and a CHECK requiring exactly one of the two. Existing grant rows already
satisfy that check, so the data migration is empty. The immutability trigger
gains IS DISTINCT FROM for both columns, copying
priv/repo/migrations/20260819080000_allow_machineless_inference_grants.exs
verbatim in shape.
What it owns. Owner (the visitor root, so DATA-004 cascade works without a
new rule), title or objective, status, the generation and lease fence borrowed
from scv_runs, budget snapshot, usage, terminal report and digest, and a
partial unique index admitting one live turn per thread — the TURN-001 pattern,
applied per thread instead of per conversation.
What it reuses. The Inference grant machinery and the proxy, unchanged
except for the fence column. The api_tokens scope ladder, unchanged:
chat:account already exists. The work_jobs delegation path, unchanged, if a
thread delegates. OpenAgents.Transparency tiers, once issue #149 adds the
artifact type. The probe runtime contract, unchanged.
Invariants touched. All additive:
- A new
THREAD-001stating what a thread is, what bounds it, and what enforces one live turn per thread. DATA-002 gains one sentence saying a thread is not a conversation. PROVENANCE-001andTURN-002are untouched, because a thread turn is not aturnsrow.FORGEAPI-001requires the new routes inOpenAgentsWeb.ApiRouteAuthority; that is a build gate, not a redesign.DATA-004needs the new tables in the export and in the cascade proof.UI-001needs one clarifying sentence before a thread switcher ships.
Verdict: additive, and it is the only option that makes the word mean something.
6.3 Reuse an existing record
A new work_jobs kind. Cheapest on paper: kind has no database
constraint, so a thread kind is a one-line change to
lib/openagents/work/job.ex:18. It fails on three counts. The conversation is
NOT NULL, immutable, and trigger-checked against the owner. Every terminal
job writes an assistant message into that conversation
(lib/openagents/work.ex:527), which is the exact defect. And work_job_steps
records tool calls with digests, not an interactive exchange — there is no
place for a user prompt after the first.
scv_runs. It has the right shape and the owner has ruled it out as the
primary record. The ruling is well founded independently: it is scoped by a
Codex driver account rather than by a person, its permission_profile is
constrained to read_only in the database
(priv/repo/migrations/20260821082652_create_scv_runs.exs:50), its driver is
constrained to codex_app_server (line 48), and its event payloads are
scrubbed of text by design. Widening any of those is a bigger migration than
creating a table.
forge_assignments. Requires an issue (null: false) and admits one live
assignment per issue. A thread that must name an issue is not a thread.
Verdict: none of them. The closest, scv_runs, is closest because it was
built for the same shape — which is the argument for borrowing its columns, not
its table.
7. A staged path
Each stage names its repository, its seam, and what is true when it is done.
Stage 1 — The threads table and a thread-scoped grant (this repository, medium)
Seam: priv/repo/migrations, a new OpenAgents.Threads context, and
lib/openagents/inference.ex.
Create threads and thread_events. Make inference_grants.conversation_id
nullable, add thread_id, and add the check requiring exactly one. Teach
Inference.mint/1 to accept either fence. Add THREAD-001 with its proof.
Done when a thread row exists, a grant can be minted against it, and
mix precommit is green with no change to any existing invariant's meaning.
Stage 2 — POST /api/v3/threads behind chat:account (this repository, small)
Seam: lib/openagents_web/router.ex,
lib/openagents_web/api_route_authority.ex,
lib/openagents_web/route_authority.ex.
Three routes: create a thread and return its grant; read its status and metered usage; revoke it. This is where the abuse controls live, and it must not be a thin wrapper. It needs a cap on concurrent active grants per account, which does not exist today (2.2), ceilings independent of the delegation ceilings, and revocation on delete and on expiry.
Done when a user token can obtain model authority that names no
conversation, and OpenAgentsWeb.ApiRouteAuthorityTest passes with the new
entries.
Stage 3 — openagents coder stops writing to the conversation (openagents monorepo, small)
Seam: packages/openagents-cli/src/coder-ox.ts, replaced by a grant-backed
source.
Exchange the token for a thread and a grant, spawn the runtime with
PROBE_INFERENCE_GRANT and PROBE_INFERENCE_URL, and delete the two chat
paths. Key the local transcript on the thread id.
Done when running openagents coder twice in two checkouts works, and
neither prompt appears in /chat.
Stage 4 — Chunked delivery from the inference proxy (both repositories, small)
Seam: lib/openagents_web/controllers/inference_proxy_controller.ex, and
the matching transport in probe.
The proxy builds the whole SSE body and sends it once. Sending each event as the provider produces it is additive and changes no contract.
Done when the terminal shows tokens as they arrive rather than in one block.
Stage 5 — Threads in /chat (this repository, medium)
Seam: lib/openagents_web/live/chat_console_live.ex,
lib/openagents/chat/account_turns.ex.
Rescope AccountTurns from conversation to thread, bound provider_history/2,
and add a thread list to the console. Amend UI-001 first.
Done when a person holds several threads in the browser, each with its own context, and Sarah's conversation is unchanged.
Stage 6 — Threads in the export and under transparency tiers (this repository, medium)
Seam: lib/openagents/data_rights.ex, lib/openagents/transparency.ex.
Add threads and thread events to the DATA-004 export, and add a thread
artifact type once issue #149 lands. Close the gap in 2.4 for
account_chat_runs at the same time.
Done when the export ledger matches the surface in both directions for threads, as EXIT-001 requires for repositories.
8. Open questions
- How many
thread/methods does the Codex protocol actually name? The taxonomy claims 131 underthreadand zero undersession. Measured against theopenai/codexclone atbf2aee99c5, the v2 schemacodex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.jsoncontains 45 distinctthread/*strings and zerosession/*; a repository-wide scan finds 69 distinctthread/*and 7 distinctsession/*, and the seven are shell-session names —session/open,session/execute,session/wait,session/terminate,session/shutdown,session/ready,session/closed— not conversation names. The direction of the claim holds and the count does not. Settle it by runningrg -o '"thread/[A-Za-z/]+"' | sort -u | wc -lagainst the pinned clone and correcting the number indocs/taxonomy.md, or by citing the method that produced 131. - Does any account in production already have more than one conversation?
The index forbids it, but a pre-index row could exist. Settle it with
SELECT visitor_id, count(*) FROM conversations GROUP BY 1 HAVING count(*) > 1against production. - Should a thread be able to cite a conversation? A coder thread that answers "as we discussed in chat" needs a pointer; a thread that never does needs none. Settle it by deciding whether recall (MEMORY-001) may read across the boundary, which is a policy decision, not a schema one.
- Which record does a nested thread hang from?
docs/taxonomy.mdreserves the term. A parent thread id onthreadsis the obvious answer, and it interacts with the delegation-depth rule in WORK-001, which caps depth at one. Settle it by reading WORK-001 against the nested-thread definition before stage 1 writes the table. - What caps concurrent threads per account? Nothing caps concurrent grants
today (2.2), and
work_jobshas no concurrency index. The Box lane's per-owner cap of four is the only precedent. Settle it by choosing a number inconfig/config.exsbefore stage 2 exposes the route. - Does
scv_runsbecome an executor behind a thread, or does it go? #152 settled the narrower half:scv_runs.issue_idis dropped, and the Codex lane becomes issue-bound through aforge_assignmentstarget kind whenever it gains a production caller. Whether a thread may also name that executor is still open. - Which invariant identifier does a thread take?
THREAD-001is free. Confirm withgrep -n "^### THREAD" INVARIANTS.md, which returns nothing today.