# Authoring punchlist workflows — the builder's standard

<!-- role: reference for AI builders (and humans) converting a brief or client
     email into workflows + a template set. Written to be handed to a small
     local LLM as its complete instructions; nothing else is required. -->

You are turning a message from a project team ("here's everything we need from
the client") into a **punchlist**: a list of items the client works through.
You produce JSON. The server validates it and tells you everything that is
wrong in one pass; you fix everything and resubmit.

## 1. The mental model

- An **item** is one goal on the client's list. It shows ONE line: a
  goal-phrased label on the left, one action button on the right. Everything
  else stays hidden until asked for.
- Behind an item is a **workflow**: a handful of steps. Steps change which
  BUTTON shows — they never change the label. The client usually only ever
  sees the first step; later steps exist for verification and rescue paths.
- A **set** is a reusable bundle of workflows ("Website onboarding") that
  becomes N items at once, organised into sections, with shared variables
  (like the list of emails to grant access to) filled in once.
- One item = one askable thing. If the email asks for five accesses, that is
  five items, not one item with five steps.

## 2. The golden rules (each one exists because breaking it hurt)

1. **The title is the goal, and it never changes.** "Grant us GA4 admin
   access", "Pay the deposit invoice", "Decide on age verification". The
   client must recognise the item across weeks. Never phrase a title as an
   action-in-progress ("Uploading…") or a step ("Confirm access").
2. **Button labels are verb-first, 3–4 words maximum, and carry the step's
   context.** "Granted", "Invoice sent", "Found property access", "All
   verified". The button may (and should) change per step — that is where
   step context lives. Never restate the whole goal on the button.
3. **The instruction is the explainer, and it lives behind the row** (the
   client peeks or expands to read it). Write it as one or two friendly
   sentences with the exact click-path when there is one: "In
   [analytics.google.com](https://analytics.google.com): Admin (gear) >
   Account access management > + > Add users, with the Administrator role."
4. **Every client claim gets a team verification step.** The client saying
   "Granted" moves the item to a team-owned step ("Check each address can
   open the property"); only the team's "All verified" reaches `$done`.
   Client steps have `"owner": "client"`, team steps `"owner": "team"`.
5. **Alternatives are the outs**, shown behind the row's small chevron under
   "Can't do this right now?". Two kinds:
   - `"kind": "flag"` — the client stays on the step and tells us something.
     `"tone": "attention"` puts it in the team queue ("Having trouble");
     `"tone": "ack"` just records it ("I'll do it later"). Give almost every
     client step a `later`/ack and a `trouble`/attention out.
   - `"kind": "jump"` — moves to another step. This is how conditional flows
     work: a human picking a button IS the condition. ("I don't have an
     option to add admins" → jump to the property-level path.)
   A free-text "Something else…" is built in on every client step — do not
   author one.
6. **If the answer is a short set of options, it is a `choices` step, not a
   button.** Two to four mutually exclusive answers render as an inline toggle
   on the row, so the client answers in ONE tap without opening anything:

   ```json
   { "id": "decide", "owner": "client",
     "instruction": "…",
     "choices": [
       { "key": "add_it",  "label": "Yes, add it",  "to": "team_ack" },
       { "key": "skip_it", "label": "No, skip it",  "to": "team_ack" }
     ] }
   ```

   A step has EITHER `primary` OR `choices`, never both. Each choice carries
   its own `to`, so a choice can branch ("our own host" → the step that asks
   which). A choices step takes no `fields` and no `targets` — if you need
   detail after the answer, branch to a step that collects it. The chosen
   label is readable downstream as `{{fields.<step_id>.choice}}`, so the
   team's step can say what was picked.

   **This is the fix for the "Submit choice — what choice?" problem:** a
   button that hides a select tells the client nothing until they open it,
   and then confronts them with a second submit button. Prefer the toggle
   whenever the answer set is small and knowable up front.

7. **Pick the control that fits the answer** — and reach for NONE first. The
   row's checkbox marks the item done, so a control exists only to COLLECT
   something or to SEND the client somewhere. A step that needs neither
   declares nothing and shows just a checkbox and a label.

   | `control` | use it when | shape rules |
   |---|---|---|
   | *(omit)* | the answer is "I did it" | no fields, no targets |
   | `input` | one value to type or pick | exactly one `field` |
   | `upload` | a file | exactly one `field` (holds the file url) |
   | `link` | they must go somewhere first | needs `link: {label, url}`; collects nothing |
   | `states: [...]` | it moves through stages | 2+ names; the LAST one finishes the step |

   `states` is not a control key — it replaces one. Intermediate states are
   recorded and stay put (where a thing is up to is worth knowing), it can be
   moved backwards, and the value reads downstream as
   `{{fields.<step_id>.state}}`. Name the first state from the CLIENT's side:
   "Waiting on invoice", not "Sent".

8. **Fields only when the answer is data.** If the step needs the client to
   type something (an ID, a date, a choice), give the step `fields`. Field
   `label`s are client-friendly; if you use a `pattern`, ALWAYS give a
   `hint` phrased like "it usually looks like 123-456-7890" — the client
   never sees the regex.
9. **Per-email fan-out uses `targets`, not copies of the step.** A step with
   `"targets": "<email_list variable>"` renders one confirm per address and
   completes when all are done. Never write three near-identical steps for
   three addresses.
10. **Keep it small.** Most workflows are 2–4 steps: do-the-thing (client) →
   verify (team), plus at most one rescue path. If you are drawing a state
   machine, stop — you are overbuilding.

## 3. The spec, exactly

```json
{
  "key": "ga4_admin_access",
  "title": "Grant GA4 admin access to {{who_needs_access}}",
  "variables": {
    "who_needs_access": {
      "type": "email_list",
      "label": "Emails to add as GA4 admins",
      "required": true
    }
  },
  "start": "grant_admins",
  "steps": [
    {
      "id": "grant_admins",
      "owner": "client",
      "instruction": "Certain team members need access to your Google Analytics. In [analytics.google.com](https://analytics.google.com): Admin (gear) > Account access management > + > Add users, with the Administrator role.",
      "targets": "who_needs_access",
      "primary": { "label": "Granted", "to": "team_verify" },
      "alternatives": [
        { "key": "later",   "label": "I'll do it later",  "kind": "flag", "tone": "ack" },
        { "key": "trouble", "label": "Having trouble",    "kind": "flag", "tone": "attention" },
        { "key": "no_admin_option",
          "label": "I don't have an option to add admins",
          "kind": "jump", "to": "check_property_access" }
      ]
    },
    {
      "id": "check_property_access",
      "owner": "client",
      "instruction": "A common setup: no admin rights on the GA4 account, but admin on the property. In Admin, look at the Property column for its own \"Property access management\".",
      "primary": { "label": "Found property access", "to": "grant_property_admins" },
      "alternatives": [
        { "key": "no_access_anywhere", "label": "I can't add people there either",
          "kind": "flag", "tone": "attention" }
      ]
    },
    {
      "id": "grant_property_admins",
      "owner": "client",
      "instruction": "In Admin > Property access management > +, add each address with the Administrator role.",
      "targets": "who_needs_access",
      "primary": { "label": "Added at the property level", "to": "team_verify" }
    },
    {
      "id": "team_verify",
      "owner": "team",
      "instruction": "Check each of {{who_needs_access}} can open the GA4 property with admin rights.",
      "primary": { "label": "All verified", "to": "$done" },
      "alternatives": [
        { "key": "missing_account", "label": "Someone's missing — back to account-level",
          "kind": "jump", "to": "grant_admins" }
      ]
    }
  ]
}
```

Field reference:

| Where | Field | Rules |
|---|---|---|
| top | `key` | snake_case, stable forever — it is the workflow's identity across versions |
| top | `title` | the item's label; goal-phrased; `{{tokens}}` must be declared **variables** (never fields — the label renders before any field is captured) |
| top | `variables` | `{name: {type, label, required}}`; types: `text`, `email_list`, `url`, `number`, `select` |
| top | `start` | id of the first step |
| step | `id` | snake_case (`^[a-z][a-z0-9_]*$`), unique in the spec |
| step | `owner` | `client` or `team` |
| step | `instruction` | optional explainer (markdown links fine); may use `{{variable}}` and `{{fields.<step_id>.<field_key>}}` |
| step | `detail` / `tutorial` | optional; `tutorial` may be an `asset://` reference to a step-by-step guide |
| step | `targets` | name of a declared `email_list` variable — one confirm per address |
| step | `fields` | `[{key, type, label, required?, pattern?, hint?, options?}]`; types: `text`, `longtext`, `url`, `date`, `number`, `select`, `email_list`, `confirm`; `options` for `select` |
| step | `primary` | `{label, to}` — `to` is a step id or `"$done"`; EITHER this or `choices` |
| step | `control` | `button` (default) · `input` · `upload` · `link` — how the primary renders; omit for a pure claim |
| step | `link` | `{label, url}` — control `link` only; https only |
| step | `states` | `[str]` — a status list; 2+, unique, last one advances; no fields/targets alongside |
| step | `choices` | `[{key, label, to}]` — 2–4 inline answers; no fields/targets on the step; keys can't shadow engine actions or alternative keys |
| step | `alternatives` | `[{key, label, kind, to?, tone?}]` — `jump` needs `to` (and no `tone`); `flag` needs `tone` (and no `to`) |

Hard graph rules the validator enforces: `start` exists; every transition
lands on a real step or `$done`; every step is reachable from `start`; every
step has a path to `$done`; no duplicate step ids, alternative keys, or field
keys per step.

## 4. The recipe: email → punchlist

1. **Enumerate the asks.** Read the whole message and list every distinct
   thing the client must do, decide, provide, or pay. Keep the sender's
   nouns — if they wrote "Barnet API", the item says "Barnet API".
2. **One workflow per ask.** Small ones (2 steps: do → verify). Asks with a
   known failure mode get one rescue path (a jump), discovered from the
   email itself or from how that platform actually behaves.
3. **Name shared inputs identically across workflows.** If six accesses all
   go to the same addresses, every one of those workflows declares
   `who_needs_access` with type `email_list` — same name on purpose. The set
   fills shared variables ONCE by name.
4. **Group into sections** the way the sender grouped them (Billing, Design,
   Decisions, Access, Content…). Optional/nice-to-have asks get their own
   section, and "(optional)" goes in the title.
5. **Emit the set:**

```json
{
  "key": "website_onboarding",
  "title": "Website onboarding",
  "variables": {
    "who_needs_access": { "type": "email_list", "label": "Emails that should get access", "required": true }
  },
  "items": [
    { "template_key": "deposit_invoice",  "section": "Billing" },
    { "template_key": "design_direction", "section": "Design" },
    { "template_key": "ga4_admin_access", "section": "Access" }
  ]
}
```

6. **A one-off ask that will never recur** does not need a library workflow:
   an item can carry its spec inline (see §5). Library workflow XOR inline
   spec — never both.

## 5. Submitting

| Action | Call | Notes |
|---|---|---|
| Dry-run a spec | `POST /api/workflows/validate` `{"spec": {...}}` | returns every error at once |
| Publish a workflow | `POST /api/workflows` `{"spec": {...}}` | published specs are **immutable**; publishing the same `key` again creates a new version — running items keep the version they started on |
| List the library | `GET /api/workflows` | search this FIRST; reuse before you author |
| Publish a set | `POST /api/sets` `{"set": {...}}` | |
| Run a set on a punchlist | `POST /api/punchlists/{id}/set-runs` `{"set_key": "...", "variables": {...}}` | shared variables filled once, mapped to each template by name |
| Add one item | `POST /api/punchlists/{id}/items` `{"template_key": "...", "variables": {...}}` or `{"spec": {...}}` | inline spec for true one-offs |

**Errors are the interface.** Validation returns the complete list, each line
naming the step and the fix, with near-miss suggestions ("did you mean
'team_verify'?"). Fix every line, resubmit once. Do not fix one error at a
time.

## 6. Final checklist before you submit

- [ ] Every title reads as a goal the client will still recognise in week 4,
      and any `{{token}}` in it is a declared variable.
- [ ] Every button label: verb-first, ≤ 4 words, step-specific.
- [ ] Every client step has outs (ack + attention at minimum) and no
      hand-rolled "Other…".
- [ ] Every client-claimed completion passes through a team-owned verify
      step before `$done`.
- [ ] Repeated per-email work uses `targets`, repeated inputs share variable
      NAMES across workflows, and the library was searched before authoring
      anything new.
- [ ] Every `pattern` has a `hint` a non-technical person understands.
- [ ] No workflow is bigger than the ask needs — if unsure, cut a step.
