Client integration
ayjnt's URL shape is different from the Agents SDK's default. That means one option on the client matters: basePath, not path.
The default SDK URL shape
The Cloudflare Agents client SDK (useAgent, AgentClient, agentFetch) constructs URLs
like this:
wss://your-worker.workers.dev/agents/<kebab-class-name>/<instance>
# e.g., for a class ChatAgent with instance name "room-42":
wss://your-worker.workers.dev/agents/chat-agent/room-42
The /agents/ prefix is hardcoded. The class name is
kebab-cased. The last segment is the instance.
The ayjnt URL shape
ayjnt doesn't use that shape. A ChatAgent in agents/chat/ lives at /chat/<instance>,
not /agents/chat-agent/<instance>. Which means
the default SDK URL doesn't match any route in our worker.
You'll get a 404.
The fix: basePath
The SDK has a basePath option that overrides the default
URL construction entirely:
import { useAgent } from "agents/react";
// WRONG — uses default /agents/chat-agent/<name> URL, gets 404
const agent = useAgent({ agent: "ChatAgent", name: "room-42" });
// RIGHT — basePath replaces the entire URL prefix
const agent = useAgent({
agent: "ChatAgent",
basePath: "chat/room-42", // → wss://host/chat/room-42
});
Same for agentFetch and AgentClient. The agent field is still required by the SDK typing, but
it's ignored when basePath is set — pass the class
name anyway so TypeScript stays happy.
There's also a path option. It doesn't do
what you want. path is appended to the
default URL (/agents/chat-agent/room-42/<your-path>),
not substituted for the prefix. basePath is the one
that replaces everything.
From the SDK's own docstring: “Full URL path — bypasses agent/name URL construction. When set, the client connects to this path directly. Server must handle routing manually.” That's exactly what ayjnt does on the server side.
Three ways to talk to an agent
The Cloudflare Agents SDK exposes three client surfaces, each with a different fit:
| Surface | Transport | Use when |
|---|---|---|
useAgent from agents/react | WebSocket |
React UI that wants live state pushes + @callable() methods. ayjnt
generates a typed wrapper at @ayjnt/<route> — use that
in app.tsx.
|
AgentClient from agents/client | WebSocket |
Standalone script (Bun, Node, Deno) that needs to call
methods or watch state. The non-React counterpart of useAgent.
|
agentFetch from agents/client | HTTP |
One-shot request/response. Hits onRequest on the agent. No
long-lived connection, no @callable()
support.
|
All three accept the same basePath override so the
gotcha above applies uniformly.
AgentClient — calling @callable methods from a script
AgentClient is a WebSocket-based class that exposes .stub.method() / .call("method", [args]) for @callable()-decorated methods, plus a typed .state that updates on every server broadcast. It's
the right pick for any script that makes more than one call or
wants to observe state changes.
import { AgentClient } from "agents/client";
import type NotesAgent from "./agents/notes/agent.ts";
const host = (process.env.HOST ?? "http://localhost:8787")
.replace(/^https?:\/\//, "");
const instance = process.env.INSTANCE ?? "default";
// Generic <NotesAgent> binds the stub against the agent class:
// client.stub.addNote(...) is typed end-to-end against every
// @callable() method on the class.
const client = new AgentClient<NotesAgent>({
agent: "NotesAgent",
basePath: `notes/${instance}`, // bypasses /agents/<kebab>/<name>
host,
onStateUpdate: (state, source) =>
console.log(`[${source}] ${state.notes.length} notes`),
onIdentity: (name, agent) =>
console.log(`connected to ${agent}#${name}`),
});
await client.ready; // wait for the identity handshake
const note = await client.stub.addNote("hello"); // typed!
const total = await client.stub.countNotes(); // → number
await client.call("deleteNote", [note.id]); // string-name fallback
client.close();
The <NotesAgent> generic binds client.stub to the agent class — every method
decorated with @callable() from "agents"
appears with full argument and return-type checking. client.call("methodName", [args]) is the
string-name fallback; it's typed when the generic is set and
drops to UntypedAgentClientCall when it isn't.
await client.ready resolves after the server emits
the CF_AGENT_IDENTITY message — handy as a debug
anchor for the basePath wiring (if it never
resolves, the URL is wrong). RPC calls before ready queue and flush automatically once identity
arrives, so the await is optional.
onStateUpdate(state, source) fires for every
state broadcast: once on connect with the current state,
then every time the agent calls setState({...})
— regardless of who triggered it. The source field is "server" for server-
originated changes and "client" when this client called client.setState({...}).
See
examples/callable-client
for the full working pair (server-side @callable
methods + this client script).
agentFetch — one-shot HTTP
When you just want to fire a request at the agent's onRequest handler and read the response — no
long-lived socket, no @callable() dispatch — use agentFetch:
import { agentFetch } from "agents/client";
const host = process.env.HOST ?? "http://localhost:8787";
const roomId = "demo-room";
// POST a message
const post = await agentFetch(
{
agent: "ChatAgent", // required by SDK typing, ignored here
basePath: `chat/${roomId}`, // owns the URL
host,
},
{
method: "POST",
body: JSON.stringify({ text: "hello from the client" }),
},
);
console.log("POST:", post.status, await post.json());
// GET the current state
const get = await agentFetch({
agent: "ChatAgent",
basePath: `chat/${roomId}`,
host,
});
console.log("GET:", await get.json());
Each call opens a fresh HTTP connection. Cheaper for two or
three calls; meaningfully worse than AgentClient
once you're making more than a handful or want to react to
state changes. Walkthrough in
examples/with-client
.
From a React app
In the browser, use useAgent from agents/react. ayjnt already generates a typed wrapper
for every agent (see
Co-located UI
), so if your UI lives in app.tsx next to the agent,
you can import the typed hook directly:
// agents/chat/app.tsx
import { useAgent } from "@ayjnt/chat"; // generated hook — basePath already wired
export function Chat() {
const agent = useAgent(); // no args — basePath derived from URL
// ...
}
If your UI is in a separate app (Next.js, Vite) and you want to
point at the agent directly, use the raw SDK with basePath:
import { useAgent } from "agents/react";
export function Chat({ roomId }: { roomId: string }) {
const agent = useAgent<ChatState>({
agent: "ChatAgent",
basePath: `chat/${roomId}`,
});
// ...
}Why the server uses getAgentByName
A side note that matters if you're ever debugging identity issues:
When a client connects via the SDK, the server sends back a CF_AGENT_IDENTITY message containing { name: this.name, agent: kebab(ClassName) }. The
client uses this to know which instance it's talking to.
For this.name on the DO to be populated, the server
must call stub.setName(name) before the request reaches
the agent. ayjnt's generated dispatch calls getAgentByName(env.BINDING, instanceId), which does idFromName + get + setName
internally. If you were hand-rolling your own dispatch and did namespace.idFromName(id) + namespace.get(id)
+ stub.fetch(...) manually, you'd skip setName and every identity message would carry an
empty name. Symptoms: the client's onIdentity
callback fires with bad data, useAgent's agent.name is undefined.
ayjnt handles this correctly out of the box. You just need to know that if you ever build your own worker entry around the same pattern, getAgentByName is the right primitive.
CORS
If your client is on a different origin from the worker, you'll need CORS. There's no built-in CORS in ayjnt — write a middleware. See the CORS pattern in the Middleware guide .
WebSocket vs HTTP on the same URL
The ayjnt worker serves three different things off the same URL depending on the request shape:
-
GETwithUpgrade: websocket→ agent's WebSocket handler (this is whatuseAgentandAgentClientuse). Carries@callable()method calls and live state pushes. -
GETwithAccept: text/html→ HTML shell (if the agent hasapp.tsx) -
Anything else → agent's
onRequest. This is whatagentFetchhits.
So the three clients ( useAgent, AgentClient, agentFetch) can coexist against the same URL — they
hit different code paths on the server, picked by the request
shape, not the path.