Expands an Agent prompt that names a markdown brief template and {{variable}} values into the finished brief before the agent spawns.

A Claude Code mod that lets an Agent call name a markdown brief instead of spelling it out. When several subagents share most of a brief, write it once with {{name}} tags and pass this JSON as the whole prompt:
{"$brief": "review-brief.md", "vars": {"crate": "parser", "focus": "error recovery"}}
The file is read, each {{name}} (or {{ name }}) is replaced by vars.name, and the result becomes the agent's prompt. A workflow script's agent() prompt takes the same request.
_, . and -. \{{ writes a literal {{.$brief path resolves against the session's working directory, not the agent's.vars key isn't a tag name, or the object has a key other than $brief and vars. Vars that match no tag are ignored.$brief key passes through untouched.Agent tool's description, so the model knows the request form.From the davesmods marketplace:
claude plugin marketplace add davethedev3/davesmods
claude plugin install brief-template@davesmods
Function-hooks mods are an early-access Claude Code feature (written against 2.1.302). To run from a checkout instead, load the folder with claude --plugin-dir /path/to/brief-template, or list it in CLAUDE_CODE_PLUGIN_DIRS (colon-separated) to load it in every session. The engine writes the plugin API's type declarations to .claude-plugin/types/ when it loads the mod; they are not part of this repo.
Checks: claude plugin validate . and claude plugin test . from this folder.
hooks/register.ts 190 lines1import type { EngineInterface, HookFailure, Register } from 'claude-code'
2
3// An Agent call whose `prompt` is a JSON object of this shape is expanded
4// before the agent spawns: the file at `$brief` is read, every `{{name}}`
5// in it is replaced by `vars[name]`, and the finished text becomes the prompt.
6type BriefRequest = { brief: string; vars: Record<string, string> }
7
8type ParsedRequest =
9 | { kind: 'none' }
10 | { kind: 'invalid'; reason: string }
11 | { kind: 'ok'; request: BriefRequest }
12
13type MissingVar = { name: string; line: number }
14
15// What the hooks share: parsing, the read, the expansion and the deny text.
16type BriefEnv = Pick<EngineInterface, 'fs' | 'session' | 'plugin'>
17
18type Resolution = { kind: 'none' } | { kind: 'deny'; deny: string } | { kind: 'text'; text: string }
19
20const TAG = /(\\?)\{\{\s*([A-Za-z_][\w.-]*)\s*\}\}/g
21const TAG_NAME = /^[A-Za-z_][\w.-]*$/
22const KEYS = new Set(['$brief', 'vars'])
23
24const USAGE = `
25
26Brief templates (brief-template mod). When several agents share most of a
27brief, write it once as a markdown file with {{name}} tags, then pass this JSON
28as the whole prompt:
29 {"$brief": "<path>", "vars": {"name": "value", ...}}
30The file is read, each {{name}} is replaced by vars.name, and the result is the
31agent's prompt. Tags may be written {{ name }}; names use letters, digits, _,
32. and -. A relative path resolves against the session's working directory, not
33the agent's cwd. A missing file, a missing var, a non-string var, or a key
34other than $brief and vars denies the call with the reason; vars that match no
35tag are ignored. Write \\{{ for a literal {{. A workflow script's agent() prompt
36takes the same request.
37
38Example. The file review-brief.md:
39 Review the {{crate}} crate in worktree {{worktree}}.
40 Focus: {{ focus }}. Report findings as text, not a file.
41The call for one reviewer:
42 {"$brief": "/path/scratchpad/review-brief.md",
43 "vars": {"crate": "parser", "worktree": "../parser-wt/lexer", "focus": "error recovery"}}`
44
45function describeType(value: unknown): string {
46 if (value === null) return 'null'
47 if (Array.isArray(value)) return 'array'
48 return typeof value
49}
50
51// A prompt with no `$brief` key is somebody else's JSON and passes through.
52// A prompt that names `$brief` but carries a malformed request is refused:
53// passing it on would hand the agent the JSON instead of the brief.
54function parseRequest(prompt: unknown): ParsedRequest {
55 if (typeof prompt !== 'string') return { kind: 'none' }
56 const text = prompt.trim()
57 if (!text.startsWith('{')) return { kind: 'none' }
58
59 let value: unknown
60 try {
61 value = JSON.parse(text)
62 } catch {
63 return { kind: 'none' }
64 }
65 if (typeof value !== 'object' || value === null || Array.isArray(value)) return { kind: 'none' }
66
67 const record = value as Record<string, unknown>
68 if (!Object.hasOwn(record, '$brief')) return { kind: 'none' }
69
70 const unknown = Object.keys(record).filter(key => !KEYS.has(key))
71 if (unknown.length > 0) {
72 const names = unknown.map(key => JSON.stringify(key)).join(', ')
73 return { kind: 'invalid', reason: `unknown key ${names}; a brief request takes $brief and vars` }
74 }
75
76 const brief = record.$brief
77 if (typeof brief !== 'string' || brief.trim() === '') {
78 return { kind: 'invalid', reason: '$brief must be a non-empty string' }
79 }
80
81 const rawVars = Object.hasOwn(record, 'vars') ? record.vars : undefined
82 if (rawVars === undefined) {
83 return { kind: 'ok', request: { brief: brief.trim(), vars: Object.create(null) } }
84 }
85 if (typeof rawVars !== 'object' || rawVars === null || Array.isArray(rawVars)) {
86 return { kind: 'invalid', reason: 'vars must be an object of strings' }
87 }
88
89 const vars: Record<string, string> = Object.create(null)
90 for (const [name, entry] of Object.entries(rawVars)) {
91 if (!TAG_NAME.test(name)) {
92 return { kind: 'invalid', reason: `vars key ${JSON.stringify(name)} is not a tag name` }
93 }
94 if (typeof entry !== 'string') {
95 return { kind: 'invalid', reason: `vars.${name} is ${describeType(entry)}, expected a string` }
96 }
97 vars[name] = entry
98 }
99 return { kind: 'ok', request: { brief: brief.trim(), vars } }
100}
101
102function countNewlines(text: string, from: number, to: number): number {
103 let count = 0
104 for (let index = from; index < to; index++) {
105 if (text.charCodeAt(index) === 10) count++
106 }
107 return count
108}
109
110function expand(template: string, vars: Record<string, string>): { text: string; missing: MissingVar[] } {
111 const missing = new Map<string, number>()
112 let line = 1
113 let seen = 0
114 const text = template.replace(TAG, (match: string, escaped: string, name: string, offset: number) => {
115 // `seen` advances with each match, so the slices partition the template
116 // and the whole scan stays linear rather than rescanning per tag.
117 line += countNewlines(template, seen, offset)
118 seen = offset
119 if (escaped !== '') return match.slice(1)
120 const value = Object.hasOwn(vars, name) ? vars[name] : undefined
121 if (value === undefined) {
122 if (!missing.has(name)) missing.set(name, line)
123 return match
124 }
125 return value
126 })
127 return { text, missing: [...missing].map(([name, line]) => ({ name, line })) }
128}
129
130async function resolveBrief($: BriefEnv, prompt: unknown): Promise<Resolution> {
131 const parsed = parseRequest(prompt)
132 if (parsed.kind === 'none') return { kind: 'none' }
133 if (parsed.kind === 'invalid') return { kind: 'deny', deny: `${$.plugin.name}: ${parsed.reason}` }
134
135 const { brief, vars } = parsed.request
136 let template: string
137 try {
138 template = await $.fs.read(brief)
139 } catch (error) {
140 const reason = error instanceof Error ? error.message : String(error)
141 let cwd: string | undefined
142 try {
143 cwd = await $.session.cwd()
144 } catch {
145 cwd = undefined
146 }
147 const where = cwd === undefined ? '' : ` from ${cwd}`
148 return { kind: 'deny', deny: `${$.plugin.name}: cannot read brief ${brief}${where}: ${reason}` }
149 }
150
151 const { text, missing } = expand(template, vars)
152 if (missing.length > 0) {
153 const tags = missing.map(({ name, line }) => `${name} (line ${line})`).join(', ')
154 return { kind: 'deny', deny: `${$.plugin.name}: ${brief} has tags with no value in vars: ${tags}` }
155 }
156 return { kind: 'text', text }
157}
158
159// A failed hook is skipped by default, which would hand the agent the JSON.
160// The cause rides along so the deny is actionable.
161function expansionFailed($: BriefEnv, failure: HookFailure): string {
162 const cause = failure.message === undefined ? failure.kind : `${failure.kind}: ${failure.message}`
163 return `${$.plugin.name}: brief expansion failed (${cause})`
164}
165
166export const register: Register = on => {
167 on('tool.describe', { tool: 'Agent' }, async ($, e, next) => {
168 const described = await next(e)
169 return { ...described, description: described.description + USAGE }
170 })
171
172 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
173 const resolved = await resolveBrief($, e.prompt)
174 if (resolved.kind === 'none') return next(e)
175 if (resolved.kind === 'deny') return { deny: resolved.deny }
176 return next({ ...e, prompt: resolved.text })
177 }).catch(($, e, next) =>
178 next.called ? next(e) : { deny: expansionFailed($, next.error) })
179
180 // The Agent tool's call already ran the hook above and carries the expanded
181 // prompt here; a workflow script's agent() call raises only this one.
182 on('agent.spawn', async ($, e, next) => {
183 const resolved = await resolveBrief($, e.prompt)
184 if (resolved.kind === 'none') return next(e)
185 if (resolved.kind === 'deny') return { deny: resolved.deny }
186 return next({ ...e, prompt: resolved.text })
187 }).catch(($, e, next) =>
188 next.called ? next(e) : { deny: expansionFailed($, next.error) })
189}
190