ADR-023: Channel Entrypoint Differentiation Layer
Status: Accepted Spec: 009-channels
Amended (2026-06-20, simplification 8.4). The per-binding workspace allowlist, the
/cwdswitch, and the peerpreferred_workspaceare withdrawn: channels run in the single Coffer-managed default workspace (~/.coffer/workspace). The/agent+/modelswitches and the entrypoint-differentiation core stand.
Context
Spec 009 made Coffer the channel-entrypoint manager: an owner pairs an IM account to a channel:<name> resource and drives any registered agent (builtin, claude_code, codex) through the chat platform's seams.
What the entrypoint manager still lacked is the layer that makes it a switchboard rather than a single fixed wire: an owner could only talk to the one agent baked into the channel config, in the one (operator-baked) working directory, with no record of who drove what, and — on a platform that cannot edit messages (SeaTalk) — no signal at all while a long bridged turn ran. These are exactly the differentiators an entrypoint manager owns and that off-the-shelf IM bots skip.
Two facts from the code shaped the design:
- A conversation pins its
agent_keyfor life and the bridged providers fixcwdatinit_conversation; there is no re-key path. Butmodelfor a bridged turn is read fresh fromagent_configevery time the adapter is rebuilt (once per turn). - The bridged agents require a
cwd(no default; a missing one is rejected) and thatcwdhad no allowlist — any host directory was accepted. Channels never offered the owner a way to choose it.
Decision
Structural vs parametric selection — one mental model for all switches.
- Structural dimensions (
agent_key,cwd) are pinned when a conversation is created and cannot change mid-conversation. Switching one opens a new conversation, carrying over the other dimension's sticky value; the old conversation stays in history./agentis structural. - Parametric dimensions (
model) are re-read each turn. Switching one takes effect on the next turn in the same conversation./modelis parametric.
This mirrors the providers' real contract (cwd/agent fixed at
init_conversation; model read atbuild_adapter), so the command semantics are honest rather than papered over with a synthetic re-key.- Structural dimensions (
Per-channel agent routing is sticky, stored on the peer.
/agent <key>validates against the agent registry and records the choice as the peer's sticky preference;/newand lazy creation use the sticky agent, falling back to the channel's configureddefault_agent. The auxiliary (vault-facing) agent is not a routing target — routing is between the chat-path agents the registry exposes (builtin,claude_code,codex, and any future registered agent), with no channel-side code per agent (spec 009 SC-004 preserved).Workspaces are a channel-level allowlist and the cwd security boundary. A channel declares a list of named workspaces (— Withdrawn (2026-06-20, simplification 8.4). Channels now run in the single Coffer-managed default workspace ({name, path}), validated to exist at registration; an optionaldefault_workspacenames the one used when none is chosen./cwd <name>selects among them (structural → new conversation). A channel never accepts a bare filesystem path from an IM message — an owner can only pick a name the operator pre-authorized.~/.coffer/workspace); there is no per-channel allowlist and no/cwdcommand.Model selection is a parametric passthrough, registry-backed only where a registry exists. For
builtin,/model <name>resolves against Coffer's model registry and sets the conversation'smodel_idoverride. For the bridged agents,/model <name>writesagent_config["model"]— the raw model string the upstream CLI understands — passed through unvalidated, because Coffer does not own that namespace; a typo surfaces as a CLI error relayed back to the chat. (This also fixes the Claude Code provider, which previously accepted amodeloption but never persisted one, so the CLI always chose.)Channel-driven work is first-class in the audit log. A new event type records what the generic per-turn audit could not:
CHANNEL_TURN_STARTEDwhen an inbound message drives a turn (who/when/which channel/which agent/which conversation). Audit lives in the channel layer because that is the only place the channel + peer context exists; the existing free-formdetails_jsoncarries the structured context with no schema change.Owner gate verifies sender identity, not just chat identity. The inbound envelope carries a
sender_id(Telegramfrom.id, SeaTalkemployee_code); pairing records it on the peer, and the owner gate checkschat_idand, when the peer has a storedsender_id, the sender. This closes the group-chat hole (any member of a paired group chat previously passed a chat-id-only gate) while staying backward compatible: a peer paired before this change has a nullsender_idand degrades to the chat-id-only gate.Turn completion is signalled explicitly, capability-agnostic. After every turn the channel sends one compact fact summary as a fresh message — success (
done, tool count, duration, tokens), error, or interrupted. It is a freshsend_text, not an edit, so it works identically on every platform; on SeaTalk (which cannot edit and showed nothing during a long bridged turn) it is the only end-of-turn signal the owner gets.
Alternatives considered
- Re-key a conversation's agent/cwd in place instead of opening a new conversation — rejected; the providers fix both at
init_conversationand a bridged CLI session is tied to its cwd, so an in-place re-key would be a fiction that breaks resume continuity. - Accept a bare cwd path from an IM message (
/cwd /some/path) — rejected; a remote-reachable entrypoint pointing an agent at an arbitrary host directory is the exact boundary the allowlist exists to hold. - Validate bridged model strings against a curated list — rejected as cargo-cult; Coffer does not own the upstream CLI's model namespace and would have to chase it. Passthrough with a relayed CLI error is honest and cheap.
- Stream token-level text and refresh a heartbeat during the turn — rejected for now; a single completion summary is the high-value, low-noise signal, and block-level tool progress already exists where the platform can edit.
Consequences
- The peer gains a column for the paired sender's identity (
sender_id) and a sticky agent preference (preferred_agent); one migration covers them. No new table. (preferred_workspaceandworkspaces/default_workspaceon config are withdrawn — see amendment above.) - The command set grows by
/agentand/model; both ride the existing slash-command seam in the channel core and select behavior from capabilities, so no adapter learns about them — the differentiation layer is channel-agnostic and any future channel inherits it (spec 009 SC-003 preserved). (/cwdis withdrawn — see amendment above.) - The audit log answers "who drove which agent through which channel" without a schema change.