/AYjnt/
All examples
multi-agentrpcuimiddleware

Multi-agent mission

A four-agent collaborative system on an asteroid-mining mission. Commander orchestrates navigator, scout, and engineer via typed RPC every 2s. Each role has its own UI so you can dive in and inspect what that crew member knows.

What you'll learn
  • Orchestrating multiple agents via typed `getAgent<T>` RPC
  • Route groups (parens) for shared middleware that doesn't leak into URLs
  • Why each agent has its own UI — separate DOs, separate state, separate concerns
01 step

Start from the blank scaffold (with UI)

Same starter as the blank template but with React, react-dom and matching @types preinstalled so your agent.ts can have an app.tsx next to it. The default project is a Counter agent — we'll replace it with the example's agent in the next steps.

~/my-agent-app
my-app/ (--with-ui scaffold)
agent.ts
app.tsx
package.json
tsconfig.json
02 step

Four agents under a shared (mission) group

The `(mission)` folder is a route group — parens strip it from the URL, so you still get `/commander/:id`, `/navigator/:id`, `/scout/:id`, `/engineer/:id`. But every request under that subtree runs the shared middleware.

~/my-agent-app
my-app/agents/
middleware.tsmission-id validation + req id
agent.ts
app.tsx
03 step

agents/(mission)/middleware.ts — shared by all four

Mission-id validation + request id. Applies to every descendant of `(mission)/`. If you wanted auth, password-gating the whole mission subtree is two lines here.

agents/(mission)/middleware.ts ts
import type { Middleware } from "ayjnt/middleware";

const middleware: Middleware = async (c, next) => {
  const missionId = c.params.instanceId;
  if (!/^[a-z0-9-]{1,40}$/.test(missionId)) {
    return c.json({ error: "invalid mission id", missionId }, 400);
  }
  const reqId = crypto.randomUUID().slice(0, 8);
  c.set("reqId", reqId);
  console.log(`[${reqId}] ${c.request.method} ${c.url.pathname}`);

  const res = await next();
  const headers = new Headers(res.headers);
  headers.set("x-mission-request-id", reqId);
  headers.set("x-mission-id", missionId);
  return new Response(res.body, { status: res.status, headers });
};

export default middleware;
04 step

The three crew agents

Each crew role is its own DO class with its own state shape and its own RPC methods. `getAgent<NavigatorAgent>(env.NAVIGATOR_AGENT, id)` returns a typed stub — rename a method in the callee and every caller breaks at compile time.

agents/(mission)/navigator/agent.ts ts
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

export type Vec3 = { x: number; y: number; z: number };
export type NavigatorStatus = { position: Vec3; target: Vec3 | null; fuel: number; heading: Vec3; arrived: boolean; trail: Vec3[]; speed: number };

export default class NavigatorAgent extends Agent<GeneratedEnv, NavigatorStatus & { lastUpdate: number }> {
  override initialState = { position: {x:0,y:0,z:0}, target: null, fuel: 100, heading: {x:1,y:0,z:0}, arrived: false, trail: [], speed: 1.4, lastUpdate: 0 };

  async setCourse(target: Vec3): Promise<NavigatorStatus> { /* recompute heading, setState */ return this.report(); }
  async refuel(): Promise<NavigatorStatus>                 { /* fuel = 100 */ return this.report(); }
  async tick(): Promise<NavigatorStatus>                   { /* advance one step toward target, burn fuel */ return this.report(); }
  async report(): Promise<NavigatorStatus>                 { const { lastUpdate, ...rest } = this.state; return rest; }

  override async onRequest(request: Request) { /* accept action=course|tick|refuel */ return Response.json(this.state); }
}
agents/(mission)/scout/agent.ts ts
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

export type Contact = { id: string; kind: "asteroid"|"debris"|"signal"|"hostile"; distance: number; bearing: number; severity: number; spottedAt: number };
export type ScoutStatus = { scanning: boolean; sensorRange: number; contacts: Contact[]; threatLevel: number; lastScan: number | null };

export default class ScoutAgent extends Agent<GeneratedEnv, ScoutStatus> {
  override initialState = { scanning: false, sensorRange: 25, contacts: [], threatLevel: 0, lastScan: null };

  async scan(): Promise<ScoutStatus> { /* generate 1-3 contacts, recompute threat */ return { ...this.state }; }
  async clear(): Promise<ScoutStatus> { this.setState({ ...this.state, contacts: [], threatLevel: 0 }); return this.state; }
  async report(): Promise<ScoutStatus> { return { ...this.state }; }

  override async onRequest() { return Response.json(this.state); }
}
agents/(mission)/engineer/agent.ts ts
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

export type SystemName = "power" | "lifeSupport" | "comms" | "hull" | "drill";
export type EngineerStatus = { systems: Record<SystemName, number>; repairs: number; repairing: SystemName | null; aggregate: number };

export default class EngineerAgent extends Agent<GeneratedEnv, EngineerStatus> {
  override initialState = { systems: { power: 100, lifeSupport: 100, comms: 100, hull: 100, drill: 100 }, repairs: 0, repairing: null, aggregate: 100 };

  async repair(system: SystemName): Promise<EngineerStatus> { /* set repairing, delay, heal, clear */ return this.state; }
  async degrade(): Promise<EngineerStatus> { /* pick random system, drop 2-8% */ return this.state; }
  async report(): Promise<EngineerStatus> { return { ...this.state }; }

  override async onRequest() { return Response.json(this.state); }
}
05 step

agents/(mission)/commander/agent.ts — orchestrator

