Summary
When a facet broadcasts after the inbound frame that started its work has completed, every broadcast costs two RPC calls to the root: __unsafe_ensureInitialized (from getAgentByName) and _cf_broadcastToSubAgent. A streamed chat turn broadcasts once per stream chunk, so one long answer makes thousands of billed Durable Object requests to the root.
Seen on agents@0.22.0. The same code path is still there in 0.23.0.
Where
src/dynamic-agents/dynamic-agents.ts (0.23.0):
routeBroadcast() (~line 824) uses the live frame bridge only while the frame's AsyncLocalStorage context is active and no older operation is pending. Otherwise it calls (await this.rootAlarmOwner())._cf_broadcastToSubAgent(...).
rootAlarmOwner() (~line 216) calls getAgentByName(binding, root.name) every time. getAgentByName always awaits stub.__unsafe_ensureInitialized() before it returns the stub.
A chat turn keeps streaming after the user's frame is acknowledged, so for the whole model stream each chunk takes the root path. That means one ensureInitialized RPC and one broadcast RPC per chunk.
Evidence
Setup: a root Agent (the user's directory) with Think chat facets, connected through useAgent({ sub: [...] }). wrangler tail on a deployed Worker during about three streamed answers:
2656 HalleyDirectory __unsafe_ensureInitialized
2611 HalleyDirectory _cf_broadcastToSubAgent
16 HalleyDirectory _cf_setSubAgentConnectionState
13 HalleyAgent _cf_initAsFacet
The two counts track each other almost 1:1.
Impact
- Each DO RPC method call is a billed request (pricing). The paid plan's 1M included requests cover only a few hundred long answers a month.
- With Workers traces on, each call emits spans. Spans become billed observability events on October 1, 2026.
- Each chunk waits on two serialized cross-object round trips. The broadcast tail makes them sequential.
Suggestion
- Resolve the root stub once per facet instance and reuse it in
rootAlarmOwner(). By the time a facet is broadcasting, the root has already initialized it, so running ensureInitialized again on every call looks redundant. This alone would halve the calls.
- Optionally, coalesce facet broadcasts on the root path, for example by batching messages queued behind the same tail into a single
_cf_broadcastToSubAgent call.
Happy to test a patch.
Summary
When a facet broadcasts after the inbound frame that started its work has completed, every broadcast costs two RPC calls to the root:
__unsafe_ensureInitialized(fromgetAgentByName) and_cf_broadcastToSubAgent. A streamed chat turn broadcasts once per stream chunk, so one long answer makes thousands of billed Durable Object requests to the root.Seen on
agents@0.22.0. The same code path is still there in0.23.0.Where
src/dynamic-agents/dynamic-agents.ts(0.23.0):routeBroadcast()(~line 824) uses the live frame bridge only while the frame'sAsyncLocalStoragecontext is active and no older operation is pending. Otherwise it calls(await this.rootAlarmOwner())._cf_broadcastToSubAgent(...).rootAlarmOwner()(~line 216) callsgetAgentByName(binding, root.name)every time.getAgentByNamealways awaitsstub.__unsafe_ensureInitialized()before it returns the stub.A chat turn keeps streaming after the user's frame is acknowledged, so for the whole model stream each chunk takes the root path. That means one
ensureInitializedRPC and one broadcast RPC per chunk.Evidence
Setup: a root
Agent(the user's directory) withThinkchat facets, connected throughuseAgent({ sub: [...] }).wrangler tailon a deployed Worker during about three streamed answers:The two counts track each other almost 1:1.
Impact
Suggestion
rootAlarmOwner(). By the time a facet is broadcasting, the root has already initialized it, so runningensureInitializedagain on every call looks redundant. This alone would halve the calls._cf_broadcastToSubAgentcall.Happy to test a patch.