# Builder: optional extras
**For:** CPO → Senior Engineer · **Date:** 2026-11-11 (CT) · Design only, no code changed · Example data only (Dave Barlow / barlowrealty.example).
**Repo read:** `/workspace/dh/repo`, branch `fix/builder-browser-test-ui` @ 769a2482 (Nov 10). Not checked against `main`.
**Screens** (`build.py` → `.html`, `shoot.js` → PNG; text ≥13px, taps ≥44px, no x-overflow at 1440 and 390):
`01-extras-step` (1440, 390) · `02-tap-opens-card` (1440, 390) · `03-skipped` (1440, 390) · `04-review-publish` (1440, 390) · `05-email-unavailable` (1440) · `06-callbacks-placeholder` (1440).
Reuses unchanged: publish checklist (`../publish-checklist/SPEC.md`), CardSummary (`../inactive-cards-v2/SPEC.md`), Transfers card (`../transfers-card/SPEC.md`).

## Pattern (decided)
I'm keeping CPO's idea with three changes:
1. **Chat asks once, and only after the last required field.** It isn't "near the end". It's the step right before the Review line, so it never sits between the owner and a required field. The Review screen does **not** ask in chat a second time.
2. **After that, extras live in the side panel, not in chat.** They sit in an "Optional extras" section under the publish checklist. That's what "lives quietly" means: nothing is re-posted and there's no badge.
3. **Calendar and transfers move out of the required setup steps and into extras.** If they stayed required yes/no asks, both would always be answered before the extras step, so they could never be offered as extras, and Joel calls them optional. Neither is a publish blocker today (`publishBlockers` has no calendar or transfer row). With nothing saved, the prompt already falls back safely: "Transfers: none saved. Offer to take a message." and "Book only when a calendar is connected." **CPO, overrule this if Joel wants them kept as required questions.** The design still works if you do: those two rows just never show for new agents.

Rejected alternatives: chips on the Hawk message (no room for a one-line reason, can't show email as disabled, and old chips stay tappable, which nags), a per-extra "declined" state (new keys for no gain), and re-offering extras after each one is saved (that's nagging).

## The extras list: `extrasFor(state, ctx)`
One pure function in `lib/db/src/lk-builder.ts`, next to `publishBlockers`. The chat card and the panel both read it, so they can't drift. It returns, in this order, **only the extras that aren't set up**:

| key | Show when | Label | Hint (copy final) | Icon (Lucide) | Opens |
|---|---|---|---|---|---|
| `texts` | `draft.texts.length === 0` | Send texts | Text a saved link during a call, like your pricing page or website. | `MessageSquareText` | `texts` card (existing `TextsCard`) via the "change saved texts" path |
| `transfers` | `!hasCompleteTransfer(draft)` | Transfer calls | Send a caller to a person when they ask. | `PhoneForwarded` | `transfer` card (the new multi-row card in transfers-card/SPEC.md) |
| `calendar` | `!draft.calendar` | Book meetings | Put consults on your calendar. | `Calendar` | `calendar` card (`CalendarConnectCard`), then `meeting` as today |
| `email` | **only once agent email ships**, and email isn't on for this agent | Send emails | Only to contacts with an email on file. | `Mail` | the email card from the email feature spec (not part of this spec) |
| `callbacks` | **never, for now** (see Gaps) | Call back later | n/a | `PhoneOutgoing` | placeholder (06) |

