# Build Plan Schema (§5) — distribution mirror

> **Mirror of the canonical contract.** Canonical source: `command-center/shared/intake-to-build-schema.md` §5 + §5A (co-owned by atlas + ghl-command-mcp). This copy travels with the distributed skill. Kept in lockstep — `schemaVersion` must match the canonical. The canonical wins on any divergence; open a contract note rather than editing divergently.

`schemaVersion`: **0.1**

The Build Plan is the reviewable, editable output of the skill. **Refs only, no real IDs.** Authored by the skill, rendered for approval (§5A), consumed by the phase-2 executor.

## Design principles (load-bearing)
1. **No real GHL IDs.** Every creatable object has a stable `ref` (`<objectType>.<slug>`). Everything that points at another object points at its `ref`.
2. **The executor resolves refs → IDs at build time**, after create + verify. Kills the silent-failure class.
3. **Logical, not native.** Workflow actions / form fields / sequences are logical (type + params + refs); the executor expands to GHL-native JSON via `action-schemas.json`.
4. **Verify-before-continue** is inherited by the executor.

### Ref grammar
`<objectType>.<slug>` (lowercase snake_case, unique within type). Namespaces: `pipeline` `stage` `field` `tag` `workflow` `form` `funnel` `page` `calendar` `email` `sms` `cv` `handoff` and, since v2 (2026-08-26): `user` `email_template` `sms_template`. One literal is allowed where a `user.*` ref belongs: **`user.__pending__`** = "no staff member yet — build the step, report it as waiting for a staff member".

## Top level
```jsonc
{
  "schemaVersion": "0.1",
  "planId": "plan_...",
  "briefId": "sub_...",
  "preset": "generic",
  "summary": "Plain-English description for the approver. Record preset id+version + brief source here.",
  "users": [...],                                   // v2 — every staff member from the brief
  "pipelines": [...], "customFields": [...], "tags": [...], "customValues": [...],
  "calendars": [...], "forms": [...], "funnels": [...],
  "emails": [...], "sms": [...],
  "templates": { "emails": [...], "sms": [...] },   // v2 — every message lives once
  "workflows": [...], "handoffs": [...],
  "buildOrder": ["user.*","tag.*","field.*","cv.*","pipeline.*","calendar.*","form.*","funnel.*","email.*","sms.*","email_template.*","sms_template.*","workflow.*"],
  "idMap": {}
}
```
`buildOrder` is advisory; the executor derives true order from dependencies. `idMap` is empty at authoring; the executor fills `ref → realId`.

