Version: 0.2
Date: 2026-02-16
Platform: Claude Cowork
Extends: coquill_mvp_spec.md (v0.1)
v2 adds four capabilities to CoQuill:
- Conditional logic — templates can use
{% if %}/{% else %}/{% endif %}to include or exclude sections based on variable values. The interview skips questions that aren't relevant. - Loops — templates can use
{% for item in items %}/{% endfor %}for repeating sections. The interview collects lists with an "add another?" flow. - Skill separation — the monolithic SKILL.md is split into three focused skills: Orchestrator, Analyzer, and Renderer.
- Developer configuration — an optional
config.yamlfile per template provides question overrides, grouping hints, validation rules, defaults, and choice types.
These limits keep v2 tractable. They can be relaxed in v3.
- Single-level nesting only — no
{% for %}inside{% if %}, or{% if %}inside{% for %}. Each block is top-level relative to the template body. - Two condition forms only:
- Truthiness:
{% if variable_name %}— variable is truthy (boolean true, non-empty) - Equality:
{% if variable_name == 'value' %}— variable equals a specific string
- Truthiness:
{% else %}supported,{% elif %}deferred —{% else %}covers the common case. Multi-way branching via{% elif %}is deferred to v3.- No computed fields or expressions — variables are always collected from the user, never calculated.
v2 splits the single SKILL.md into three skills:
| Skill | Location | Purpose | Triggered by |
|---|---|---|---|
| Orchestrator | .claude/skills/coquill/SKILL.md |
Entry point. Discovery, interview, confirmation, post-render. | User ("prepare an NDA") |
| Analyzer | .claude/skills/coquill-analyzer/SKILL.md |
Template parsing, variable extraction, manifest generation. | Orchestrator |
| Renderer | .claude/skills/coquill-renderer/SKILL.md |
Docx/HTML rendering, output validation. | Orchestrator |
The Orchestrator is the only user-facing skill. It invokes the Analyzer and Renderer via prompt-level instructions (e.g., "Run the coquill-analyzer skill on this template directory").
Analyzer
- Input: Template directory path (e.g.,
templates/meeting_notes/) - Output:
manifest.yamlwritten to the template directory - Behaviour: Checks cache validity first. If
config.yamlis present, merges overrides into the manifest.
Renderer
- Input: Template path, format (
docxorhtml), variable dictionary, output directory path - Output: Rendered document(s) in a job folder under
output/ - Behaviour: Renders, validates output for unfilled placeholders, reports results.
Orchestrator
- Input: User's natural language request
- Output: Completed document(s)
- Behaviour: Discovery → Analyze → Interview Plan → Interview Loop → Confirmation → Render → Post-Render
The manifest is the single source of truth the Orchestrator reads. The Analyzer produces it. The Orchestrator never reads config.yaml directly — config overrides are merged into the manifest by the Analyzer.
# ── Metadata ──────────────────────────────────────────────
schema_version: 2
template: "meeting_notes.md"
template_path: "templates/meeting_notes/meeting_notes.md"
format: md # "docx", "html", or "md"
analyzed_at: "2026-02-16T10:30:00Z"
variable_count: 14 # total unique variables (all scopes)
# ── From config.yaml (optional, passthrough) ──────────────
meta: # present only if config.yaml provides it
display_name: "Meeting Notes"
description: "Structured meeting notes with action items, optional sections, and meeting-type-specific content"
# ── Unconditional variables ───────────────────────────────
# Always collected during interview.
variables:
- name: meeting_title
label: "Meeting Title"
type: text
# Optional fields (from config.yaml merge):
# question: "What is the title of this meeting?"
# required: true
- name: meeting_type
label: "Meeting Type"
type: choice # new type in v2
choices: # required when type is "choice"
- standup
- workshop
- review
# question: "What type of meeting was this?"
# default: "standup"
- name: meeting_date
label: "Meeting Date"
type: date
# ── Conditional blocks ────────────────────────────────────
# Variables gated by {% if %} / {% else %} blocks.
# Asked only if the condition evaluates to true/false.
conditionals:
- condition: "meeting_type == 'workshop'" # equality test
gate_type: equality
gate_variable: meeting_type # which variable to check
gate_value: "workshop" # what value triggers the if-branch
if_variables:
- name: workshop_materials
label: "Workshop Materials"
type: text
else_variables: []
- condition: "include_next_meeting" # truthiness test
gate_type: boolean # "boolean" or "equality"
if_variables:
- name: next_meeting_date
label: "Next Meeting Date"
type: date
- name: next_meeting_topic
label: "Next Meeting Topic"
type: text
else_variables: [] # populated if {% else %} block has variables
# ── Loop blocks ───────────────────────────────────────────
# Variables inside {% for %} blocks. Collected as lists.
loops:
- loop_var: "item" # iteration variable name
collection: "action_items" # collection variable name
label: "Action Item" # human-readable label for the loop
min_items: 1 # minimum items required (default: 1)
variables: # sub-variables per item
- name: description
label: "Description"
type: text
- name: assignee
label: "Assignee"
type: text
- name: due_date
label: "Due Date"
type: date
# ── Dependencies (pre-computed) ───────────────────────────
# Gate variable → list of dependent variable names.
# Used by the Orchestrator to quickly determine which
# questions to skip without re-parsing conditions.
dependencies:
meeting_type:
- workshop_materials
- items_reviewed
include_next_meeting:
- next_meeting_date
- next_meeting_topic
# ── Interview groups (optional) ───────────────────────────
# If present (from config.yaml), the Orchestrator uses these
# groups instead of auto-grouping. If absent, the Orchestrator
# groups variables by logical affinity as in v1.
groups:
- name: "Meeting Details"
variables: [meeting_title, meeting_date, meeting_type, facilitator_name, meeting_location]
- name: "Attendees & Agenda"
variables: [attendees, agenda]
- name: "Workshop Materials"
condition: "meeting_type == 'workshop'" # conditional group
variables: [workshop_materials]
- name: "Action Items"
loop: action_items # loop group
variables: [description, assignee, due_date]
- name: "Next Meeting"
condition: include_next_meeting # conditional group
variables: [next_meeting_date, next_meeting_topic]
# ── Validation rules (optional, from config.yaml) ────────
validation:
- rule: "next_meeting_date > meeting_date"
message: "The next meeting date must be after this meeting's date"schema_version — Always 2 for v2 manifests. The Analyzer regenerates the manifest if it encounters a v1 manifest (which has no schema_version field).
Backward compatibility — Templates with no conditionals or loops produce a v2 manifest with empty conditionals: [] and loops: []. The Orchestrator treats this identically to v1 behaviour.
Variable fields reference:
| Field | Source | Required | Description |
|---|---|---|---|
name |
Analyzer | Yes | Variable name as it appears in the template |
label |
Analyzer (auto) or config | Yes | Human-readable label |
type |
Analyzer (inferred) or config | Yes | text, date, number, email, phone, boolean, choice |
question |
config only | No | Custom interview question |
description |
config only | No | Context for Claude when asking the question |
default |
config only | No | Default value (special: "today" for dates) |
required |
config only | No | Whether the variable must have a value (default: true) |
format_hint |
config only | No | Format guidance shown to user (e.g., "DD/MM/YYYY") |
choices |
config only | When type=choice | List of valid values |
validation |
config only | No | Validation rules (min, max for numbers) |
New types in v2:
| Type | Description | Interview behaviour |
|---|---|---|
boolean |
True/false value | Yes/no question |
choice |
One of a fixed set of values | Present options, validate against choices list |
An optional YAML file placed alongside the template by the template developer. It overrides the Analyzer's auto-inferred values and controls the interview flow. If absent, the Analyzer and Orchestrator use their default behaviours.
# ── Template metadata ─────────────────────────────────────
meta:
display_name: "Meeting Notes" # shown to user during discovery
description: "Structured meeting notes with action items, optional sections, and meeting-type-specific content"
category: "productivity" # reserved for v3 categorisation
# ── Variable overrides ────────────────────────────────────
# Keys are variable names from the template.
# Any field here overrides the Analyzer's auto-inferred values.
variables:
meeting_title:
label: "Meeting Title"
question: "What is the title of this meeting?"
required: true
meeting_date:
question: "When was the meeting held?"
default: "today" # resolves to current date at interview time
format_hint: "DD/MM/YYYY"
meeting_type:
type: choice
choices:
- standup
- workshop
- review
question: "What type of meeting was this?"
default: "standup"
decisions_made:
type: boolean
question: "Were any decisions made during this meeting?"
default: false
include_next_meeting:
type: boolean
question: "Is a follow-up meeting scheduled?"
default: false
# ── Interview groups ──────────────────────────────────────
# Overrides the Orchestrator's auto-grouping.
# Each group defines which variables are asked together.
groups:
- name: "Meeting Details"
variables: [meeting_title, meeting_date, meeting_type, facilitator_name, meeting_location]
- name: "Attendees & Agenda"
variables: [attendees, agenda]
- name: "Workshop Materials"
condition: "meeting_type == 'workshop'" # only asked if meeting type is workshop
variables: [workshop_materials]
- name: "Action Items"
loop: action_items # collected as a list
variables: [description, assignee, due_date]
- name: "Next Meeting"
condition: include_next_meeting # only asked if gate is true
variables: [next_meeting_date, next_meeting_topic]
# ── Cross-field validation ────────────────────────────────
# Evaluated during the Confirmation phase after all values are collected.
# Rules are simple comparisons the Orchestrator can evaluate.
validation:
- rule: "next_meeting_date > meeting_date"
message: "The next meeting date must be after this meeting's date"When config.yaml is present, the Analyzer merges it into the manifest as follows:
meta— copied to manifest as-isvariables— for each variable name in config, override matching fields in the manifest. Config values take precedence. Fields not in config retain their auto-inferred values.groups— copied to manifest as thegroupssection. If absent, the manifest has nogroupssection and the Orchestrator auto-groups.validation— copied to manifest as-is
The Analyzer does not create variables that appear in config but not in the template. Config can only override variables the template actually uses.
The v1 Analyzer only extracts {{ variable_name }} patterns. The v2 Analyzer additionally detects:
| Pattern | Regex | Purpose |
|---|---|---|
| If open | \{%[-\s]*if\s+(.+?)\s*[-]?%\} |
Start of conditional block |
| Else | \{%[-\s]*else\s*[-]?%\} |
Else branch |
| Endif | \{%[-\s]*endif\s*[-]?%\} |
End of conditional block |
| For open | \{%[-\s]*for\s+(\w+)\s+in\s+(\w+)\s*[-]?%\} |
Start of loop block |
| Endfor | \{%[-\s]*endfor\s*[-]?%\} |
End of loop block |
| Variable | \{\{\s*([\w.]+)\s*\}\} |
Variable reference (now supports dotted names for loop sub-vars) |
Pass 1 — Block structure:
Scan the full text content and identify all block boundaries. Build a list of scopes:
TEMPLATE_TEXT
├── [top-level] ... {{ meeting_title }} ... {{ meeting_date }} ...
├── [if meeting_type == 'workshop']
│ └── [if-branch] ... {{ workshop_materials }} ...
├── [if meeting_type == 'review']
│ └── [if-branch] ... {{ items_reviewed }} ...
├── [for item in action_items]
│ └── [loop-body] ... {{ item.description }} ... {{ item.assignee }} ... {{ item.due_date }} ...
└── [if include_next_meeting]
├── [if-branch] ... {{ next_meeting_date }} ... {{ next_meeting_topic }} ...
└── [else-branch] ... (static text, no variables) ...
Pass 2 — Variable classification:
For each {{ variable }} found, classify it based on its scope:
| Scope | Classification | Manifest location |
|---|---|---|
| Top-level | Unconditional | variables |
Inside {% if %} if-branch |
Conditional (if) | conditionals[].if_variables |
Inside {% if %} else-branch |
Conditional (else) | conditionals[].else_variables |
Inside {% for %} body |
Loop-scoped | loops[].variables |
If a variable name appears only as a condition in {% if variable_name %} (truthiness test) and never inside {{ variable_name }}, infer it as type: boolean. The Orchestrator will ask a yes/no question for it.
If a variable appears both as a condition and as a {{ }} reference, keep its inferred type from the {{ }} context (it's used as both a gate and a display value).
For {% for item in action_items %} containing {{ item.description }}:
action_itemsis the collection variable (type:list, not directly asked)itemis the loop variable (iteration alias)item.description→ sub-variabledescription(strip the loop variable prefix)- Apply standard type inference to the sub-variable name:
description→text,assignee→text,due_date→date
The Analyzer extracts condition metadata for each {% if %} block:
| Condition form | Example | gate_type |
gate_variable |
gate_value |
|---|---|---|---|---|
| Truthiness | {% if include_ip %} |
boolean |
include_ip |
— |
| Equality | {% if method == 'bank' %} |
equality |
method |
"bank" |
For equality conditions, the Analyzer extracts the variable name and comparison value using regex: (\w+)\s*==\s*['\"](.+?)['\"]
In .docx files, Word may split Jinja2 tags across multiple XML runs (e.g., {% in one run and if x %} in another). The Analyzer must handle this:
- Load the template via
docxtpl.DocxTemplate(path) - Access the preprocessed XML source where
docxtplhas merged split runs - Apply regex extraction on the merged text
If accessing docxtpl internals proves fragile, fall back to: extract all text by concatenating paragraph runs, table cell text, header/footer text, then apply regex. This loses positional information but is robust.
For HTML templates, read the raw file text directly — no preprocessing needed.
After extracting all conditionals, the Analyzer builds the dependencies map:
dependencies = {}
for cond in conditionals:
gate_var = cond["gate_variable"] # or condition name for boolean
dep_vars = [v["name"] for v in cond["if_variables"] + cond["else_variables"]]
dependencies.setdefault(gate_var, []).extend(dep_vars)- Malformed blocks (e.g.,
{% if %}without{% endif %}) — warn the user, skip the block, extract variables as unconditional - Nested blocks — warn the user that nesting is not supported in v2; treat inner block as part of the outer block's scope
- Variable in multiple scopes — if the same variable name appears both at top-level and inside a conditional, classify it as unconditional (it's always needed)
- Empty conditional/loop body — valid; record the block with no variables
{% else %}without variables — recordelse_variables: []
The v1 interview plan groups all variables by logical affinity. The v2 plan must additionally account for:
- Gate variables — boolean and choice variables that control conditionals must be asked before their dependent groups
- Conditional groups — groups that are only asked if a gate condition is met
- Loop groups — groups that collect lists, planned after unconditional/conditional groups
Planning algorithm:
- Read the manifest's
variables,conditionals,loops,dependencies, andgroups - If
groupsis present (from config), use it as the interview structure - If
groupsis absent, auto-group: a. Separate unconditional variables into logical groups (same as v1) b. Ensure gate variables are placed in groups that come before their dependent conditional groups c. Add one group per conditional block d. Add one group per loop block, placed last - Order: unconditional groups → conditional groups (interleaved after their gates) → loop groups
During the interview loop:
- Ask the group containing the gate variable (e.g., "Does this engagement involve IP assignment?")
- Record the answer
- Evaluate the condition:
- Boolean gate:
trueif user answered yes/affirmative,falseotherwise - Equality gate:
trueif the collected value matchesgate_value
- Boolean gate:
- If condition is true: ask the
if_variablesgroup - If condition is false and
else_variablesis non-empty: ask theelse_variablesgroup - If condition is false and
else_variablesis empty: skip, move to next group
For each loop group:
- Introduce: "Now let's add the project milestones. I'll ask for each one individually."
- First item: Ask for all sub-variables as a group: "What's the description, due date, and amount for the first milestone?"
- Confirm item: "Got it — Milestone 1: 'Design phase', due 15 March 2026, $5,000."
- Continue: "Would you like to add another milestone?"
- Repeat steps 2-4 until the user says no
- Minimum check: At least
min_items(default 1) must be collected - Summary: "I have 3 milestones recorded: [numbered list]"
The collected data is a list of dictionaries:
{
"milestones": [
{"description": "Design phase", "date": "2026-03-15", "amount": "5000"},
{"description": "Development", "date": "2026-04-30", "amount": "10000"},
]
}The v2 confirmation summary:
- Shows unconditional values grouped as before
- Shows conditional sections with their gate status:
- If included: display the collected values
- If skipped: "IP Assignment: Skipped (not applicable)"
- Shows loop items as a numbered list
- Allows the user to:
- Edit any individual value
- Change a gate answer (which reveals or hides dependent sections; if revealing, asks the newly-required questions)
- Add, edit, or remove loop items
If the manifest includes validation rules (from config), the Orchestrator evaluates them during confirmation:
- After the user confirms all values, check each rule
- If a rule fails, report the error message and ask the user to correct the relevant values
- Re-validate after corrections
- Do not proceed to rendering until all rules pass
The renderer requires minimal changes because docxtpl and jinja2 natively support {% if %} and {% for %}:
-
Data structure — the context dictionary now contains both flat values and list values (for loops). No code change needed — both
docxtpl.render(context)andjinja2.Template.render(context)accept nested structures. -
Boolean coercion — ensure boolean variables are passed as Python
True/False, not strings"yes"/"no". The renderer should coercetype: booleanvariables before rendering. -
Validation scan update — the post-render validator should check for unrendered
{% %}tags in addition to{{ }}placeholders. Add regex:\{%.*?%\}. An unrendered control tag means a conditional or loop was not processed correctly.
coquill/
├── CLAUDE.md
├── README.md
├── LICENSE
├── .gitignore
├── .claude/
│ └── skills/
│ ├── coquill/
│ │ └── SKILL.md # Orchestrator (entry point)
│ ├── coquill-analyzer/
│ │ └── SKILL.md # Analyzer
│ └── coquill-renderer/
│ └── SKILL.md # Renderer
├── docs/
│ ├── coquill_mvp_spec.md # MVP specification
│ └── coquill_v2_spec.md # This file
├── templates/
│ ├── _examples/
│ │ ├── Bonterms_Mutual_NDA/
│ │ │ ├── Bonterms-Mutual-NDA.docx
│ │ │ └── Readme.md
│ │ ├── invoice/
│ │ │ └── invoice.html
│ │ └── meeting_notes/ # v2 example (md, conditionals + loops)
│ │ ├── meeting_notes.md
│ │ └── config.yaml
│ └── <user_template>/
│ ├── <name>.docx or <name>.html
│ ├── manifest.yaml # auto-generated
│ └── config.yaml # optional, developer-authored
└── output/
└── <job_name>/
User: I need to prepare meeting notes
Claude: I'll use the Meeting Notes template. I've found 14 fields to
complete across several sections. Some sections are optional depending
on your answers.
Let's start with the meeting details. What is the title of the
meeting, and when was it held?
User: Q1 Sprint Retrospective, 28 February 2026
Claude: What type of meeting was this?
Options: standup, workshop, or review
User: Workshop
Claude: Who facilitated the meeting, and where was it held?
User: Sarah Chen, Conference Room B
Claude: Now the attendees and agenda. Who attended the meeting?
User: Sarah Chen, James Tan, Priya Sharma, David Kim
Claude: What was the agenda?
User: Review sprint outcomes, workshop on improving CI pipeline, assign follow-up tasks
Claude: Since this was a workshop, what materials were used or distributed?
User: CI pipeline architecture diagrams, benchmark results spreadsheet
Claude: Summarize the key discussion points.
User: Reviewed sprint velocity trends. Discussed bottlenecks in the CI pipeline. Workshopped solutions including parallel test execution and caching strategies.
Claude: Now let's add the action items. I'll ask for each one.
What's the description, assignee, and due date for the first item?
User: Implement parallel test execution, James Tan, 7 March 2026
Claude: Got it — Action Item 1: Implement parallel test execution,
assigned to James Tan, due 7 March 2026.
Would you like to add another action item?
User: Yes. Set up build caching, Priya Sharma, 14 March 2026
Claude: Action Item 2: Set up build caching, assigned to Priya Sharma,
due 14 March 2026. Another?
User: No, that's all
Claude: Were any decisions made during this meeting?
User: Yes
Claude: What decisions were made?
User: Agreed to adopt parallel test execution as the default CI strategy starting next sprint.
Claude: Is a follow-up meeting scheduled?
User: Yes, 7 March 2026, CI pipeline progress review
Claude: Who recorded these notes?
User: David Kim
Claude: Here's a summary of all the details:
Meeting Details:
- Title: Q1 Sprint Retrospective
- Date: 28/02/2026
- Type: Workshop
- Facilitator: Sarah Chen
- Location: Conference Room B
Attendees: Sarah Chen, James Tan, Priya Sharma, David Kim
Agenda: Review sprint outcomes, workshop on improving CI pipeline,
assign follow-up tasks
Workshop Materials: CI pipeline architecture diagrams, benchmark
results spreadsheet
Action Items:
1. Implement parallel test execution — James Tan — 7 March 2026
2. Set up build caching — Priya Sharma — 14 March 2026
Decisions: Agreed to adopt parallel test execution as the default
CI strategy starting next sprint.
Next Meeting: 7 March 2026 — CI pipeline progress review
Recorder: David Kim
Shall I produce the document?
User: Yes
Claude: Done! Your meeting notes have been saved as:
output/meeting_notes_q1_sprint_retrospective_2026-02-28/
meeting_notes_q1_sprint_retrospective_2026-02-28.md
| Decision | Choice | Rationale |
|---|---|---|
| Nesting depth | Single-level only | Covers majority of real templates; nested flows too complex for prompt-based skills |
| Condition forms | Truthiness + equality | Evaluable from collected answers without expression parsing |
| Config → Manifest | Analyzer merges config into manifest | Orchestrator reads one file; config changes only affect Analyzer |
{% elif %} |
Deferred to v3 | Multi-way branching complicates manifest and interview; rare in practice |
| Skill invocation | Prompt-level references | No programmatic API in Cowork; consistent with how MVP works |
| Loop minimum | At least 1 item (configurable via min_items) |
A {% for %} block with 0 items renders empty |
| Manifest format | YAML (unchanged from MVP implementation) | Human-readable, extensible, consistent |
schema_version |
Integer, starting at 2 | Forward compatibility; Analyzer regenerates stale manifests |