- A row that's added just disappears from the list, the same way a done checklist row disappears. The value shows on the summary card (Calendar, Transfers, Saved texts rows).
- `calendarDeclined` / `transfersDeclined` don't hide a row. In the panel it's fine to offer them quietly, and chat never asks twice anyway.
- If the list is empty, the chat step is skipped and the panel section isn't rendered.
- **Email availability** has one state input: `ctx.mailboxReady: boolean`. It's true when `/api/integrations/accounts` (the same call that already lists calendars, `routes/gmail.ts` L140) returns any account with `status === "connected"` and `capabilities.send` (the `accountCanSend` rule in `lib/email-sender.ts`). Pass it in the existing `context` event next to `calendars` and `textingReady`. If it's false, the row still shows but is disabled (see 05). I chose disabled over hidden so owners learn email exists and what it needs. The reason is plain and has one link.
- "Contact has an email on file" is a per-call runtime check, not a builder state. It's only stated in the hint, and the row never hides because of it.
- `texts` doesn't need a texting check: a number is required before this step, and `textingReady` is already `Boolean(phone)`.

## Chat step (01, 02, 03)
- **Trigger:** in `nextSetupStep`, after `aiDisclosure` and before the `review` return:
  `if (state.draft.mode === "new" && !state.messages.some(m => m.card === "extras") && extrasFor(state, ctx).length) return { reply: EXTRAS_ASK, show_card: "extras" };`
  - "Asked once" is worked out from the messages, so there's no new key. Messages already survive OAuth round-trips in the `BUILDER_SNAPSHOT` sessionStorage.
  - Edit mode (existing agents) never asks in chat. Those owners see the panel section only.
- **Card type:** add `"extras"` to `ChatCard` and `SHOW_CARDS`. In `showCardOf` (the Gemini path), coerce `"extras"` to `"none"` so only code can open it, the same way `transfer` is code-only today.
- **Copy:**
  - Hawk: `EXTRAS_ASK = "Required setup is done. Want to add any extras? They’re optional. You can publish without them."`
  - Card band: **Optional extras**, with "Not needed to publish" on the right (13px, depth-75).
  - Rows: 60px min, full-row button, icon tile, label, hint, chevron.
  - Footer: full-width outline button **No thanks**.
- **Tap a row (02):** dispatch `{ type: "open-extra", key }`. The reducer:
  1. appends a user message with the label, e.g. "Send texts";
  2. runs the existing path for that card. For texts it's the `"change saved texts"` branch: `suggestedTexts`, then `TEXT_ASK` with the `texts` card. For calendar it's `askCalendar` → `calendar` / `tools`. For transfers it's the `transfer` card. Don't route it through Gemini.
  3. **Collapse:** the new card is now the latest card, so the extras card turns into a CardSummary under the inactive-v2 rule (only the latest `message.card` is live). No special case is needed.
  4. After the opened card saves, `settledCardTurn` → `nextSetupStep` returns `review` ("That's everything…"). The extras card is in messages, so it isn't asked again. Any remaining extras stay in the panel.
- **No thanks (03):** appends a user "No thanks" and the Hawk line "Okay. You can add any of them later from Optional extras. That’s everything. You can test it in the browser.", then the normal review. If the owner types something else instead of tapping, treat it the same as skipped. The step is still answered.
- **CardSummary line** (add a row to the inactive-v2 table): label **Extras**, icon `Sparkles`. Value: the label tapped ("Extras · Send texts"), or **Extras · Skipped**. The check shows in both cases, because the step is resolved.
- **Phone (390):** same card, full width in the Chat tab. The Preview tab badge still counts **required items only**. Extras never add to it.

## Side panel: next to the checklist (04, 05)
Order in `.side`:
1. the publish checklist: amber "Needed to publish" or green "Ready to publish" (unchanged);
2. **Optional extras**, a neutral white card;
3. the summary card.

| | Needed to publish | Optional extras |
|---|---|---|
| Color | amber wash and border, open rings | white, green-25 border, teal-10 icon tiles. **Never amber, never a ring.** |
| Header | "Needed to publish" + "N left" pill | "Optional extras" + gray "Not needed to publish" tag |
| Sub | "Finish these to publish to live calls." | "Add any of these now or later." |
| Row action | chevron → jump to field | outline **Add** pill (44px) → same `open-extra` dispatch. Desktop: scroll the chat to the new card. Phone: switch to the Chat tab first. |
| Affects Publish button / count / Preview badge | yes | **no** |

