SLOPSHOPPER

brief-template

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

guardagents
★ 1v0.3.0MITupdated 2026-10-18davethedev3/brief-template
A shopper browsing a rack in a slop shop
README

brief-template

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.

  • Tag names use letters, digits, _, . and -. \{{ writes a literal {{.
  • A relative $brief path resolves against the session's working directory, not the agent's.
  • The call is denied, with the reason, when the file can't be read, a tag has no value (the deny names each tag and its line), a var isn't a string, a 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.
  • A JSON prompt with no $brief key passes through untouched.
  • The mod appends this usage to the Agent tool's description, so the model knows the request form.

Install

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.

Source 1 files
hooks/register.ts 190 lines
1import 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