Routing & URL shape
Folder tree → URL tree. This page documents exactly how a request finds its way to an agent instance, including how longest-prefix matching works and why we don't use the SDK's routeAgentRequest.
The URL shape
ayjnt produces URLs of the form:
/<route-path>/<instanceId>[/<path-suffix>]-
<route-path>— the folder path relative toagents/, with any(route-group)segments stripped. -
<instanceId>— the first path segment after the route prefix. Selects which DO instance handles the request. Any string is fine; the SDK callsstub.idFromName(instanceId)under the hood. -
<path-suffix>— everything after the instance id. Forwarded to the DO unchanged (visible asc.params.pathSuffixin middleware).
Examples
| Folder | URL prefix | Example full URL |
|---|---|---|
agents/chat/ | /chat | /chat/room-42 |
agents/admin/users/ | /admin/users | /admin/users/bob |
agents/(public)/status/ | /status | /status/uptime |
agents/(auth)/(v2)/account/ | /account | /account/alice |
Matching algorithm
The generated worker entry contains a route table of all discovered agents, sorted longest-prefix first so specific routes beat general ones:
const ROUTES: Route[] = [
{ prefix: "/admin/users", binding: "ADMIN_USERS_AGENT", ... },
{ prefix: "/admin/audit", binding: "ADMIN_AUDIT_AGENT", ... },
{ prefix: "/admin", binding: "ADMIN_AGENT", ... },
{ prefix: "/chat", binding: "CHAT_AGENT", ... },
];
function matchRoute(pathname: string): Match | null {
for (const route of ROUTES) {
if (
pathname === route.prefix ||
pathname.startsWith(route.prefix + "/")
) {
// ... extract instanceId from the next segment
return match;
}
}
return null;
}
For a request to /admin/users/bob/edit, the matcher
finds /admin/users first (before it would match /admin), extracts bob as the instance id,
and sets pathSuffix to /edit.
routeAgentRequest(request, env) helper that matches
URLs like /agents/<kebab-name>/<instance>.
ayjnt uses its own matcher instead, for three reasons:
-
We want the URL shape to reflect the folder shape, not a
hardcoded
/agents/prefix. -
We need to intercept HTML requests to serve
app.tsxbundles — the SDK matcher doesn't expose a hook for that. - We need to apply middleware chains before forwarding to the DO.
Forwarding to the DO
Once the matcher picks a route, the worker forwards to the DO via
the SDK's getAgentByName:
import { getAgentByName } from "agents";
// match.instanceId = "bob", match.rest = "/edit"
const stub = await getAgentByName(env[match.binding], match.instanceId);
const forwarded = new URL(url);
forwarded.pathname = match.rest || "/";
return stub.fetch(new Request(forwarded, request));
Why getAgentByName and not the lower-level namespace.idFromName(id) + namespace.get(id)? Because getAgentByName
internally calls stub.setName(name) — that step is what
teaches the DO its own identity. Skip it and CF_AGENT_IDENTITY messages (the ones the client SDK uses
to know which instance it's talking to) carry no name, and
strange client-side bugs ensue.
HTML vs agent on the same URL
When an agent has a co-located app.tsx, the same URL
serves two different responses depending on how the client asks:
| Request | Response |
|---|---|
GET with Accept: text/html, no Upgrade header
|
HTML shell from .ayjnt/assets/__ayjnt/<route>/index.html
via the Assets binding
|
GET with Upgrade: websocket | WebSocket upgrade handled by the agent |
Any other method, or GET without Accept: text/html | Forwarded to the agent's onRequest |
Browser navigation to /counter/room-1 gets the UI; curl /counter/room-1 (which doesn't set Accept: text/html by default) gets JSON from the agent; useAgent() in the bundled JS upgrades to WebSocket on
the same URL and starts syncing state. One URL, three shapes.
Custom paths inside an agent
The path suffix after the instance id is forwarded unchanged, so you
can implement REST-style sub-routes inside onRequest:
export default class OrdersAgent extends Agent<Env, State> {
override async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
// pathSuffix is everything after /orders/<customer>/ — "/line-items", "/cancel", "/"
switch (url.pathname) {
case "/line-items":
return Response.json({ items: this.state.items });
case "/cancel":
if (request.method !== "POST") return new Response(null, { status: 405 });
this.setState({ ...this.state, cancelled: true });
return Response.json({ ok: true });
default:
return Response.json(this.state);
}
}
}
The inner pathname is relative — /line-items, not /orders/:id/line-items. That's because the worker
rewrites the URL before forwarding to the DO, so the agent sees a
clean suffix. If you need the original URL for logging, middleware
can capture it before dispatching.
What's not routable
-
Requests to
/or any path that doesn't match a route prefix return a404from the generated entry. You can catch these in middleware if you want a custom 404 page — or add an agent at the root (agents/root/agent.tsserving a marketing homepage, for example) though the URL shape makes that a little awkward. -
The reserved
/__ayjnt/*prefix is claimed by the Assets binding for servingapp.tsxbundles. Don't create agents under that segment.