- The section hides when `extrasFor` is empty. There's no "all extras added" message.
- Publish and "Finish N things" read only `publishBlockers`. `extrasFor` is never passed to the button or the server.

## Email unavailable (05)
- The row shows disabled: gray icon tile, depth-75 label, `aria-disabled`, and the row itself doesn't respond to taps.
- Reason line: **Connect Gmail or Outlook first.** with an info icon. On the right is one link, **Open Integrations**, which opens the Integrations page in a new tab.
- When the tab regains focus, refetch the accounts. If a sending account is now there, the row becomes live. There's no polling and no toast.
- **Not shipped yet:** until agent email ships, there's no email row at all (no "coming soon"). Add the row in the same PR that ships email. That isn't a feature flag, and I'm not proposing one. Today the agent's RULES say "Never promise to … send email", and `needsFix` flags email promises, so a visible row would contradict the agent.

## Gaps found in the repo
1. **Callbacks / follow-ups don't exist for agents.** RULES (`lk-builder.ts` L573) forbid promising a callback. `needsFix` flags it with "Nothing in DialHawk places outbound callbacks yet." Workflows (Workflows page, the `long_term_follow_up` starter, Follow Up Boss create-task node) are a separate product, not wired to the builder. **Decision:** the row and card are a placeholder only (06) and stay hidden until a callback spec exists. That spec needs to cover: who gets called back, when, which number, calling-hours and consent rules, and what shows in results. It also adds outbound minutes. **Flag: cost.**
2. **Saved texts can't be added by hand in the card.** `TextsCard` only approves or removes Hawk's suggestions. With no suggestions, Hawk says `TEXTS_EMPTY` and shows no card.
   - `suggestedTexts` picks the first URL in facts or the meeting link. It doesn't suggest a "Website" text from `draft.websiteUrl`, and it doesn't specifically suggest a pricing-page text.
   - **Decision:** add two suggestions in `suggestedTexts`:
     - "Website" (body `draft.websiteUrl`, when "When the caller asks for the website") when `websiteUrl` is set;
     - "Pricing page" when a confirmed fact URL path contains `pricing`.
   - No new manual-add form, since typing in chat already edits texts. Shown in 02.
3. **The Transfers card in chat is still the single-phone `TransferPhoneCard`** (`cards.tsx` L1115). The extras row should open the new multi-row card from `transfers-card/SPEC.md`. Until that ships, it opens the current card.
4. **No `mailboxReady` in builder state** today. It's added as above (one boolean, from a call the builder already makes for calendars).
5. **No email capability for agents** today (see Email). Workflows already send through connected Gmail/Outlook (`workflow-email.ts`, `resolveWorkflowEmailSender`). That's the sender to reuse when agent email ships.

## Open questions, with my answer
| Question | Decided |
|---|---|
| Ask in setup and again in Review? | Setup only, once. Review has the panel section, not a second chat ask. |
| Calendar and transfers: required asks or extras? | Extras (see Pattern 3). CPO/Joel can overrule; the design holds either way. |
| Store a "declined extras" flag? | No. "Asked" comes from `messages`, and per-extra declines aren't stored. |
| Edit mode asks in chat? | No, panel only. |
| Email with no mailbox: hide or disable? | Disable, with a reason and an Open Integrations link. |
| Email before it ships? | Hidden. No "coming soon", no flag. |
| Callbacks? | Hidden placeholder that needs a spec. |
| Order of rows? | texts, transfers, calendar, email. Fixed, no ranking or "recommended". |
| Do extras count toward Publish or the Preview badge? | Never. |

## Cost flags (for Joel, never in UI copy)
- Nothing in this design turns on a new service.
- Texts and transfers use existing texting and calling usage when the agent uses them. Calendar and email use the owner's connected Google/Microsoft accounts.
- **Callbacks would add outbound call minutes**, so they need Joel's OK in the callback spec.
