The agent/ directory
The file convention that defines an agent — agent.ts, instructions and per-surface variants, tools, skills, channels, connections — and defineAgent(), its programmatic form.
The convention#
An agent is a directory. Where a file sits decides what it does:
app/agent/
agent.ts → default-exports the config { name, model?, description?, instructions?, surfaces? }
instructions.md → base system prompt
instructions.slack.md → variant for turns arriving via the "slack" source
tools/
create_order.ts → default-exports a defineAction
drive.ts → …or an array of them (e.g. googleDriveTools())
approve_refund.ts → …or a raw Tool (for ctx.requestInput)
skills/
refunds.md → a procedure loaded on demand via read_skill
channels/
slack.ts → default-exports a Channel or an (env) => Channel factory
connections/
crm.ts → default-exports a Connection
In a June app the directory is app/agent/. The name comes from
agent.runtime.dir in june.config.ts and defaults to "agent". A standalone
Worker keeps it at <root>/agent/. Files whose names start with _ are
private and never scanned. That's how the generated _agent.gen.ts stays out
of its own tool list. Code must be .ts and prose must be .md.
Native dev discovers the directory with the filesystem and dynamic imports.
Workers have no filesystem, so june gen compiles the same directory into
_agent.gen.ts, with static imports and the markdown inlined. Both routes
assemble through the same core function, so the result can't differ.
agent.ts#
agent.ts default-exports a plain object:
// app/agent/agent.ts
export default {
name: "ops",
model: "claude-opus-4-8",
description: "An ordering assistant that places orders.",
surfaces: {
slack: { mode: "append", denyTools: ["issue_refund"] },
},
};
| field | type | meaning |
|---|---|---|
name |
string |
The agent's name. If agent.ts is missing, the directory's basename is used. |
model |
string? |
The model id handed to the Anthropic adapter, both in dev and in the generated Worker entry. If omitted, the adapter's own default applies (see Models below). |
description |
string? |
Carried on the assembled definition. |
instructions |
string? |
An inline system prompt. Used only when instructions.md is missing or empty. |
surfaces |
Record<source, { mode?, denyTools? }>? |
Mechanics for each inbound source (see Surfaces below). |
instructions.md and per-surface variants#
instructions.md is the base system prompt. If the agent has skills, a
one-line index of them is appended (see skills/ below).
instructions.<source>.md is a variant that applies only to turns whose
inbound event came through that source. The source is the channel that produced
the event, such as slack or crisp. One agent can then behave differently on
Slack than over HTTP. The switch is keyed on the real inbound source, not on a
marker in the user's text.
How a variant combines with the base is set per source in agent.ts:
mode: "append"is the default. The variant goes after the base instructions.mode: "replace"makes the variant the turn's entire system prompt, and the base is dropped. Use it when a surface really isn't the base agent's behavior.
The rules are strict:
- Setting
modewithout a variant file throws at assembly:surfaces.<source> declares mode "…" but instructions.<source>.md does not exist. A mode with nothing to compose is a wiring error. denyToolswithout a variant file is fine. It's policy with no prose.- A surface key that matches no mounted channel logs a warning (
surface policy "<source>" matches no mounted channel … it will never fire). - Filenames must be a single lowercase segment. Only
instructions.<source>.mdwith<source>matching[a-z0-9_-]+is parsed. Any otherinstructions.*.mdlogsunrecognized instructions variantand is ignored. The file doesn't silently do nothing. - Locale variants are not built yet.
instructions.slack.zh-TW.mdhits the warning above. Locale variants are planned (junebuild/june#149). channels/<source>.mdis deprecated. It's still honored, but it logsdeprecated: channels/<source>.md — move it to instructions.<source>.md. Behavior prose belongs to the agent. Channels are pure transport. If the same source is defined both ways, assembly throws.
tools/#
Each tools/*.ts file default-exports one tool or an array of tools. Arrays are
flattened, so an integration can ship several capabilities from one file.
// app/agent/tools/lookup_order.ts
import { defineAction } from "@junejs/core/agent";
import { db } from "@junejs/db";
export default defineAction({
id: "lookup_order",
description: "Look up one of the caller's orders by id.",
input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
requiresPrincipal: true,
run: async ({ id }, ctx) => {
return db.query("select * from orders where id = ? and user_id = ?", [id, ctx.user!.id]);
},
});
A tool file is a normal defineAction, the same object your UI calls and /mcp
lists. When the agent runs it:
- The action's
idis the tool name, anddescriptionandinputare its spec. Two tools with the same name make assembly throw (duplicate tool name). This includes clashes with channel-provided tools. - The turn's identity becomes
ctx.user. The resolved principal (a verified identity from a channel, never the model's input) is passed asctx.user. The same check you wrote for the UI covers the agent. requiresPrincipal: truehides the tool on anonymous turns. The model doesn't see it in its tool list and can't call it.- Sync or async decides the delivery guarantee.
run is |
the engine treats it as | guarantee |
|---|---|---|
| a plain (sync) function | a local tool | exactly-once for writes through ctx.store.unwrap(): those, the checkpoint, and the transcript append commit in one transaction |
an async function |
a remote tool | at-least-once: it runs, then its result is checkpointed, so a crash in between re-runs it |
The transaction only covers the session store's own handle, which lives on the
raw tool context — a defineAction can't reach it. A sync tool that writes to
another database, or causes any other side effect, gets no rollback: treat that
effect as at-least-once and make it idempotent. See
Durable turns for an exactly-once write.
The engine checks for an async function specifically
(run.constructor.name === "AsyncFunction"). A plain function that returns a
promise is still classified as sync.
Raw tools and ctx.requestInput#
A tool file may also default-export a raw Tool ({ spec, run }). A
defineAction's run only gets { user }. A raw tool's run gets the full
ToolContext, which includes event, principal, initiator, store, and
requestInput. It's the only way to park a turn for human input:
// app/agent/tools/approve_refund.ts
import type { Tool } from "@junejs/core/agent-runtime";
const approveRefund: Tool = {
spec: {
name: "approve_refund",
description: "Ask a human to approve a refund before issuing it.",
input: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] },
},
requiresPrincipal: true,
run: async (input: { orderId: string }, ctx) => {
const answer = await ctx.requestInput({ id: "approve", prompt: `Refund order ${input.orderId}?` });
return { orderId: input.orderId, approved: answer };
},
};
export default approveRefund;
requestInput works only from an async tool. A sync tool commits in one
transaction and can't park, so calling it there throws. A raw tool isn't a
defineAction, so it isn't in the action registry: it doesn't appear on
/mcp or as a server action. Parking and resuming are covered in
Durable turns.
skills/#
Each skills/*.md file is a procedure the model loads only when it needs it.
Frontmatter is optional:
---
name: refunds
description: How to process a refund end to end
when-to-use: the customer asks for their money back
---
1. Look up the order with lookup_order.
2. …
Without frontmatter, the skill's name is the filename and its description is the
first non-empty line, minus a leading # . If any skills exist:
- A
read_skilltool is added automatically. It takes{ name }and returns the skill's body. - The system prompt gets an index section,
## Available skills (call read_skill to load one), with one line per skill:- name: description — when to use: ….
channels/ and connections/#
-
channels/*.tsdefault-exports aChannelor an(env) => Channelfactory. Use the factory on Workers, where secrets only exist inenv. The channel's key is the file's basename. A channel's own tools (for example,slackChannel'sslack_read_thread) are merged into the agent's tools. See Channels.// 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 ?? "", }); -
connections/*.tsdefault-exports aConnection, built withdefineMcpConnection,defineOpenapiConnection, ordefineProviderConnectionfrom@junejs/core/connections. They're wired where the agent runs, and their remote tools join the tool list. See Connections.
Surfaces#
surfaces in agent.ts holds the mechanical policy for each source, next to
the prose in the instructions.<source>.md variants:
surfaces: {
slack: { mode: "replace", denyTools: ["issue_refund", "delete_account"] },
}
denyTools is enforced, not suggested. On a turn from that source, the listed
tools are dropped from the model's tool list and can't be dispatched. A
call to one fails as an unknown tool. No prompt injection can talk the model
into a tool it was never given. The deny list is stored with the turn's
checkpoint, so it survives a crash-replay or a resume.
Models#
The model behind an agent is a plain function over the Model interface from
@junejs/core/agent-runtime. It takes the messages and tool specs and streams
back deltas. June ships one adapter, anthropic() from
@junejs/core/agent-models:
import Anthropic from "@anthropic-ai/sdk";
import { anthropic } from "@junejs/core/agent-models";
// native: the SDK is imported lazily; ANTHROPIC_API_KEY comes from process.env
const model = anthropic({ model: "claude-opus-4-8", maxTokens: 16000 });
// bundled / edge: inject the client so the bundler sees the SDK
const edgeModel = anthropic({ client: new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }) });
| option | default | notes |
|---|---|---|
model |
"claude-opus-4-8" |
The value the adapter uses when agent.ts has no model |
apiKey |
— | Only for the lazy-import path. Leave it out on native, where ANTHROPIC_API_KEY is read. Don't use it on the edge: apiKey alone still takes the lazy import a bundler can't see. Inject client built with the edge secret instead |
system |
— | A construction-time system prompt. The runtime's per-turn system prompt (the agent's instructions) wins |
maxTokens |
16000 |
|
thinking |
false |
true sends adaptive thinking. It's off by default because the transcript doesn't yet persist thinking blocks |
client |
— | An already-built SDK client. It skips the lazy @anthropic-ai/sdk import, and it's required in a bundled app (bun build --compile, a single-file Worker bundle), where the bundler can't see that import |
@anthropic-ai/sdk is an optional peer dependency (>=0.60.0). The dev server
and the generated Workers entry both build anthropic({ model: <agent.ts model> })
for you. To use another provider or a scripted test model, mount the agent
programmatically and pass any function that fits Model. For example,
examples/agent-edge falls back to a deterministic scripted model when no API
key is set.
defineAgent(): the programmatic form#
The directory is sugar over defineAgent() from @junejs/core/agent-config,
which takes the same pieces as values:
import { defineAgent } from "@junejs/core/agent-config";
import { httpChannel } from "@junejs/core/channels";
import { createOrder } from "./actions";
export const agent = defineAgent({
name: "ops",
model: "claude-opus-4-8",
instructions: "You are an ordering assistant.",
tools: [createOrder], // defineActions, raw Tools, or arrays of either
skills: [], // Skill objects ({ name, description, whenToUse?, body })
channels: [httpChannel()],
surfaces: { slack: { denyTools: ["create_order"] } },
surfaceInstructions: {}, // what instructions.<source>.md would supply
});
It does the same work as directory assembly. It adapts actions into tools,
merges channel tools, adds read_skill when skills exist, rejects duplicate
tool names, and derives the per-surface policies. To run the result
in-process, pair it with a model on a runtime from @junejs/server/agent-native:
import { anthropic } from "@junejs/core/agent-models";
import { createNativeRuntime, mountAgent, toAgentDef } from "@junejs/server/agent-native";
const rt = await createNativeRuntime({ [agent.name]: toAgentDef(agent, anthropic({ model: agent.model })) }, "./agent.db");
const { surface } = mountAgent(agent, rt); // POST /message + the agent's channels
Build the runtime entry with toAgentDef(agent, model). Then the engine and the
channels share one definition. If they diverge, mountAgent logs a warning.
Why it matters#
The directory is the manifest: adding a tool means adding a file. Tools are
the actions you already have, so the agent is checked by the same gate as your
UI. Per-surface behavior is split into prose (a markdown variant) and
enforcement (denyTools), so what the model is told and what it's allowed to do
can't drift apart.