Run & deploy: from an in-process runtime to one Durable Object per session
The same agent/ directory runs on an in-process SQLite runtime in dev and compiles into a Durable Object class on Cloudflare Workers, one object per session.
The shape#
| stage | what runs the turn | where state lives |
|---|---|---|
june dev |
NativeRuntime, in process |
SQLite (bun:sqlite under Bun, node:sqlite under Node) |
june gen |
— | compiles agent/ into _agent.gen.ts |
june build |
emits a JuneAgentDO class + the AGENT binding |
— |
| Workers | AgentDurableObject, one per session |
the object's own ctx.storage.sql |
The turn engine is the same in every row. Only the store and the transport change.
Dev: auto-mount#
When agent.runtime.enabled is on (the default) and app/agent/ exists, the dev
server:
- discovers the directory (
discoverAgent), - builds a model with
anthropic({ model })fromagent.ts, which readsANTHROPIC_API_KEYfrom the environment, - creates the runtime for
agent.runtime.backend("durable"falls back to"native", since dev has no Durable Object), - mounts the chat endpoint (
POST agent.runtime.chat.path, default/message) and, whenagent.runtime.channelsis on, the directory's channels, - runs one-shot channels (
start) once.
curl -sX POST localhost:3000/message \
-H 'content-type: application/json' \
-d '{"message":"order 3 widgets","session":"s1"}'
# → {"text":"..."}
The native chat endpoint always answers with JSON { text }. Live streaming in
dev goes through a channel's ctx.runStream.
The auto-mount opens the native runtime with no file path, so its SQLite is
:memory:. Turns are durable while the process runs and gone after a restart.
For a store that survives restarts, mount the runtime yourself.
Dev: mounting it yourself#
import { anthropic } from "@junejs/core/agent-models";
import { discoverAgent } from "@junejs/server/agent-discover";
import { createNativeRuntime, mountAgent, toAgentDef } from "@junejs/server/agent-native";
const agent = await discoverAgent("./app/agent");
const runtime = await createNativeRuntime(
{ [agent.name]: toAgentDef(agent, anthropic({ model: agent.model })) },
"./agent.sqlite", // default ":memory:"
{ maxSessions: 1000 }, // the default
);
const mounted = mountAgent(agent, runtime, { chatPath: "/message" });
await mounted.startAll();
Bun.serve({ fetch: async (req) => (await mounted.surface(req)) ?? new Response("not found", { status: 404 }) });
toAgentDef(agent, model)takes the tools (channel tools andread_skillincluded), the system prompt, and the per-surface policies from the one definition.mountAgentwarns when the runtime's tools differ from the definition's.mountAgentreturnssurface(chat endpoint plus channels),fetch(channels only),startAll, and thectxchannels drive turns through:run,runDetached,runStream,resumeStream,resetSession. It has norunDelivered/resumeDelivered, because those exist to escape the edgewaitUntillimit and a native host doesn't have one. Channels fall back to the streaming variants.createAgentRuntime(agents, { backend, path, maxSessions })picks"native"(the default) or"memory", and throws for"durable".
Session actors and eviction#
NativeRuntime keeps one AgentSession actor per (agent, session) in an LRU,
capped by maxSessions (default 1000). It is a soft cap. When a new actor is
needed and the cap is reached, the least recently used idle actors are
dropped. Idle means session.idle() is true (no turn running or queued, no reset
pending) and no live subscriber is attached. Busy actors are never dropped, so
the count can go over the cap while they run. A dropped actor's state is all in
SQLite, and the next session() call rebuilds it.
maxSessions must be an integer ≥ 1, or Infinity for no cap. Anything else
(0, NaN, 1.5) throws a RangeError at construction.
Because actors can be evicted, call runtime.session() where you use it. Don't
hold an AgentSession across an await and start turns on it later: if it was
evicted in between, a fresh actor for the same session would run turns in
parallel with it.
The memory backend (MemoryRuntime) never evicts, since the actor is the
state, so it grows with every session. Use it for dev and tests, not a
long-running host.
Version guard#
idle() is part of the server↔core runtime contract, now at
RUNTIME_API_VERSION = 2. NativeRuntime, MemoryRuntime, and
AgentDurableObject check it at construction. If a package manager nests a
second, older @junejs/core under @junejs/server, you get an error naming both
versions at startup, not a failure in the middle of a turn. Dedupe to one core.
Build: june gen#
Workers has no filesystem, so native discovery can't run there. june gen
compiles the agent directory into _agent.gen.ts inside it. The output uses
static imports for tools/, channels/, and connections/, and inlines the
markdown (instructions, variants, skills) as strings:
june gen # writes app/agent/_agent.gen.ts (or ./agent/ in a wrangler-first worker)
june gen --check # writes nothing; exits 1 if the file is stale (the CI gate)
It looks for app/<dir> first, then <root>/<dir>, where <dir> is
agent.runtime.dir. Files starting with _ are skipped, so the generated
module never scans itself. A legacy channels/<source>.md still compiles, with a
deprecation warning pointing at instructions.<source>.md.
Build: june build#
On a target with Durable Objects (the default workers() adapter), june build
compiles app/agent/ the same way and wires it into the generated worker entry.
Other targets skip the agent with a warning.
Before bundling, it checks that the app can bundle the SDK. It walks up from the
app root looking for node_modules/@anthropic-ai/sdk, and fails the build with
the fix if the SDK isn't there:
app/agent/ mounts a durable agent whose model is Claude — add the SDK to the app
so it bundles for workerd: bun add @anthropic-ai/sdk
The generated entry assembles the module with assembleDurable and exports the
Durable Object class:
// dist/worker.js (generated — shown as source)
export class JuneAgentDO extends DurableObject {
#agent = new AgentDurableObject(this.ctx, {
...__agentDef, // tools, instructions, surface policies, channels, connections
model: anthropic({ model: __agentModule.config.model, client: new Anthropic({ apiKey: this.env.ANTHROPIC_API_KEY }) }),
env: this.env,
// + resources / services when june.config declares them
});
fetch(req) { return this.#agent.fetch(req); }
}
It also puts the agent's name and channels on the worker manifest and adds the
binding to the emitted dist/wrangler.jsonc:
"compatibility_flags": ["nodejs_compat"],
"durable_objects": { "bindings": [{ "name": "AGENT", "class_name": "JuneAgentDO" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JuneAgentDO"] }]
If the app has its own wrangler.toml or wrangler.jsonc, June doesn't touch
it. The build warns and prints the snippet to add when that config doesn't bind
JuneAgentDO under AGENT. A class named only in migrations, a binding under
another name, or a commented-out table all trigger the warning.
On Workers#
One Durable Object per session#
The worker addresses each session's object with
idFromName("<agent>:<session>") and sends the session key in the
x-june-session header (exported as SESSION_HEADER). A Durable Object can't
read its own name, so it saves the first key it's given and rejects a mismatched
one with 409. The store needs no session_id column. The object is the session:
agent_messages, agent_steps, and agent_meta in its SQLite, with
transactions via ctx.storage.transactionSync, so the exactly-once contract is
the same as native — including its scope: only writes through ctx.storage.sql
join the step's transaction.
The object's HTTP surface:
| route | does |
|---|---|
POST /turn |
start a turn and stream its TurnEvents as SSE (:hb heartbeat every 20 s, cache-control: no-store) |
POST /turn?detach=1 |
202 once accepted; the turn runs with no consumer |
POST /turn?deliver=1 |
202 once accepted; the object renders the reply through the source channel's deliver() |
POST /turn?replace=1 |
cancel unfinished turns first (combines with the above) |
POST /resume |
apply a human's answer and stream the continuation (403 unauthorized, 409 stale); ?deliver=1 renders through deliverResume() |
POST /reset |
archive the history; returns { previousSession, generation } |
GET /transcript |
the folded transcript |
The worker side#
Two helpers from @junejs/server/agent-durable route to those objects:
durableAgentSurface(getNamespace, { agentName, chatPath })is the chat endpoint. The body is{ message, session? }. It pipes the SSE through when the request sendsAccept: text/event-streamand returns{ text }otherwise. A session key that can't go in a header gets a 400.durableChannelSurface(getNamespace, { agentName, channels, env, services?, waitUntil? })mounts the channel webhooks. It resolves(env) => Channelfactories with the worker'senvand gives channels actxwhoserun,runStream,runDetached,runDelivered,resumeStream,resumeDelivered, andresetSessionall call the session's Durable Object. Theservicesbag is memoized per(env, agentName).
In a built June app you don't write either. The generated worker mounts both
when the manifest names an agent, reading env.AGENT from the current
request's env and passing that request's waitUntil. A module-level "current
request" would give a webhook another concurrent request's env and secrets.
Escaping the waitUntil limit#
A webhook ACKs fast and renders the reply in the background. On Workers that
background work lives in ctx.waitUntil, which the runtime cancels shortly after
the response ends. A long multi-round turn would stop mid-reply with no error.
The delivered variants move the rendering into the Durable Object, which stays alive while it has pending work:
ctx.runDelivered(text, opts)sends/turn?deliver=1. The object runs the turn and renders it through the channel's owndeliver().ctx.resumeDelivered(opts)sends/resume?deliver=1and renders the continuation into the Approve / Deny message throughdeliverResume().
Both need the channel wired into the object (DoAgentDef.channels), which the
generated entry does. When the object can't deliver, it refuses with 501
before starting the turn or applying the answer. The worker turns that into
DeliverUnsupportedError, the one error a channel may answer by rendering
itself, because the turn is guaranteed not to be running. slackChannel tries
runDelivered first when stream: true, and always tries resumeDelivered
first for button clicks.
Resources, services, failures#
A Durable Object is a separate isolate, so the worker's request scope doesn't
reach it. DoAgentDef.resources and services are built from the object's own
env and installed around every turn, so a tool reads ambient db and
currentServices() the same way a route loader does. The generated entry passes
the ones june.config.ts declares. The scope uses node:async_hooks, which
needs the nodejs_compat flag. The generated config sets it, and a hand-written one
should too. Without it the object has no request scope, and the two ambient APIs
fail differently: db / kv / blob throw ("used outside a request scope"),
while currentServices() quietly returns undefined — so a missing service shows
up later, as an undefined value in your own code.
Turn failures go to console.error with the step and cause chain.
DoAgentDef.onTurnError replaces that with your own telemetry. If the hook throws,
the default log still runs.
The Anthropic SDK#
@anthropic-ai/sdk is an optional peer of both @junejs/core and
@junejs/server. anthropic() imports it lazily with a specifier bundlers can't
see, which keeps core installable without it. The trade-off is that a bundled app
(a Worker, bun build --compile) can't find the SDK at runtime. Bundled apps
import it themselves and inject the client:
import Anthropic from "@anthropic-ai/sdk";
import { anthropic } from "@junejs/core/agent-models";
const model = anthropic({ model: "claude-opus-4-8", client: new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }) });
june build does this in the generated entry. If the lazy import fails, or the
module has no default export that can be called with new, anthropic() throws
one error naming both fixes (install the SDK, or inject client) and keeps the
original error as cause.
Secrets and env#
ANTHROPIC_API_KEY: fromprocess.envnatively. On Workers the generated Durable Object reads it fromthis.env, so set it as a Worker secret. If it's missing, the SDK's own construction error shows up on the first agent request.- Channel secrets (Slack signing secret, bot token, Crisp keys) exist only in
envon Workers, never at module scope. Write the channel as an(env) => Channelfactory. The worker resolves it for the webhook and the Durable Object resolves it again for the channel's tools:
// app/agent/channels/crisp.ts
import { crispChannel } from "@junejs/core/channels";
export default (env: { CRISP_SIGNATURE_SECRET?: string; CRISP_IDENTIFIER?: string; CRISP_KEY?: string }) =>
crispChannel({
signingSecret: env.CRISP_SIGNATURE_SECRET ?? "",
identifier: env.CRISP_IDENTIFIER ?? "",
key: env.CRISP_KEY ?? "",
});
Deploy walkthrough#
A June app (app/agent/ inside the app):
bun add @anthropic-ai/sdk # the build preflight requires it
june build # dist/worker.js + dist/wrangler.jsonc with the AGENT binding
june deploy # build → wrangler deploy
Then set ANTHROPIC_API_KEY, plus any channel secrets, as secrets on the
deployed worker.
A wrangler-first worker (no June app, as in examples/agent-edge): keep
agent/ next to worker.ts, run june gen, and write the shell june build
would have generated:
// worker.ts
import { DurableObject } from "cloudflare:workers";
import Anthropic from "@anthropic-ai/sdk";
import { AgentDurableObject, durableAgentSurface, durableChannelSurface, type DurableObjectNamespace } from "@junejs/server/agent-durable";
import { anthropic } from "@junejs/core/agent-models";
import { assembleDurable } from "@junejs/core/agent-config";
import agentModule from "./agent/_agent.gen";
type Env = { AGENT: DurableObjectNamespace; ANTHROPIC_API_KEY?: string };
const def = assembleDurable(agentModule);
export class JuneAgentDO extends DurableObject<Env> {
#agent = new AgentDurableObject(this.ctx, {
...def,
model: anthropic({ model: agentModule.config.model, client: new Anthropic({ apiKey: this.env.ANTHROPIC_API_KEY }) }),
env: this.env,
});
fetch(req: Request) { return this.#agent.fetch(req); }
}
export default {
fetch(req: Request, env: Env, ctx: { waitUntil(p: Promise<unknown>): void }) {
const chat = durableAgentSurface(() => env.AGENT, { agentName: def.name, chatPath: "/message" });
const channels = durableChannelSurface(() => env.AGENT, { agentName: def.name, channels: def.channels, env, waitUntil: ctx.waitUntil.bind(ctx) });
return chat(req).then((r) => r ?? channels(req)).then((r) => r ?? new Response("not found", { status: 404 }));
},
};
// wrangler.jsonc
{
"name": "june-agent-edge",
"main": "worker.ts",
"compatibility_date": "2025-04-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": { "bindings": [{ "name": "AGENT", "class_name": "JuneAgentDO" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JuneAgentDO"] }]
}
bunx wrangler dev # local, DO SQLite included
wrangler secret put ANTHROPIC_API_KEY # once
bunx wrangler deploy
examples/agent-edge swaps in a scripted model when no key is set, so
wrangler dev runs the whole durable loop offline. examples/slack-agent wires
slackChannel the same way, with the Events API and Interactivity request URLs
both pointing at /channels/slack.
Why it matters#
You don't port an agent to production. You compile it. The directory you ran in dev becomes a Durable Object class with the same engine, the same checkpoints, and the same channel code, and each conversation gets its own single-threaded object with its own SQLite. The things that differ between dev and Workers (where secrets live, how long a request may run, how the SDK is bundled) are handled by the build or reported by it.