Agents
Bind a responsibility's prompt, chain, grants, and capabilities under one id — so a wiring mistake fails the build, not the user.
Here is an incident shape you may recognize. A supervisor delegates research to a sub-agent. The sub-agent spends its entire budget on tool calls, returns an empty string — and the supervisor politely asks the user to review a document that was never written. The label worth keeping: an empty delegation read as done.
The root cause is that the relationship between the two agents lived in prose. A prompt said "delegate research to the researcher"; nothing declared what the researcher could touch, who paid for its steps, or what counted as done. In keel, an agent is that missing declaration:
- one id, all dependencies named — chain, prompt, state grants, tools, and output parts are bound together in one definition,
- defining is inert —
defineAgentnever calls a model; a turn runs the definition for a request, - wiring is checked at boot — an undeclared read or write throws before any traffic arrives,
- delegation is explicit — a specialist becomes a tool whose call opens an inner turn, on a budget the caller grants.
Define the researcher
The reference app's researcher, complete with its imports, lives in
src/agents/researcher/researcher.agent.ts. Shared blocks, tools, and
parts live under src/shared/, reached through the app's @app/* alias
for src/* — no ../../.. chains:
import { defineAgent, type AgentDef } from "@keel-dev/core";
import { notesBlock } from "@app/shared/state/notes.block.ts";
import { sourcesPart } from "@app/shared/wire/sources.part.ts";
import { workspaceSearchTool } from "@app/shared/tools/workspace-search.tool.ts";
import { readTokens } from "@app/budgets.ts";
import type { LiveKeys } from "@app/config.ts";
import { researcherChain } from "./researcher.chain.ts";
import { RESEARCHER_PROMPT } from "./researcher.prompt.ts";
export function researcherAgent(keys: LiveKeys = {}): AgentDef {
return defineAgent({
id: "researcher",
chain: researcherChain(keys),
prompt: RESEARCHER_PROMPT,
reads: [{ block: notesBlock, budgetTokens: readTokens.notesSummary, reader: "researcher" }],
tools: [workspaceSearchTool],
emits: [sourcesPart],
});
}The factory passes credentials to the chain; the declarations describe the
responsibility independently of them. Six fields are required — and an
empty array is an explicit declaration, not a missing one: tools: []
says "this agent calls nothing", it never defaults.
| Field | Required | Researcher's choice | Why |
|---|---|---|---|
id | yes | "researcher" | one name binds the definition, its turns, and its records |
chain | yes | its own tiers | model choice follows the job, not the app |
prompt | yes | research instructions | how to approach the task — guidance, not enforcement |
reads | yes | notes, via the researcher reader | a small view made for it, on a token budget |
tools | yes | workspace search | the one capability it needs |
emits | yes | the sources part | the structured output it may put on the wire |
The optional fields each open a chapter of their own:
| Field | Declare it when | Covered in |
|---|---|---|
writes | a tool feeds or commits results into a block | Updating state |
skills | reusable instruction sections beyond the prompt | Assembling context |
historyWindow | the agent should see recent stored messages | History and transcripts |
continuation | a length-cut answer may earn bounded extra rounds | Outcomes and completion |
context | this agent's token allocation overrides the chain's | Limits and usage |
There is no writes on the researcher because no tool feeds a block. An
agent carrying a tool that does must declare that block in writes. If
reader is omitted on a read grant, keel uses the agent's id.
Boot checks: wiring fails loudly
defineAgent refuses a definition whose declarations disagree — at boot,
before any request. Five checks:
| Mistake | What happens |
|---|---|
reads names a reader the block doesn't declare | throws |
tools declared, but any chain tier lacks supportsTools: true | throws, naming the incompatible tiers |
a delegate tool forwards a part missing from the caller's emits (matched by name) | throws |
a tool feeds a block the agent doesn't declare in writes | throws |
a tool feeds via a reducer the block doesn't declare | throws |
The third check is the delegation half of the wire contract: forwarding makes a child's part appear on the caller's wire, so the caller must have declared it as something it may say. Known tool-capability mismatches also fail TypeScript checking; dynamic configurations are validated at startup. See Tool capabilities.
Delegation: a tool call that opens a turn
The assistant connects the researcher in its own tools array — the grant
lives with the caller, because the budget belongs to the caller:
// in the assistant's definition
asTool(researcherAgent(keys), {
budget: grants.researcher, // { steps: 6 } from budgets.ts
deliverable: sourcesPart, // the child must submit this part
})By default this creates a run-researcher tool accepting a string brief.
Calling it opens a child turn — the same gates, its own explicit budget,
ambient context inherited but never widened. The input is validated first: a
delegation call whose arguments don't parse never opens a child turn.
The budget is required. asTool without one throws — no budget, no
connection — because a silent default step limit is exactly the kind of
number that ends up discovered during an outage.
One default runs the other way: asTool defaults access to "public",
where defineTool throws without an explicit rule. The asymmetry is
deliberate — a delegate tool's border is already declared piece by piece
(the child's own access rules, the caller's budget, the deliverable
contract), so the connection itself defaults open; a raw capability has no
such layers, so it must name its rule. Pass access on the connection to
gate the delegation itself.
Return to the caller
Delegation borrows execution, not ownership. The parent awaits the child, receives a structured result or failure, and resumes its own model loop. A nested child returns to its immediate caller; only the root settles the user's turn.
Child text and parts stay private by default. Studio can inspect each child
through its own events and turn record. The caller decides which results to
present. Explicit forward options can expose progress and selected parts,
but forwarded output cannot satisfy the caller's completion check.
See Delegation for result shapes, forwarding, and cancellation.
The deliverable contract
deliverable names a part the child declares in emits. With it, the empty
delegation from the opening story becomes unrepresentable: the child turn
cannot settle completed without a schema-valid sources part on its wire,
and the assistant's model receives that structure — not prose — as the
call's result. asTool build-checks the contract itself:
- the part must appear in the child's
emits— a promise the child never made fails the build, - the part must be
persist: "content"— a deliverable is substance, never ephemeral UI, - the runtime derives a
submit_sourcestool for the child; carrying your own tool by that name is refused, - the plain form grants 1 nudge round by default; pass
{ part, nudges }to set your own.
At runtime, the contract is "the validated part exists on this turn's
wire" — the derived submit tool is the forceable door, not the only one. A
marker of the contract's part name that promotes cleanly fulfills the
contract too. And because a deliverable is substance, a child that submits
it and says nothing else still completes: the deliverable alone passes the
close gate, with zero prose. Submitting twice replaces the earlier
submission and raises a warn on the bus — the last valid one is what the
caller receives.
If the child stops without submitting, the close gate grants the contract's
nudge rounds: extra model calls beyond budget.steps in which only the
submit tool is accepted — any other call is refused with "submit the
deliverable". A child that still hasn't submitted after its nudges fails
with deliverable-missing, never a quiet empty success.
The turn covers the close gate in full.
Instructions are not permissions
The single most useful habit: notice which half of the system each requirement belongs to.
| Requirement | Put it in |
|---|---|
| "search before answering" | the prompt |
| "search requires a workspace" | the tool's access rule |
| "the researcher sees only a notes summary" | the block reader and read grant |
| "the researcher gets six steps" | the caller's delegation budget |
| "the researcher must return sources" | the deliverable contract |
Prompts steer; declarations enforce
A prompt can be ignored on any given sample. Everything in the right-hand
column above is enforced by the framework whether or not the model
cooperates. When you change a prompt's expected flow, update the adjacent
.mock.ts — mocks don't read prompts.
Next: Tools — typed capabilities, and the border every call crosses.
