Build your own Claude Code
Subagents·20 min

Agent definitions

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage agent-definitions --watch

Before you can run a subagent you need to know what it is. In Claude Code an agent is a file: .claude/agents/<name>.md — YAML frontmatter describing the agent, then a Markdown body that becomes its system prompt. Your job this step is to read one of those files into a struct and answer the one question that makes a subagent safe: which tools is it actually allowed to call?

That question has a subtle answer. There's an allowlist (tools) and a denylist (disallowedTools), and when they conflict, deny wins. Get that precedence wrong and a Bash-banned research agent quietly runs Bash.

.claude/agents/code-reviewer.md
---
name: CodeReviewer
description: Reviews code
tools:
  - Read
  - Grep
disallowedTools:
  - Bash
maxTurns: 5
---
You review code.

Six fields matter:

  • name — how the parent refers to this agent (Task(CodeReviewer, …)).
  • description — when the agent should be picked.
  • tools — the allowlist. If present, the agent may call only these. If absent, it inherits the full set of available tools.
  • disallowedTools — the denylist. Removed from whatever the allowlist would otherwise grant.
  • maxTurns — a hard cap on loop iterations (you'll use it next step).
  • model — an optional model override; null when unset.

The Markdown after the closing --- is the agent's system prompt.

What to build

Your task

Implement parseAgentDef + effectiveTools

In src/subagents.ts, implement two functions.

parseAgentDef(md) splits the frontmatter from the body (reuse your parseFrontmatter helper), and returns an AgentDef with name, description, systemPrompt (the trimmed body), and the optional tools, disallowedTools, maxTurns, model. Leave a field undefined when it isn't in the frontmatter.

effectiveTools(def, allTools) resolves the final allowlist: start from def.tools if it's non-empty, otherwise fall back to allTools; then subtract every tool in def.disallowedTools. Deny wins — a tool in both lists is excluded.

src/subagents.ts
export interface AgentDef {
  name: string;
  description: string;
  systemPrompt: string;
  tools?: string[];
  disallowedTools?: string[];
  maxTurns?: number;
  model?: string;
}

/** Parse a `.claude/agents/<name>.md` definition. */
export function parseAgentDef(md: string): AgentDef {
  // 1. const { data, body } = parseFrontmatter(md)
  // 2. map data.name / description / tools / disallowedTools / maxTurns / model
  //    systemPrompt = body.trim()
}

/** Allowlist minus denylist; deny wins. */
export function effectiveTools(def: AgentDef, allTools: string[]): string[] {
  // allowed = (def.tools?.length ? def.tools : allTools)
  // return allowed without any tool in def.disallowedTools
}
Deny wins, and absent ≠ empty

Two traps live here. (1) When tools is absent the agent inherits all tools — don't return an empty list. (2) When a tool appears in both tools and disallowedTools, it must be excluded: the denylist filters the allowlist, not the other way around. The grader's fixture puts Bash on the denylist while tools is [Read, Grep], and checks that effectiveTools returns exactly [Read, Grep].

How it's graded

process mode, JSON match. The grader runs program.ts agent-def, which calls parseAgentDef(md) and effectiveTools(def, allTools) and prints { parsed, effectiveTools }. parsed normalizes every optional field to null when unset, so your undefineds must round-trip cleanly. Both objects are compared to the reference exactly — including the resolved allowlist after the denylist is applied.

How the grader runs it
./your_program.sh agent-def
# md: CodeReviewer, tools:[Read,Grep], disallowedTools:[Bash], maxTurns:5
# allTools: [Read, Grep, Bash, Edit]
# -> { "parsed": { "name":"CodeReviewer", …, "model":null },
#      "effectiveTools": ["Read","Grep"] }