Inter-agent RPC
One agent can call another's methods directly via getAgent<T>. Typed at compile time, native Workers RPC at runtime, no HTTP round-trip.
Why this exists
Cloudflare Agents are Durable Objects, and DOs already support RPC
between instances. The SDK ships getAgentByName(namespace, name) which returns a typed
stub. ayjnt wraps it as getAgent<T>(namespace, name) with a nicer generic
order, so the call site reads cleanly without threading Env
as a type argument.
Signature
import { getAgent } from "ayjnt/rpc";
function getAgent<T extends Rpc.DurableObjectBranded | undefined>(
namespace: DurableObjectNamespace<T>,
name: string,
): Promise<DurableObjectStub<T>>; T is the agent class. namespace is the DO
binding from your env (env.INVENTORY_AGENT). name is the instance id — same thing you'd use in a
URL. The returned stub is typed to T, so every method
declared on the class is available with full argument and return
type inference.
A complete example
The callee holds the stock counters and exposes a method that can throw:
import { Agent } from "agents";
type State = { stock: Record<string, number> };
export default class InventoryAgent extends Agent<{}, State> {
override initialState: State = { stock: { widget: 10 } };
async decrement(sku: string, qty: number): Promise<number> {
const current = this.state.stock[sku] ?? 0;
if (current < qty) {
throw new Error(`insufficient stock for ${sku}: have ${current}`);
}
const remaining = current - qty;
this.setState({ stock: { ...this.state.stock, [sku]: remaining } });
return remaining;
}
}
The caller imports the type of the callee, declares its
binding in Env, and calls the method:
import { Agent } from "agents";
import { getAgent } from "ayjnt/rpc";
import type InventoryAgent from "../inventory/agent.ts";
type Env = {
INVENTORY_AGENT: DurableObjectNamespace<InventoryAgent>;
};
type State = { orders: { sku: string; qty: number; remaining: number }[] };
export default class OrdersAgent extends Agent<Env, State> {
override initialState: State = { orders: [] };
override async onRequest(request: Request): Promise<Response> {
const { sku, qty } = (await request.json()) as { sku: string; qty: number };
try {
const inv = await getAgent<InventoryAgent>(this.env.INVENTORY_AGENT, "main");
const remaining = await inv.decrement(sku, qty);
this.setState({
orders: [...this.state.orders, { sku, qty, remaining }],
});
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 });
}
}
}What happens at runtime
-
getAgentinternally callsenv.INVENTORY_AGENT.idFromName("main")and.get(id)to get a stub. -
It calls
stub.setName("main")so the target DO knows its own identity (same reason the dispatch layer does this — see Routing ). -
await inv.decrement(sku, qty)triggers Workers native DO RPC. The call is serialized (structured clone), sent to the target DO, executed, and the return value or thrown exception comes back the same way. -
If the callee throws, the caller's
awaitre-throws the same error. Exception propagation just works.
Typing guarantees
-
Method names autocomplete on the stub —
inv.decrementshows up in your editor, arbitrary strings don't. - Argument types are checked.
inv.decrement("widget")is a compile error. -
Return type is inferred.
remaining: numberwithout annotating it. -
Rename
decrement→debiton the callee and TypeScript immediately flags every call site.
Five gotchas worth memorizing
Arguments must be structured-cloneable
Workers RPC uses structured clone to cross the DO boundary. Plain
data — strings, numbers, arrays, plain objects, Uint8Array, Map, Set — works. What doesn't:
- Functions
- Class instances with methods
- DOM nodes / React elements
- WebSocket / Request / Response objects (use their plain fields instead)
See Cloudflare's RPC lifecycle docs for the full list.
Errors propagate — translate at HTTP boundaries
InventoryAgent.decrement throws “insufficient
stock”. The caller await inv.decrement(...)
re-throws it. If the caller's onRequest doesn't
catch, the worker returns a 500 with a plain-text
stack trace. Any client doing res.json() on that
response crashes with “Failed to parse JSON.”
The fix (shown in the example above): wrap every getAgent call in try/catch and translate
domain errors into structured responses with meaningful status
codes. A 409 Conflict with { ok: false, error } is a lot easier to handle
client-side than a 500 plus stack trace.
Every call is an async trip
Each await stub.method(...) round-trips to the target
DO, possibly on another machine. It's much cheaper than HTTP
(no URL parsing, no body parse), but it isn't free — sequential
awaits in a loop will be slow.
// Bad: N round-trips
for (const item of items) {
await inv.decrement(item.sku, item.qty);
}
// Better: one method that handles the batch
await inv.decrementMany(items);When you catch yourself making N RPC calls, consider adding a batch method to the callee.
The binding type is your responsibility (for now)
The caller's Env must declare DurableObjectNamespace<InventoryAgent> on the
binding you're using. ayjnt generates GeneratedEnv
with these types filled in — extend it:
import type { GeneratedEnv } from "@ayjnt/env";
export default class OrdersAgent extends Agent<GeneratedEnv, State> {
// this.env.INVENTORY_AGENT is now typed to DurableObjectNamespace<InventoryAgent>
}
If you need extra non-DO bindings, type MyEnv = GeneratedEnv & { KV: KVNamespace }.
Methods vs. onRequest — pick by use case
Methods are great for server-to-server calls where types matter and
HTTP ceremony is wasteful. For client-facing interaction, prefer onRequest (REST-style) or WebSocket state sync (via the
SDK's connection API). Methods aren't accessible from the
outside world directly — only from other workers, agents, or
WebSocket RPC.
Sharding — picking the right instance id
getAgent<T>(namespace, "main") always
talks to the same instance, because “main” is a
single string. If you need concurrency / independence, shard the
instance id:
// Single global inventory — serializes all decrements through one DO.
const inv = await getAgent<InventoryAgent>(env.INVENTORY_AGENT, "main");
// One inventory per SKU — separate DOs, separate storage, decrements in parallel.
const inv = await getAgent<InventoryAgent>(env.INVENTORY_AGENT, sku);
// One per warehouse — scales with warehouses, locality per region.
const inv = await getAgent<InventoryAgent>(env.INVENTORY_AGENT, warehouseId);This is the same decision you make when using the SDK directly — it's a DO design question, not an ayjnt one. But it's the first thing to think about when designing a multi-agent system.