Summary
configureChannels() policy is never applied to turns that arrive over the WebSocket chat protocol (cf_agent_use_chat_request). Declaring a web channel with instructions, tools or maxTurns has no effect on a browser chat client — the turn runs with no channel context, silently and with no warning.
The implicit web channel is registered at startup and looks ready to serve exactly this case, so this reads as an unfinished seam rather than a deliberate exclusion.
What I expected
Per the docs, declaring a web channel and giving it instructions should prepend those instructions to the system prompt for turns on that surface. The documented example is precisely this:
web: { kind: "web", ingress: { transport: "websocket" },
instructions: "You are chatting in a web app. Use markdown freely." },
voice: { kind: "voice", ingress: { transport: "voice" },
instructions: "Keep replies short and speakable. No markdown.", maxTurns: 3 }
What happens
Nothing. this.activeChannel is undefined for the turn, and no channel policy — instructions, tools, or maxTurns — is applied.
Why (trace, line numbers from the bundled dist/think.js in 0.17.0)
-
resolveChannels() seeds the registry with an implicit web entry (:156-178), so this._channels always contains "web" with ingress: { transport: "websocket" }.
-
_handleChatRequest admits the turn without a channel key (:6300-6307):
await this._admitTurn({
admission: "queue",
trigger: "ws-chat",
requestId,
generation: epoch,
continuation: false,
onQueued: releaseIfPending,
execute: async () => { ... }
})
_admitTurn forwards spec.channel (undefined) to _withChannelContext (:3596), and _resolveChannelContext returns immediately (:1775):
_resolveChannelContext(channel) {
if (!channel) return;
...
}
_runInferenceLoop therefore sees no context, and every consumer is skipped (:2670-2675):
const channelContext = this._activeChannelContext; // undefined
const channelDefinition = channelContext ? this._channels?.get(channelContext.channelId) : void 0; // undefined
if (channelDefinition?.tools) tools = channelDefinition.tools(tools); // skipped
const channelInstructions = channelDefinition?.instructions && channelContext ? ... : void 0; // undefined
const baseSystem = channelInstructions ? `${channelInstructions}\n\n${rawBaseSystem}` : rawBaseSystem;
maxTurns is skipped the same way at :2724.
- The ws path also calls
_runInferenceLoop directly (:6323-6334) rather than going through submitMessages(), so it never reaches _stampChannel (callers are only :3654, :5450, :5899). Nothing is persisted on the user message, so _channelFromLatestUserMessage() cannot recover a channel for a continuation or retry either.
Every other ingress does pass one: messenger turns via chatWithMessengerContext (channel: context.messengerId, :1737), and chat() / runTurn() / submitMessages() via options.channel (:3651, :3775-3828, :5450). The WebSocket path is the only one that does not.
The client cannot supply it either
channel does not appear anywhere in @cloudflare/think's dist/react.d.ts or in agents@0.22.0's dist/chat/react.d.ts — there is no option on useAgent / useAgentChat to name a channel.
- Unrecognised keys on the request frame land in
customBody / _lastBody and are never consulted for channel selection.
- A client-supplied
metadata.channel is stripped at intake: RESERVED_MESSAGE_METADATA_KEYS = ["channel", "turnMetadata"] (:807), removed by _stripReservedMessageMetadata (:7145-7147), whose docstring says a client must never be able to forge them.
So there is no supported workaround short of reimplementing the prepend in beforeTurn.
It fails silently
The console.warn at :1778 only fires when a channel was requested and is not registered. A turn that never requested one produces no warning, so a declared-but-inert channel looks like it is working.
The type docs claim the opposite
dist/index-B7zEkBBM.d.ts:336-341, on RunTurnBase.channel:
/**
* Channel id this turn belongs to (resolved against `configureChannels()` /
* `getMessengers()`). Sets the turn-scoped channel context and is persisted on
* the user message so a recovered/continued turn re-resolves it. Defaults to
* the implicit `web` channel.
*/
channel?: string;
"Defaults to the implicit web channel" is not what any turn path does — there is no ?? "web" on a turn path anywhere in the bundle; the only one is deliverNotice's (:1868). docs/channels.md:103 agrees with the code rather than the type: "A turn with no channel runs without a channel context and applies no channel policy."
Whichever of those two is intended, they currently disagree.
Repro
- A
Think subclass reached over the WebSocket chat protocol (useAgentChat).
- Implement
configureChannels() returning { web: { kind: "web", ingress: { transport: "websocket" }, instructions: "Always begin every reply with the word BANANA." } }.
- Send a message from the browser client.
- The reply does not begin with BANANA, and
this.activeChannel is undefined inside the turn.
Suggested fix
Stamp the implicit web channel on ws-chat turns — pass channel: "web" in the _admitTurn spec at :6300, and persist it so continuations re-resolve — which would make the behaviour match the RunTurnBase.channel docstring.
If it is intended to stay opt-in instead, then either a way for the ws client to name its channel, or a note in docs/channels.md that a web entry applies to server-driven turns only, would close the gap. At the moment the documented web example cannot work for the surface it names.
Version
@cloudflare/think@0.17.0, agents@0.22.0.
Summary
configureChannels()policy is never applied to turns that arrive over the WebSocket chat protocol (cf_agent_use_chat_request). Declaring awebchannel withinstructions,toolsormaxTurnshas no effect on a browser chat client — the turn runs with no channel context, silently and with no warning.The implicit
webchannel is registered at startup and looks ready to serve exactly this case, so this reads as an unfinished seam rather than a deliberate exclusion.What I expected
Per the docs, declaring a
webchannel and giving itinstructionsshould prepend those instructions to the system prompt for turns on that surface. The documented example is precisely this:What happens
Nothing.
this.activeChannelisundefinedfor the turn, and no channel policy —instructions,tools, ormaxTurns— is applied.Why (trace, line numbers from the bundled
dist/think.jsin 0.17.0)resolveChannels()seeds the registry with an implicitwebentry (:156-178), sothis._channelsalways contains"web"withingress: { transport: "websocket" }._handleChatRequestadmits the turn without achannelkey (:6300-6307):_admitTurnforwardsspec.channel(undefined) to_withChannelContext(:3596), and_resolveChannelContextreturns immediately (:1775):_runInferenceLooptherefore sees no context, and every consumer is skipped (:2670-2675):maxTurnsis skipped the same way at:2724._runInferenceLoopdirectly (:6323-6334) rather than going throughsubmitMessages(), so it never reaches_stampChannel(callers are only:3654,:5450,:5899). Nothing is persisted on the user message, so_channelFromLatestUserMessage()cannot recover a channel for a continuation or retry either.Every other ingress does pass one: messenger turns via
chatWithMessengerContext(channel: context.messengerId,:1737), andchat()/runTurn()/submitMessages()viaoptions.channel(:3651,:3775-3828,:5450). The WebSocket path is the only one that does not.The client cannot supply it either
channeldoes not appear anywhere in@cloudflare/think'sdist/react.d.tsor inagents@0.22.0'sdist/chat/react.d.ts— there is no option onuseAgent/useAgentChatto name a channel.customBody/_lastBodyand are never consulted for channel selection.metadata.channelis stripped at intake:RESERVED_MESSAGE_METADATA_KEYS = ["channel", "turnMetadata"](:807), removed by_stripReservedMessageMetadata(:7145-7147), whose docstring says a client must never be able to forge them.So there is no supported workaround short of reimplementing the prepend in
beforeTurn.It fails silently
The
console.warnat:1778only fires when a channel was requested and is not registered. A turn that never requested one produces no warning, so a declared-but-inert channel looks like it is working.The type docs claim the opposite
dist/index-B7zEkBBM.d.ts:336-341, onRunTurnBase.channel:"Defaults to the implicit
webchannel" is not what any turn path does — there is no?? "web"on a turn path anywhere in the bundle; the only one isdeliverNotice's (:1868).docs/channels.md:103agrees with the code rather than the type: "A turn with nochannelruns without a channel context and applies no channel policy."Whichever of those two is intended, they currently disagree.
Repro
Thinksubclass reached over the WebSocket chat protocol (useAgentChat).configureChannels()returning{ web: { kind: "web", ingress: { transport: "websocket" }, instructions: "Always begin every reply with the word BANANA." } }.this.activeChannelisundefinedinside the turn.Suggested fix
Stamp the implicit
webchannel on ws-chat turns — passchannel: "web"in the_admitTurnspec at:6300, and persist it so continuations re-resolve — which would make the behaviour match theRunTurnBase.channeldocstring.If it is intended to stay opt-in instead, then either a way for the ws client to name its channel, or a note in
docs/channels.mdthat awebentry applies to server-driven turns only, would close the gap. At the moment the documentedwebexample cannot work for the surface it names.Version
@cloudflare/think@0.17.0,agents@0.22.0.