`scheduleEvery(2, "tick")` drives the mission. Every tick, commander calls nav.tick(), eng.degrade(), and every third tick scout.scan(). It aggregates all three statuses into its own state and decides whether to advance the mission phase.

agents/(mission)/commander/agent.ts ts
import { Agent } from "agents";
import { getAgent } from "ayjnt/rpc";
import type { GeneratedEnv } from "@ayjnt/env";
import type NavigatorAgent from "../navigator/agent.ts";
import type ScoutAgent from "../scout/agent.ts";
import type EngineerAgent from "../engineer/agent.ts";

type Phase = "idle" | "survey" | "approach" | "extract" | "return" | "done";
type State = { phase: Phase; cycle: number; running: boolean; scheduleId: string | null; log: { at: number; text: string; level: string }[]; crew: { navigator: any; scout: any; engineer: any } };

export default class CommanderAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = { phase: "idle", cycle: 0, running: false, scheduleId: null, log: [], crew: { navigator: null, scout: null, engineer: null } };

  async start(): Promise<State> {
    const s = await this.scheduleEvery(2, "tick");
    this.setState({ ...this.state, running: true, scheduleId: s.id });
    await this.nav().then((a) => a.setCourse({ x: 40, y: 10, z: 0 }));
    return this.state;
  }

  async tick() {
    if (!this.state.running) return;
    const nav = await this.nav();
    const scout = await this.scout();
    const eng = await this.engineer();

    const [navStatus, engStatus] = await Promise.all([nav.tick(), eng.degrade()]);
    const scoutStatus = this.state.cycle % 3 === 0 ? await scout.scan() : await scout.report();

    // Fuel emergency?  divert to return phase
    // Arrived?  advance to next phase
    this.setState({ ...this.state, cycle: this.state.cycle + 1,
      crew: { navigator: navStatus, scout: scoutStatus, engineer: engStatus } });
  }

  private async nav() { return getAgent<NavigatorAgent>(this.env.NAVIGATOR_AGENT, this.name); }
  private async scout() { return getAgent<ScoutAgent>(this.env.SCOUT_AGENT, this.name); }
  private async engineer() { return getAgent<EngineerAgent>(this.env.ENGINEER_AGENT, this.name); }

  override async onRequest(request: Request) { /* POST start/stop/reset */ return Response.json(this.state); }
}
06 step

Four URLs, four UIs, one mission

Every crew member has an app.tsx. Use the same mission id across URLs to join them up: /commander/apollo, /navigator/apollo, /scout/apollo, /engineer/apollo. Each UI connects to its own DO over WebSocket and re-renders when commander's tick updates that DO's state.

tie-together.ts ts
// every tab → its own WebSocket → its own DO's state feed
//
// /commander/apollo  ── uses @ayjnt/commander  (typed to CommanderAgent state)
// /navigator/apollo  ── uses @ayjnt/navigator  (typed to NavigatorAgent state)
// /scout/apollo      ── uses @ayjnt/scout      (typed to ScoutAgent state)
// /engineer/apollo   ── uses @ayjnt/engineer   (typed to EngineerAgent state)
//
// Mission id is the URL segment after the route prefix. Using the same id
// across URLs is what ties the crew together — commander's tick calls
// getAgent<NavigatorAgent>(env.NAVIGATOR_AGENT, this.name), so "apollo"
// commander talks to "apollo" navigator (not "luna").
07 step

Run + engage

Open the commander UI and click ENGAGE. The crew agents start reporting. The navigator moves through survey → approach → extract → return waypoints; engineer degrades a random system each tick; scout surfaces contacts every third tick. You can drill into any crew UI while the mission runs.

~/my-agent-app
08 step

What it looks like

Commander is the big-picture view. Each crew tab is a dense single-role console. Click the role cards on the commander page to jump to any crew UI; use the ← commander link in each crew tab to come back.

four tabs, one mission result
  ┌──────────── /commander/apollo ────────────────────────────┐
  │ MISSION CONTROL   APOLLO                 [ENGAGE] [reset] │
  │ survey-and-extract · cycle 7 · APPROACH                   │
  ├───────────────────────────────────────────────────────────┤
  │  NAVIGATOR ↗    SCOUT ↗         ENGINEER ↗                │
  │    87.3%          34%              78%                    │
  │    fuel          threat           health                  │
  │    en route →    5 contacts       2 repairs               │
  ├───────────────────────────────────────────────────────────┤
  │ mission log                                               │
  │   14:32:18  phase → approach                              │
  │   14:32:16  systems degrading (68%)                       │
  │   14:32:12  phase → survey                                │
  │   14:32:10  mission engaged                               │
  └───────────────────────────────────────────────────────────┘

  /navigator/apollo            /scout/apollo             /engineer/apollo
  ┌─────── radar ──────┐       ┌ threat  34% ─┐          ┌ aggregate 78% ─┐
  │   · BASE  ◯ TGT     │       │ ████████▁▁▁▁ │          │   power  71% ▆│
  │     ━━━▶             │       │ [scan now]   │          │   life   58% ▃│
  │  ship  ship          │       │              │          │   comms  92% ▅│
  │  trail               │       │ contacts     │          │   hull  100% █│
  │                      │       │ ▸ hostile  · │          │   drill  80% ▇│
  │ POS 51,19 FUEL 87.3% │       │ ▸ asteroid · │          └─────────────────┘
  │ TGT 80,30  EN-ROUTE  │       │ ▸ signal   · │
  └─────────────────────┘       └──────────────┘
09 step

Deploy

`ayjnt deploy` checks your git tree is clean + synced with origin, regenerates the wrangler config from scratch, then shells out to `wrangler deploy`. The committed migrations.json file is the source of truth for what's in production.

~/my-agent-app