ayjnt
All examples
client-sdkwebsocketgotcha

Client SDK + basePath

Connect from the Cloudflare Agents client SDK. Explains the path vs basePath gotcha and why server-side getAgentByName matters for identity messages.

What you'll learn
  • Why `path` doesn't replace the SDK's default URL prefix
  • How `basePath` lets you own the URL shape ayjnt generates
  • How the server-side `getAgentByName` call wires up CF_AGENT_IDENTITY messages
01 step

Start from the blank scaffold

Every ayjnt example starts here. `bunx ayjnt new` drops a one-agent project with a single `alive` agent that responds "I'm alive" to any request — enough to prove the pipeline works before you replace it with the real thing.

~/my-agent-app
my-app/ (blank scaffold)
agent.ts
package.json
tsconfig.json
.gitignore
README.md
02 step

Add the chat agent

Same shape as the basic example — one folder, one class. The client work happens in a separate file (client.ts) you call with `bun run client.ts`.

~/my-agent-app
agents/chat/agent.ts ts
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

type State = { messages: { role: "user" | "assistant"; text: string }[] };

export default class ChatAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = { messages: [] };

  override async onRequest(request: Request): Promise<Response> {
    if (request.method === "POST") {
      const { text } = (await request.json()) as { text: string };
      this.setState({ messages: [...this.state.messages, { role: "user", text }] });
      return Response.json({ ok: true, name: this.name });
    }
    return Response.json({ ...this.state, name: this.name });
  }
}
03 step

Client: basePath, not path

The Cloudflare Agents SDK hardcodes `/agents/<kebab-class-name>/<id>` as its prefix. `path` appends to that URL; `basePath` replaces it entirely. ayjnt serves at `/<route>/:id`, so you want the latter.

client.ts ts
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",
    basePath: `chat/${roomId}`,  // ← full URL override
    host,
  },
  {
    method: "POST",
    body: JSON.stringify({ text: "hello from the client" }),
  },
);
console.log(await post.json());
04 step

The URL-shape trade-off

Three client patterns, three different URLs. `path` is an append, `basePath` is a replace. The SDK's own JSDoc says: "when `basePath` is set, the server must handle routing manually" — that's exactly what ayjnt's generated worker does.

url-shapes.ts ts
// { agent: "ChatAgent", name: "42" }
//   → wss://host/agents/chat-agent/42           ← SDK default (doesn't work with ayjnt)

// { agent: "ChatAgent", name: "42", path: "/x" }
//   → wss://host/agents/chat-agent/42/x         ← still has /agents prefix

// { agent: "ChatAgent", basePath: "chat/42" }
//   → wss://host/chat/42                        ← matches ayjnt's routing
05 step

Run client + server together

Start ayjnt dev in one terminal, run the client in another. The response includes `name` so you can see it reached the right DO instance.

~/my-agent-app
06 step

What it looks like

The interesting output is that `name: "demo-room"` round-trips correctly. A hand-rolled dispatch that skips `setName` would echo back `name: ""` here.

client ↔ server round-trip result
client.ts                           worker (generated entry.ts)
─────────                           ────────────────────────────
agentFetch({                        router match: /chat/:id
  agent: "ChatAgent",       ───▶    stub = getAgentByName(env.CHAT_AGENT, id)
  basePath: "chat/demo-room",       stub.setName("demo-room")   ← critical!
  host                              stub.fetch(request)
})                                  │
                                    ▼
                                    ChatAgent (DO "demo-room")
                                      this.name === "demo-room" ✓
                                      setState(...)
                                      return { ok:true, name: this.name }
{ ok: true,
  name: "demo-room" }       ◀───    response