## Object shapes (abbrev — full examples in `examples/sample-build-plan.json`)
- **users** *(v2)*: `{ref, firstName, lastName, email, role, phone?}` — one per `brief.team.staff[]` entry; `role` ∈ admin/user (the owner and whoever runs the account = admin; everyone else = user); `email` must be a real address (GHL creates the login from it) and unique across users. Every step that pings a person points at a user by `userRef`; the plan never carries a real user id.
- **pipelines:** `{ref, name, stages:[{ref, name, position}]}`
- **customFields:** `{ref, name, dataType, model?, options?}` — `dataType` ∈ TEXT/LARGE_TEXT/NUMERICAL/PHONE/**MONETORY**/CHECKBOX/SINGLE_OPTIONS/MULTIPLE_OPTIONS/FLOAT/DATE/TEXTBOX_LIST/FILE_UPLOAD/SIGNATURE; `model` ∈ contact/opportunity; choice types (SINGLE_OPTIONS/MULTIPLE_OPTIONS/CHECKBOX) MUST carry `options:[...]`
- **tags:** `{ref, name}`
- **customValues:** `{ref, name, value, filledBy?}` — `value` may be blank when produced by a handoff
- **calendars:** `{ref, name, calendarType, openHours, availabilityType, requiresStaff, teamMemberRefs?, slotDuration?, slotDurationUnit?, slotInterval?, slotBuffer?}` — `calendarType` ∈ round_robin/event/class_booking/collective/service_booking. One calendar per `brief.calendars[]` entry (brief `one_on_one` → `event`, `round_robin` → `round_robin`, `class` → `class_booking`); `teamMemberRefs` *(v2)* = the `user.*` refs on it (a staffed calendar with none → warning, and it waits for a manual assignment). **`slotDuration`** = the appointment length in minutes (`slotDurationUnit` "mins", default; "hours" allowed), carried from `brief.calendars[].durationMinutes` / the calendar answer ("Discovery Call, 15 minutes" → `slotDuration: 15`). Omitted → GoHighLevel builds **30-minute** slots and the validator warns; on a re-run that binds an existing calendar with a different length, the build reports the mismatch as a manual step and never changes the calendar itself. `slotInterval` (minutes between start times) and `slotBuffer` (minutes after each appointment) are optional pass-throughs.
- **forms:** `{ref, name, fields:[{type:"standard"|"custom", key?|fieldRef?, required}]}`
- **funnels:** `{ref, name, target?, host?, domain?, pages:[{ref, name, role, outline, formRef?, calendarRef?}]}` — `target` ∈ ghl (default) / external; `host` ∈ cloudflare (default) / vercel (external only); `domain` external only, optional (else host subdomain). All three are additive + optional → a plan with no `target` builds in GHL (today's behavior); `schemaVersion` stays 0.1. When `target: "external"` the funnel takes the external lane (`references/external-funnel.md`): the site is generated + user-hosted, not built in GHL.
- **emails:** `{ref, name, subject?, bodyOutline?, body?, mergeTags?}` — an email SENT by a workflow needs a full `body` (an outline-only asset reports `needsContent` and will not build)
- **sms:** `{ref, name, bodyOutline?, body?, mergeTags?}` — same `body` rule for any SMS a workflow sends
- **templates** *(v2)*: `{emails:[{ref: "email_template.<slug>", name, subject, html}], sms:[{ref: "sms_template.<slug>", name, body}]}` — every message lives ONCE as an account-level email template / SMS snippet the client can edit in GHL without opening a workflow. Templates are created in the editor's own format (vibe-editor) so they open and edit in Marketing → Emails → Templates, and each saved body is read back from GHL's preview before it is reported as written. Content is FULL (`html` / `body`), never an outline. A send step references one with `templateRef`; the executor creates the template AND inlines the same body into the step. Names unique per kind (bound by name). Write the copy in the client's voice: `brief.voice.threeWords` sets the tone and `brief.voice.signatureLine` is reused verbatim in the first email / text.
- **workflows:** `{ref, name, trigger?, stopOnResponse?, actions:[logical actions, refs not IDs]}` — max 40 actions; split longer flows. `trigger.type` ∈ the executor's native set so it auto-builds: `contact_tag` (tagRef), `form_submission` (formRef), `appointment` (+ `appointmentStatus`: confirmed/noshow/new/showed/cancelled/invalid; calendarRef optional), `customer_reply`, `pipeline_stage_updated` (pipelineRef+stageRef), `inbound_webhook`, `payment_received`. `form_submitted`/`contact_replied` are aliases; `tag_added`/`appointment_status`/`appointment_booked` are NOT recognized → manual step.
- **handoffs:** `{ref, owner, title, trigger?, instruction, produces?, successCheck, blocks?}` — `owner` ∈ **OPERATOR-UI/OPERATOR-EXT/TEAM** (legacy JERRY-UI/JERRY-EXT/SASHA accepted + normalized, never emitted). To gate a workflow DRAFT, `blocks` must list its `workflow.*` ref (a bare `sms.*` wildcard gates only the asset surface).

## Logical workflow actions (executor expands to native)
`add_contact_tag {tagRef}`, `remove_contact_tag {tagRef}`, `send_email {emailRef | templateRef}`, `send_sms {smsRef | templateRef}` (one of the two is required — `templateRef` points at `templates.*`, v2), `wait {value, unit}`, `wait_appointment {value, unit}` (integer; appointment-triggered workflows only), `internal_notification {userRef, title, body}` (`userRef` = a `user.*` ref or `user.__pending__`; the 0.1 `to` literal is still accepted with a warning), `task_notification {title, body?, dueDate?, userRef?}` (`assignedTo` literal still accepted with a warning; no assignee = warning), `assign_user {userRef}` *(v2 — GHL "Assign to user"; the contact's owner becomes that person)*, `update_contact_field {fieldRef, value}`, `add_notes {body}`, `create_opportunity {pipelineRef, stageRef, name?, value?}`, `update_opportunity {pipelineRef, stageRef, value?}` (forces allowBackward), `add_to_workflow {workflowRef}`, `remove_from_workflow {workflowRef}`, `goal_event {goalCondition, action?}`, and `find_opportunity {pipelineRef, found:[...], notFound:[...]}` — the only branching action; it MUST be the LAST action (branches do not rejoin). All pointers are refs. The executor owns the failure-prone native shapes; the plan never contains them.

## v2 rules the validator enforces (2026-08-26) — read before writing workflows
Two are **hard errors on purpose** even for plans that use no new field. Both come from a real build the owner inspected: the nurture went quiet after a week and the speed-to-lead ended with a text and dropped the lead. Error strings start with the code.

| Code | Rule | What to write instead |
|---|---|---|
| `E_NURTURE_TOO_SHORT` | A workflow whose **name** contains "nurture" must span **≥ 30 days**: the sum of its `wait` steps (minutes/hours converted; `wait_appointment` not counted; a terminal `find_opportunity` adds its longer arm). The error reports the computed days. | Keep adding touches + waits until the waits add up to 30+ days (e.g. 1, 2, 4, 7, 7, 9 = 30). |
| `E_NO_HANDOFF` | A workflow whose **name** contains "speed" (speed-to-lead) must **end** with a hand-off: its **last** action is `add_to_workflow`, or `add_contact_tag` with a tag that some nurture-named workflow's trigger fires on. Tagging at the START does not count (the nurture would run in parallel with the first-touch texts). A terminal `find_opportunity` passes only when both arms end in a hand-off. | End with `{ "type": "add_contact_tag", "tagRef": "tag.nurture_start" }` and give the nurture `trigger: { "type": "contact_tag", "tagRef": "tag.nurture_start" }`. |
| `E_NO_USER_REF` | An `internal_notification` with neither `userRef` nor a legacy `to`. | Add `userRef` (a `users[]` ref) or `user.__pending__`. |
| `E_NO_MESSAGE_REF` | A `send_email` / `send_sms` with neither an asset ref nor a `templateRef`. | Point it at `templates.*` (preferred) or a 5.8 asset. |

Warnings (advisory): `user.__pending__` on a step → "WAITING FOR A STAFF MEMBER" (the approval view shows it); a legacy `to` / `assignedTo` literal → move to `userRef`; a task with no assignee; a staffed calendar with no `teamMemberRefs` when the plan declares users.

### Worked example — users, calendars, templates, and the two rules together
```jsonc
{
  "users": [
    { "ref": "user.jane_smith", "firstName": "Jane", "lastName": "Smith", "email": "jane@glow.com", "role": "admin", "phone": "+15551234567" },
    { "ref": "user.mark_lee",   "firstName": "Mark", "lastName": "Lee",   "email": "mark@glow.com", "role": "user" }
  ],
  "tags": [{ "ref": "tag.hot_lead", "name": "hot-lead" }, { "ref": "tag.nurture_start", "name": "nurture-start" }],
  "calendars": [{ "ref": "calendar.consult", "name": "New Patient Consult", "calendarType": "round_robin",
                  "slotDuration": 45, "slotDurationUnit": "mins",
                  "requiresStaff": true, "teamMemberRefs": ["user.jane_smith", "user.mark_lee"] }],
  "templates": {
    "emails": [{ "ref": "email_template.nurture_1", "name": "Nurture 1 — Value",
                 "subject": "The thing most people get wrong about aging skin",
                 "html": "<p>Hi {{contact.first_name}} — You will never look overdone here. …</p>" }],
    "sms":    [{ "ref": "sms_template.nurture_1", "name": "Nurture text 1",
                 "body": "Hi {{contact.first_name}}, still want that consult? Reply STOP to opt out." }]
  },
  "workflows": [
    { "ref": "workflow.speed_to_lead", "name": "Speed to Lead",
      "trigger": { "type": "form_submission", "formRef": "form.intake" }, "stopOnResponse": true,
      "actions": [
        { "type": "add_contact_tag", "tagRef": "tag.hot_lead" },
        { "type": "create_opportunity", "pipelineRef": "pipeline.main", "stageRef": "stage.new_lead", "name": "{{contact.name}} - New Lead" },
        { "type": "internal_notification", "userRef": "user.jane_smith", "title": "New lead", "body": "New inquiry from {{contact.first_name}}" },
        { "type": "task_notification", "userRef": "user.jane_smith", "title": "Call {{contact.first_name}} within 5 minutes", "dueDate": "1" },
        { "type": "assign_user", "userRef": "user.mark_lee" },
        { "type": "wait", "value": 5, "unit": "minutes" },
        { "type": "send_sms", "templateRef": "sms_template.nurture_1" },
        { "type": "add_contact_tag", "tagRef": "tag.nurture_start" }        // ← the hand-off: LAST
      ] },
    { "ref": "workflow.lead_nurture", "name": "Lead Nurture",
      "trigger": { "type": "contact_tag", "tagRef": "tag.nurture_start" }, "stopOnResponse": true,
      "actions": [
        { "type": "wait", "value": 1, "unit": "days" },  { "type": "send_email", "templateRef": "email_template.nurture_1" },
        { "type": "wait", "value": 6, "unit": "days" },  { "type": "send_sms",   "templateRef": "sms_template.nurture_1" },
        { "type": "wait", "value": 9, "unit": "days" },  { "type": "send_email", "templateRef": "email_template.nurture_1" },
        { "type": "wait", "value": 14, "unit": "days" }, { "type": "add_contact_tag", "tagRef": "tag.hot_lead" }
      ] }                                                                     // waits: 1+6+9+14 = 30 days ✓
  ]
}
```
No staff listed in the brief? Keep the steps and write `"userRef": "user.__pending__"` — the build ships, the approval view says who is still needed.

## §5A approval view
Rendered as two ordered checklists (auto-build vs manual handoffs). Spec + template in `references/approval-view.md`.
