---
spec_name: "CEO Mission Dashboard — monday.com + Cloudflare Worker + Slack"
spec_version: "1.0"
last_updated: 2026-05-27
target_audience: "Designers, creative directors, and design-led agency owners running 5+ active client projects on monday.com who want a real 40,000-foot Mission % dashboard instead of monday.com's broken native Progress column"
expected_outcome: "A Cloudflare Worker that runs daily at 8 AM your-timezone, reads status from every tracked monday.com board, calculates a weighted Mission %, writes it back to each board's summary item, and posts a digest to Slack — all for $0/month"
ai_tools_supported: [claude_code, google_antigravity, cursor]
based_on: https://mikekwal.com/blog/ceo-mission-dashboard-90-min/
author: Mike Kwal
---

# CEO Mission Dashboard — Implementation Spec

## Instructions for the AI

You are helping a designer or design-led agency owner build a Cloudflare Worker that turns monday.com's broken native Progress column into a real weighted Mission % dashboard. They live in monday.com daily but probably haven't touched Cloudflare Workers before.

For every step:
- Confirm the platform (Windows/macOS, monday.com plan tier, Cloudflare account access).
- Explain what each step buys them before you act.
- Ask which monday.com boards they want tracked before assuming.
- Give them copy-pasteable code and exact CLI commands.
- Keep the audit log of what was done — they'll want to share it with a future hire.

Goal: by end of one focused 45-minute session, they have a Cloudflare Worker named `mission-progress` running, a daily cron firing at 8 AM their-timezone, and a Slack message landing in a channel of their choice each morning.

---

## Step 1: Generate a monday.com Personal API Token

**What it does:** Lets the Worker read and write monday.com data on the user's behalf.

1. Open monday.com → click avatar (top right) → **Developers** → **My Access Tokens**.
2. Click **Show** on the existing token, OR generate a new one if none exists.
3. Copy the token. It looks like `eyJhbGciOi...` (a JWT).

**Verify:** The token starts with `eyJ`. Save it to a safe location (password manager or a local secrets file). You'll paste it into Cloudflare in Step 4.

---

## Step 2: Add a "Mission %" Numbers column to each tracked monday.com board

**What it does:** Monday's Progress column is read-only — Monday calculates it itself using only `is_done=true` labels. You need a writeable target for the Worker. A Numbers column is the simplest.

For each board you want tracked:

1. Open the board → click the **+** button at the right end of the column header row.
2. Pick **Numbers** column type.
3. Name it `Mission %`.
4. (Optional but recommended) Add a one-line description: *"Weighted progress % calculated by Cloudflare Worker."*

**Also: create one summary item per board.**

1. At the top of each board, create an item literally named `📊 Overall Project Progress`.
2. This item is the write target. The Worker writes the calculated % into THIS item's Mission % column, not into every task.

**Verify:** Each tracked board now has a Mission % column AND a single summary item. The column is currently empty everywhere — the Worker fills it.

---

## Step 3: Inventory the IDs you'll need

**What it does:** The Worker code needs four IDs per board. Capture them once, paste into the code in Step 4.

For each board, you need:

| ID | How to get it |
|---|---|
| **boardId** | The number in the board URL: `monday.com/boards/<boardId>` |
| **summaryItemId** | Click your `📊 Overall Project Progress` item → check the URL: `/pulses/<summaryItemId>` |
| **statusColumnId** | Open monday.com Developer Tools → Inspect the status column header → look for `data-column-id="..."` OR use the monday.com API explorer with `query { boards(ids: [BOARD_ID]) { columns { id title } } }` |
| **missionPctColumnId** | Same as above for the new Numbers column you added in Step 2 |

**Tip for technical users:** ask Claude to query the monday.com API for you with the boardId — it'll print all columns + the summary item ID in one shot.

**Verify:** You have a table with N rows (one per board) and 5 columns: name, boardId, summaryItemId, statusColumnId, missionPctColumnId.

---

## Step 4: Create the Cloudflare Worker

**What it does:** The Worker is the brain — it reads from monday.com, calculates, writes back, posts to Slack.

