On a cold Durable Object instance, submitMessages() works and saveMessages() throws. Both are public RPC methods on the same object.
The difference is that submitMessages persists its work and defers execution to an alarm that runs onStart(), while saveMessages assumes that onStart() has already run. Native DO RPC never triggers onStart. partyserver runs onStart from fetch, alarm, the webSocket* handlers, and its own setName / __unsafe_ensureInitialized RPCs, but not from anything else. So any public or user-defined RPC method that touches state that onStart establishes fails whenever it is the call that wakes the instance, which is the normal case for an agent that goes idle between messages, due to DO eviction policies.
For Think the missing state is session, workspace, the hydrated message view, protocol handlers and recovery. chat, runTurn, getMessages and the history mutators all depend on this state. Agent.onStart also restores MCP connections and drives fiber and agent-tool recovery, and a subclass's own onStart hasn't run either.
Reproduction
package.json
{
"name": "think-cold-rpc-mre",
"private": true,
"type": "module",
"dependencies": {
"@cloudflare/think": "0.15.0",
"agents": "0.20.0",
"ai": "^6.0.0"
},
"devDependencies": {
"wrangler": "^4.114.0"
}
}
wrangler.jsonc
src/index.ts
import { Think } from "@cloudflare/think";
import type { UIMessage } from "ai";
type Env = {
ColdAgent: DurableObjectNamespace<ColdAgent>;
};
export class ColdAgent extends Think<Env> {}
export default {
async fetch(_req: Request, env: Env): Promise<Response> {
const stub = env.ColdAgent.get(env.ColdAgent.idFromName("mre"));
// This instance has never run, so the RPC below cold-starts it and
// this.session is still undefined when saveMessages reaches it.
const message: UIMessage = {
id: "m1",
role: "user",
parts: [{ type: "text", text: "hi" }]
};
await stub.saveMessages([message]);
return new Response("ok");
}
} satisfies ExportedHandler<Env>;
npm install
npx wrangler dev --port 8899 &
curl http://localhost:8899/
Observed:
TypeError: Cannot read properties of undefined (reading 'appendMessage')
Expected: the call behaves as it would on an initialized instance.
The MRE uses a never-woken name for brevity, but the same failure reproduces on an established agent by evicting it and calling any of those entrypoints on the existing stub.
Other failure modes
The missing state doesn't always result in a throw. Where the missing state is a cache rather than an object, the call succeeds with a wrong answer and no error:
getMessages() returns [] — an empty transcript for a conversation that has one.
getMcpServers() reports every registered server as not-connected, with an empty tool list.
Why the caller can't close this
getAgentByName does close it, by making one extra RPC (setName) that synchronises onStart. But that guarantee belongs to the instance it initialised, and stubs outlive instances. Any hibernation, eviction or deployment between acquisition and use can cause the DO belonging to the stub to go cold.
Callers who can't reach the helper have it worse. getAgentByName needs a parameterised namespace, so cross-script bindings and stubs received over RPC are left hand-rolling the handshake as stub.setName(name), which is deprecated, even though getServerByName relies on it.
What I'd like
Ideally I'd like a solution that requires no per-method boilerplate. Initialisation on cold start is a framework responsibility, and every entrypoint the framework owns already upholds it. agents opens its own internal RPC entrypoints with __unsafe_ensureInitialized(). Application-defined entrypoints are the gap.
I can live with a solution that requires some level of boilerplate, but that would erode some of the benefit that I expected from Think. I decided to trial Think mostly because I didn't want to have to handle eviction and cold starts in my application code.
I'd be happy to work on a PR for this, but I'd like to know what type of solution the maintainers would be willing to accept before I start, as I suspect that fixing this properly may require some breaking changes, and may require enforcing some restrictions on the public API surface (e.g. everything must be async).
On a cold Durable Object instance,
submitMessages()works andsaveMessages()throws. Both are public RPC methods on the same object.The difference is that
submitMessagespersists its work and defers execution to an alarm that runsonStart(), whilesaveMessagesassumes thatonStart()has already run. Native DO RPC never triggersonStart. partyserver runsonStartfromfetch,alarm, thewebSocket*handlers, and its ownsetName/__unsafe_ensureInitializedRPCs, but not from anything else. So any public or user-defined RPC method that touches state thatonStartestablishes fails whenever it is the call that wakes the instance, which is the normal case for an agent that goes idle between messages, due to DO eviction policies.For Think the missing state is
session,workspace, the hydrated message view, protocol handlers and recovery.chat,runTurn,getMessagesand the history mutators all depend on this state.Agent.onStartalso restores MCP connections and drives fiber and agent-tool recovery, and a subclass's ownonStarthasn't run either.Reproduction
package.json
{ "name": "think-cold-rpc-mre", "private": true, "type": "module", "dependencies": { "@cloudflare/think": "0.15.0", "agents": "0.20.0", "ai": "^6.0.0" }, "devDependencies": { "wrangler": "^4.114.0" } }wrangler.jsonc
{ "name": "think-cold-rpc-mre", "main": "src/index.ts", "compatibility_date": "2026-06-11", "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [{ "class_name": "ColdAgent", "name": "ColdAgent" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ColdAgent"] }] }src/index.ts
npm install npx wrangler dev --port 8899 & curl http://localhost:8899/Observed:
Expected: the call behaves as it would on an initialized instance.
The MRE uses a never-woken name for brevity, but the same failure reproduces on an established agent by evicting it and calling any of those entrypoints on the existing stub.
Other failure modes
The missing state doesn't always result in a throw. Where the missing state is a cache rather than an object, the call succeeds with a wrong answer and no error:
getMessages()returns[]— an empty transcript for a conversation that has one.getMcpServers()reports every registered server asnot-connected, with an empty tool list.Why the caller can't close this
getAgentByNamedoes close it, by making one extra RPC (setName) that synchronisesonStart. But that guarantee belongs to the instance it initialised, and stubs outlive instances. Any hibernation, eviction or deployment between acquisition and use can cause the DO belonging to the stub to go cold.Callers who can't reach the helper have it worse.
getAgentByNameneeds a parameterised namespace, so cross-script bindings and stubs received over RPC are left hand-rolling the handshake asstub.setName(name), which is deprecated, even thoughgetServerByNamerelies on it.What I'd like
Ideally I'd like a solution that requires no per-method boilerplate. Initialisation on cold start is a framework responsibility, and every entrypoint the framework owns already upholds it.
agentsopens its own internal RPC entrypoints with__unsafe_ensureInitialized(). Application-defined entrypoints are the gap.I can live with a solution that requires some level of boilerplate, but that would erode some of the benefit that I expected from Think. I decided to trial Think mostly because I didn't want to have to handle eviction and cold starts in my application code.
I'd be happy to work on a PR for this, but I'd like to know what type of solution the maintainers would be willing to accept before I start, as I suspect that fixing this properly may require some breaking changes, and may require enforcing some restrictions on the public API surface (e.g. everything must be async).