keel

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 — defineAgent never 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.

FieldRequiredResearcher's choiceWhy
idyes"researcher"one name binds the definition, its turns, and its records
chainyesits own tiersmodel choice follows the job, not the app
promptyesresearch instructionshow to approach the task — guidance, not enforcement
readsyesnotes, via the researcher readera small view made for it, on a token budget
toolsyesworkspace searchthe one capability it needs
emitsyesthe sources partthe structured output it may put on the wire

The optional fields each open a chapter of their own:

FieldDeclare it whenCovered in
writesa tool feeds or commits results into a blockUpdating state
skillsreusable instruction sections beyond the promptAssembling context
historyWindowthe agent should see recent stored messagesHistory and transcripts
continuationa length-cut answer may earn bounded extra roundsOutcomes and completion
contextthis agent's token allocation overrides the chain'sLimits 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:

MistakeWhat happens
reads names a reader the block doesn't declarethrows
tools declared, but any chain tier lacks supportsTools: truethrows, 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 writesthrows
a tool feeds via a reducer the block doesn't declarethrows

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
})
TURN · user → supervisoradmitfitstepscloserun-researchera tool call, budget: {steps: 6}briefTURN · supervisor → researcheradmitfitstepscloseits own model chain, tools, and window — ambient inherited, never wideneddeliverable | defect
Every child returns to its caller. Results are private by default; the root agent owns the final response.

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_sources tool 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.

RequirementPut 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.