1. Sign in to Cloudflare → **Workers & Pages** → **Create application** → **Create Worker**.
2. Name it `mission-progress` (or whatever you want — must be lowercase, no spaces).
3. Click **Deploy** (with the default Hello World code — we'll replace it).
4. Click **Edit code** → delete everything in the editor.
5. Paste the Worker code below (replace `BOARDS` array with your inventory from Step 3):

```javascript
// Mission Progress Worker
// Reads status from N monday.com boards, computes weighted Mission %,
// writes the % to each board's summary item, posts a Slack digest.

const BOARDS = [
  // PASTE YOUR INVENTORY HERE — one entry per tracked board
  { name: 'Client A', boardId: 1234567890, summaryItemId: 9876543210, statusColumnId: 'status', missionPctColumnId: 'numeric_xxx' },
  // ... repeat for each board
];

const WEIGHTS = {
  'done':              1.00,
  'mission complete':  1.00,
  'approved':          1.00,
  'internal review':   0.85,
  'client review':     0.85,
  'pending approval':  0.85,
  'doing':             0.50,
  'assigned':          0.25,
  'on hold':           0.25,
  'stuck':             0.25,
  'awaiting...':       0.10,
};

const EXCLUDE = new Set(['', 'n/a', 'not applicable', 'rejected', 'declined']);

const MONDAY_URL = 'https://api.monday.com/v2';

async function mondayGql(token, query, variables) {
  const res = await fetch(MONDAY_URL, {
    method: 'POST',
    headers: { 'Authorization': token, 'Content-Type': 'application/json', 'API-Version': '2024-01' },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  if (json.errors) throw new Error(JSON.stringify(json.errors));
  return json.data;
}

async function fetchBoardStatusItems(token, board) {
  const items = [];
  let cursor = null;
  const query = `query ($boardId: ID!, $cursor: String) {
    boards(ids: [$boardId]) {
      items_page(limit: 500, cursor: $cursor) {
        cursor
        items { id state parent_item { id } column_values(ids: ["${board.statusColumnId}"]) { text } }
      }
    }
  }`;
  do {
    const data = await mondayGql(token, query, { boardId: String(board.boardId), cursor });
    const page = data.boards?.[0]?.items_page;
    if (!page) break;
    items.push(...page.items);
    cursor = page.cursor;
  } while (cursor);
  return items;
}

function calcMissionPct(items, summaryItemId) {
  let totalWeight = 0, included = 0;
  const summaryIdStr = String(summaryItemId);
  for (const item of items) {
    if (item.state === 'archived') continue;
    if (item.parent_item) continue;
    if (item.id === summaryIdStr) continue;
    const status = (item.column_values?.[0]?.text || '').trim().toLowerCase();
    if (EXCLUDE.has(status)) continue;
    totalWeight += WEIGHTS[status] ?? 0;
    included += 1;
  }
  return included === 0 ? 0 : Math.round((totalWeight / included) * 100);
}

async function writeMissionPct(token, board, pct) {
  const mutation = `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
    change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
  }`;
  const columnValues = JSON.stringify({ [board.missionPctColumnId]: String(pct) });
  await mondayGql(token, mutation, { boardId: String(board.boardId), itemId: String(board.summaryItemId), columnValues });
}

async function runAll(env) {
  const results = [];
  for (const board of BOARDS) {
    try {
      const items = await fetchBoardStatusItems(env.MONDAY_API_TOKEN, board);
      const pct = calcMissionPct(items, board.summaryItemId);
      await writeMissionPct(env.MONDAY_API_TOKEN, board, pct);
      const prev = await env.MISSION_KV.get(`pct:${board.boardId}`);
      const delta = prev !== null ? pct - parseInt(prev, 10) : null;
      await env.MISSION_KV.put(`pct:${board.boardId}`, String(pct));
      results.push({ name: board.name, pct, delta, status: 'ok' });
    } catch (e) {
      results.push({ name: board.name, status: 'error', error: e.message });
    }
  }
  await postSlackDigest(env, results);
  return results;
}

async function postSlackDigest(env, results) {
  if (!env.SLACK_WEBHOOK_URL) return;
  const today = new Date().toISOString().slice(0, 10);
  const ok = results.filter(r => r.status === 'ok').sort((a, b) => b.pct - a.pct);
  const avg = ok.length ? Math.round(ok.reduce((s, r) => s + r.pct, 0) / ok.length) : 0;
  const lines = ok.map(r => {
    const arrow = r.delta === null ? '·' : r.delta > 0 ? `↑${r.delta}` : r.delta < 0 ? `↓${Math.abs(r.delta)}` : '→';
    return `• \`${String(r.pct).padStart(3)}%\` ${arrow}  ${r.name}`;
  }).join('\n');
  await fetch(env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      blocks: [
        { type: 'header', text: { type: 'plain_text', text: `📊 Mission % — ${today}` } },
        { type: 'section', text: { type: 'mrkdwn', text: `Portfolio avg: *${avg}%* across ${ok.length} active projects` } },
        { type: 'divider' },
        { type: 'section', text: { type: 'mrkdwn', text: lines } },
      ],
    }),
  });
}

export default {
  async scheduled(event, env, ctx) { ctx.waitUntil(runAll(env)); },
  async fetch(request, env) {
    const results = await runAll(env);
    return new Response(JSON.stringify(results, null, 2), { headers: { 'content-type': 'application/json' } });
  },
};
```

6. Click **Save and Deploy**.

**Verify:** Cloudflare shows the Worker as deployed. Worker URL is visible at the top: `https://mission-progress.<your-subdomain>.workers.dev`.

**Confirm prompt:**
> Paste this Worker code into Cloudflare. Replace the BOARDS array with this inventory: [paste your Step 3 table]. Tell me when it's deployed.

---

## Step 5: Wire the Cloudflare bindings

**What it does:** The Worker code references three bindings — without them, it crashes on first run.

In your Worker → **Settings** tab:

