Sully Fazie

Sales Control Panel

15-minute setup (one time)

A sales dashboard that reads your email, calendar, calls and pipeline, then brings your top priorities to your attention.

Before you start

If you don't have the Claude desktop app, you'll need it first, so download it here.

(You'll also need a Claude Pro Plan, around $20)

Part 1 · Claude Cowork setup(five mins)
1

Install Cowork

Download the Claude desktop app and sign in on a paid plan. It has to be the desktop app, not the browser, because that is the version that can read your files and connect to your tools.

2

Open Cowork and give it a folder

Cowork is where Claude actually does work on your computer, not just chat. Get in and point it at one folder:

  • Open the desktop app and switch to Cowork, the middle of the three modes at the top.
  • Click New Task, then Work in a Project, then Choose a folder.
  • Make a fresh, empty folder (I call mine Claude Playground).

Claude only ever sees that one folder, nothing else on your computer, and it uses it to save your panel and your data. So it stays private and tidy.

Part 2 · Sales Panel setup(ten mins)
1

Run the panel

Paste the Master Prompt below into that task and send it. It then asks you two quick questions: what you sell, and where your deal data lives.

Tip: not ready to connect your own data? Pick the demo data and watch it build first, so you can see it working before plugging anything in.

2

Connect your tools (when ready)

Go to Customize > Connect your apps. Then for each tool:

  • Type the tool into the search bar, for example Gmail.
  • Click it, a browser window opens, you log in, done.
  • Repeat for your other tools. The ones that matter most are your email and your pipeline or CRM.

Quick tip: you can run it without your own data first, just to see how it feels. Then come back to these steps to connect your apps and make it properly yours.

3

Where it lives

Once it is built, open Live Artifacts in the left sidebar. That is your panel. It refreshes automatically at 5am. If your computer is off at 5am, it runs the moment you next open the desktop app, so it is always ready when you sit down.

The Master Prompt
You are building a **Sales Control Panel** for me as a live Cowork artifact. This is a daily operating system for sales, not a dashboard. Every panel must answer "what do I do next?". Passive dashboards drown the day; action surfaces run it.

## What you are building

A single-page HTML artifact, persisted in Cowork, that opens each morning and shows me what to do today. Populated from connected data sources and an Excel deals file. The artifact is the **view**. `deals.xlsx` is the **state**. Gmail, the connected call-transcription tool, Calendar, and any prospect connector are **inbound channels**. Never invent critical data. If a field is missing, ask or fall back to a stated default. Never fabricate.

Five zones, in this order:

1. **KPI strip** — Action needed today, Stale deals, Pipeline this month (weighted), Meetings today.
2. **Today** — Today's plan + Today's meetings (with click-to-expand prep).
3. **Commitments** — read-only ledger of promises I have made.
4. **Pipeline** — Stale deals + Pipeline forecast with stage drill-down.
5. **Footer** — data source attribution and refresh meta.

## Before you start — connector check

Match connectors by **tool-name suffix**, not full name (Cowork MCP tool names carry a session-random server id). Look for these suffixes:

- **Email** — Gmail exposes `__search_threads`. Microsoft 365 / Outlook exposes a Microsoft Graph mail tool whose suffix contains `message` or `mail` (commonly `__list_messages`, `__list_mail_messages`, or `__search_messages`). Match **either** as the single "email" channel; whichever resolves is the email tool for the rest of the build. [NEEDS LIVE VERIFY: confirm the exact Microsoft mail suffix the first time an MS365 connector is authed, then pin it here.]
- **Calendar** — `__list_events`. Both Google Calendar and Microsoft 365 expose this suffix, so if two resolve, pick the one from the **same provider as the resolved email tool**. Microsoft may instead expose `__list_calendar_view`; accept that as calendar too.
- **Call transcription** — `__get_meeting_transcript` or `__list_meetings` (Fathom, Granola, Fellow, Grain, Fireflies, Circleback, Otter all qualify; use the friendly name in the UI based on which one resolves)
- Apollo prospect enrichment is optional

Treat the email channel as **provider-agnostic** from here on: everywhere downstream that says "Gmail", read it as "the resolved email tool (Gmail or Microsoft)". The two differ in query syntax — see Step 5 step 2 for the Microsoft equivalents of `in:sent` / `newer_than:1d` and the thread-link URL.

When you write these into the artifact's `window.cowork.callMcpTool(...)` calls, paste the **full session-resolved tool name**, not the suffix. Use ToolSearch to discover them.

**Commitment matching depends on identity.** Email-and-transcript tools (Gmail, Gong, Fireflies, Otter, Granola) expose participant **emails** or **names**, which match a deal by email, domain, or exact name. Phone-dialer tools (Aircall, Dialpad, CallRail) identify the other party only by **phone number**. If the connected call tool is a phone-dialer and my deals carry no phone field, tell me plainly that verbal commitments cannot be auto-attached without a phone-to-deal mapping, rather than silently dropping them. Offer to add a `prospect_phone` column.

**Blank prospect emails break matching silently — guard against it.** Sent-email and meeting matching key on the deal's `prospect_email` (then domain). If that column is blank, those deals can't be matched from email, and a missing-email deal also can't be matched from a bare recipient address. Handling: (1) the blank-email count is reported in the Step 2c completeness check, so it isn't surprising at build time. (2) Fall back to **exact full-name match** (case-insensitive, trimmed) when an email/domain match fails and the source gives a participant name. (3) Never silently show an empty Commitments table as if there were simply no promises — use the explicit empty-state copy in Zone 3.

Note which connectors resolved, but **do not stop here**. The connector check only matters for live commitment extraction, and the demo seed (Appendix A) provides its own commitments, so a connector-less member must still be able to see the panel. Defer any stop until after Q2: if I answer Q2 with **demo data**, always continue regardless of connectors. Only if I answer Q2 with **real/live data** (drop a file, workspace path, or CRM export) **and** both an email tool (Gmail or Microsoft) **and** a call-transcription tool are missing, stop at that point and tell me the commitments tracker has nothing to work with. If only one is missing, continue.

**If no email or calendar tool resolves at all but the run continues** (demo data, or one channel present), say so in one plain line, e.g. "No email/calendar connector resolved, so commitments and meeting prep will run on demo content only." Never let a connector-less run look like live data with nothing in it.

**Domain inference (don't ask).** To separate me and my colleagues from prospects (used by meeting attendee filtering and email matching), derive **my own email domain** from the connected mailbox account (the authenticated user's address). Treat that domain, and any obvious alias of it, as internal. If no mailbox is connected (demo, or calendar-only), fall back to the env `user` email's domain; if that's also absent, skip internal/external filtering and just use external-looking attendees. Never prompt me for my domain.

## Before Q1 — fit check

This tool fits B2B-style deals: named prospects, multi-touch sales cycles with stages (Discovery, Demo, Proposal, Negotiation), commitments owed to specific people, weighted pipeline. It does not fit e-commerce, marketplaces (Amazon, Shopify, Etsy), ad performance, or transactional flows without a named buyer.

If my offer reads like one of those, stop and flag the mismatch in one short paragraph. Then offer three picks: (a) build the panel for my B2B side instead (wholesale buyers, retailer pitches, licensing deals), (b) seed demo data so I can see what the panel does, (c) this isn't the right tool, stop here. Wait for my answer before moving to Q1.

## Step 1 — Ask the setup questions, one at a time

Ask each question on its own. Wait for my answer before asking the next. Do not proceed to Step 2 until they are answered. If I cancel or reject any, apply the stated default, tell me what you defaulted to in one line, and continue.

Q1 and Q2 are always asked. **Q3 is asked only on a real-data run (Q2 = a/b/c). A demo run (Q2 = d) asks nothing further — go straight to the seed.**

1. **What do you sell, in one sentence?** Free text. If I give a category ("AI consulting company"), accept it and continue. Don't loop.
2. **Where is your deals data?** Options: (a) drop an Excel/CSV file, (b) workspace file path, (c) export from a CRM (HubSpot, Pipedrive, Salesforce, Attio — propose a nightly sync into `deals.xlsx`), (d) use demo data (seed `deals.xlsx` from Appendix A so I can see the panel populated; swap in real data later).
3. **(real data only) How many days untouched before a deal counts as stale?** Default 5. Accept any positive integer. Long sales cycles (enterprise, 60-90 day) should set this higher so the panel doesn't flag every deal as stale. Store the chosen value and use it everywhere stale is computed. Demo always uses 5.

Do not ask about colour, layout, or anything visual. Those are fixed below.

**File-upload shortcut:** if I drop a file into uploads at any point during setup, treat that as the answer to Q2(a) and proceed straight to schema mapping.

## Step 2 — Clean and map the deals file

Three rules before column-matching:

1. **Strip summary and total rows.** User files commonly carry `SUMMARY`, `Total unweighted`, `Weighted closing ≤ 31 May` rows. Filter the Deals sheet to rows where `deal_id` matches `^[DR]-\d+$` (or whatever id convention I use). Drop everything else. If the source has no id column at all (most CRM exports), generate ids `D-001`, `D-002`… in row order instead of filtering on id.
2. **Drop stored derived columns** (`weighted_value`, `days_since_touch`, anything that should be computed from primary fields). Compute on read, never store.
3. **Then** fuzzy-match my columns to the canonical schema. Ambiguous = ask one targeted question. Missing = follow the rules.

### Canonical schema (Deals sheet)

| Field | Type | Required? | If missing |
|---|---|---|---|
| deal_id | string | yes | Generate `D-001`, `D-002`… |
| prospect_name | string | yes | Compose from contact fields if possible (see Step 2b), else stop and ask |
| prospect_title | string | no | Blank |
| prospect_email | string | no | Blank (no source link possible) |
| company_name | string | yes | Stop and ask |
| company_size | int | no | Blank |
| location | string | no | Blank |
| stage | enum | yes | Map via Step 2b; if unmappable, stop and ask |
| probability | percent | no | Stage defaults: Discovery 20%, Demo 40%, Proposal 60%, Negotiation 80%, Active Retainer 100%, Closed Won 100%, Closed Lost 0% |
| deal_type | enum | no | Default "One-off" |
| deal_value | number | yes | Coerce per Step 2b (strip currency symbols/commas, convert currency). If still unparseable, blank and flag. |
| monthly_value | number | no | Blank unless deal_type = Retainer |
| expected_close | date | no | Blank (excluded from "this month" KPI) |
| last_touch | date | yes | If the column exists but rows are empty, fill those rows with `expected_close - 30 days` and tell me the count. If the column is absent entirely, stop and ask. |
| source_channel | string | no | Blank |
| whale | bool | no | Auto-flag the top 10% by **comparable value** across **all open deals** (one-offs and retainers), tie-inclusive. Comparable value = `deal_value` for one-offs, `monthly_value × 12` (annualised) for retainers, so a big recurring account isn't invisible next to one-offs. Rank all open deals by comparable value, take the value at the 10% cutoff rank (round, minimum 1), flag every deal at or above that cutoff. Boundary ties all flagged. |
| notes | string | no | Blank |

The "stop and ask" entries above are **not** first-hit stops: don't halt on the first missing required field. Accumulate them and surface everything together in the Step 2c completeness check, so I make one decision instead of being interrupted per field.

### Step 2b — Real CRM / messy-source adapter (run before column-matching when the source is a CRM export or any messy file)

Real exports do not arrive in this schema. Coerce them first, or the Pipeline Forecast and the weighted KPIs will be wrong. Apply in this order:

1. **Stage mapping (this is the one that silently breaks everything).** The panel only understands five stages: Discovery, Demo, Proposal, Negotiation, Active Retainer (plus Closed Won / Closed Lost). No major CRM ships these names, so map every source stage onto one of them. Default maps:
   - **HubSpot:** Appointment scheduled → Discovery; Qualified to buy → Discovery; Presentation scheduled → Demo; Decision maker bought-in → Negotiation; Contract sent → Proposal; Closed won → Closed Won; Closed lost → Closed Lost.
   - **Salesforce:** Prospecting / Qualification → Discovery; Needs Analysis / Value Proposition → Demo; Proposal/Price Quote → Proposal; Id. Decision Makers / Perception Analysis / Negotiation/Review → Negotiation; Closed Won / Closed Lost map straight through.
   - **Pipedrive:** custom per pipeline, so read the stage names and map by meaning (Lead In / Contact Made → Discovery; Demo Scheduled → Demo; Proposal Made → Proposal; Negotiations Started → Negotiation).
   - Match case-insensitively and trim whitespace ("Closed won" must normalise to Closed Won, or a won deal gets counted as open). Any stage you cannot confidently map: list them back to me and ask, do not silently keep the raw value.
2. **Won/lost status (Pipedrive especially).** Some CRMs keep a `Status` column (open / won / lost) separate from the stage name, so a won deal still shows "Negotiations Started". If a status/won-time/lost-time column exists, it overrides the stage: status won → Closed Won, status lost → Closed Lost, regardless of the stage label. Otherwise the panel counts closed deals as open pipeline.
3. **Currency — infer it, do not ask.** Determine the **reporting currency** from the data itself: read a currency column if present (Pipedrive `Currency`, Salesforce `Currency ISO Code`), else the CRM/account default, else infer from the symbols in `deal_value`, else default GBP. Then:
   - **Single currency** (all rows the same, or only one detected): use it as the reporting currency as-is. No conversion, no FX risk.
   - **Mixed currencies:** pick the dominant currency as the reporting currency and convert the rest into it. You do **not** have a live FX feed, so the rate is an estimate: label it plainly, e.g. "converted 3 USD deals to GBP at an approximate rate ~0.79 (verify before trusting totals)", and add that line to the build summary. Never add mixed currencies as if they were one.
   - Carry the chosen reporting currency (ISO code + symbol) through to the build so the money formatter and all labels use it.
4. **Money coercion.** Strip currency symbols, thousands separators and stray text from `deal_value` and cast to a number (`"£12,000"` → `12000`). If a value still will not parse, leave it blank and flag the count; never let a text value flow through as a deal value.
5. **Dates.** Parse each date cell independently (CRM exports mix ISO `2026-07-15` with locale `07/15/2026` or `15/07/2026` in the same column). Do not infer one format from the first row and apply it to all; that silently nulls the odd rows, which then drop out of "this month" and can never go stale. Anything you cannot parse: leave blank and report the count.
6. **Name composition.** HubSpot deal exports have no single prospect name (it is deal title plus an associated contact); Salesforce uses "Contact: Full Name"; some exports split first/last. Compose a real `prospect_name` for every row. If you genuinely cannot, stop and ask.

After Step 2b, every row should have a canonical stage, a numeric `deal_value` in the single reporting currency, ISO dates, and a prospect name. Tell me in one line what you mapped (e.g. "Mapped 6 HubSpot stages to the panel's 5; reporting in GBP, converted 3 USD deals at an approximate ~0.79").

### Step 2c — Completeness check (real data only; demo skips this)

After mapping and Step 2b coercion, run **one** completeness pass over the open deals before building anything. Do not stop at the first missing field; **accumulate every gap and present them together**, then let me decide. This replaces the scattered "stop and ask" behaviours in the schema table — collect, report once, ask once.

Classify fields:

- **Required (panel can't render correctly without them):** `prospect_name`, `company_name`, `stage`, `deal_value`, `last_touch`.
- **Feature fields (panel renders, but a feature silently thins without them):** `prospect_email` (commitments + meeting prep), `expected_close` (the "this month" pipeline KPI), `monthly_value` (retainer deals only).

Count rows missing each field, then post a single plain-language summary, e.g.:

> Before I build, here's what's missing in your data:
> - last_touch: 4 of 22 deals (required — these can't appear in Stale and I'd have to estimate them)
> - email: 14 of 22 (commitments and meeting prep will only work for the 8 that have it)
> - expected_close: 3 of 22 (these drop out of the "pipeline this month" total)
>
> Want me to carry on with these gaps, or pause so you can fill them in?

Then wait. **If I say carry on:** apply the documented fallbacks (compose `prospect_name` where possible; `last_touch` empty → `expected_close - 30 days`, and if `expected_close` is *also* blank flag the deal as undateable and leave it out of Stale rather than inventing a date; blank `expected_close` → excluded from the month KPI; blank email → name-match only) and note in one line what you filled or excluded. **If I say I'll fill them:** tell me which columns to add and stop until I re-drop the file. **Excel/CSV** gets this full check. **A CRM export** should already be complete, so if gaps show, say so plainly ("your CRM export is missing X, which is unusual") since it usually points at the wrong export view.

**If the data genuinely can't be ingested** (unreadable file, schema you can't map, repeated parse failures, or the build won't populate from real data), don't loop or fail silently. Stop and say: "I couldn't get your data loaded cleanly. Please reach out to Sully at sullyfazie@gmail.com and he'll help you fix this." Offer to fall back to demo data in the meantime so I still see the panel.

### Cohort folder

Each demo or cohort lives in its own subfolder under the workspace so multiple cohorts don't tangle (I might run a demo and a real-data version side-by-side; people who paste this prompt will be running their own demo).

Pick a short cohort label: one word, lowercase, hyphens fine. Derive it from the buyer cohort in my Q1 offer sentence: "AI consulting for UK accountancy firms" becomes `accountancy-firms`; "marketing services for SaaS founders" becomes `saas-founders`. If the offer is too generic to derive a useful cohort (just "software" or "consulting"), default to `default`. If I explicitly name a cohort in my message ("call this run X"), use that instead. If the filename I dropped already hints at the cohort (e.g. `deals-marketing-agency.xlsx`), prefer that hint.

Tell me in one line which label you picked.

Create `cohort-<label>/` under the workspace root if it doesn't exist. If it already exists with a different `deals.xlsx` inside, append `-v2` (or `-v3`, etc.) and use that. Save the cleaned file as `cohort-<label>/deals.xlsx`. The artifact reads `deals.xlsx` exclusively from this cohort path. Use this same path everywhere a path is needed downstream: the build script, the scheduled refresh task, the CRM sync task.

### Companion sheets (create if absent)

- **Commitments** — commit_id, promise_text, source_quote (≤120 chars), source_type (Email/Call), source_date, owed_to, deal_id, linked_value, due_date, status, created_date.
- **Plan** — task_id, task_text, source_type (Commit/Stale/Prep/Manual/Carry/Retainer), linked_id, status, date_added, date_completed, carry_count.
- **Daily snapshot** — date, tasks_total, tasks_done, tasks_carried, meetings_held, replies_received, commits_added, commits_closed.

## Step 3 — Build the artifact

Single self-contained HTML file. Inline all CSS/JS. Light mode only.

### Build pipeline (mandatory)

1. Build the JSON data object in Python from cleaned `deals.xlsx`. Normalise NaN → null, dates → ISO strings, integer-valued floats → int.
2. **Embed open deals only.** The panel only ever shows open pipeline, today's actions and recent commitments, so embed only deals whose stage is in (Discovery, Demo, Proposal, Negotiation, Active Retainer). Closed Won / Closed Lost history stays in `deals.xlsx` but is not inlined. Drop the heavy `notes` free-text from the embedded copy. This keeps the inline JSON small even when the source file has tens of thousands of historical rows (a 50k-row export drops from ~27 MB inlined to a few MB).
3. Embed it into the HTML template using a `const DATA = __DATA_JSON__;` placeholder + Python `str.replace`. Do not build via string concatenation — apostrophes, em dashes, and unicode in deal notes will break things.
4. Run `node --check` against the embedded `<script>` block before pushing. The `£`-in-identifier bug, smart-quote escapes, and accidental `</script>` strings all blank the panel silently; node catches them in under a second.
5. Choose the artifact id by data source so a demo run never overwrites a real panel: if I answered Q2 with **demo data**, use id `sales-control-panel-demo`; if I answered with **real/live data**, use id `sales-control-panel`. Then call `list_artifacts`: if the chosen id already exists, use `update_artifact`, otherwise `create_artifact`. Use this same chosen id everywhere downstream (the scheduled refresh task's `Artifact id` line in Step 5 must match it).

### Visual design tokens (do not deviate)

```
--bg:#f6f5ef  --bg-soft:#f1efe7  --card:#ffffff  --border:#e8e5dc
--ink:#1a1a1a  --ink-muted:#6a6a6a  --ink-soft:#8a8a8a
--accent:#1f5f4a  --accent-soft:#ecf5f1
--warn:#b91c1c  --warn-soft:#fef1f1
--warm:#92400e  --warm-soft:#fef6ec
--purple:#5b21b6  --purple-soft:#f3eefc
--blue:#1e40af  --blue-soft:#eef2fb
shadow:0 1px 2px rgba(15,23,42,0.04)
```

Font: `-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif`. No animation > 300ms. Tabular numerals on any number column.

### Money format — compact everywhere

`£131k`, `£28.8k`, `£1.3m`. Never `£131,000`. Retainers show monthly value as `£6k/mo` when space is tight. Apply to KPIs, forecast banner, stage rows, stale values, meeting values, commit values, drill-downs.

The currency symbol is the **reporting currency** detected in Step 2b (default `£`). Inject it as a constant rather than hard-coding `£`, so a USD or EUR shop renders `$131k` / `€131k`. Use the matching `toLocaleString` locale (`en-GB` for GBP, `en-US` for USD, etc.; default `en-GB`).

```js
const CUR = '£';            // reporting-currency symbol from Step 2b
const CUR_LOCALE = 'en-GB'; // matching locale
const fmtMoneyCompact = n => {
  if (n == null || isNaN(n)) return '-';
  const abs = Math.abs(n);
  if (abs >= 1e6) { const m = n/1e6; return CUR + (m%1===0?m.toFixed(0):m.toFixed(1)) + 'm'; }
  if (abs >= 1e3) { const k = n/1e3; return CUR + (k%1===0?k.toFixed(0):k.toFixed(1)) + 'k'; }
  return CUR + Math.round(n).toLocaleString(CUR_LOCALE);
};
```

### Responsive

KPI strip stays 4-across at all widths down to 480px, then 2×2 below. Never let it collapse to a single column. Today and Pipeline rows can stack at ≤900px.

```css
.kpi-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
@media (max-width:900px){.row.today,.row.pipeline{grid-template-columns:1fr}}
@media (max-width:480px){.kpi-strip{grid-template-columns:repeat(2,1fr)}}
```

### KPI card alignment

`display:flex;flex-direction:column;min-height:104px`. Label has `min-height:28px;line-height:1.25` so single-line and wrapped labels reserve the same vertical space. Push the sub-line with `margin-top:auto`. Keeps the big numbers at the same y-coordinate across all four.

### Zone 1 — KPI strip

- **Action needed today** — count of (commits due today + overdue). Number red if > 0. Sub: `X overdue · Y due today`.
- **Stale deals** — count where `today - last_touch >= [stale threshold]` (note `>=`, not `>`; threshold is the Q3 value, default 5). Sub: `Over [N]-day cadence · X whales`.
- **Pipeline this month** — `SUM(deal_value × probability)` where `expected_close ≤ end of current month` AND stage not in (Closed Won, Closed Lost, Active Retainer). Sub: `Weighted, close by [last day of month]`.
- **Meetings today** — count from Calendar (or mocks). Sub: `Next at HH:MM` or `No events today`.

### Zone 2 — Today (split 1.5fr / 1fr)

**Left: Today's plan.** Header: title, sub "Auto-seeded from commitments, stale deals and meeting prep", right meta chip `X / Y`. Below: `X of Y done`. Then a dashed-border quick-capture input. Then a `Show N completed` toggle (collapsed by default). Then the list.

Seed order (priority): Open commits with `due_date ≤ today` (badge Commit/red) → top 2-3 stale deals by `days_cold × deal_value` (Stale/warm) → open commits owed to today's meeting attendees (Prep/blue) → retainer renewals within 7 days (Retainer/purple) → yesterday's open carries (Carry/warm, increment carry_count) → manual quick-capture (Manual/accent).

**Dedupe: each deal appears at most once on the plan.** Walk the seed order top to bottom; the first (highest-priority) source that pulls in a given deal wins, and later sources skip it. So a deal that is both an overdue commit and a stale deal shows once as a Commit, not twice. Manual tasks are exempt (always kept). Count toward the 6-10 total after dedupe.

Total 6-10 tasks. Pad with a prospecting task if under 4. Scroll within `max-height:480px` if over 10. Completed tasks strike through and grey out. Manual tasks get a hover X; auto-seeded tasks are not deletable.

**Right: Today's meetings.** Header: title, sub "Live from Google Calendar" (or fallback), right meta chip `N today`.

Pull today's events from Calendar. For each event: take external attendees only (drop yourself and same-domain colleagues — "your" domain is inferred from the connected mailbox, see Domain inference below), then match to a deal using this order so a shared domain can't attach the wrong prospect:

1. **Exact email match** to a deal's `prospect_email` — strongest, use it first.
2. **Domain match** only if the domain is *not* a generic/free provider (gmail.com, outlook.com, hotmail.com, yahoo.com, icloud.com, etc. — never domain-match on those) **and** exactly one open deal has that domain. If two or more deals share the domain (multiple contacts at one account, parent/subsidiary), do not guess: pick none, show the meeting with its raw title, and flag it `ambiguous — multiple deals at this domain`.
3. No match → show the event with its raw title and no prep.

Use the event start time. Handle the messy cases: an all-day event has a `date` not a `dateTime`, so show "All day" rather than crashing; an event with no external attendees (a focus block, an internal standup) has no prospect, so do not invent one; if the matched attendee's `responseStatus` is `declined`, still show the meeting but flag it.

Row: time (50px bold tabular) · prospect name + firm · context line · value in accent green · stage badge · chevron. Click to expand inline prep. Only one expanded at a time.

Inline prep: soft-background, green "Meeting prep" pill, two-column grid.

**Left column:**
- **AI talking points** — exactly 2 bullets. Each is short (10–15 words), action-led (verb-first: "Lead with…", "Confirm…", "Reference…"), and specific (quotes or paraphrases the prospect's actual language from notes/transcripts/emails). Preserve casing from source fields. At build time these are seeded from `deal.notes` + the top open commit; on expansion the artifact MAY call `window.cowork.askClaude` with the deal, summary, recent emails and open commits to regenerate sharper ones.
- **Last call summary** — italic muted text from whichever call tool is connected. Clickable, with `Open in [Tool name] →`. If none, `No previous calls.`

**Right column:**
- **Recent emails** — exactly 2 (1 received, 1 sent). Direction, subject, snippet, time, and an open-link labelled for the resolved provider. Gmail: `Open in Gmail →`, URL `https://mail.google.com/mail/u/0/#inbox/<thread_id>`. Microsoft 365: `Open in Outlook →`, URL `https://outlook.office.com/mail/inbox/id/<message_id>` (use the Graph message/conversation id the mail tool returns). If the provider is unknown, label it `Open email →` and drop the link rather than guessing a URL.
- **Open commitments** — list where `owed_to = prospect_name` and `status = Open`. Promise + due-date badge. Empty state: `No open commitments yet.`

### Zone 3 — Commitments tracker (full width)

Read-only table. Sub: "Promises extracted from your sent email and [call tool name] transcripts · read-only · click a row to open source".

**Empty-state must state the reason, never just blank.** When the table has no rows, render one explanatory line, picking the true cause: if no email/call connector resolved → "No commitments yet — connect email or a call tool to auto-extract promises."; if connectors resolved but over 30% of deals have blank emails → "No commitments matched — [N] of [M] deals have no email, so promises can't be linked. Add an email column."; if connectors resolved and emails are present but nothing was found → "No open promises found in your recent sent email or calls." Same logic applies to the meeting-prep "Open commitments" sub-list.

```css
.commit-table{table-layout:fixed}
.commit-table td:nth-child(1){width:auto}  /* Promise */
.commit-table td:nth-child(2){width:22%}   /* Owed to */
.commit-table td:nth-child(3){width:18%}   /* Deal */
.commit-table td:nth-child(4){width:90px;text-align:right} /* Value */
.commit-table td:nth-child(5){width:100px} /* Due */
```

Due badges: `min-width:58px;text-align:center;white-space:nowrap` (otherwise `2d late` balloons into an oval).

Source dot: blue for email, purple for call. Click row to open source (the email thread URL for the resolved provider per Zone 2, or the call tool URL). Legend strip below.

**What counts as a commitment (precision matters — this is the headline feature).** A commitment is a **concrete promise by me (the seller) to do a specific thing**, ideally with an implied or stated deadline. Extract these. Do not extract vague intentions, the prospect's promises, pleasantries, or questions. Bias toward precision: if it isn't clearly a promise I made, leave it out — a short accurate ledger beats a padded one.

- **Capture:** "I'll send the proposal by Friday." · "I'll loop in our solutions engineer this week." · "We'll get you revised pricing tomorrow." · "I'll follow up with the case studies."
- **Skip:** "We should catch up soon." (vague, no commitment) · "Let me know what you think." (no promise) · "Can you send me the deck?" (their ask, not mine) · "Thanks, great chatting." (pleasantry) · "I think we can probably help." (opinion, not a deliverable).
- **Ownership:** only promises where the doer is me or my team. A prospect saying "I'll get sign-off" is **their** commitment — do not log it as mine.
- **Due dates:** set `due_date` only from explicit timing ("by Friday", "tomorrow", "next week" → resolve to a date relative to the message date). If timing is absent or fuzzy, leave `due_date` blank rather than guessing.

### Zone 4 — Pipeline (split equal)

**Left: Stale deals.** Each row: warm-soft tile (`#fef6ec`) if days_cold ≤ 7, warn-soft (`#fef1f1`) if ≥ 8 — big number + tiny "days" label. Body: status dot + prospect name + optional WHALE tag (red-soft, **left side**, after the name). Sub: `Stage · last touch [date]`. Right: tabular bold value. Whale tag never glues to the value.

**Right: Pipeline forecast.** Green-accent banner: `Weighted, expected close ≤ [last day of month]` label, large value, sub `From £X total weighted across N open deals`. Then four stage rows: dot colours `#c7d2fe → #93c5fd → #60a5fa → #2563eb` (Discovery → Negotiation). Click stage to expand a drill-down. **Cap each drill-down at the top 20 deals by value, with a `+N more` line and the stage total**, so a stage holding thousands of open deals does not build thousands of DOM rows. Each drill row: name, company, days-since-touch, value. Bottom of drill-down: `Open [stage] stage in Excel →` button (toast: "Filter Stage column in deals.xlsx").

### Header

Brand mark (40px navy `#0f172a` square, white SVG) by default. **Optional logo (real-data runs only):** offer once, "drop a logo image for the header, or I'll keep the default mark." If I provide one, embed it base64 in place of the square (constrain to 40px height, preserve aspect ratio); if I skip, keep the default mark. Don't auto-scrape a logo from a website — favicons come out low-res. Title "Sales Control Panel". Sub `[Friday, 15 May 2026 format] · Good morning, [first name]`. First name comes from the env `user` block; if absent, drop the suffix.

Right side: chip row showing all five services (email, Calendar, call tool, prospect enrichment, Excel). Label the email chip for the resolved provider: "Gmail" if Gmail resolved, "Outlook" if Microsoft 365 resolved, plain "Email" if neither. Connected = green `#22c55e` dot + full-opacity text; disconnected = grey `#cbd5e1` dot + muted text. Below: `Auto-refreshed at HH:MM · Yesterday` (Yesterday is a clickable link → toast with snapshot stats).

### Footer

`Data sources: [connected services only] · Built in Cowork · Refreshes daily at [refresh time]`.

The refresh time string is rewritten by the daily refresh task using the actual scheduler response, not hard-coded.

### Badges (uniform)

`font-size:10px;padding:2px 8px;border-radius:100px;font-weight:600`. No badge style heavier than the others.

- commit: warn-soft / warn
- stale, carry: warm-soft / warm
- prep: blue-soft / blue
- retainer: purple-soft / purple
- manual: accent-soft / accent
- whale: warn-soft / warn, after prospect name
- demo: `font-size:9px`, bg-soft / ink-soft, uppercase, on any mocked content

### Mocks — bounded set

The artifact must render populated even when connectors return empty. Mocks are scoped to: the four meetings in MOCK_MEETINGS, plus the top 3 stale deals (by days × value). All other deals fall back to "No previous calls." / "No recent emails." Do not author mocks for all 22 seed deals.

```js
const MOCK_MEETINGS = [
  {time:'10:30', deal_id:'D-001', stage_label:'Discovery',  context:'first call today'},
  {time:'12:00', deal_id:'D-009', stage_label:'Demo',       context:'asked about pricing'},
  {time:'15:00', deal_id:'D-005', stage_label:'Discovery',  context:'first call today'},
  {time:'16:30', deal_id:'D-015', stage_label:'Proposal',   context:'decision this week'}
];
```

Stage labels in mocks must be canonical (Discovery, Demo, Proposal, Negotiation, Active Retainer) — never editorial variants like "Demo 2" or "Intro".

Each `window.cowork.callMcpTool(...)` is wrapped in try/catch. Both errors and empty/null responses fall through to mocks. Live data always takes precedence; the `Demo data` badge only renders when mocks fire.

## Step 4 — Quick capture

Plan card includes a dashed-border input. Enter → prepend a new task with `Manual` badge, show toast `Added to today's plan`, X visible on hover. Manual-only. Does not route to commitments.

## Step 5 — Schedule daily refresh

`mcp__scheduled-tasks__create_scheduled_task`. Default cron `0 5 * * *` (local time).

**Avoid stacking refresh tasks.** Before creating one, call `mcp__scheduled-tasks__list_scheduled_tasks`. Schedule exactly one refresh per artifact id. When building a **real** panel after a demo, remove (or repoint) the leftover `sales-control-panel-demo` refresh so you don't keep a daily task grinding on fake data; a real seller shouldn't have two panels refreshing each morning. If a refresh for the current artifact id already exists, update it rather than adding a duplicate.

**Read the actual schedule string from the scheduler response** (it applies a small dispatch delay; e.g. "At 05:05 AM, every day"). Use that string when telling me what time it will run, and substitute it into the artifact footer on the next refresh.

Task prompt (substitute `[OFFER SENTENCE]` with the answer to Q1, `[COHORT FOLDER]` with the cohort folder chosen in Step 2, and `[ARTIFACT ID]` with the id chosen in Step 3 build pipeline step 5: `sales-control-panel-demo` for a demo run, `sales-control-panel` for a real-data run):

> You are the daily refresh task for the Sales Control Panel. The user sells: [OFFER SENTENCE]. State of truth: `[COHORT FOLDER]/deals.xlsx`. Artifact id: `[ARTIFACT ID]`.
>
> 1. Load `[COHORT FOLDER]/deals.xlsx`. If missing or unreadable, stop and notify, and add: "If this keeps happening, reach out to Sully at sullyfazie@gmail.com."
> 1b. **If `[ARTIFACT ID]` ends in `-demo`, this is the demo panel: re-anchor the seed dates before anything else.** Shift every `last_touch` and `expected_close` so each row keeps its original offset relative to *today's* run date (i.e. recompute as `today - N` / `today + N` using the N baked in at seed time), then save. This keeps roughly 6-9 of the deals stale at the 5-day threshold every day instead of the whole set ageing into stale. Do not run live email/call extraction on a demo panel; its commitments come from the seed. Skip to step 5.
> 2. Pull sent emails. Gmail: `__search_threads` with `in:sent newer_than:1d`. Microsoft 365: the resolved Graph mail tool, filtering to sent items in the last day (Graph has no `in:sent`/`newer_than` — use its sent-folder + received/sent-date filter equivalent). Identify explicit promises. Match recipient to a prospect by email or domain, then by exact full name if no email/domain hit.
> 3. If a call-transcription tool is connected, pull new transcripts and extract verbal commitments, matching the external participant to a prospect by email, domain, or exact name. If the call tool is a phone-dialer that only provides a number, skip and note it.
> 4. Append new commitments, applying the commitment definition in Zone 3 (only concrete promises **I/my team** made; skip vague intentions, the prospect's promises, pleasantries, questions; bias to precision). Concise paraphrase, verbatim quote ≤120 chars, due_date only from explicit timing relative to send date (blank if ambiguous).
> 5. Recompute the Plan sheet (priority order above). Keep 6-10 tasks.
> 6. Append today's row to Daily snapshot.
> 7. Save `[COHORT FOLDER]/deals.xlsx`. Regenerate the artifact HTML by replacing the `const DATA` JSON literal with fresh data (open deals only, notes dropped); update header date and refresh time. Run `node --check` before pushing. Call `update_artifact`.
>
> Rules: British English. No em dashes as connectors. Never fabricate (the demo re-anchor in step 1b is the one allowed date shift, and only on a `-demo` artifact). Stage default probabilities as above. Whale = top 10% by comparable value across all open deals, tie-inclusive (comparable value = deal_value for one-offs, monthly_value × 12 for retainers; flag every deal at or above the 10% cutoff rank). Money format `£131k` compact, retainers `£6k/mo`. Preserve casing from source fields. Never use non-ASCII characters in JS identifiers. Stale threshold = the stored value (default 5; demo always 5).
>
> End with: `Daily refresh: N commits added, M tasks on today's plan, plan ready.`

## Step 6 — CRM sync (only if I answered Q2(c))

Register a second scheduled task at `30 4 * * *` (runs before the 05:00 refresh). Pulls open deals from my CRM and merges into `[COHORT FOLDER]/deals.xlsx`, running them through the Step 2b adapter (stage mapping, won/lost status, currency normalisation to the reporting currency, date parsing, name composition) before the merge. Preserve `deal_id` and user-added notes. Updates the `whale` flag. Does not touch Commitments, Plan, or Daily snapshot — those belong to the daily refresh.

## Operating rules

1. Never invent critical fields. Missing data = ask, or blank.
2. The artifact reads `deals.xlsx` only. Never queries a CRM directly.
3. Source links degrade silently. Missing email tool (Gmail or Microsoft) → drop email links. Missing call tool → drop call links.
4. One source of truth per fact. Stale = `today - last_touch`. Pipeline = `deal_value × probability`. Compute on read.
5. British English.
6. No em dashes as connectors in any user-facing copy (artifact, scheduled task summaries, confirmation messages).
7. Plan list 6-10. Pad if under 4; scroll if over 10.
8. Whale = top 10% by comparable value across all open deals, tie-inclusive. Comparable value = `deal_value` for one-offs, `monthly_value × 12` for retainers. Rank all open deals, take the value at the 10% cutoff rank (round, minimum 1), flag every deal at or above that cutoff. Boundary ties all flagged (so a tie at the cutoff flags both).
9. CRM users: separate sync task that runs through the Step 2b adapter. Artifact still only reads `deals.xlsx`.
10. Demo fallback always on. Mocks render only when live calls return empty. `Demo data` tag is mandatory on mocked content.
11. Validate JS before pushing. The `£`-in-identifier bug blanks the panel silently.
12. Stale threshold = the Q3 value (default 5). Demo always uses 5. Store it in `deals.xlsx` meta (or the refresh task) so the daily refresh uses the same number, and label the Stale KPI sub-line with it.
13. Coerce real sources through Step 2b before mapping: map stages onto the five, read won/lost status, settle on one reporting currency (convert only when mixed, with an approximate-rate flag), parse each date cell independently, compose a prospect name. Embed open deals only.
14. Support escalation: if real-data ingestion, the build, or the daily refresh fails in a way you can't resolve, surface it plainly and tell me to **reach out to Sully at sullyfazie@gmail.com**. Never leave a real-data run broken or silently empty without that line.

## Confirmation message

When done, post one short summary. Money values in compact format. Use the scheduler's actual schedule string.

```
Built. Connectors: [list]. Loaded N deals, M commits. Today's numbers: action X, stale Y (over [threshold]-day cadence), pipeline this month [reporting currency]Zk. Daily refresh: [scheduler string, e.g. "At 05:05 AM, every day"]. Tell me what to tweak.
```

Then stop.

---

## Appendix A — First-run demo seed

22 deals across 5 stages, sized for an SMB or mid-market services seller. Today is the run date; compute `last_touch` and `expected_close` as `today - N days` and `today + N days` at write time, not as absolute dates. **Record the per-row offset N somewhere stable (a `seed_offset_days` column, or a fixed offset table in the refresh task) so the demo refresh (Step 5 step 1b) can re-anchor these dates to each day's run and the stale ratio holds. Without a stored offset the demo ages into all-stale within a week.**

Match the buyer cohort to my Q1 offer sentence. If I sell to accountancy firms, seed accountancy firms; if to marketing agencies, seed marketing agencies; if to manufacturers, seed manufacturers. Pick a plausible UK-based mid-market industry from the offer if it's ambiguous. All firm names fictional, locations spread across UK cities, deal sizes proportionate to the cohort (one-off projects £8k-£35k, retainers £4k-£10k/mo by default; scale up if the offer implies enterprise). Tell me in one line which cohort you seeded.

8 Discovery, 6 Demo, 4 Proposal, 2 Negotiation, 2 Active Retainer. Whales auto-flag from top 10%; typically D-016 (£50k), D-019 (£45k), D-020 (£45k). 8 open commitments seeded into Commitments sheet.

Tune `last_touch` offsets so 6-9 deals land stale at the 5-day threshold. Avoid 18-of-20 stale, looks broken not informative.

Before you finish (quick checks)

New How was this skill built? (Click here)
How was this skill built?

Every skill ships only after planning, research, building, testing and re-testing. Here is the record for the Sales Control Panel.

3 stress-test rounds 16 improvements shipped 74 automated checks
Planning
Mapped the five panel zones, the data model and the edge cases before a line of code.
Research & background checks
Real CRM export formats, connector behaviour, and the currency and date pitfalls that silently break dashboards.
Build
A single self-contained panel, values computed live, JavaScript validated before every release.
Testing & re-testing
Render, ingestion, integration and big-file suites. Every failure reproduced in code, fixed, then retested.
Organisation
Versioned prompt, a full changelog, and a daily auto-refresh so the panel keeps working after setup.
Key strengths
Works with Gmail or Outlook, and reads real CRM exports from HubSpot, Salesforce and Pipedrive.
Handles multiple currencies and messy date formats without silently dropping deals.
Never invents data: missing fields are flagged and you decide before it builds.
Want the full video walkthrough?
You'll find it with my best AI-for-sales prompts in my community, full of people who sell stuff working with AI.
Join free
Sully Fazie
By Sully Fazie
sullyfazie.com