Your first agent
Build a working agent end-to-end. The example is a counter: one number, two endpoints, one method. In ten minutes you'll have run, inspected, mutated, and understood every piece.
Create the project
bunx ayjnt new counter-demo
cd counter-demo
bun install
You'll see a counter-demo/agents/chat/agent.ts
that the minimal template created. Delete chat/ — we're
going to write our own:
rm -rf agents/chat
mkdir -p agents/counterWrite the agent
Create agents/counter/agent.ts with this content:
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";
type State = { count: number };
export default class CounterAgent extends Agent<GeneratedEnv, State> {
override initialState: State = { count: 0 };
override async onRequest(request: Request): Promise<Response> {
if (request.method === "POST") {
const { by } = (await request.json()) as { by?: number };
const next = this.state.count + (by ?? 1);
this.setState({ count: next });
return Response.json({ count: next, instance: this.name });
}
return Response.json({ count: this.state.count, instance: this.name });
}
}Four things are worth naming:
-
extends Agent<GeneratedEnv, State>— the first type parameter is the worker's env (DO bindings + any KV/R2/vars you declare).GeneratedEnvis regenerated by ayjnt on every build and contains exactly the DO bindings for every agent in your tree. The second is your state shape. -
override initialState— the valuethis.statestarts at when a new DO instance boots for the first time. Required because the base class declares aninitialStatefield. -
override async onRequest— the HTTP entrypoint. Return aResponse. Called per request; the DO is the same instance across calls. -
this.setState— persists new state and broadcasts it to any connected WebSocket clients. Don't mutatethis.statedirectly — it's the durable shape and ayjnt/Cloudflare both key behavior offsetStatebeing the only writer.
Run it
bun run dev
# ✓ ayjnt: 1 agent(s) → .ayjnt/dist/wrangler.jsonc
# ⎔ Listening on http://localhost:8787In another terminal:
# Read — no POST body, count starts at 0
curl http://localhost:8787/counter/demo
# → {"count":0,"instance":"demo"}
# Increment by 1
curl -X POST http://localhost:8787/counter/demo \
-H "content-type: application/json" -d '{}'
# → {"count":1,"instance":"demo"}
# Increment by 7
curl -X POST http://localhost:8787/counter/demo \
-d '{"by":7}' -H "content-type: application/json"
# → {"count":8,"instance":"demo"}
# Different instance — isolated state
curl http://localhost:8787/counter/another
# → {"count":0,"instance":"another"}/counter/demo — the first segment counter
matches the folder path; the second segment demo is
the DO instance id. Every unique second segment gets its own
Durable Object with isolated state and storage.
Add a method callable from another agent
The real power of agents shows up when they call each other. Add a reset method to the class:
export default class CounterAgent extends Agent<GeneratedEnv, State> {
override initialState: State = { count: 0 };
/** Public method — callable across the DO boundary via getAgent<T>. */
async reset(): Promise<void> {
this.setState({ count: 0 });
}
override async onRequest(request: Request): Promise<Response> {
if (request.method === "DELETE") {
await this.reset();
return Response.json({ ok: true });
}
// ...rest unchanged
}
}
Wrangler picks up the change via HMR, the DO reloads (in dev), and
you can curl -X DELETE http://localhost:8787/counter/demo
to reset. From inside another agent,
(await getAgent<CounterAgent>(env.COUNTER_AGENT,
"demo")).reset()
would invoke the same method over Workers RPC with full type
inference.
Deploy
Commit what you have, then:
git add -A && git commit -m "first counter agent"
git push
bun run deploy
ayjnt checks that your tree is clean and in sync with origin, writes
the pending migration into .ayjnt/migrations.json, and then hands off to wrangler deploy. The first deploy will prompt you to wrangler login.
ayjnt build staged a new migration but you
haven't committed .ayjnt/migrations.json, ayjnt deploy will refuse to run. Commit it, git push, then retry. This is the git-safety contract
that keeps two developers from racing divergent schemas to prod.
What you just did
-
Created one Durable Object class (CounterAgent), bound at
/counter/:instance. -
Registered it as a DO binding in the generated
wrangler.jsonc, with an automatic migration entry in the lockfile. -
Exercised state persistence — each
/counter/<x>is a separate DO. -
Added a public method that's both HTTP-accessible via
onRequestand RPC-accessible viagetAgent. - Deployed it to production behind a git-safety check.
Next:
Project anatomy
walks you through the .ayjnt/ directory so you know
which files you own and which the framework regenerates.