1. **Variables and Secrets** → **Add** → Type: **Secret**:
   - Name: `MONDAY_API_TOKEN`, Value: paste the token from Step 1
   - Name: `SLACK_WEBHOOK_URL`, Value: leave empty for now (we'll add in Step 7)

2. **KV Namespace Bindings** → first create a KV namespace:
   - Workers & Pages → **KV** → Create a namespace → name it `mission-progress` → Add
   - Back to the Worker's Settings → KV Namespace Bindings → Add:
     - Variable name: `MISSION_KV`
     - KV namespace: select `mission-progress`

**Verify:** Settings tab shows 1 secret + 1 KV binding.

---

## Step 6: Add the cron trigger

**What it does:** Fires the Worker automatically every morning.

1. Settings tab → **Trigger Events** → **Cron Triggers** → **Add Cron Trigger**.
2. Schedule (Cloudflare uses UTC, so adjust):
   - 8 AM Eastern Time → `0 12 * * *`
   - 8 AM Pacific Time → `0 15 * * *`
   - 8 AM London → `0 7 * * *` (or `0 8 * * *` during BST)
3. Save.

**Verify:** Trigger Events panel shows `Cron: 0 12 * * *` (or your time).

---

## Step 7: Set up the Slack webhook + add it as a secret

**What it does:** Creates the channel where your morning digest lands.

1. Open https://api.slack.com/apps → **Create New App** → **From scratch**.
2. App Name: `Mission Progress Worker`. Pick your workspace → **Create App**.
3. Left sidebar → **Incoming Webhooks** → toggle **ON**.
4. Scroll to **Webhook URLs for Your Workspace** → **Add New Webhook to Workspace**.
5. Pick the channel (create a new one called `#mission-brief` first if you want).
6. Copy the webhook URL (`https://hooks.slack.com/services/T.../B.../xxx`).
7. Back in Cloudflare → Worker Settings → Variables and Secrets → click pencil on `SLACK_WEBHOOK_URL` → paste the URL → Save.

**Verify:** Settings tab shows 2 secrets + 1 KV binding.

---

## Step 8: Manually trigger the Worker to confirm it works

**What it does:** Validates the entire pipeline before the first cron fires.

1. Visit your Worker URL in a browser: `https://mission-progress.<your-subdomain>.workers.dev`
2. You should see JSON output with one entry per board: `{ name, pct, delta, status: 'ok' }`.
3. Open Slack — the digest message should have landed in your channel.
4. Open one of your monday.com boards — the `📊 Overall Project Progress` summary item should show a number in the Mission % column.

**Verify:** All three signals (JSON, Slack message, monday.com number) are present.

**If anything is empty or wrong:** see the Failure Modes table at the end of this spec.

---

## Step 9: Run for a day, then check the deltas

**What it does:** Day-over-day arrows (↑↓→) need yesterday's data in KV. After the first run, KV is populated; the second run shows real deltas.

Tomorrow morning at your cron time, the Slack message should include arrows next to each %. If one project moved from 78% to 82%, you'll see `↑4`.

**Verify:** Day 2 Slack message includes day-over-day arrows.

---

## Failure Modes (Save Yourself Time)

| Symptom | Likely cause | Fix |
|---|---|---|
| All boards show 0% | Wrong statusColumnId | Re-verify column IDs in Step 3 |
| Some boards show "Unmapped status labels: ..." | A status label isn't in WEIGHTS | Add it to the WEIGHTS map and redeploy |
| 401 from monday.com API | Token expired or wrong | Regenerate token in monday.com → Developers → My Access Tokens, update Cloudflare secret |
| "Cannot read property 'get' of undefined" | KV binding name in dashboard ≠ name in code | Match `env.MISSION_KV` in code to the binding variable name in CF |
| Slack message never lands | Webhook URL stale or revoked | Regenerate webhook in Slack app, paste into Cloudflare secret |
| One board fails, others OK | Wrong summaryItemId for that board | Re-query monday.com for the correct ID |
| Worker URL returns "Unauthorized" | You added an auth check but the request lacks the header | Either drop the auth check or send the right Bearer token |

---

## Extension Ideas (Optional)

Once the basics work, you can:

1. **Branch by team.** Send separate Slack digests for design vs PPC vs continuity by grouping boards.
2. **Alert on stagnation.** If a board hasn't moved in 7 days, flag it red in the Slack message.
3. **Add a /history endpoint.** KV stores 90 days — build a small HTML page that shows trend charts.
4. **Map team workload.** Cross-reference Mission % with who's assigned, surface "Anastasia owns 4 stalled tasks" in the digest.

---

## What This Replaces

- 30-60 min/day of you opening monday.com and trying to figure out what's happening
- The "ask everyone for a status update" Slack ritual
- monday.com's built-in Progress column (broken for the reasons in the blog post)
- Weekly PM brief meetings ("how's project X doing?")
- The illusion of visibility you get from looking at 11 boards in 11 tabs

---

## Closing

You now have a CEO dashboard that runs without you. Total monthly cost: $0. Total monthly maintenance: ~5 minutes if a new client's status labels need mapping.

If you build this and want to show me — DM me on Instagram (@mikekwal) or post in the Talk-to-Build community. Stuck? Book a working session at [mikekwal.com/contact](https://mikekwal.com/contact).
