Agents: a feature of your app, not a separate runtime
Drop an agent/ directory into your June app and it becomes a running, durable agent whose tools are the same defineActions your UI and /mcp already use.
The feature#
June's agent layer turns a directory into a running conversational agent,
inside the app you already have. You don't need a separate agent server, an SDK wrapper, or a
second tool registry. Put an agent/ directory in app/ and the dev server
discovers it, mounts a chat endpoint, and starts serving turns.
app/
page.tsx
agent/
agent.ts → { name, model?, description?, surfaces? }
instructions.md → the system prompt
tools/*.ts → each default-exports a defineAction (or an array of them)
skills/*.md → procedures the model loads on demand
channels/*.ts → inbound edges (HTTP, Slack, Crisp)
connections/*.ts → outbound tool sources (MCP, OpenAPI, providers)
The directory is the manifest. No central registry has to stay in sync with it.
The mental model#
- The
agent/directory becomes an assembled agent. Native discovery (discoverAgent) and the edge compiler (june gen→_agent.gen.ts) both produce the same raw module and go through one assembly function, so dev and production can't disagree about what the directory means. - Tools are
defineActions. The same object is a UI server action, an/mcptool, a plain-HTTPPOST /api/<id>endpoint, and an agent tool. The turn's verified identity arrives asctx.user, so the authorization you wrote for the UI also covers the agent. ArequiresPrincipal: trueaction is hidden from anonymous turns. - Channels bring messages in. A channel is pure transport: an HTTP endpoint,
a signed Slack or Crisp webhook. It maps an inbound message to a session and
runs a turn. Some channels also give the agent tools, such as
slackChannel'sslack_read_thread. - Connections reach out. A connection points at an external MCP server, an OpenAPI document, or a provider (Google Drive). Its remote tools join the agent's tool list.
- Turns are durable. Every model step and tool call is checkpointed to the
session's store. A sync tool's step commits in one transaction with its
checkpoint and transcript append — and so do writes it makes through the
store's own handle, which makes those exactly-once. Any other effect (another
database, an API call) and every async tool are at-least-once. A tool can park a turn to wait for a human
(
ctx.requestInput) and resume it later. - Where it runs. In dev, turns run on the in-process
NativeRuntime(SQLite). On Cloudflare Workers, each session is its own Durable Object, and the loop commits to that object'sctx.storage.sql.
Quick start#
The smallest working agent is one file. With no agent.ts, the agent is named
after its directory:
app/
agent/
instructions.md
You are a concise assistant for this site. Answer in one or two sentences.
Add a tool by dropping a defineAction into tools/:
// app/agent/tools/create_order.ts
import { defineAction } from "@junejs/core/agent";
export default defineAction({
id: "create_order",
description: "Place an order for an item.",
input: { type: "object", properties: { item: { type: "string" }, qty: { type: "number" } }, required: ["item"] },
run: (input) => ({ orderId: 1, item: input.item, qty: input.qty ?? 1 }),
});
The dev server's model is the built-in Anthropic adapter. It needs the optional
peer @anthropic-ai/sdk installed and reads ANTHROPIC_API_KEY from the
environment:
bun add @anthropic-ai/sdk
ANTHROPIC_API_KEY=sk-ant-... june dev
Talk to the agent by POSTing to the chat endpoint, which is /message by default:
curl -sX POST localhost:3000/message \
-H 'content-type: application/json' \
-d '{"message":"order 3 widgets","session":"s1"}'
# → {"text":"..."}
The body is { message, session? } and the response is { text }, the turn's
final reply. session names the conversation. Send the same value again to
continue it. If you omit it, the turn uses the session "default".
The runtime is configured in june.config.ts under agent.runtime. These are
the defaults:
// june.config.ts
import { defineJune } from "@junejs/core/config";
export default defineJune({
agent: {
runtime: {
enabled: true, // mounted only when the agent/ directory exists
dir: "agent", // relative to app/
backend: "native", // "native" (SQLite) | "memory" (ephemeral) | "durable" (Durable Object)
chat: { path: "/message" },
channels: true, // also mount the directory's channels/
},
},
});
In dev, backend: "durable" falls back to native because dev has no Durable
Object. The dev server opens the native runtime without a file path, so its
SQLite is in-memory: turns are durable while the process runs, but the
transcript does not survive a restart.
Status#
The agent layer is part of June's 0.0.x preview. The agent/ directory,
channels, connections, and durable turns run in dev and on Workers Durable
Objects, and they're dogfooded. The config and channel APIs are still changing.
See Stability for where each piece stands.
Where to next#
- The agent/ directory: every file the convention reads,
agent.tsfields, per-surface instructions, tools, skills, models. - Channels: HTTP, Slack, and Crisp inbound, plus channel tools.
- Connections: external MCP, OpenAPI, and provider tool sources.
- Durable turns: checkpoints, exactly-once and at-least-once tools, and human-in-the-loop.
- Deploy: one Durable Object per session on Cloudflare Workers.
Why it matters#
An agent built this way can't do more than your app. Its tools are the actions
you already wrote, and they're checked by the same run(input, ctx) gate. Its
state follows the same durability rules in dev and in production. Adding an
agent means adding a directory, not running a second system alongside your app.