# Daily Performance Report

> Generate a Daily Performance Console — multi-channel operational dashboard for performance marketers. Blended KPI strip (Spend / Impressions / Clicks / CTR / Conversions / CPA / CR / ROAS), per-channel split with health flags, universal trend chart with date / metric / channel filters, and three tabs (Warnings / Recommendations / Monthly Pacing). Discovery API only, all data live. Built via /business-intelligence-editor as ONE custom-component widget that contains the entire console.

- Skill: `tekliner/daily-performance-report` (Agent Skill)
- Install (CLI): `npx skillmds@latest add tekliner/daily-performance-report`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tekliner/daily-performance-report/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: tekliner (https://skillmd.com/u/tekliner)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/tekliner/daily-performance-report

---


# Daily Performance Console

## Purpose

Produce a daily operational dashboard for performance marketers running multi-platform paid media. Single-screen, blended-then-drilled, action-oriented:

- **Glance KPI strip** — 8 blended metrics with DoD deltas + MTD pacing flags.
- **Per-channel split** — health card per platform (spend, daily avg, ROAS, CPA, plan-pct meter).
- **Revenue progress bar** — current vs monthly plan, EOM projection, variance.
- **Universal trend chart** — line chart with three filter dropdowns (Granularity / Metric × 1–2 / Channels) + date-range picker.
- **Tabs** — Warnings (cap-exhaustion, CPA spikes, ad-disapprovals), Recommendations (3 actionable cards), Monthly Pacing (channel-stacked bar).

The console is **operational** (read 5–20× per day, act on warnings within hours), not a brief or a deck. Sharing happens via dashboard URL or screenshot — there is no Slack-ready text artifact.

*Derived from Marketing OS use-case UC-DPA-1 v2.1 (Daily Performance Console). Canonical visual contract lives in the Improvado Obsidian vault and is mirrored as the HTML in Appendix A below — that HTML is the single source of truth for the widget structure.*

This skill **replaces v1.0.0** (the prior tactical Slack-brief format). The XML `<analytics><changes><actions>` artifact and "VP-of-Marketing voice" are deprecated. v2.0.0 ships a dashboard.

---

## Invocation Context

Two trigger paths feed this skill:

### Path A — Onboarding dispatch (new users)

**Company personalization:** if the dispatch message carries an `alg-prebrief` fenced block with a `company_research:` line, tailor exactly ONE headline/intro sentence in the closing chat hand-off to this company — always hedged ("looks like…"), never as a fact. If absent, proceed generically.

If the conversation contains an `onboarding_summary` message with `Interview answers:`, scan those answers for **bias signals**:

- **Role**
  - `marketer` / `performance-marketer` → operator voice; KPI strip surfaces all 8 metrics; Warnings tab is the default landing tab; Recommendations card severity emphasised
  - `cmo-director` / `c-level` → executive voice; subtitle prepends a one-line summary of pacing + top warning; default tab is **Monthly Pacing** instead of Warnings
  - `analyst-bi` → analyst voice; Universal trend chart pre-selects two metrics (CPA + ROAS) so the dual-axis is visible from first paint; warnings + recos shown but de-emphasised
- **Reconciled metrics** — if `ROAS` / `CPA` listed as primary, pre-pin those as the default Universal-chart metrics (overrides default `spend`)
- **AI wish** — if mentions "what's wasting money" / "wasted spend", auto-open the **Action needed** recommendation card; if mentions "pacing" / "vs plan", default tab → Monthly Pacing
- **Reporting cadence** — if mentions "daily standup" / "morning review", set auto-refresh suggestion to weekday-mornings; if "weekly review", suggest weekly cadence

### Path B — Free-form chat (returning users, repeat use)

User asks naturally — "give me today's report", "what's running today", "show me the daily numbers", "where am I vs plan". No `onboarding_summary`; use defaults (Warnings tab open, Spend selected in chart, all channels visible).

---

## When to Trigger

User asks for any of:

- "daily report", "daily performance", "daily KPI report"
- "daily console", "daily dashboard", "show me my numbers"
- "what's running today", "what changed this week"
- "send me the daily numbers", "leadership-ready report"
- "where am I vs plan", "pacing dashboard"
- "send to leadership", "daily VP brief"
- "tuesday vs tuesday", "drill into <channel>"
- "spend ROAS CPA today", "week over week performance"

If the user instead asks for **funnel-stage / executive cross-channel narrative** ("CMO dashboard", "Awareness vs Consideration vs Purchase", "where is my money going across the funnel") — fall back to a different skill (CMO Cross-Channel Dashboard, separate UC).

If the user wants an **ad-hoc single-channel chart** ("show me Meta spend chart") — use `/business-intelligence-editor` directly with the relevant single-source widget; do NOT invoke this skill.

If the user wants an **exhaustive rule-quality scorecard** with per-rule $-at-risk — invoke the audit-orchestrator skill instead.

---

## Output Mode

```
DEFAULT → Mode B: Dashboard via /business-intelligence-editor (ONE custom-component widget)
IF user explicitly says "document" / "markdown" / "deck" → Mode A: createDocument fallback
IF user asks for one chart → Mode C: visualizationTool (single chart in chat)
```

### Mode B: Dashboard (DEFAULT)

Use `/business-intelligence-editor` skill with **ONE** `custom-component` widget. The entire console (KPI strip, channel split, universal chart, tabs, warnings, recommendations, pacing) lives inside that single widget — its `componentCode` is the canonical HTML from Appendix A with Discovery API data substituted at the marked INJECT points.

**This is the load-bearing pattern.** Do NOT decompose the console into multiple BI widgets (KPI tile widget + chart widget + table widget — that breaks the visual contract). The console is ONE atomic widget.

If `/business-intelligence-editor` is unavailable at runtime, **degrade to Mode A** with a one-line note ("Saved as a document — dashboard skill not available right now.").

### Mode A: Document (fallback)

Use `createDocument`. Markdown report with:
- KPI table (8 metrics × today / DoD / MTD / pacing flag columns)
- Channel split table
- Warnings list
- Recommendations list with $-impact

No HTML, no charts. This mode is the v1-style deliverable — kept only as a fallback when the dashboard mode is unavailable.

### Mode C: Quick Visualization (single chart)

Use `visualizationTool` for a single chart in chat. Useful when user asks "just show me spend trend by channel". Skip the dashboard / document; return one chart with the requested metric × channel × granularity.

### Auto-Refresh Strategy

The console is a point-in-time snapshot — but the natural cadence is daily. After save, offer:

> "Want me to refresh this dashboard every morning at 9 AM with the latest 24h of data?"

If yes → `scheduleChatTool` re-runs the skill daily at 09:00 user-local. Each run pulls a fresh 30-day rolling window for the chart and refreshes today's KPI / warnings / recos.

If user mentions "weekly review" in onboarding → suggest Monday morning cadence instead.

---

## MCP Tools Used

- `discoveryListConnectorsTool` — find ad-platform connectors per workspace
- `discoveryListAccountsTool` — resolve ad accounts when >1 per platform (use § 0.1 account-selection rule from Marketing OS — ask user if >5)
- `discoveryRequestTool` — fetch all KPI / channel / daily-trend / drill data (Rule 0 — see below)
- `clickhouseTool` — **NOT used by default**; only as fallback for 28-day σ-baseline computations if the channel's API doesn't expose history
- `getCurrentWorkspaceContextTool` — workspace name + ID for header
- `getConnectionsTool` — enumerate active marketing platforms (without `datasourceName` filter)
- `/business-intelligence-editor` skill — Mode B dashboard save
- `createDocument` — Mode A fallback
- `visualizationTool` — Mode C single chart
- `scheduleChatTool` — daily auto-refresh

For ClickHouse-fallback table coverage (rare path, only when API history is unavailable), see `discovery-api/marketing-platforms-reference.md` in this repo.

---

## Rule 0 — Discovery API mandatory for ALL data queries (HARD)

**EVERY data query for this skill MUST go through `discoveryRequestTool`.** No exceptions. No fallbacks to `clickhousePalantirTool` for marketing data, no direct SDK calls, no local API keys, no scraped CSVs.

This applies to:
- All per-channel KPI fetches (Meta `/insights`, Google `searchStream`, TikTok `/report/integrated/get/`, LinkedIn `/rest/adAnalytics`, Pinterest `/v5/.../analytics`, etc.)
- 28-day σ-baseline windows (use API where available; ClickHouse fallback table only if the platform doesn't expose history)
- DoD comparison data (today vs same-day-last-week)
- MTD pacing aggregates
- Channel split metrics
- Universal-chart custom date ranges
- Warning-source data (search-term reports, asset-group breakdowns, ad-disapproval lists)
- Recommendation evidence (creative CTR-decay curves, IS-lost ratios)

**Flow:** `getAvailableClusters` → `createImpersonationContext` → `discoveryListConnectorsTool(dataSource=<channel>)` → `discoveryRequestTool(connectionId, method, path, body_data)`.

If a connector is missing — surface honestly: *"No active connection for `<channel>` in this workspace. Connect it at <link> to populate this section."* Do **NOT** mock data; do **NOT** fall through to other sources.

---

## Output Contract (HARD)

**The result of this skill is the canonical widget HTML (Appendix A) with every INJECT point filled by Discovery API data — NOT the canonical alone, NOT the data alone, NOT regenerated HTML.**

Two pieces, atomically combined:

```
  ┌────────────────────────┐      ┌──────────────────────────┐
  │ Canonical widget HTML  │      │ Discovery API responses  │
  │ (Appendix A)           │  +   │ (per § Discovery Mapping)│
  │ — fixed structure      │      │ — live workspace data    │
  │ — INJECT markers       │      │                          │
  │ — visual contract      │      │                          │
  └─────────┬──────────────┘      └────────────┬─────────────┘
            │                                  │
            └────────────► SUBSTITUTE ◄────────┘
                                │
                                ▼
                  ┌─────────────────────────────┐
                  │ Filled HTML → componentCode  │
                  │ for ONE custom widget        │
                  │ → /business-intelligence-    │
                  │   editor save                │
                  └─────────────────────────────┘
```

**The deliverable is NEVER:**
- The canonical alone (placeholders show literally — broken UI).
- A new HTML written from scratch (regeneration breaks visual fidelity).
- Data without the canonical (no UI to deliver).
- Canonical filled with mocked or static data (violates Rule 0).

---

## Quick Reference

- **Channels supported:** Google Ads, Meta, TikTok, Pinterest, LinkedIn, Reddit, The Trade Desk (any active Discovery API ad-platform connector). The widget chip row + KPI blend adapt to whatever is connected. **Minimum:** 2 active channels (else fall back to single-channel ad-hoc chart).
- **8 KPIs (in this fixed order):** Spend, Impressions, Clicks, CTR, Conversions, CPA, CR, ROAS.
- **Universal chart:** 1–2 metrics × 1–N channels × Daily/Weekly/Monthly granularity × custom date range. If 2 metrics — secondary y-axis, secondary line dashed.
- **Tabs:** Warnings (default) / Recommendations / Monthly Pacing.
- **Recommendations:** exactly **3 cards**, ranked by `|$-impact|`. Below threshold (<7 days data, <30 conversions, <$500 spend) — drop the candidate.
- **Visual chrome:** product chrome (Inter typography, indigo `#4F46E5` accent). NOT client-branded. No Brandfetch, no per-client palette.

---

## STEP 0: Workspace + Connection Detection

```
0A. Resolve workspace context
    ws = getCurrentWorkspaceContextTool(impersonation_context_id)
    → workspace_name, workspace_id

0B. Enumerate active ad-platform connections
    all_conns = getConnectionsTool(impersonation_context_id)  # no datasourceName filter
    active = [c for c in all_conns if c.is_active and c.data_source in MARKETING_PLATFORMS]
    by_ds = group_by(active, key=c.data_source)

0C. Connection count gate
    IF len(by_ds) < 2:
        Surface: "Daily Performance Console requires ≥2 active ad-platform connections. You have {N}.
                  Connect another platform, or use a single-channel chart instead."
        STOP — fall back to Mode C single-channel.

0D. Account selection per channel
    FOR each channel in by_ds:
        accounts = discoveryListAccountsTool(connectionId=channel.id)
        IF len(accounts) > 5:
            Ask user which account(s) to include (don't pick blindly — § 0.1 rule).
        IF len(accounts) ≥ 2 and ≤5:
            Use highest-spend account by default; surface choice in subtitle.
        IF len(accounts) == 1:
            Auto-select.
```

---

## STEP 1: Fetch Per-Channel Data (Discovery API parallel calls)

Run in parallel for every active channel. Each channel returns:
- Today's metrics (last 24h close)
- Same-day-last-week metrics (for DoD delta)
- MTD aggregates
- 30-day daily series (for chart granularity)
- Drill-deep data (search-term reports, asset-group breakdowns, ad-disapproval lists — only fetched when warnings/recos generation needs them)

### Google Ads — `googleAds:searchStream` via `discoveryRequestTool`

```
GAQL: SELECT
  segments.date,
  metrics.cost_micros,
  metrics.impressions,
  metrics.clicks,
  metrics.ctr,
  metrics.average_cpc,
  metrics.conversions,
  metrics.conversions_value,
  metrics.value_per_conversion,
  metrics.search_impression_share,
  metrics.search_budget_lost_impression_share
FROM customer
WHERE segments.date BETWEEN '<date_start>' AND '<date_end>'

# For warnings drill-deep: search_term_view, asset_group_performance_view, etc.
```

### Meta — `/v23.0/act_<id>/insights`

```
fields: spend, impressions, clicks, ctr, frequency, actions, action_values, purchase_roas
level: account
time_increment: 1
date_preset: custom
since: <date_start>, until: <date_end>

# For warnings drill: level=ad_set or level=ad with breakdowns
```

### TikTok — `/v1.3/report/integrated/get/`

```
data_level: AUCTION_ADVERTISER
dimensions: ['stat_time_day']
metrics: [spend, impressions, clicks, ctr, conversion, cost_per_conversion,
          complete_payment_value, complete_payment_roas]
start_date: <date_start>
end_date: <date_end>
```

### LinkedIn — `/rest/adAnalytics?q=analytics`

```
pivot: ACCOUNT
timeGranularity: DAILY
fields: spend, impressions, clicks, costInUsd, oneClickLeads, externalWebsiteConversions
dateRange: { start: ..., end: ... }
```

### Pinterest — `/v5/ad_accounts/<id>/analytics`

```
granularity: DAY
columns: SPEND_IN_DOLLAR, IMPRESSION_1, CLICKTHROUGH_1, CLICKTHROUGH_RATE,
         TOTAL_CONVERSIONS, COST_PER_CONVERSION, TOTAL_CONVERSION_VALUE_IN_DOLLAR
start_date: ..., end_date: ...
```

### Per-channel timeout

8s per channel. If a channel times out → fail that channel, continue others. Surface in topbar: `"Live · 12:08 UTC — Pinterest delayed (retry 12:13)"`. Do NOT mock; render `—` for missing channel cells.

---

## STEP 2: Compute Derived Fields

After parallel fetch:

```
2A. Blended KPIs (top strip, 8 metrics)
    today_spend     = SUM channel.today.spend across channels
    today_impr      = SUM channel.today.impressions
    today_clicks    = SUM channel.today.clicks
    today_ctr       = today_clicks / today_impr * 100
    today_conv      = SUM channel.today.conversions
    today_cpa       = today_spend / today_conv if today_conv > 0 else null
    today_cr        = today_conv / today_clicks * 100 if today_clicks > 0 else null
    today_revenue   = SUM channel.today.conversion_value
    today_roas      = today_revenue / today_spend if today_spend > 0 else null

    # Same for MTD aggregates (replace today.* with mtd.*)
    # Same for last-week-same-day (for DoD delta)

    DoD_delta(metric) = (today.metric - last_week.metric) / last_week.metric * 100
    # Use 'pp' (percentage points) for CTR, CR. Use '×' for ROAS.

2B. Pacing flags per metric
    pct_elapsed = days_elapsed_in_month / days_in_month * 100
    mtd_pct = mtd.metric / plan.metric * 100  # uses biz_workspace_plans
    deviation_pct = mtd_pct - pct_elapsed
    flag = 'ok' if abs(deviation_pct) ≤ 5
           else 'warn' if abs(deviation_pct) ≤ 15
           else 'over'

    EOM_projection = mtd.metric / pct_elapsed * 100   # linear extrapolation

2C. Channel split objects (per-channel cards)
    For each channel:
        mtd_spend = MTD spend
        daily_avg = mtd_spend / days_elapsed
        plan      = biz_workspace_plans.channel_spend_plans[channel] OR Edit-plan modal
        share     = mtd_spend / total_mtd_spend * 100
        roas, cpa = MTD-aggregated
        health    = 'ok' if (ROAS ≥ target AND CPA ≤ target)
                    else 'warn' if one missed
                    else 'bad' if both missed OR CPA >150% target
        note      = derived 1-liner from health + dominant signal

2D. Warnings — rule-based scan over fetched data
    See § "Warnings rule library" below.

2E. Recommendations — rule-based candidate generator + $-impact ranking
    See § "Recommendations rule library" below. Cap at 3.

2F. Pacing-block aggregation
    MTD_total_spend  = SUM channel.mtd_spend
    Plan_total_spend = SUM channel.plan
    EOM_proj_spend   = MTD_total_spend / pct_elapsed
    Variance         = (EOM_proj_spend - Plan_total_spend) / Plan_total_spend * 100

2G. Revenue progress bar
    rev_current  = SUM channel.mtd.conversion_value
    rev_plan     = biz_workspace_plans.revenue_plan OR Edit-plan modal
    rev_pct      = rev_current / rev_plan * 100
    rev_proj     = rev_current / pct_elapsed
    rev_proj_class = 'good' if rev_proj ≥ rev_plan
                     else 'warn' if rev_proj ≥ rev_plan * 0.95
                     else 'bad'
```

---

## STEP 3: Discovery API → INJECT-marker mapping

This is the **CONNECTING CONTRACT** between Appendix A's INJECT markers and live data. For every INJECT point, the source endpoint + transformation + format.

### Inline placeholders ({{INJECT:name}})

| Marker | Source | Transformation → Format | Example |
|---|---|---|---|
| `workspace_name` | `getCurrentWorkspaceContextTool()` | `.workspace_name` | `Apex Outdoors` |
| `workspace_id` | same | `.workspace_id` | `wkspc-7892` |
| `updated_at` | system clock (UTC) | `HH:mm UTC` | `12:08 UTC` |
| `period_label` | derived from selected range | `<MMM YYYY>` of start_date | `May 2026` |
| `day_counter` | derived from `now()` + month length | `Day {dom} of {dim} · {pct}% elapsed` | `Day 4 of 31 · 13% elapsed` |
| `date_range_label` | from date-picker state | `<MMM D> – <MMM D>` | `Apr 4 – May 4` |
| `date_start`, `date_end` | from date-picker | ISO `YYYY-MM-DD` | `2026-04-04` |
| `rev_current` | per-channel revenue (per Step 1 + 2G) | SUM, format `$N,NNN` | `$58,272` |
| `rev_plan` | `biz_workspace_plans.revenue_plan` ← OR ← Edit-plan modal | format `$NNN,NNN` | `$500,000` |
| `rev_pct` | `(rev_current / rev_plan) × 100` | 1 decimal `N.N%` | `11.7%` |
| `rev_proj_label` | `(rev_current / pct_elapsed)` + delta vs plan | `↗ Proj $XK · ±N% above\|below plan` | `↗ Proj $564K · +12.7% above plan` |
| `rev_proj_class` | rule on projection vs plan | `good`/`warn`/`bad` | `good` |
| `rev_fill_pct` | `min((rev_current / rev_plan) × 100, 100)` | `N.NN%` | `11.65%` |
| `rev_today_pct` | `(today_dom / days_in_month) × 100` | `N.N%` | `13.0%` |

### Block: `channels_data` (JS const C — variable channel count)

For each active channel from `getConnectionsTool` filtered to ad-platforms, emit one entry:

```js
const C = {
  <key>: {
    name:       string,            // 'Google', 'Meta', 'TikTok', 'Pinterest', 'LinkedIn', 'Reddit'
    color:      string,            // canonical brand hex (Google #4285F4, Meta #0866FF, TikTok #000000, Pinterest #E60023)
    logo:       string,            // SVG symbol id 'logo-google' / 'logo-meta' / 'logo-tiktok' / 'logo-pinterest' (from <defs>)
    mtd_spend:  number,            // dollars MTD
    daily_avg:  number,            // dollars/day MTD
    plan:       number,            // monthly spend plan from biz_workspace_plans
    share:      number,            // % of total spend
    roas:       number,            // ROAS x
    cpa:        number,            // dollars
    health:     'ok'|'warn'|'bad',
    note:       string             // ≤30 chars
  }
};
```

### Block: `today_kpis` (JS const TODAY — 8 metrics, fixed order)

```js
const TODAY = {
  spend:       { v, d, dc, mtd, plan, flag, note },
  impressions: { v, d, dc, mtd, plan, flag, note },
  clicks:      { v, d, dc, mtd, plan, flag, note },
  ctr:         { v, d, dc, mtd, plan, flag, note },
  conversions: { v, d, dc, mtd, plan, flag, note },
  cpa:         { v, d, dc, mtd, plan, flag, note },
  cr:          { v, d, dc, mtd, plan, flag, note },
  roas:        { v, d, dc, mtd, plan, flag, note }
};

// v   = today's value, formatted ($N,NNN / N / N.NN% / N.NNx)
// d   = DoD delta string with sign (+12% DoD, −9% DoD, +0.14pp)
// dc  = 'up'|'down'|'cost-up'|'cost-down' (cost-* for spend, CPA where increase = bad)
// mtd = abbreviated MTD ($13.5K, 1.42M, 742)
// plan = abbreviated plan ($120K, 11.4M, 6,200)
// flag = 'ok'|'warn'|'over' (per Step 2B)
// note = ≤20-char pacing line ('Proj +9%', 'On track', '−9% target')
```

### Block: `daily_base` (per-channel daily series)

For real data, replace the synthetic `genDaily()` function in canonical with pre-fetched arrays:

```js
const DAILY = {
  google:    { spend: [...], impressions: [...], clicks: [...], ctr: [...],
               conversions: [...], cpa: [...], cr: [...], roas: [...] },
  meta:      { ... },
  tiktok:    { ... },
  pinterest: { ... }
  // any other connected channel
};
```

Each array length = `(date_end − date_start)` in days. The `aggregate()` function in canonical handles weekly/monthly rollups from the daily base.

### Block: `warnings_block` (rule-based scan)

| Rule | Trigger | Severity | API source |
|---|---|---|---|
| Daily-budget-cap exhaustion | projected exhaustion before EOD AND positive-ROAS campaign | `critical` | per-campaign `daily_budget` + cumulative `cost` for current day |
| CPA spike vs target | CPA > target +50% AND spend ≥ $500 in last 3 days | `high` | per-ad-group/ad-set CPA |
| Ad disapproval | any disapproved ad serving | `info` | platform ad-status field |
| Creative fatigue | CTR decline >30% AND creative age >7 days | `high` | per-creative CTR over last 14 days |
| IS lost to budget | Google `search_budget_lost_impression_share` >25% on positive-ROAS campaign | `high` | Google `searchStream` |

Cap at 6 visible warnings; surface top by severity then $-impact. Each → one `<div class="warning ...">` per Appendix A widget shape.

### Block: `recos_block` (3-card cap, $-impact ranked)

| Type | Trigger | $-impact computation |
|---|---|---|
| **Shift & scale** (`opportunity`) | Channel A: CPA >150% target AND ROAS < target; Channel B: daily-budget-capped >50% of last 30d AND ROAS > target × 1.5 | shifted_spend × (B.ROAS − A.ROAS) per week × 4 |
| **Pause** (`action`) | spend ≥ $500 over ≥5 days AND zero/near-zero conversions AND no recovery trend | weekly_spend × 4 (savings) |
| **Expand & test** (`test`) | Creative theme/audience: CTR ≥ 2× account avg AND share <30% | (target_share − current_share) × current_revenue / current_share |

Rank by **|$-impact|**, take top 3. **Calibration threshold:** require ≥7d data AND (≥30 conversions OR ≥$500 spend) on the drill entity. Below threshold → drop candidate.

### Block: `pacing_block` (channel-stacked bar)

Channel segments come from `channels_data`. Header numbers from § Step 2F.

### Block: `plan_modal` (Edit Plan defaults)

Source: `biz_workspace_plans` (TODO if not exists). Fields: spend_plan, conversions_plan, target_cpa, revenue_plan, target_roas, target_ctr.

---

## STEP 4: Build Custom Widget HTML

Take the canonical HTML from **Appendix A**. Find every INJECT marker. Substitute per Step 3 mapping.

```
For each {{INJECT:name}} placeholder:
    Find the literal string {{INJECT:name}} in canonical
    Replace with computed value (string)

For each block marker pair:
    // === INJECT: <name> ===
    <existing default content>
    // === END INJECT: <name> ===

    Replace the inner content with the computed JSON / HTML.
    KEEP the marker pair intact (so future regen can find them).
```

**HARD RULE:** if you find yourself writing `<style>` or restructuring `<div class="kpi">` — STOP. You are regenerating, not substituting. Re-load Appendix A and substitute only at marked points. The canonical is NEVER edited per-render.

After substitution, the entire HTML is ONE string — assign it as `componentCode` of a single `custom-component` widget.

---

## STEP 5: Save Dashboard via /business-intelligence-editor

Assemble dashboard config:

```json
{
  "dashboardTitle": "Daily Performance Console",
  "dashboardSubtitle": "{N_active_channels} channels | {date_range_label} | Updated {updated_at}",
  "dashboardUrl": "clients/template/dashboards/CrossChannelEditableDashboard.tsx",
  "isMenuItem": true,
  "defaultTimePeriod": "30",
  "editState": {
    "schemaVersion": 2,
    "appearance": {
      "hideTitle": true,
      "hideFilters": true,
      "colorMode": "light"
    },
    "widgets": [
      {
        "id": "main-grid-1",
        "widgetType": "custom-component",
        "componentCode": "<the entire substituted HTML from Step 4>"
      }
    ],
    "layout": {
      "items": [
        { "i": "main-grid-1", "x": 0, "y": 0, "w": 12, "h": 24 }
      ]
    }
  }
}
```

`appearance.hideTitle` + `hideFilters` + `colorMode: "light"` matches the precedent set by `weekly-creative-performance` v6 — the canonical owns its own header (workspace name + chips + date picker). The dashboard chrome would duplicate / conflict.

`dashboardUrl: "clients/template/dashboards/CrossChannelEditableDashboard.tsx"` — emit the canonical host-TSX path directly (precedent: `full-marketing-audit/dashboard-template.json:104` and `weekly-creative-performance/dashboard-template.json:112` v7.4.1+). The Miras viewer (`useRepoDashboardData` in `main/components/repo/hooks/useRepoDashboardData.ts:55`) reads `dashboard_url` verbatim as the file path on `/experimental/agent/api/repo/file?repo_id=dashboard&path=<dashboardUrl>`; emitting the canonical path here removes the slug→path indirection that previously depended on BIE skill-cli's `HARDCODED_DASHBOARD_URL` override (`skill-cli.ts:14`). Earlier drafts shipped the slug `"daily-performance"`; that worked only when the save flowed through skill-cli — any save that bypassed it (direct curl/fetch, pre-2026-01-30 PR #415 historical save, fixture/migration) wrote the slug into the DB and the viewer 404'd with `Failed to fetch repo file: 500` (`Dashboard file API 404 Not Found`). There is no `DailyPerformanceDashboard.tsx` host TSX — `CrossChannelEditableDashboard.tsx` renders this skill's dashboard via `settings_id`.

`appearance` and `schemaVersion` ride through BIE's schema via `.passthrough()`. `editState.widgets` and `editState.layout.items` are required by `DashboardConfigSchema`.

**Layout:** ONE widget covering full width × tall enough for the entire console (12 cols × ~24 rows in the BIE grid). The console is intentionally a single tall widget — there is NO second widget below it.

Save:

```
Skill('business-intelligence-editor') with the assembled JSON
# CLI form: npx tsx .claude/skills/business-intelligence-editor/skill-cli.ts save --config '<full_config_json>'
```

Open preview (always with `--production`):

```
python3 frontend-cli.py open-preview --production "clients/template/dashboards/CrossChannelEditableDashboard.tsx?settings_id={NEW_ID}"
```

Present to user with:
- Dashboard URL
- 1-line summary of what's surfaced (top warning + top reco)
- Auto-refresh suggestion (per § Auto-Refresh Strategy)

---

## STEP 6: Auto-Refresh Suggestion

After the user sees the dashboard, offer:

> *"Want me to refresh this every morning at 9 AM with last-24h numbers?"*

If yes → `scheduleChatTool` with cadence `weekday-09:00-user-tz` (or `daily-09:00` if onboarding said "every day"). Each scheduled run re-executes the skill: re-fetches Discovery API, re-substitutes Appendix A, overwrites the saved dashboard at the same `dashboardUrl`. Channel logos / palette / structure stay frozen — only the data churns.

If onboarding said "weekly review" → suggest Monday-09:00 instead of daily.

---

## Anti-patterns (HARD STOP if you find yourself doing these)

- **Regenerating HTML from scratch.** The canonical (Appendix A) is the source of truth. Only substitute at INJECT markers.
- **Decomposing the console into multiple BIE widgets.** Use ONE custom-component widget. Built-in KPI/chart/table widgets MUST NOT appear in the dashboard composition.
- **Querying data outside Discovery API.** Per Rule 0, every fetch goes through `discoveryRequestTool`. No `clickhousePalantirTool` for marketing data.
- **Applying client-brand resolution.** This is product chrome — fixed Inter typography + indigo `#4F46E5` accent. No Brandfetch, no client logo in header, no per-client palette.
- **Producing the v1 Slack-text artifact.** v1 deprecated. Sharing happens via dashboard URL or screenshot.
- **Stopping at campaign-level for warnings/recos.** Drill to keyword (search) / creative (social) / asset-group (PMax) / SKU (shopping) / line item (programmatic) / subreddit (Reddit).
- **More than 3 recommendations.** The 3-card cap is the value — forces $-impact selection.
- **Recommendations without $-impact.** Money-tied or skip.
- **Hedging in recommendations.** Forbidden: "may want to consider", "could potentially", "if applicable". Confident verbs only: "Pause", "Raise", "Cut", "Shift", "Expand".
- **Education in the brief.** "ROAS measures the return on ad spend..." — the audience knows. Skip the lecture.
- **Mocked or static data in production.** If Discovery API fails — surface the failure honestly with `—` placeholders + retry banner.
- **Editing dashboard chrome.** `hideTitle: true`, `hideFilters: true`, `colorMode: "light"` — required for visual contract.

---

## Tone Rules — scoped to Recommendations cards only

Apply to the 3 recommendation cards (NOT to KPI tiles, NOT to warnings — those are factual/structural):

- **Confident verbs only** — "Pause", "Raise", "Cut", "Shift", "Expand", "Pin". Not "consider pausing".
- **No hedging.** Forbidden: "may want to", "could potentially", "if applicable".
- **Money-tied or skip.** Each card's `.reco-impact` MUST show $-value (gain or savings). If you can't quantify, drop the card.
- **Calibration threshold:** ≥7d data AND (≥30 conversions OR ≥$500 spend) on the drill entity. Below threshold → skip the candidate.
- **3-card cap** — the constraint is the value.
- **No education.** Audience knows the metrics.

Warnings tone: factual + actionable, brief. No tone constraints beyond clarity.

---

## Channel-Aware Drill Matrix

When generating warnings or recommendations, drill to the lowest controllable entity per channel:

| Channel | Drill entity | Channel-native KPI | Common issue patterns |
|---|---|---|---|
| **Paid Search** (Google/Bing) | Keyword → ad group | Quality Score, Search IS (lost: budget vs rank), CVR/keyword | Generic broad-match leak; brand cannibalization; IS lost to budget on top converters |
| **YouTube/Video** (Google) | Video asset | View rate, completion (25/50/75/100%), CPV | Low completion = wrong audience or weak hook; non-skippable starving spend |
| **Performance Max** | Asset group → product | ROAS by asset group, conversion mix (search vs display vs YT) | Brand cannibalization; spend imbalance to display; weak audience signals |
| **Shopping** | Product/SKU | ROAS/SKU, IS/SKU, % zero-impression catalog | Long tail of zero-imp SKUs; OOS still serving |
| **Meta** | Ad creative → ad set | Hook rate (3s/imp), Hold rate (15s+/3s), Frequency, CTR | Frequency >5; single creative carrying spend; ASC w/o exclusions |
| **LinkedIn** | Ad creative + audience segment | CTR vs platform avg, lead form CR, CPL by job title/seniority | Audience too narrow (<5K); single creative across segments |
| **TikTok** | Ad creative | Hook rate (2s), 6s view rate, completion (p25–p100), creative freshness >7d | Stale creatives >7d; sound-off completion drop |
| **Pinterest** | Pin / creative | Save rate, outbound CTR, idea-pin completion | Static-pin fatigue; missing Verified Merchant Program tag |
| **Reddit** | Ad creative + subreddit | Comment sentiment, engagement rate, CVR by subreddit | Subreddit blocklist gaps; non-native creative tone |

**Cross-cutting drills (all channels):** pacing (delivery vs plan), dayparting/hour-of-day, device split, geo target, audience saturation / new-vs-returning mix.

---

## Failure modes — what to render when something goes wrong

Per Rule 0 — never mock, never fall through. Behavior matrix:

| Failure mode | UI behavior |
|---|---|
| Connection missing for a channel | Drop that channel from `channels_data`; if active count drops <2 → fall back to Mode C single-channel chart |
| API returns NULL/empty for a metric on one channel | That channel's `today_kpis` contribution = 0; blended metric still renders; channel-card health flag → `bad` with note "No data" |
| Whole API call fails (timeout, 5xx) | Topbar banner: "Live · {time} — `<channel>` delayed (retry {next_retry})". Render last-known cached values if <2h old; else `—` placeholders WITH explicit error indicator (not the canonical's INJECT placeholders) |
| `biz_workspace_plans` row missing | Use last-month actuals as plan baseline; show banner "No plan set for {month} — using last-month actuals as baseline. [Set plan]" |
| Workspace has only 1 active channel | Console requires ≥2 channels. Surface message; offer Mode C single-channel chart instead. |

---

## Quality gates

- **No empty tiles in the KPI strip.** If a metric is fundamentally unavailable on every active channel — render the tile but with `—` and an explicit reason.
- **Pacing flag accuracy.** `flag: 'over'` means projecting >15% deviation from monthly plan. Don't mark `ok` for missing data.
- **Recommendation $-impact.** Every card MUST surface $-value. Never publish a recommendation without it.
- **Data freshness.** Live-API call timestamp shown in topbar. If stale >2h, banner: "Data delayed — last fetch HH:mm UTC".
- **Visual contract.** Indigo accent, Inter typography, Chart.js for trend chart, custom HTML for KPI strip and channel cards. Do not deviate.

---

## Appendix A — Canonical Widget HTML (single source of truth)

This is the **canonical visual contract** for the Daily Performance Console. The widget's `componentCode` (per Step 5) is THIS HTML with every INJECT marker substituted by Discovery API data (per Step 3).

LLM agents performing this skill: load this section as raw text, find INJECT markers, substitute per § Discovery API mapping, output the result as the widget's `componentCode` string.

**Mirrored at** `algorithms/revenue_div/marketing_dpt/01_projects/alg/claude/daily-performance/canonical.html` in the team-internal Marketing OS repo for filesystem-level access during design iteration. Edit BOTH atomically when the template changes.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Daily Performance · {{INJECT:workspace_name}} — All Channels</title>
<!--
  ════════════════════════════════════════════════════════════════════════════
  CANONICAL TEMPLATE — UC-DPA-1 Daily Performance Console
  ════════════════════════════════════════════════════════════════════════════
  This file is the canonical visual template for UC-DPA-1.
  Spec: data_sources/obsidian/vault/Improvado/05-Product/Marketing OS (Operational System)/use-cases/UC-DPA-1 Daily Performance Analysis.md

  USAGE FOR LLM AGENTS:
    1. Load this file as raw text
    2. Find INJECT markers (see list below)
    3. Substitute data between START/END markers, OR replace {{INJECT:name}} placeholders
    4. Output the resulting HTML — DO NOT regenerate structure from scratch

  INJECTION POINTS:
    Inline placeholders ({{INJECT:name}}):
      - workspace_name, workspace_id     — topbar / title
      - date_range_label                 — date picker pill text
      - date_start, date_end             — date picker inputs (YYYY-MM-DD)
      - period_label                     — "May 2026"
      - day_counter                      — "Day 4 of 31 · 13% elapsed"
      - rev_current, rev_plan, rev_pct   — revenue block numbers
      - rev_proj_label, rev_proj_class   — "↗ Proj $564K · +12.7%" / class: good|warn|bad
      - rev_fill_pct, rev_today_pct      — bar widths

    Block markers (<!-- === INJECT: name === --> ... <!-- === END INJECT: name === -->):
      - channels_data    — JS const C = {...}
      - today_kpis       — JS const TODAY = {...}
      - daily_base       — JS daily-data base values per channel/metric
      - warnings_block   — array of warning HTML cards
      - recos_block      — array of recommendation HTML cards
      - pacing_block     — pacing tab content
      - plan_modal       — edit-plan modal default values

  EDIT POLICY:
    Update both this template AND the spec atomically.
    See spec § "Data shape contracts" for required JSON shapes.
  ════════════════════════════════════════════════════════════════════════════
-->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
  :root {
    --bg:        #FAFBFC;
    --surface:   #FFFFFF;
    --surface-2: #F5F7FA;
    --line:      #E5E9EF;
    --line-soft: #EFF2F6;

    --ink:       #0F172A;
    --text:      #1F2937;
    --muted:     #64748B;
    --subtle:    #94A3B8;

    --accent:        #4F46E5;
    --accent-soft:   #EEF2FF;
    --accent-hover:  #4338CA;

    --good:       #047857;
    --good-bg:    #ECFDF5;
    --good-line:  #A7F3D0;

    --warn:       #B45309;
    --warn-bg:    #FFFBEB;
    --warn-line:  #FCD34D;

    --bad:        #B91C1C;
    --bad-bg:     #FEF2F2;
    --bad-line:   #FCA5A5;

    --info:       #0369A1;
    --info-bg:    #F0F9FF;
    --info-line:  #BAE6FD;

    --c-google:    #4285F4;
    --c-meta:      #0866FF;
    --c-tiktok:    #000000;
    --c-pinterest: #E60023;

    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-lg: 12px;
    --shadow-sm: 0 1px 0 rgba(15,23,42,0.04);
    --shadow:    0 1px 2px rgba(15,23,42,0.04), 0 1px 3px rgba(15,23,42,0.05);
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body {
    font: 14px/1.5 'Inter', system-ui, -apple-system, sans-serif;
    color: var(--text); background: var(--bg);
    -webkit-font-smoothing: antialiased;
    overflow-x: hidden;
  }
  .num  { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; }

  .page { max-width: 1340px; margin: 0 auto; padding: 16px 24px 24px; }

  /* ── LOGO MARKS ───────────────────────── */
  .logo {
    display: inline-block; flex: 0 0 auto;
    border-radius: 4px; overflow: hidden;
    width: 18px; height: 18px;
  }
  .logo svg { display: block; width: 100%; height: 100%; }
  .logo.sm { width: 14px; height: 14px; border-radius: 3px; }
  .logo.md { width: 22px; height: 22px; border-radius: 5px; }
  .logo.lg { width: 28px; height: 28px; border-radius: 6px; }

  /* ── TOP BAR ──────────────────────────── */
  .topbar {
    display: flex; align-items: center; gap: 10px;
    padding-bottom: 12px; border-bottom: 1px solid var(--line);
    margin-bottom: 14px;
  }
  .crumb { font-size: 13px; color: var(--muted); font-weight: 500; }
  .crumb b { color: var(--text); font-weight: 600; }
  .topbar-spacer { flex: 1; }
  .control-pill {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 6px 12px; border-radius: 999px;
    background: var(--surface); border: 1px solid var(--line);
    font-size: 12.5px; font-weight: 500; color: var(--text);
    cursor: pointer; transition: all .15s;
  }
  .control-pill:hover { border-color: var(--subtle); background: var(--surface-2); }
  .control-pill.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  .control-pill.primary:hover { background: var(--accent-hover); }
  .dot-live { width: 6px; height: 6px; background: #22C55E; border-radius: 50%; box-shadow: 0 0 0 3px rgba

…(truncated)
