Gotchas
Every sharp edge we've found so far, documented. Most of these are surprising on first encounter but obvious in retrospect — the goal is to make them discoverable before you hit them.
Client SDK: basePath, not path
The Agents client SDK builds URLs as /agents/<kebab-class-name>/<instance>.
path appends to that default; basePath
replaces it.
// WRONG — appends to the default, hits 404 in an ayjnt worker
useAgent({ agent: "ChatAgent", name: "42", path: "/custom" });
// → wss://host/agents/chat-agent/42/custom
// RIGHT — replaces the default
useAgent({ agent: "ChatAgent", basePath: "chat/42" });
// → wss://host/chat/42
ayjnt's URL shape is /<route>/<instance>,
not /agents/<kebab>/<instance>, so the
default SDK URL doesn't match any route in our generated
worker. basePath is the fix.
Full detail: Client integration .
Durable Object state persists across dev restarts
This is correct platform behavior and surprising the first time
you hit it. rm -rf .wrangler wipes local DO storage;
without that, state from your previous dev session is still there.
inter-agent example, for instance),
either expose a reset endpoint and call it first, or wipe .wrangler/ between runs.
RPC errors propagate — translate at HTTP boundaries
When you await stub.method(...) and the method
throws, the exception comes back to the caller. If the caller is
an HTTP handler and doesn't catch, the worker returns a 500
with a plain-text stack trace — breaking any client doing res.json() on it.
// BAD — "insufficient stock" → 500 → client crashes on res.json()
const remaining = await inv.decrement(sku, qty);
// GOOD — translate into a 409
try {
const remaining = await inv.decrement(sku, qty);
return Response.json({ ok: true, remaining });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ ok: false, error: message }, { status: 409 });
}Full detail: Inter-agent RPC .
MCP detection is source-level
The scanner looks at the literal text extends McpAgent in your source. Aliased imports
don't work:
// ✗ NOT detected as an MCP agent
import { McpAgent as M } from "agents/mcp";
export default class Tools extends M { ... }
// ✓ Detected
import { McpAgent } from "agents/mcp";
export default class Tools extends McpAgent { ... }If detection fails, the agent is treated as a regular agent and dispatched normally — which means the MCP protocol handler never runs and tool calls don't work.
tsconfig paths need the leading ./
TypeScript rejects path mappings without baseUrl set
unless the path starts with ./. This comes up if you
inline the paths yourself instead of extending .ayjnt/tsconfig.json:
// WRONG — TS5090: Non-relative paths are not allowed when 'baseUrl' is not set
{
"paths": {
"@ayjnt/env": [".ayjnt/env.d.ts"],
"@ayjnt/*": [".ayjnt/client/*"]
}
}
// RIGHT
{
"paths": {
"@ayjnt/env": ["./.ayjnt/env.d.ts"],
"@ayjnt/*": ["./.ayjnt/client/*"]
}
}Don't consume the response stream in middleware
Response bodies are streams. If you await res.text()
or await res.json() inside middleware and then return
the original Response, the client sees an empty body — the stream
was already consumed.
// WRONG — consumes the body, the client sees nothing
const res = await next();
await res.text();
return res;
// RIGHT — copy headers + pass the body stream through
const res = await next();
const headers = new Headers(res.headers);
headers.set("x-custom", "value");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});Middleware c.set doesn't reach the agent
c.set("user", token.user) in middleware is
visible only to other middleware in the same request. The
agent runs inside a Durable Object — a different execution context
— and doesn't see the stash.
To pass a value into the agent, put it in a request header before
calling next(), or include it in the request body.
Assets: html_handling must be “none”
The generated wrangler.jsonc sets html_handling: "none" on the Assets binding.
If you override this to the default (auto-trailing-slash),
Cloudflare redirects /foo/index.html → /foo/, which breaks the useAgent hook (it
reads window.location.pathname to derive the instance
name; if the URL gets rewritten, every user ends up on the default instance).
Don't override this unless you know what you're doing and have a plan for deriving the instance differently.
McpAgent URL shape doesn't include an instanceId
Normal agents are at /<route>/<instance>.
MCP agents are at just /<route>. The MCP
protocol manages sessions via the Mcp-Session-Id
header (or sessionId query param for SSE), and one
DO instance is created per session.
This means you can't pick an MCP agent's instance from the URL. If you need shared state across sessions (global rate limit, counters across all tool calls), keep it in KV or in another DO and fetch from tool handlers.
Renaming a folder without an agentId wipes storage
Default agentId is derived from the folder path. If
you rename agents/chat/ to agents/messaging/ without setting an explicit agentId, the migration diff sees chat as deleted and messaging as new —
wrangler deletes the old DO storage on next deploy.
// Before you rename, add:
export const agentId = "chat_v1"; // stable across folder moves
export default class ChatAgent extends Agent<Env, State> { ... }
Recommended: set agentId on every agent at creation
time. Details in
Migrations
.
Initial state on the client is undefined until first message
agent.state from useAgent() is undefined until the first CF_AGENT_STATE
message arrives over WebSocket. Handle it with optional chaining
or a loading guard:
const count = agent.state?.count ?? 0; // safe default
// or
if (!agent.state) return <Loading />;
The generated typed hook types it as State | undefined,
so TypeScript will force you to handle the undefined case.
Don't mutate this.state directly
this.state looks like a regular object; appending to
an array on it appears to work. But no persistence hook fires and
no clients get broadcast. Next hibernation, the mutation is lost.
// WRONG
this.state.messages.push(newMsg);
// RIGHT
this.setState({
...this.state,
messages: [...this.state.messages, newMsg],
});When ayjnt deploy refuses
Three preflight checks gate every deploy. If any fails, the deploy aborts. Remedy for each:
| Error | Fix |
|---|---|
| uncommitted changes detected | git commit or git stash |
| N unpushed commits | git push |
| N unpulled commits from origin | git pull --rebase |
| pending migration detected | ayjnt build, commit .ayjnt/migrations.json, push, retry.
|
--force exists for emergencies but is loud about it.
Details in
Deployment
.