diff --git a/.claude/settings.json b/.claude/settings.json
index d91aa1028..ca26ebc51 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -3,5 +3,31 @@
"deny": [
"Read(.env)"
]
+ },
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/skills/update-docs/scripts/pw-guard-hook.sh\" pre",
+ "timeout": 10
+ }
+ ]
+ }
+ ],
+ "PostToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/skills/update-docs/scripts/pw-guard-hook.sh\" post",
+ "timeout": 60
+ }
+ ]
+ }
+ ]
}
}
diff --git a/.claude/skills/documentation/SKILL.md b/.claude/skills/documentation/SKILL.md
deleted file mode 100644
index 479e32a6a..000000000
--- a/.claude/skills/documentation/SKILL.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-name: documentation
-description: Generate, update, or review Glific user-facing documentation. Use when a developer asks to document a feature, write a doc page, update existing docs, or after a PR ships to refresh affected docs.
----
-
-# Documentation Skill
-
-Reference and workflow for generating Glific's user-facing Docusaurus documentation. This repo is `glific/docs`.
-
-## When to use
-
-Trigger when the user asks to:
-- Generate or update a doc page for a feature ("write docs for Flows", "document HSM Templates")
-- Refresh docs after a PR ships ("update docs for #142 in glific-frontend")
-- Review existing docs for completeness or accuracy
-
-Start by reading `workflow.md`.
-
-## IA Principles
-
-The docs have 8 top-level sections. Each has a different audience and purpose — read `sections.md` before placing any content.
-
-### Primary audience
-
-NGO program managers and field workers. They are **not technical users**. They need:
-- Plain language explanations
-- Step-by-step instructions with screenshots
-- To know what something does and how to use it — not how it works internally
-
-### Frontend code location
-
-The Glific frontend lives at `../glific-frontend/` relative to this repo.
-
-| Path | What it contains |
-|------|-----------------|
-| `../glific-frontend/src/containers/` | Feature-level UI components (one dir per feature) |
-| `../glific-frontend/src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx` | All route paths (maps feature name → URL) |
-| `../glific-frontend/src/graphql/queries/` | GraphQL queries (understand data a feature displays) |
-
-## File index
-
-| File | Purpose |
-|------|---------|
-| `workflow.md` | Step-by-step process from research to published markdown |
-| `sections.md` | All 8 doc sections: audience, purpose, what belongs where |
-| `style-writing.md` | Audience, voice, instructions format, anti-patterns |
-| `style-page.md` | Page structure templates for each section type |
-| `style-images.md` | Image naming, directory mapping, static/img/ conventions |
-| `screenshots.md` | Playwright recipe system for automated screenshots |
diff --git a/.claude/skills/documentation/screenshots.md b/.claude/skills/documentation/screenshots.md
deleted file mode 100644
index f1af05df0..000000000
--- a/.claude/skills/documentation/screenshots.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Screenshot System
-
-Automated Playwright-based screenshot capture. Recipes define what to capture; `scripts/screenshot.js` runs them.
-
-## Local setup (one-time)
-
-### 1. Install Playwright
-
-```bash
-cd /path/to/glific/docs
-yarn add --dev playwright js-yaml
-npx playwright install chromium
-```
-
-### 2. Add credentials to `.env`
-
-Create a `.env` file in the root of this repo (it is gitignored):
-
-```
-GLIFIC_URL=https://glific.test:3000
-GLIFIC_PHONE=+91XXXXXXXXXX
-GLIFIC_PASSWORD=your_password
-```
-
-Use a **test/demo account** — not a production account. The script will log in and navigate through the UI.
-
-### 3. Local Glific must be running
-
-Before running the screenshot script:
-- Start the backend: follow `glific/glific` setup (Elixir/Phoenix on port 4001)
-- Start the frontend: `cd ../glific-frontend && yarn dev` (runs at `https://glific.test:3000`)
-
-The script ignores HTTPS certificate errors from `mkcert`, so the self-signed cert is fine.
-
-## Running the script
-
-```bash
-# Single feature
-node scripts/screenshot.js flows
-
-# All recipes
-node scripts/screenshot.js
-
-# Output lands in static/img/{output_dir}/
-```
-
-## Recipe format
-
-Each recipe is a YAML file in `scripts/recipes/{feature-slug}.yaml`.
-
-```yaml
-name: flows # Human-readable name
-output_dir: flows # Maps to static/img/{output_dir}/
-flows:
- - name: flow_list # Slug for this flow
- description: The list of all flows
- required: true # If true, failure aborts the recipe. If false, logs warning and continues.
- steps:
- - navigate: /flow # Navigate to this path
- - wait: '[data-testid="flow-list"]' # Wait for selector (CSS or data-testid)
- - snap: flows_list.png # Take screenshot, save as this filename
- - click: '[data-testid="add-flow"]' # Click a selector
- - wait_text: Create Flow # Wait for visible text to appear
- - snap: flows_create_dialog.png
-```
-
-### Step types
-
-| Step key | What it does |
-|----------|-------------|
-| `navigate: /path` | Go to `GLIFIC_URL + path` |
-| `wait: 'selector'` | Wait for CSS selector to appear (8s timeout) |
-| `wait_text: 'text'` | Wait for visible text to appear on the page |
-| `click: 'selector'` | Click a CSS selector |
-| `snap: filename.png` | Take a full-viewport screenshot |
-| `snap: filename.png` + `element: 'selector'` | Crop to that element only — hides sidebar, nav, unrelated UI |
-| `sleep: 500` | Wait N milliseconds (use sparingly, prefer `wait`) |
-
-### Element cropping
-
-**Always add `element:` when the screenshot should show only one section.** This keeps docs focused and avoids capturing sidebar/nav that may change independently.
-
-```yaml
-# Full viewport — only for canvas/editor views where context matters
-- snap: flows_editor_canvas.png
-
-# Cropped to element — use for lists, forms, dialogs, cards
-- snap: flows_list.png
- element: '[data-testid="flow-list"]'
-
-- snap: flows_create_dialog.png
- element: '[role="dialog"]'
-```
-
-Common element selectors:
-- Dialog/modal → `[role="dialog"]`
-- Named section → `[data-testid="..."]`
-- Main content only (no sidebar) → `main`
-
-### Selector strategy
-
-Prefer in this order:
-1. `[data-testid="..."]` — most stable, survives CSS refactors
-2. ARIA role + name: `role=button[name="Create Flow"]`
-3. Visible text: use `wait_text` step, then screenshot
-4. CSS class — **avoid**, breaks on any style change
-
-Glific's frontend has `data-testid` on most interactive elements. Check the container's `.tsx` file to find them.
-
-### Auth flow
-
-The script authenticates once per run using phone + password (Glific uses phone number login, not email). The session cookie is reused across all flows in the recipe.
-
-Login selectors (from `Auth.tsx` / `Login.tsx`):
-- Phone field: `input[name="phoneNumber"]`
-- Password field: `input[name="password"]`
-- Submit button: `[data-testid="SubmitButton"]`
-- Post-login: waits for URL to contain `/chat`
-
-### Nuances
-
-- The PhoneInput component renders a flag picker + text input. The script fills the text portion with `input[name="phoneNumber"]`. If the phone field has a different rendered `name`, inspect the DOM and update the selector in `scripts/screenshot.js`.
-- After login, some features require seed data (e.g., Flow list needs existing flows). Use a demo account with pre-created data.
-- The frontend uses Apollo Client with WebSocket subscriptions — some pages load data asynchronously. Use `wait` steps (not `sleep`) to wait for data to appear.
-
-## Adding a new recipe
-
-1. Find the feature's route in `../glific-frontend/src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx`
-2. Find `data-testid` attributes in `../glific-frontend/src/containers/{Feature}/`
-3. Create `scripts/recipes/{feature-slug}.yaml`
-4. Run `node scripts/screenshot.js {feature-slug}` and iterate on selectors until all snaps land
-5. Commit the recipe + screenshots together with the doc page
diff --git a/.claude/skills/documentation/sections.md b/.claude/skills/documentation/sections.md
deleted file mode 100644
index 434d445a5..000000000
--- a/.claude/skills/documentation/sections.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Glific Docs Sections
-
-The sidebar is autogenerated from the `docs/` directory tree. Folder names with number prefixes control order.
-
-## Section map
-
-| Folder | Audience | Purpose | Content type |
-|--------|----------|---------|--------------|
-| `2. Pre Onboarding/` | Implementation partners, technical staff | Steps to complete *before* Glific onboarding — external platform setup | Procedural guides (numbered steps, external links) |
-| `3. Starter Kit/` | NGO managers | Pre-launch checklists and best practices before going live | Checklists, short guides |
-| `4. Product Features/` | Day-to-day users | Reference docs for every UI feature in Glific | Feature reference pages |
-| `5. Integrations/` | Technical staff | How to connect and use third-party tools (OpenAI, Sheets, etc.) | Setup + usage guides |
-| `6. WhatsApp Groups Automation/` | NGOs using WA Groups | Features specific to WhatsApp Groups (not 1:1 chats) | Feature + setup guides |
-| `7. Use Cases/` | All users | Scenario-based problem → solution guides | Narrative walkthroughs |
-| `8. FAQ/` | All users | Quick answers to specific how-to questions | Short Q&A pages |
-
-## Deciding where a new page belongs
-
-1. Is it about a UI screen or button in the Glific web app? → **4. Product Features/**
-2. Is it about connecting an external service to Glific? → **5. Integrations/**
-3. Is it specific to WhatsApp Groups (not regular chats)? → **6. WhatsApp Groups Automation/**
-4. Is it a step required *before* going live (Gupshup, GCS, Facebook)? → **2. Pre Onboarding/**
-5. Is it a real-world scenario showing how to accomplish a goal? → **7. Use Cases/**
-6. Is it a specific question that doesn't fit anywhere else? → **8. FAQ/**
-
-## Product Features sub-structure
-
-Current files under `4. Product Features/`:
-```
-01. Chats.md
-02. Speed Sends.md
-03. Flows/ ← subdirectory when a feature has multiple pages
-04. Triggers.md
-05. Searches.md
-06. HSM Templates.md
-07. Interactive Messages.md
-08. Notifications.md
-09. Reporting & Dashboard/
-10. Others/
-```
-
-Rules:
-- Top-level feature → numbered `.md` file (`11. Feature Name.md`)
-- Feature with multiple sub-pages → numbered directory (`11. Feature Name/`) with an `index.md` inside
-- Unnumbered files at the end are legacy; new pages must be numbered
-
-## Route → section mapping
-
-From `../glific-frontend/src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx`:
-
-| Route | Feature | Section |
-|-------|---------|---------|
-| `/chat` | Chats | 4. Product Features |
-| `/speed-send` | Speed Sends | 4. Product Features |
-| `/flow` | Flows | 4. Product Features |
-| `/trigger` | Triggers | 4. Product Features |
-| `/search` | Searches | 4. Product Features |
-| `/template` | HSM Templates | 4. Product Features |
-| `/notifications` | Notifications | 4. Product Features |
-| `/collection` | Collections | 4. Product Features > Others |
-| `/contact-management` | Contact Management | 4. Product Features > Others |
-| `/staff-management` | Staff Management | 4. Product Features > Others |
-| `/settings` | Settings | 4. Product Features > Others |
-| `/sheet-integration` | Sheet Integration | 5. Integrations |
diff --git a/.claude/skills/documentation/style-images.md b/.claude/skills/documentation/style-images.md
deleted file mode 100644
index 78e5e6d53..000000000
--- a/.claude/skills/documentation/style-images.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# Style: Images
-
-## Storage policy
-
-| Image type | Where to store |
-|------------|----------------|
-| New screenshots (all new pages) | `static/img/{feature}/` in this repo |
-| Existing GitHub CDN images on old pages | Leave as-is — do not migrate unless rewriting the page |
-
-**Never add new GitHub CDN image URLs to a page.** Use local `static/img/` for all new content.
-
-## Directory mapping
-
-One directory per feature area under `static/img/`:
-
-| Feature | Directory |
-|---------|-----------|
-| Chats | `static/img/chats/` |
-| Speed Sends | `static/img/speed-sends/` |
-| Flows | `static/img/flows/` |
-| Triggers | `static/img/triggers/` |
-| Searches | `static/img/searches/` |
-| HSM Templates | `static/img/hsm-templates/` |
-| Interactive Messages | `static/img/interactive-messages/` |
-| Notifications | `static/img/notifications/` |
-| Collections | `static/img/collections/` |
-| Contact Management | `static/img/contact-management/` |
-| Staff Management | `static/img/staff-management/` |
-| Settings | `static/img/settings/` |
-| Integrations (OpenAI) | `static/img/integrations/openai/` |
-| Integrations (Sheets) | `static/img/integrations/sheets/` |
-| WhatsApp Groups | `static/img/wa-groups/` |
-
-If a feature doesn't have a directory yet, create it. The screenshot script creates it automatically.
-
-## File naming
-
-Format: `{feature}_{description}.png` — lowercase, underscores, no spaces.
-
-Examples:
-- `flows_list.png` — the list of flows
-- `flows_create_dialog.png` — the create flow dialog
-- `hsm-templates_new_template_form.png` — the new template form
-- `chats_session_timer.png` — the session timer in the chat window
-
-## Markdown syntax
-
-Docusaurus serves `static/` at the site root. Always use `/img/` prefix (not `static/img/`):
-
-```markdown
-
-```
-
-Never use:
-- `import Image from ...` + JSX
-- Absolute GitHub CDN URLs for new images
-- Relative paths like `../../static/img/...`
-
-## Screenshot placement
-
-Screenshot goes **after** the step it illustrates, never before. One screenshot per major step or UI state.
-
-## Missing screenshots
-
-If the feature isn't built yet or requires backend data that can't be seeded locally:
-
-```markdown
-:::info Screenshot coming soon
-:::
-```
-
-Never use HTML comment placeholders (``).
-
-## Image dimensions
-
-Capture at **1440×900 viewport** (set in `scripts/screenshot.js`). Do not resize or compress — Docusaurus serves them at natural size and the browser scales them.
diff --git a/.claude/skills/documentation/style-writing.md b/.claude/skills/documentation/style-writing.md
deleted file mode 100644
index da4e62955..000000000
--- a/.claude/skills/documentation/style-writing.md
+++ /dev/null
@@ -1,87 +0,0 @@
-# Style: Writing
-
-Read at least two existing pages in the same section before writing a new one — match their tone and density.
-
-## Audience
-
-Primary: **NGO program managers and field workers** using Glific daily. Non-technical. Need to know *what* to do, not *why* the code works.
-
-Secondary: **Implementation partners** setting up Glific for NGOs. More technical, but still prefer step-by-step clarity.
-
-**Plain language rules:**
-- High-school reading level.
-- One idea per sentence.
-- Explain what things do, not how they work internally.
-- If a technical term is unavoidable ("flow", "template", "collection"), explain it in context on first use or link to another doc.
-- Use "you" and "your". Active voice, present tense.
-
-## Voice
-
-| Don't | Do instead |
-|---|---|
-| "Click the button" | "Select **Create Flow**" |
-| "The user should navigate to" | "Open **Flows** from the left menu" |
-| "It is possible to" | "You can" |
-| "In order to" | "To" |
-| Passive voice | Active, second-person, present tense |
-| "Please note that" | Just say it |
-
-## Page header format
-
-Every page starts with a read-time and difficulty badge, matching the existing Glific style:
-
-```markdown
-###**{N} minute read ... `{Level}`**
-```
-
-Use these levels:
-- `Beginner` — anyone can follow with no prior Glific knowledge
-- `Intermediate` — assumes familiarity with basic Glific features
-- `Advanced` — requires understanding of flows, APIs, or technical setup
-
-Example (copy the spacing exactly — the ` ` padding pushes the badge to the right):
-```markdown
-###**3 minute read `Beginner`**
-```
-
-## Instructions format
-
-Numbered steps for sequential actions. Each step = one action.
-
-**Rules:**
-1. **Bold every UI element** the user interacts with: button labels, menu items, tab names, field labels.
-2. Place a screenshot *after* the step it illustrates, not before.
-3. Keep steps atomic — one click or one fill per step.
-4. Use "Select" not "Click" (works for touchscreens too).
-5. Use quotes for exact text the user types; bold for UI labels they select.
-
-**Example:**
-```markdown
-1. Open **Flows** from the left menu.
-
-
-
-2. Select **+ Create Flow**.
-3. Enter a name for your flow and select **Save**.
-```
-
-## Section intro pattern
-
-After the header badge, start with a **bold one-liner** that tells the reader exactly what this feature does:
-
-```markdown
-**Flows let you build automated conversation sequences that run without staff involvement.**
-```
-
-Then 1–2 sentences of context if needed. Then jump straight into steps or sub-sections.
-
-## Anti-patterns
-
-| Don't | Why |
-|---|---|
-| GitHub CDN image URLs in new pages | Images can break; use `static/img/` instead |
-| `` tags with GitHub asset URLs for new screenshots | Use standard markdown `` |
-| Long intros before the first action | Badge → bold one-liner → steps |
-| Duplicating WhatsApp/Meta documentation | Link to official docs instead |
-| Explaining GraphQL, Apollo, or internal architecture | Audience doesn't need this |
-| `Note:` in plain text | Use `:::note` admonition |
diff --git a/.claude/skills/documentation/workflow.md b/.claude/skills/documentation/workflow.md
deleted file mode 100644
index ec1c876d5..000000000
--- a/.claude/skills/documentation/workflow.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Documentation Workflow
-
-Process to generate or update a doc page — from research to published markdown.
-
-## 1. Parse Input & Determine Mode
-
-**Mode A — Feature Name** (default): input is a feature name (e.g. "Flows", "HSM Templates", "Speed Send").
-
-**Mode B — PR Number**: input matches `#\d+`, a GitHub PR URL for `glific/glific-frontend`, or a commit range.
-
-## 2. Load Reference Files
-
-Read what's needed for the task:
-- `sections.md` — to determine which of the 8 sections the content belongs in
-- `style-writing.md`, `style-page.md` — for writing conventions and templates
-- `style-images.md`, `screenshots.md` — for image handling and screenshot recipes
-
-## 3. Research the Feature
-
-**Mode A — Feature Name:**
-1. Find the container: `../glific-frontend/src/containers/{FeatureName}/`
-2. Read the main list and form components (e.g. `FlowList.tsx`, `Flow.tsx`) to understand what the UI shows and does
-3. Read `../glific-frontend/src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx` to find the URL route
-4. Check existing docs in `docs/` for the feature (search for the feature name in file names and content)
-5. Optionally read `../glific-frontend/src/graphql/queries/{Feature}.ts` to understand what data the feature displays
-
-**Mode B — PR Number:**
-1. `gh pr diff #PR_NUMBER --repo glific/glific-frontend` to see what changed
-2. Identify affected containers from the changed file paths
-3. Read the changed files in full to understand new behavior
-4. Check existing docs for the affected area
-
-## 4. Determine Placement
-
-Use `sections.md` to map the feature to the right section. For Product Features, check the existing numbered file list to determine the next number.
-
-Print the placement plan and **ask the user to confirm** before writing anything:
-
-```
-Placement plan:
-- Doc: docs/{section}/{filename}.md
-- Images: static/img/{feature}/
-- Action: {new page | update existing | add sub-page to existing dir}
-- Recipe: scripts/recipes/{feature-slug}.yaml ({exists | needs creating})
-```
-
-## 5. Capture Screenshots
-
-Check for an existing recipe at `scripts/recipes/{feature-slug}.yaml`.
-
-**Recipe missing** → create it. Read `screenshots.md` for the exact format. Find `data-testid` values in the relevant container files.
-
-**Recipe exists** → review it against what you read in step 3. Add or update flows for any user path the doc will reference. Fix selectors if the UI has changed.
-
-Then run:
-```bash
-node scripts/screenshot.js {feature-slug}
-```
-
-Verify all expected files land in `static/img/{feature}/`. Iterate on any selector that misses — never ship a doc that references a non-existent image.
-
-Use `:::info Screenshot coming soon` only when the feature requires backend data that cannot be seeded (e.g., live WhatsApp delivery receipts, billing pages).
-
-## 6. Write Documentation
-
-Choose the correct template from `style-page.md` based on the section. Save to the confirmed path from step 4.
-
-Follow `style-writing.md` for voice and format. The most common mistake: writing for a technical audience. Imagine explaining this to an NGO field worker on their first day.
-
-## 7. Validate
-
-- [ ] All images referenced in the doc exist in `static/img/{feature}/`
-- [ ] Image paths use `/img/` prefix (Docusaurus convention), not `static/img/`
-- [ ] No new GitHub CDN image URLs (only old untouched pages keep those)
-- [ ] Page header has the read-time badge and difficulty level
-- [ ] Page matches the correct section template from `style-page.md`
-- [ ] Numbered file prefix is correct and doesn't conflict with existing files
-
-## 8. Print Next Steps
-
-```
-Documentation generated:
-- Doc: docs/{section}/{filename}.md
-- Images: static/img/{feature}/ ({N} screenshots captured)
-- Recipe: scripts/recipes/{feature-slug}.yaml
-
-Next:
-1. Preview: yarn start (in this repo)
-2. Review at http://localhost:3000/docs/{slug}
-3. Commit doc + recipe + screenshots together, then open a PR
-```
diff --git a/.claude/skills/update-docs/SKILL.md b/.claude/skills/update-docs/SKILL.md
new file mode 100644
index 000000000..0ae2864d9
--- /dev/null
+++ b/.claude/skills/update-docs/SKILL.md
@@ -0,0 +1,91 @@
+---
+name: update-docs
+description: Generate, update, review or restructure Glific user-facing documentation in this repo (glific/docs), using the live (production) Glific app through a read-only, auto-redacting playwright-cli browser for UI checks and screenshots. Use when someone asks to document a feature, write a doc page, refresh or update existing docs, update docs after a glific-frontend PR ships, review docs for consistency, or restructure docs (e.g. with tabs). Also use for "/update-docs ...".
+argument-hint:
+---
+
+# update-docs
+
+Updates the Glific docs in this repo (`glific/docs`, Docusaurus) using the **live production Glific app** as the source of truth.
+
+The browser is logged in to a **real organisation's production account**. Two rules override everything else in this skill:
+
+1. **Never change anything in Glific.** Only look and take screenshots. A network guard blocks writes. If the guard trips, **stop at once and warn the user** (see [safety.md](safety.md)).
+2. **Never publish an unredacted screenshot.** Take every image with `gdocs shot`. It removes contact data, checks that nothing is left, and records the image so `gdocs lint` can check it later. Then look at each PNG yourself before you use it.
+
+Users don't need to install anything. The cloud environment provides `node`, `playwright-cli`, Chromium and these env vars: `GLIFIC_URL`, `GLIFIC_PHONE`, `GLIFIC_PASSWORD`, `PLAYWRIGHT_BROWSERS_PATH`. Never print, echo or read the credential values. `gdocs` reads them itself.
+
+All commands below run from the repo root. `gdocs` is short for `node .claude/skills/update-docs/scripts/gdocs.mjs`.
+
+## Step 0 — Preflight (always, before anything else)
+
+```bash
+node .claude/skills/update-docs/scripts/gdocs.mjs preflight
+```
+
+This checks the env vars, `playwright-cli`, the browser launch and the read-only guard. The guard check sends a canary mutation that must be blocked. It also runs a redaction self-test and then logs in to Glific. Only a run that ends with `PREFLIGHT OK` passes.
+
+- **If any check fails**, stop and show the user the `[FAIL]` lines. Explain the likely cause using [troubleshooting](#troubleshooting). Do not try to install anything, and do not work around the guard.
+- For a **docs-only** request that needs no browser (pure review or restructure), you may ask the user whether to continue without the live app. If you do, say in the final summary that nothing was checked against the live UI.
+
+## Step 1 — Classify the request
+
+| Intent | Example | Browser? | Read |
+|---|---|---|---|
+| **Refresh** an existing page | `refresh the analytics page with new navigation tabs`, `update docs for glific-frontend#1234` | yes | [workflow-refresh.md](workflow-refresh.md) |
+| **New** page | `write a new doc page for HSM v2 page` | yes | [workflow-new.md](workflow-new.md) |
+| **Review** a set of pages for consistency | `review document structure for all webhooks and ensure consistency of writing` | optional, for spot-checks | [workflow-review.md](workflow-review.md) |
+| **Restructure** (tabs, merges, moves) | `restructure docs with tabs like ...` | optional | [workflow-restructure.md](workflow-restructure.md) |
+
+If the request mixes intents, do them in this order: review/restructure plan → refresh/new content → screenshots.
+
+Always read these too:
+- [browsing.md](browsing.md): how to drive the session, take screenshots and find your way around Glific.
+- [safety.md](safety.md): what is blocked, the stop-and-warn protocol, and redaction limits.
+- The house style guides. They are the single source for docs conventions:
+ - [sections.md](sections.md): the 8 doc sections, where new pages go, numbering, and route → section mapping
+ - [style-writing.md](style-writing.md): audience (non-technical NGO staff), voice, UI labels, the **page header block**, step format and anti-patterns
+ - [style-page.md](style-page.md): page templates per section, sibling consistency, and when to use tabs
+ - [style-images.md](style-images.md): image directories, naming, framing, `/img/` paths and placeholders
+
+## Step 2 — Plan, then confirm
+
+Before you edit files, print a short plan and **wait for the user to confirm**:
+
+```
+Plan ():
+- Pages: docs//.md (new | update | move → )
+- Screenshots: static/img//.png (new | replace) — N total
+- Live UI checked:
+- Out of scope / can't capture:
+```
+
+## Step 3 — Do the work
+
+Follow the workflow file for the intent. While you work:
+
+- After **every** browser action the PostToolUse hook checks the guard. If it reports `READ-ONLY GUARD TRIPPED`, follow the stop-and-warn protocol in [safety.md](safety.md) before you do anything else.
+- Never click buttons that save, submit, send, delete, publish, start, import, upload, sync, assign or change state, even though the guard would block them. Open forms and dialogs only to photograph them, then close them with **Cancel**, **Escape** or by going back.
+- Write only what you saw in the live app. If the UI and the user's request disagree, ask.
+
+## Step 4 — Validate and hand off
+
+```bash
+node .claude/skills/update-docs/scripts/gdocs.mjs lint # image provenance + /img refs
+node .claude/skills/update-docs/scripts/gdocs.mjs stop # close the browser
+```
+
+- `lint` must pass. It checks image provenance, `/img/` refs and relative `.md` links in changed pages. A provenance failure means an image was not made by `gdocs shot`. Retake it.
+- If `node_modules/` exists, also run `yarn build` to catch MDX errors (broken links are ignored by the site config; `lint` covers them). Otherwise say that CI will run the build.
+- Finish with a summary: pages changed, screenshots added or replaced, anything that couldn't be captured and why, and any blocked write attempts (passive ones too). Don't commit or open a PR unless the user asks.
+
+## Troubleshooting
+
+| Preflight failure | Likely cause | What to tell the user |
+|---|---|---|
+| `env — missing …` | Env vars not set in the cloud environment | Add `GLIFIC_URL`, `GLIFIC_PHONE`, `GLIFIC_PASSWORD` to the cloud environment's env settings. |
+| `playwright-cli — not on PATH` | Running locally, or the setup script didn't run | Use the cloud environment, or run the env setup script once. |
+| `browser launch — … not installed` | `@playwright/cli` and the installed Chromium are different versions | Re-run the environment setup script so both are `@latest` together. |
+| `read-only guard — canary … NOT blocked` | Unexpected playwright-cli behaviour | **Do not proceed.** Report it. |
+| `login — phone field …` / `page says: …` | Wrong phone format (`91XXXXXXXXXX`), wrong password, or a non-Indian dial code | Check `GLIFIC_PHONE` / `GLIFIC_PASSWORD`. |
+| `login — Timeout …waitForURL` | Slow first load or a changed login UI | Retry once with `gdocs start`. If it fails again, report it. |
diff --git a/.claude/skills/update-docs/browsing.md b/.claude/skills/update-docs/browsing.md
new file mode 100644
index 000000000..c6605e901
--- /dev/null
+++ b/.claude/skills/update-docs/browsing.md
@@ -0,0 +1,67 @@
+# Browsing the live app
+
+One browser session, `glific-docs`, is opened by `gdocs preflight`/`gdocs start`. It is already logged in, guarded and set to a 1440×900 viewport. Drive it with `playwright-cli -s=glific-docs `. The hook rejects any other session and any command not listed below.
+
+## Session lifecycle
+
+```bash
+node .claude/skills/update-docs/scripts/gdocs.mjs status # alive + guarded? (exit 1 = no, 3 = guard tripped)
+node .claude/skills/update-docs/scripts/gdocs.mjs start # reopen + re-login if the session died (idle 1h)
+node .claude/skills/update-docs/scripts/gdocs.mjs stop # at the end
+```
+
+If a command says the browser `is not open`, run `gdocs start`. Never use `playwright-cli open`.
+
+## Allowed commands
+
+| Purpose | Command |
+|---|---|
+| Navigate | `goto /` (use the literal origin printed by preflight), `go-back`, `go-forward`, `reload` |
+| See the page | `snapshot` (accessibility tree with refs such as `e12`, or `f3e12` inside frames), `snapshot `, `find ""` |
+| Interact (read-only intent) | `click `, `hover `, `press Escape`, `type`/`fill` **only in search or filter boxes**, `select` on filter dropdowns, `mousewheel 0 600` |
+| Inspect | `eval ""` (e.g. `document.title`, `window.scrollTo(0,0)`), `console`, `requests` |
+| Tabs | `tab-list`, `tab-new `, `tab-select `, `tab-close ` |
+| Wait | `eval "new Promise(r => setTimeout(r, 1500))"`, then `snapshot` again |
+
+Glific loads data with Apollo after the page renders. After `goto`, check with `snapshot` or `find` that the list or data has loaded before you take a screenshot.
+
+Snapshot output contains real contact data. Use it to navigate. Don't copy names, numbers or message text into docs, commit messages or replies.
+
+## Screenshots
+
+```bash
+# full viewport
+node .claude/skills/update-docs/scripts/gdocs.mjs shot static/img//_.png
+
+# cropped to one element (preferred for lists, forms, dialogs, cards)
+node .claude/skills/update-docs/scripts/gdocs.mjs shot static/img//_.png --target e42
+node .claude/skills/update-docs/scripts/gdocs.mjs shot static/img//_.png --target '[role="dialog"]'
+
+# extra redaction for free-text PII the automatic pass can't detect
+node .claude/skills/update-docs/scripts/gdocs.mjs shot static/img/flows/flows_list.png --target main --hide 'tbody td:first-child'
+```
+
+- The output path must be under `static/img/`. Follow the naming and directory rules in [style-images.md](style-images.md).
+- Redaction changes the live DOM. After a shot, `reload` before you read real values again.
+- When you **replace** an existing image, keep the same filename so links don't break, unless the page is being restructured.
+- After every shot, **Read the PNG** and check it: no real names, numbers, message text, emails, keys or org-identifying details, and the right UI state is shown. If anything leaks, retake it with `--hide`/`--mask` or a tighter `--target`.
+- `shot` refuses to capture while an unacknowledged guard trip exists. Clear it with the stop-and-warn protocol in `safety.md`.
+
+## Finding routes and UI
+
+1. Snapshot the left sidebar after login. The menu items are the feature entry points.
+2. If `../glific-frontend/` exists, read `src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx` for the full route list, and `src/containers//` for labels and `data-testid`s.
+3. Otherwise read the same files from GitHub without cloning:
+ ```bash
+ gh api repos/glific/glific-frontend/contents/src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx --jq .content | base64 -d
+ ```
+4. For "what changed" questions, check recent merged PRs: `gh pr list --repo glific/glific-frontend --state merged --search "" --limit 10`.
+
+Common entry routes (check them against the sidebar, they change): `/chat`, `/flow`, `/trigger`, `/speed-send`, `/template`, `/interactive-message`, `/search`, `/collection`, `/contact-management`, `/staff-management`, `/notifications`, `/analytics`, `/settings`.
+
+## Opening forms safely
+
+Clicking **Create**/**Add** to photograph an empty form is fine. Then:
+- Don't type into form fields, except to show a field's validation or helper text, and then only with obviously fake text such as `Sample template`.
+- Leave with **Cancel**, `press Escape` or `go-back`. Never click Save, Submit, Send, Create, Delete, Confirm, Import, Upload, Sync, Start or Publish.
+- If a confirmation dialog appears unexpectedly, dismiss it (`press Escape` or click **Cancel**).
diff --git a/.claude/skills/update-docs/safety.md b/.claude/skills/update-docs/safety.md
new file mode 100644
index 000000000..2a299354b
--- /dev/null
+++ b/.claude/skills/update-docs/safety.md
@@ -0,0 +1,72 @@
+# Safety: read-only browsing and redaction
+
+The session is logged in to a real organisation's **production** Glific account. Contacts, messages and staff are real people. Every safeguard below fails closed: when in doubt, it blocks or refuses.
+
+## Layers
+
+| Layer | Where | What it does |
+|---|---|---|
+| Network guard | `gdocs.mjs` → `guardCode` (installed on the browser context **before** login) | Blocks every write request. See the rules below. Blocked GraphQL calls get a fake error response. Other blocked calls get a 403. Nothing reaches the server. |
+| Canary | `gdocs preflight` / `start` / `status` / `shot` | Sends a fake `mutation DocsGuardCanary`. If it isn't blocked, the session is closed and not used. |
+| Command hook (pre) | `.claude/settings.json` → `scripts/pw-guard-hook.sh pre` | Direct `playwright-cli` calls must use `-s=glific-docs` and only an allow-listed set of navigation and inspection commands. It blocks `open`, `run-code`, `unroute`, `screenshot`, `state-save`, cookie and storage access, tracing, video, uploads, and any command that mentions `GLIFIC_PASSWORD`. |
+| Guard monitor (post) | `pw-guard-hook.sh post` | After each `glific-docs` command, reads the guard's log. If a non-passive write was blocked, it returns `READ-ONLY GUARD TRIPPED` to you. |
+| Redaction | `gdocs shot` | Replaces PII in the DOM of every frame, re-scans what is visible, and refuses to capture if anything phone- or email-like remains. It then masks media and avatars and records the image's SHA-256 in `.playwright-cli/redacted-manifest.json`. |
+| Provenance lint | `gdocs lint` | Fails if any new or changed image under `static/img/` isn't in the manifest with a matching hash. |
+| Your eyes | You | Read every PNG before you reference it. Regex can't catch names in free text. |
+
+## Guard rules
+
+Allowed:
+- `GET`, `HEAD` and `OPTIONS`, unless the URL contains a GraphQL `mutation`.
+- `POST /api` when **every** operation is a plain `query`.
+- Login and session upkeep: `POST /api/v1/session`, `/session/renew`, `/session/name`.
+- Analytics embed token: `POST /api/v1/get-embed-token`. Superset (`moonshine.projecttech4dev.org`) chart-data POSTs use a read-only guest token.
+
+Blocked, and recorded:
+- Any GraphQL `mutation` or `subscription` over HTTP, multipart or upload requests, and requests without query text.
+- Side-effect queries `flowGet`, `flowRelease`, `simulatorGet` and `simulatorRelease`. They take or release edit locks, so opening the **flow editor** or the **simulator** would lock them for the org's real staff.
+- Every other `POST`, `PUT`, `PATCH` or `DELETE` to the Glific host, including logout, the simulator, onboarding and registration (which sends an OTP).
+- WebSocket frames that carry a mutation.
+
+Dropped silently (not recorded): writes to third-party hosts, such as telemetry (PostHog, Sentry).
+
+**Passive** blocks are fired by the UI on its own, not by something you clicked: `markContactMessagesAsRead` when a chat is opened, `markNotificationAsRead`, and `/session/tracker`. They are blocked and listed, but you don't need to stop for them. Mention them in the final summary.
+
+## Stop-and-warn protocol
+
+Do this when `READ-ONLY GUARD TRIPPED` appears (from the hook or from `gdocs blocked`/`shot`/`status`), or when you notice you were about to change data:
+
+1. **Stop.** Make no more browser calls.
+2. Tell the user right away, in this form:
+ > ⚠️ **Read-only guard tripped.** My last action, ``, tried to `` on the live Glific account. It was blocked and nothing was changed on the server. ``.
+3. Run `gdocs blocked` and include its lines.
+4. **Wait** for the user to say how to proceed. When they reply, run `gdocs blocked --ack`, reload the page (the UI may now show an error state), and continue without repeating that action.
+
+Never try to "fix" a trip by changing the guard, the hook, the session or the scripts. Never run `playwright-cli` outside the `glific-docs` session.
+
+## Things you can't capture on production
+
+| UI | Why | Do instead |
+|---|---|---|
+| Flow editor canvas, flow simulator | Opening them takes a server-side lock (`flowGet`, `simulatorGet`) | Keep the existing screenshots, or use `:::info Screenshot coming soon`. Tell the user. |
+| Saved or confirmed states ("Template submitted", success toasts) | Would need a write | Describe them in text. Photograph only the form before it is submitted. |
+| Contact profiles and chat threads with real conversations | Heavy PII | Prefer list or empty views. If you must, crop tightly with `--target` and check the redaction carefully. |
+| Settings → credentials and provider keys | Secrets | Inputs that look like keys are masked automatically. Prefer the list view. |
+
+## Redaction details and limits
+
+`gdocs shot` replaces:
+- Phone-like numbers (10–15 digits) → `+91 XXXXX XXXXX`; emails → `user@example.org`; long token-like strings → `••••••••`.
+- Contact names (chat list, conversation header, profile header, WA group members, sender names) → `Contact Name`; staff name in the user menu → `Staff Member`.
+- Table columns headed Name, Contact, Beneficiary, Staff, User, Member, Assigned to or Owner → `Contact 1…n`.
+- Message text in chat bubbles and previews → `Sample message text`.
+- Input values that look like secrets → `••••••••••`. Other input values are scanned for phones and emails.
+- Images, video, stickers, documents, locations and avatars in chats → blurred, and masked in the capture.
+
+What it **can't** detect on its own: names or personal details written in free text, such as flow names like "Asha's follow-up", collection names, notes, labels, template bodies with real names, and data inside charts. When you see any of these in the PNG:
+
+```bash
+node .claude/skills/update-docs/scripts/gdocs.mjs shot static/img//.png --hide ''
+```
+
+`--hide` overwrites the text with `████` and blurs images. `--mask` adds an opaque box over an element in the capture. You can repeat either flag. Retake until the image is clean.
diff --git a/.claude/skills/update-docs/scripts/cli.config.json b/.claude/skills/update-docs/scripts/cli.config.json
new file mode 100644
index 000000000..ff4e98379
--- /dev/null
+++ b/.claude/skills/update-docs/scripts/cli.config.json
@@ -0,0 +1,20 @@
+{
+ "browser": {
+ "browserName": "chromium",
+ "isolated": true,
+ "launchOptions": {
+ "headless": true
+ },
+ "contextOptions": {
+ "viewport": { "width": 1440, "height": 900 },
+ "ignoreHTTPSErrors": true,
+ "locale": "en-US",
+ "timezoneId": "Asia/Kolkata"
+ }
+ },
+ "outputDir": ".playwright-cli",
+ "timeouts": {
+ "action": 10000,
+ "navigation": 60000
+ }
+}
diff --git a/.claude/skills/update-docs/scripts/gdocs.mjs b/.claude/skills/update-docs/scripts/gdocs.mjs
new file mode 100755
index 000000000..ac6af462c
--- /dev/null
+++ b/.claude/skills/update-docs/scripts/gdocs.mjs
@@ -0,0 +1,818 @@
+#!/usr/bin/env node
+/**
+ * gdocs — read-only, redacting wrapper around `playwright-cli` for the
+ * update-docs skill. Zero dependencies: Node built-ins + the preinstalled
+ * `playwright-cli` binary only.
+ *
+ * node .claude/skills/update-docs/scripts/gdocs.mjs [args]
+ *
+ * Commands:
+ * preflight Check env, CLI, browser, guard, redaction and login. Run first.
+ * start Open (or reuse) the guarded, logged-in session.
+ * status Is the session alive and the guard active?
+ * shot [--target ] [--mask ]... [--hide ]...
+ * Redact the page, verify nothing sensitive is left, then screenshot.
+ * blocked [--ack] Show write attempts the guard blocked (exit 3 if any need attention).
+ * lint [files...] Check new/changed images came from `shot` and /img refs resolve.
+ * stop Close the session.
+ *
+ * Credentials come from env: GLIFIC_URL, GLIFIC_PHONE, GLIFIC_PASSWORD.
+ * They are never printed. The agent should never handle them directly.
+ */
+
+import { spawnSync } from 'node:child_process';
+import { createHash, randomBytes } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const SESSION = 'glific-docs';
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
+const CONFIG = path.join(SCRIPT_DIR, 'cli.config.json');
+const ROOT = findRoot();
+const WORK = path.join(ROOT, '.playwright-cli');
+const TMP = path.join(WORK, 'tmp');
+const MANIFEST = path.join(WORK, 'redacted-manifest.json');
+const MARK = 'GLIFIC_DOCS_READONLY_BLOCKED';
+const GUARD_COOKIE_URL = 'https://docs-guard.invalid';
+
+// Hosts the Glific UI legitimately POSTs to for *reads*. Superset (Analytics
+// page) runs with a read-only guest token and fetches chart data via POST.
+const READ_ONLY_POST_HOSTS = ['moonshine.projecttech4dev.org'];
+
+function findRoot() {
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
+ return r.status === 0 ? r.stdout.trim() : process.cwd();
+}
+
+// ---------------------------------------------------------------------------
+// playwright-cli plumbing
+// ---------------------------------------------------------------------------
+
+function cli(args, { quiet = false, timeout = 180000 } = {}) {
+ const r = spawnSync('playwright-cli', [`-s=${SESSION}`, ...args], {
+ cwd: ROOT,
+ encoding: 'utf8',
+ timeout,
+ env: process.env,
+ });
+ const out = `${r.stdout || ''}${r.stderr || ''}`;
+ if (!quiet) process.stdout.write(scrub(out));
+ return { status: r.status, out, error: r.error };
+}
+
+// Runs a Playwright function in the session. The code is written to a private
+// temp file and the CLI echo (which repeats the code) is never printed, so
+// credentials embedded in the code don't leak into the transcript.
+function runCode(code) {
+ fs.mkdirSync(TMP, { recursive: true, mode: 0o700 });
+ const file = path.join(TMP, `rc-${randomBytes(6).toString('hex')}.js`);
+ fs.writeFileSync(file, code, { mode: 0o600 });
+ try {
+ const rel = path.relative(ROOT, file);
+ const { out, status } = cli(['run-code', `--filename=${rel}`], { quiet: true });
+ return { raw: out, status, result: parseResult(out) };
+ } finally {
+ fs.rmSync(file, { force: true });
+ }
+}
+
+function parseResult(out) {
+ const m = out.match(/### Result\s*\n([\s\S]*?)(?:\n###|$)/);
+ if (!m) return null;
+ let v = m[1].trim();
+ for (let i = 0; i < 2 && typeof v === 'string'; i += 1) {
+ try { v = JSON.parse(v); } catch { break; }
+ }
+ return v;
+}
+
+function scrub(text) {
+ let t = String(text);
+ for (const key of ['GLIFIC_PASSWORD', 'GLIFIC_PHONE']) {
+ const val = process.env[key];
+ if (val && val.length >= 4) t = t.split(val).join(`<${key}>`);
+ }
+ return t;
+}
+
+function sessionAlive() {
+ const { out, status } = cli(['eval', '1'], { quiet: true, timeout: 30000 });
+ return status === 0 && !/is not open/.test(out);
+}
+
+// ---------------------------------------------------------------------------
+// Env
+// ---------------------------------------------------------------------------
+
+function env() {
+ const url = (process.env.GLIFIC_URL || '').trim().replace(/\/+$/, '');
+ const phone = (process.env.GLIFIC_PHONE || '').replace(/\D/g, '');
+ const password = process.env.GLIFIC_PASSWORD || '';
+ return { url, phone, password };
+}
+
+function hostOf(url) {
+ try { return new URL(url).hostname; } catch { return ''; }
+}
+
+// ---------------------------------------------------------------------------
+// In-browser code (serialised into run-code snippets)
+// ---------------------------------------------------------------------------
+
+function guardCode(glificHost) {
+ return `async page => {
+ const ctx = page.context();
+ const MARK = ${JSON.stringify(MARK)};
+ const GLIFIC_HOST = ${JSON.stringify(glificHost)};
+ const COOKIE_URL = ${JSON.stringify(GUARD_COOKIE_URL)};
+ const READ_ONLY_POST_HOSTS = ${JSON.stringify(READ_ONLY_POST_HOSTS)};
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
+ // REST endpoints that only authenticate or read.
+ const ALLOWED_POST_PATHS = [/^\\/api\\/v1\\/session\\/?$/, /^\\/api\\/v1\\/session\\/renew\\/?$/,
+ /^\\/api\\/v1\\/session\\/name\\/?$/, /^\\/api\\/v1\\/get-embed-token\\/?$/];
+ // Fired automatically by the UI; still blocked, but not a reason to stop.
+ const PASSIVE = [/^\\/api\\/v1\\/session\\/tracker/, /\\bmarkContactMessagesAsRead\\b/, /\\bmarkNotificationAsRead\\b/];
+ // Queries with server-side side effects (they take or release edit locks).
+ const LOCKING_FIELDS = /\\b(flowGet|flowRelease|simulatorGet|simulatorRelease)\\b/;
+
+ const isGlific = h => !!GLIFIC_HOST && (h === GLIFIC_HOST || h.endsWith('.' + GLIFIC_HOST));
+
+ const gqlVerdict = body => {
+ let payload;
+ try { payload = JSON.parse(body); } catch { return 'unparseable GraphQL body'; }
+ for (const op of Array.isArray(payload) ? payload : [payload]) {
+ if (!op || typeof op.query !== 'string') return 'GraphQL request without query text';
+ const q = op.query.replace(/#[^\\n]*/g, '');
+ if (/\\bmutation\\b/.test(q)) return 'GraphQL mutation ' + (op.operationName || (q.match(/mutation\\s+(\\w+)/) || [])[1] || '');
+ if (/\\bsubscription\\b/.test(q)) return 'GraphQL subscription over HTTP';
+ const lock = q.match(LOCKING_FIELDS);
+ if (lock) return 'side-effect query ' + lock[1] + ' (takes/releases an edit lock)';
+ }
+ return null;
+ };
+
+ const record = async (why, req) => {
+ const body = req.postData() || '';
+ if (body.includes('DocsGuardCanary')) return;
+ const u = new URL(req.url());
+ const passive = PASSIVE.some(r => r.test(u.pathname) || r.test(body));
+ const entry = { at: new Date().toISOString(), method: req.method(), host: u.hostname, path: u.pathname, why, passive };
+ try {
+ const prev = (await ctx.cookies(COOKIE_URL)).find(c => c.name === 'blocked');
+ let list = prev ? JSON.parse(decodeURIComponent(prev.value)) : [];
+ list.push(entry);
+ list = list.slice(-8);
+ await ctx.addCookies([{ name: 'blocked', value: encodeURIComponent(JSON.stringify(list)), url: COOKIE_URL }]);
+ } catch (e) {}
+ };
+
+ await ctx.route('**/*', async route => {
+ const req = route.request();
+ const method = req.method().toUpperCase();
+ let u;
+ try { u = new URL(req.url()); } catch { return route.fallback(); }
+ if (!/^https?:$/.test(u.protocol)) return route.fallback();
+
+ let why = null;
+ if (SAFE_METHODS.includes(method)) {
+ let search = u.search;
+ try { search = decodeURIComponent(search); } catch (e) {}
+ if (/\\bmutation\\b/.test(search)) why = 'GraphQL mutation via GET';
+ } else if (READ_ONLY_POST_HOSTS.includes(u.hostname)) {
+ why = null;
+ } else if (!isGlific(u.hostname)) {
+ // Third-party writes (telemetry, CDNs). Drop quietly.
+ return route.fulfill({ status: 204, headers: { 'access-control-allow-origin': req.headers()['origin'] || '*' } });
+ } else if (method === 'POST' && ALLOWED_POST_PATHS.some(r => r.test(u.pathname))) {
+ why = null;
+ } else if (method === 'POST' && /^\\/api\\/?$/.test(u.pathname)) {
+ const ct = req.headers()['content-type'] || '';
+ why = ct.includes('json') ? gqlVerdict(req.postData() || '') : 'non-JSON GraphQL request (file upload?)';
+ } else {
+ why = method + ' ' + u.pathname + ' is a write';
+ }
+ if (!why) return route.fallback();
+
+ await record(why, req);
+ const origin = req.headers()['origin'];
+ const headers = { 'content-type': 'application/json', 'access-control-allow-origin': origin || '*' };
+ if (origin) headers['access-control-allow-credentials'] = 'true';
+ const message = MARK + ': ' + why + ' — blocked by the docs skill (read-only mode)';
+ if (/^\\/api\\/?$/.test(u.pathname)) {
+ return route.fulfill({ status: 200, headers, body: JSON.stringify({ data: null, errors: [{ message }] }) });
+ }
+ return route.fulfill({ status: 403, headers, body: JSON.stringify({ error: message }) });
+ });
+
+ // Absinthe socket carries subscriptions only; drop anything else that looks like a write.
+ try {
+ await ctx.routeWebSocket(/\\/socket\\//, ws => {
+ const server = ws.connectToServer();
+ ws.onMessage(msg => {
+ const text = typeof msg === 'string' ? msg : '';
+ if (/\\bmutation\\b/.test(text) || LOCKING_FIELDS.test(text)) return;
+ server.send(msg);
+ });
+ });
+ } catch (e) {}
+
+ return 'GUARD_INSTALLED';
+}`;
+}
+
+function canaryCode(target) {
+ return `async page => {
+ const res = await page.evaluate(async url => {
+ try {
+ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'text/plain' },
+ body: JSON.stringify({ query: 'mutation DocsGuardCanary { __typename }' }) });
+ return await r.text();
+ } catch (e) { return 'FETCH_ERROR ' + e.message; }
+ }, ${JSON.stringify(target)});
+ return res.includes(${JSON.stringify(MARK)}) ? 'GUARD_OK' : 'GUARD_MISSING';
+}`;
+}
+
+function loginCode({ url, phone, password }) {
+ const local = phone.slice(-10);
+ const dial = phone.slice(0, -10) || '91';
+ return `async page => {
+ const BASE = ${JSON.stringify(url)};
+ const LOCAL = ${JSON.stringify(local)};
+ const DIAL = ${JSON.stringify(dial)};
+ const PASSWORD = ${JSON.stringify(password)};
+ let last = '';
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
+ try {
+ await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1500);
+ if (new URL(page.url()).pathname.startsWith('/chat')) return JSON.stringify({ ok: true, already: true });
+ await page.waitForSelector('[data-testid="AuthContainer"]', { timeout: 20000 });
+ const phone = page.locator('input[name="phoneNumber"]');
+ await phone.waitFor({ state: 'visible' });
+ // PhoneInput prepends the dial code after mount; type the local part after it.
+ await page.waitForFunction(() => {
+ const el = document.querySelector('input[name="phoneNumber"]');
+ return !!el && /^\\+\\d/.test(el.value);
+ }, null, { timeout: 10000 });
+ await phone.click();
+ await phone.press('End');
+ await phone.pressSequentially(LOCAL);
+ if ((await phone.inputValue()).replace(/\\D/g, '') !== DIAL + LOCAL) throw new Error('phone field did not accept GLIFIC_PHONE');
+ await page.fill('input[name="password"]', PASSWORD);
+ await page.click('[data-testid="SubmitButton"]');
+ await page.waitForURL(u => u.pathname.startsWith('/chat'), { timeout: 45000 });
+ return JSON.stringify({ ok: true, attempt });
+ } catch (e) {
+ last = String((e && e.message) || e).split(PASSWORD).join('***').split(LOCAL).join('***');
+ }
+ }
+ let onPage = '';
+ try { onPage = (await page.locator('[data-testid="AuthContainer"]').innerText()).slice(0, 300); } catch (e) {}
+ return JSON.stringify({ ok: false, error: last.slice(0, 400), page: onPage.split(LOCAL).join('***') });
+}`;
+}
+
+// Runs inside every frame. Replaces PII in place; returns counts.
+function redactInFrame(opts) {
+ const counts = { phone: 0, email: 0, secret: 0, name: 0, message: 0, input: 0, masked: 0, media: 0 };
+ const PHONE_RE = /(? s.replace(/\D/g, '').length;
+ const scrubText = s => {
+ let out = s.replace(EMAIL_RE, m => {
+ if (m === 'user@example.org') return m;
+ counts.email += 1;
+ return 'user@example.org';
+ });
+ out = out.replace(PHONE_RE, m => {
+ const n = digits(m);
+ if (n < 10 || n > 15) return m;
+ counts.phone += 1;
+ return '+91 XXXXX XXXXX';
+ });
+ out = out.replace(SECRET_RE, () => { counts.secret += 1; return '••••••••'; });
+ return out;
+ };
+ const textNodes = root => {
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode: n => (n.nodeValue && n.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
+ });
+ const list = [];
+ while (w.nextNode()) list.push(w.currentNode);
+ return list;
+ };
+ const relabel = (el, label, key) => {
+ const nodes = textNodes(el);
+ // Compare text, not a marker: React may re-render the node with the real value.
+ if (!nodes.length || nodes[0].nodeValue === label) return;
+ nodes[0].nodeValue = label;
+ for (const n of nodes.slice(1)) n.nodeValue = scrubText(n.nodeValue);
+ counts[key] += 1;
+ };
+ const q = sel => { try { return Array.from(document.querySelectorAll(sel)); } catch (e) { return []; } };
+
+ if (!document.body) return counts;
+
+ for (const sel of NAME_SELECTORS) q(sel).forEach(el => relabel(el, 'Contact Name', 'name'));
+ for (const sel of STAFF_SELECTORS) q(sel).forEach(el => relabel(el, 'Staff Member', 'name'));
+ for (const sel of MESSAGE_SELECTORS) q(sel).forEach(el => relabel(el, 'Sample message text', 'message'));
+
+ // Name-like table columns.
+ for (const table of q('table')) {
+ const heads = Array.from(table.querySelectorAll('thead th, thead td'));
+ heads.forEach((th, idx) => {
+ if (!NAME_HEADERS.test((th.innerText || '').trim())) return;
+ table.querySelectorAll('tbody tr').forEach((tr, i) => {
+ const cell = tr.children[idx];
+ if (cell) relabel(cell, `Contact ${i + 1}`, 'name');
+ });
+ });
+ }
+
+ for (const n of textNodes(document.body)) {
+ const next = scrubText(n.nodeValue);
+ if (next !== n.nodeValue) n.nodeValue = next;
+ }
+
+ const setValue = (el, v) => {
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
+ setter.call(el, v);
+ };
+ for (const el of q('input, textarea')) {
+ if (!el.value) continue;
+ const hint = [el.type, el.name, el.id, el.placeholder, el.getAttribute('aria-label')].join(' ');
+ if (el.type === 'password' || SENSITIVE_INPUT.test(hint)) {
+ if (el.type !== 'password') setValue(el, '••••••••••');
+ counts.input += 1;
+ continue;
+ }
+ const next = scrubText(el.value);
+ if (next !== el.value) { setValue(el, next); counts.input += 1; }
+ }
+
+ for (const sel of MEDIA_SELECTORS) {
+ q(sel).forEach(el => { el.style.filter = 'blur(14px)'; counts.media += 1; });
+ }
+ for (const sel of opts.hide || []) {
+ q(sel).forEach(el => {
+ textNodes(el).forEach(n => { n.nodeValue = '█'.repeat(Math.min(12, Math.max(4, n.nodeValue.trim().length))); });
+ el.querySelectorAll('img, video, canvas, svg').forEach(m => { m.style.filter = 'blur(14px)'; });
+ counts.masked += 1;
+ });
+ }
+ return counts;
+}
+
+// Runs inside every frame after redaction. Returns leak descriptions (never the values).
+function verifyInFrame(opts) {
+ const PHONE_RE = /(? {
+ if (!el) return false;
+ const r = el.getBoundingClientRect();
+ if (!r.width || !r.height) return false;
+ if (!clip) return true;
+ return r.right > clip.x && r.left < clip.x + clip.width && r.bottom > clip.y && r.top < clip.y + clip.height;
+ };
+ const where = el => {
+ const t = el.closest('[data-testid]');
+ return t ? `[data-testid="${t.dataset.testid}"]` : el.tagName.toLowerCase();
+ };
+ const check = (text, el) => {
+ if (!text || !visible(el)) return;
+ if ((text.match(EMAIL_RE) || []).some(m => m !== 'user@example.org')) leaks.push({ kind: 'email', where: where(el) });
+ for (const m of text.match(PHONE_RE) || []) {
+ const n = m.replace(/\D/g, '').length;
+ if (n >= 10 && n <= 15) { leaks.push({ kind: 'phone', where: where(el) }); break; }
+ }
+ };
+ const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+ while (w.nextNode()) check(w.currentNode.nodeValue, w.currentNode.parentElement);
+ document.querySelectorAll('input, textarea').forEach(el => {
+ if (el.type !== 'password') check(el.value, el);
+ });
+ return leaks.slice(0, 10);
+}
+
+function shotCode({ out, target, hide, masks }) {
+ const MEDIA = [
+ '[data-testid="imageMessage"]', '[data-testid="videoMessage"]', '[data-testid="stickerMessage"]',
+ '[data-testid="documentMessage"]', '[data-testid="locationMessage"]', '.MuiAvatar-root',
+ ];
+ return `async page => {
+ const OUT = ${JSON.stringify(out)};
+ const TARGET = ${JSON.stringify(target || null)};
+ const HIDE = ${JSON.stringify(hide)};
+ const MASKS = ${JSON.stringify([...masks, ...MEDIA])};
+ const redact = ${redactInFrame.toString()};
+ const verify = ${verifyInFrame.toString()};
+
+ const loc = TARGET ? page.locator(/^(f\\d+)?e\\d+$/.test(TARGET) ? 'aria-ref=' + TARGET : TARGET).first() : null;
+ let clip = null;
+ if (loc) {
+ await loc.scrollIntoViewIfNeeded();
+ clip = await loc.boundingBox();
+ if (!clip) return JSON.stringify({ ok: false, error: 'target not visible: ' + TARGET });
+ }
+ await page.waitForTimeout(300);
+
+ const counts = {};
+ const errors = [];
+ for (let pass = 0; pass < 2; pass += 1) {
+ for (const f of page.frames()) {
+ try {
+ const c = await f.evaluate(redact, { hide: HIDE });
+ for (const [k, v] of Object.entries(c)) counts[k] = (counts[k] || 0) + (pass === 0 ? v : 0);
+ } catch (e) {
+ if (pass === 1 && f.url() !== 'about:blank') errors.push('could not redact frame ' + new URL(f.url()).host);
+ }
+ }
+ await page.waitForTimeout(150);
+ }
+ if (errors.length) return JSON.stringify({ ok: false, error: errors.join('; ') });
+
+ const leaks = [];
+ for (const f of page.frames()) {
+ try {
+ const l = await f.evaluate(verify, { isMain: f === page.mainFrame(), clip });
+ leaks.push(...l);
+ } catch (e) {}
+ }
+ if (leaks.length) return JSON.stringify({ ok: false, error: 'sensitive data still visible after redaction', leaks });
+
+ const mask = MASKS.map(s => page.locator(s));
+ const opts = { path: OUT, animations: 'disabled', caret: 'hide', mask, maskColor: '#C9CED6' };
+ if (loc) await loc.screenshot(opts);
+ else await page.screenshot(opts);
+ return JSON.stringify({ ok: true, counts, url: new URL(page.url()).pathname });
+}`;
+}
+
+const readBlockedCode = `async page => {
+ const c = (await page.context().cookies(${JSON.stringify(GUARD_COOKIE_URL)})).find(x => x.name === 'blocked');
+ return c ? decodeURIComponent(c.value) : '[]';
+}`;
+
+const ackBlockedCode = `async page => {
+ await page.context().clearCookies({ name: 'blocked', domain: 'docs-guard.invalid' });
+ return 'ACKED';
+}`;
+
+const REDACTION_FIXTURE = `
+
Asha Kumari
My son's results came today
+
Call +91 98765 43210 or 919876543210 or email asha.k@example.com
+
Token docsfixture9aB3xY7qLmN2pQ4rS6tU8vW0
+
Name
Phone
Ravi Patel
+91-99887-66554
+
+
Date 2026-10-10, count 12345, flow id 42
+`;
+
+function redactionSelfTestCode() {
+ return `async page => {
+ const redact = ${redactInFrame.toString()};
+ const verify = ${verifyInFrame.toString()};
+ await page.setContent(${JSON.stringify(REDACTION_FIXTURE)});
+ await page.evaluate(redact, { hide: [] });
+ const leaks = await page.evaluate(verify, { isMain: true, clip: null });
+ const text = await page.evaluate(() => document.body.innerText + ' ' + Array.from(document.querySelectorAll('input')).map(i => i.value).join(' '));
+ const survivors = ['Asha', 'Ravi', 'results', '98765', '99887', 'docsfixture', 'abcdef123456', '9123456780'].filter(s => text.includes(s));
+ const keptBenign = ['2026-10-10', '12345', 'flow id 42'].every(s => text.includes(s));
+ await page.goto('about:blank');
+ return JSON.stringify({ ok: !leaks.length && !survivors.length && keptBenign, leaks, survivors, keptBenign });
+}`;
+}
+
+// ---------------------------------------------------------------------------
+// Commands
+// ---------------------------------------------------------------------------
+
+function line(ok, label, detail = '') {
+ const tag = ok === true ? 'PASS' : ok === false ? 'FAIL' : 'INFO';
+ console.log(`[${tag}] ${label}${detail ? ` — ${detail}` : ''}`);
+}
+
+function openSession() {
+ const { status, out } = cli(['open', `--config=${path.relative(ROOT, CONFIG)}`, 'about:blank'], { quiet: true });
+ if (status !== 0) {
+ const reason = scrub(out).split('\n').find(l => /Error|not installed|not found/i.test(l)) || 'unknown error';
+ return { ok: false, reason: reason.trim() };
+ }
+ return { ok: true };
+}
+
+function installGuard(host) {
+ const g = runCode(guardCode(host));
+ return g.result === 'GUARD_INSTALLED';
+}
+
+function canary(url) {
+ return runCode(canaryCode(`${url}/api`)).result === 'GUARD_OK';
+}
+
+function login(e) {
+ const r = runCode(loginCode(e));
+ const res = r.result && typeof r.result === 'object' ? r.result : { ok: false, error: scrub(r.raw).slice(-400) };
+ return res;
+}
+
+// Opens a fresh guarded session, or validates an existing one. Never leaves an
+// unguarded session running.
+function start({ verbose = true } = {}) {
+ const e = env();
+ if (!e.url || !e.phone || !e.password) {
+ line(false, 'env', 'GLIFIC_URL, GLIFIC_PHONE and GLIFIC_PASSWORD must all be set');
+ return false;
+ }
+ const host = hostOf(e.url);
+
+ if (sessionAlive()) {
+ if (canary(e.url)) {
+ if (verbose) line(true, 'session', `reusing guarded session "${SESSION}"`);
+ return true;
+ }
+ line(null, 'session', 'existing session has no guard — closing it');
+ cli(['close'], { quiet: true });
+ }
+
+ const o = openSession();
+ if (!o.ok) { line(false, 'browser launch', o.reason); return false; }
+ if (!installGuard(host) || !canary(e.url)) {
+ line(false, 'read-only guard', 'could not install/verify — closing session');
+ cli(['close'], { quiet: true });
+ return false;
+ }
+ if (verbose) line(true, 'read-only guard', 'installed before any page load');
+
+ const res = login(e);
+ if (!res.ok) {
+ line(false, 'login', `${res.error || 'failed'}${res.page ? ` | page says: ${res.page.replace(/\s+/g, ' ')}` : ''}`);
+ return false;
+ }
+ if (!canary(e.url)) {
+ line(false, 'read-only guard', 'not active after login — closing session');
+ cli(['close'], { quiet: true });
+ return false;
+ }
+ if (verbose) line(true, 'login', `signed in to ${host}`);
+ return true;
+}
+
+function preflight() {
+ console.log('update-docs preflight\n');
+ let ok = true;
+
+ const node = process.versions.node;
+ line(Number(node.split('.')[0]) >= 18, 'node', node);
+
+ const e = env();
+ const missing = ['GLIFIC_URL', 'GLIFIC_PHONE', 'GLIFIC_PASSWORD'].filter(k => !process.env[k]);
+ if (missing.length) { line(false, 'env', `missing ${missing.join(', ')}`); ok = false; }
+ else line(true, 'env', `GLIFIC_URL=${e.url}, phone ends ••${e.phone.slice(-2)}, password set`);
+ if (e.url && !/^https?:\/\/[^/]+$/.test(e.url)) { line(false, 'GLIFIC_URL', 'must be an origin like https://org.glific.com'); ok = false; }
+ if (e.phone && (e.phone.length < 10 || e.phone.length > 13)) { line(false, 'GLIFIC_PHONE', 'expected 10–13 digits, e.g. 91XXXXXXXXXX'); ok = false; }
+
+ const v = spawnSync('playwright-cli', ['--version'], { encoding: 'utf8' });
+ if (v.error || v.status !== 0) {
+ line(false, 'playwright-cli', 'not on PATH — this skill needs the cloud environment with @playwright/cli preinstalled');
+ ok = false;
+ } else line(true, 'playwright-cli', v.stdout.trim());
+ line(null, 'PLAYWRIGHT_BROWSERS_PATH', process.env.PLAYWRIGHT_BROWSERS_PATH || '(default cache)');
+
+ if (!ok) { console.log('\nPREFLIGHT FAILED'); return 1; }
+
+ // Fresh session so every check runs against a clean browser.
+ if (sessionAlive()) cli(['close'], { quiet: true });
+ const o = openSession();
+ if (!o.ok) {
+ line(false, 'browser launch', o.reason);
+ console.log('\nPREFLIGHT FAILED');
+ return 1;
+ }
+ line(true, 'browser launch', 'chromium headless');
+
+ if (!installGuard(hostOf(e.url)) || !canary(e.url)) {
+ line(false, 'read-only guard', 'canary mutation was NOT blocked');
+ cli(['close'], { quiet: true });
+ console.log('\nPREFLIGHT FAILED');
+ return 1;
+ }
+ line(true, 'read-only guard', 'canary mutation blocked');
+
+ const st = runCode(redactionSelfTestCode()).result;
+ if (!st || !st.ok) {
+ line(false, 'redaction self-test', JSON.stringify(st));
+ cli(['close'], { quiet: true });
+ console.log('\nPREFLIGHT FAILED');
+ return 1;
+ }
+ line(true, 'redaction self-test', 'phones, emails, names, messages, secrets removed');
+
+ const res = login(e);
+ if (!res.ok) {
+ line(false, 'login', `${res.error || 'failed'}${res.page ? ` | page says: ${res.page.replace(/\s+/g, ' ')}` : ''}`);
+ console.log('\nPREFLIGHT FAILED');
+ return 1;
+ }
+ line(true, 'login', `signed in to ${hostOf(e.url)}`);
+
+ if (!canary(e.url)) {
+ line(false, 'read-only guard', 'not active after login');
+ cli(['close'], { quiet: true });
+ console.log('\nPREFLIGHT FAILED');
+ return 1;
+ }
+ line(true, 'read-only guard', 'still active after login');
+ runCode(ackBlockedCode);
+ console.log(`\nPREFLIGHT OK — session "${SESSION}" is open, logged in, read-only.`);
+ return 0;
+}
+
+function status() {
+ const e = env();
+ if (!sessionAlive()) { line(false, 'session', 'not open — run `gdocs start`'); return 1; }
+ if (!canary(e.url)) { line(false, 'read-only guard', 'MISSING — run `gdocs stop` then `gdocs start`'); return 1; }
+ line(true, 'session', 'open and guarded');
+ return blocked([]) === 3 ? 3 : 0;
+}
+
+function readBlocked() {
+ const r = runCode(readBlockedCode).result;
+ try { return Array.isArray(r) ? r : JSON.parse(r || '[]'); } catch { return []; }
+}
+
+function blocked(args) {
+ if (!sessionAlive()) { line(null, 'blocked', 'session not open'); return 0; }
+ const list = readBlocked();
+ if (args.includes('--ack')) { runCode(ackBlockedCode); line(null, 'blocked', `cleared ${list.length} entr${list.length === 1 ? 'y' : 'ies'}`); return 0; }
+ if (!list.length) { if (!args.includes('--quiet')) line(true, 'blocked', 'no write attempts'); return 0; }
+ const active = list.filter(b => !b.passive);
+ for (const b of list) {
+ console.log(`${b.passive ? ' passive' : ' STOP '} ${b.at} ${b.method} ${b.host}${b.path} — ${b.why}`);
+ }
+ if (active.length) {
+ console.log(`\nREAD-ONLY GUARD TRIPPED: ${active.length} write attempt(s) were blocked.\n` +
+ 'STOP. Tell the user which action caused this, then wait for their go-ahead.\n' +
+ 'After reporting, clear with: gdocs blocked --ack');
+ return 3;
+ }
+ if (!args.includes('--quiet')) line(null, 'blocked', 'only passive background writes (blocked, safe to continue)');
+ return 0;
+}
+
+function parseFlags(args) {
+ const pos = [];
+ const flags = { hide: [], masks: [], target: null };
+ for (let i = 0; i < args.length; i += 1) {
+ const a = args[i];
+ const needsValue = ['--target', '--hide', '--mask'].includes(a);
+ if (needsValue && (i + 1 >= args.length || !args[i + 1])) throw new Error(`${a} needs a value`);
+ if (a === '--target') flags.target = args[++i];
+ else if (a === '--hide') flags.hide.push(args[++i]);
+ else if (a === '--mask') flags.masks.push(args[++i]);
+ else pos.push(a);
+ }
+ return { pos, flags };
+}
+
+function shot(args) {
+ const { pos, flags } = parseFlags(args);
+ const outArg = pos[0];
+ if (!outArg || !outArg.endsWith('.png')) { console.error('usage: gdocs shot [--target ] [--mask ] [--hide ]'); return 2; }
+ const out = path.resolve(ROOT, outArg);
+ if (!out.startsWith(path.join(ROOT, 'static', 'img') + path.sep)) {
+ console.error('screenshots must be written under static/img/');
+ return 2;
+ }
+ if (!sessionAlive() || !canary(env().url)) { line(false, 'session', 'not open or not guarded — run `gdocs start`'); return 1; }
+ if (blocked(['--quiet']) === 3) return 3;
+
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ const r = runCode(shotCode({ out, target: flags.target, hide: flags.hide, masks: flags.masks }));
+ const res = r.result;
+ if (!res || typeof res !== 'object' || !res.ok) {
+ fs.rmSync(out, { force: true });
+ line(false, 'shot', res && res.error ? res.error : scrub(r.raw).slice(-500));
+ if (res && res.leaks) {
+ for (const l of res.leaks) console.log(` leak: ${l.kind} in ${l.where}`);
+ console.log('Add --hide "" for that area (or --target a tighter element) and retry.');
+ }
+ return 1;
+ }
+ if (!fs.existsSync(out)) { line(false, 'shot', 'screenshot file not written'); return 1; }
+
+ const sha = createHash('sha256').update(fs.readFileSync(out)).digest('hex');
+ const manifest = fs.existsSync(MANIFEST) ? JSON.parse(fs.readFileSync(MANIFEST, 'utf8')) : {};
+ manifest[path.relative(ROOT, out)] = { sha256: sha, at: new Date().toISOString(), url: res.url, counts: res.counts };
+ fs.writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2));
+
+ const c = Object.entries(res.counts || {}).filter(([, v]) => v).map(([k, v]) => `${k}:${v}`).join(' ');
+ line(true, 'shot', `${path.relative(ROOT, out)} (redacted ${c || 'nothing'})`);
+ console.log('Now open the PNG with the Read tool and visually confirm no names, numbers or message text remain.');
+ return 0;
+}
+
+function gitLines(args) {
+ const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
+ return r.status === 0 ? r.stdout.split('\n').filter(Boolean) : [];
+}
+
+function lint(files) {
+ let ok = true;
+ const changed = new Set([
+ ...gitLines(['diff', '--name-only', '--diff-filter=AM', 'main...HEAD']),
+ ...gitLines(['diff', '--name-only', '--diff-filter=AM']),
+ ...gitLines(['diff', '--name-only', '--cached', '--diff-filter=AM']),
+ ...gitLines(['ls-files', '--others', '--exclude-standard']),
+ ]);
+ const manifest = fs.existsSync(MANIFEST) ? JSON.parse(fs.readFileSync(MANIFEST, 'utf8')) : {};
+
+ for (const f of changed) {
+ if (!/^static\/img\/.*\.(png|jpe?g|gif|webp)$/i.test(f)) continue;
+ const abs = path.join(ROOT, f);
+ if (!fs.existsSync(abs)) continue;
+ const sha = createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
+ if (!manifest[f] || manifest[f].sha256 !== sha) {
+ line(false, 'image provenance', `${f} was not produced by \`gdocs shot\` (unredacted?)`);
+ ok = false;
+ }
+ }
+
+ const docs = files.length ? files : [...changed].filter(f => /^docs\/.*\.mdx?$/.test(f));
+ for (const f of docs) {
+ const abs = path.resolve(ROOT, f);
+ if (!fs.existsSync(abs)) continue;
+ const text = fs.readFileSync(abs, 'utf8');
+ for (const m of text.matchAll(/\]\((\/img\/[^)\s]+)\)|src=["'](\/img\/[^"']+)["']/g)) {
+ const ref = decodeURI(m[1] || m[2]);
+ if (!fs.existsSync(path.join(ROOT, 'static', ref))) {
+ line(false, 'image ref', `${f} → ${ref} does not exist`);
+ ok = false;
+ }
+ }
+ // onBrokenLinks is "ignore" in docusaurus.config.js, so the build won't catch these.
+ for (const m of text.matchAll(/\]\(((?!https?:|mailto:|#|\/)[^)\s]+?\.mdx?)(#[^)\s]*)?\)/g)) {
+ let target;
+ try { target = decodeURIComponent(m[1]); } catch { target = m[1]; }
+ if (!fs.existsSync(path.resolve(path.dirname(abs), target))) {
+ line(false, 'doc link', `${f} → ${m[1]} does not exist`);
+ ok = false;
+ }
+ }
+ if (/static\/img\//.test(text)) { line(false, 'image path', `${f} uses static/img/ — use /img/`); ok = false; }
+ if (/user-images\.githubusercontent\.com/.test(text) && changed.has(f) && !gitLines(['ls-files', f]).length) {
+ line(false, 'image host', `${f} is new but uses GitHub CDN images`);
+ ok = false;
+ }
+ }
+ if (ok) line(true, 'lint', `${docs.length} doc(s) checked`);
+ return ok ? 0 : 1;
+}
+
+const [cmd, ...args] = process.argv.slice(2);
+let code = 0;
+try {
+switch (cmd) {
+ case 'preflight': code = preflight(); break;
+ case 'start': code = start() ? 0 : 1; break;
+ case 'status': code = status(); break;
+ case 'shot': code = shot(args); break;
+ case 'blocked': code = blocked(args); break;
+ case 'lint': code = lint(args); break;
+ case 'stop': cli(['close'], { quiet: true }); line(null, 'session', 'closed'); break;
+ default:
+ console.log('usage: gdocs ');
+ code = cmd ? 2 : 0;
+}
+} catch (err) {
+ console.error(`gdocs ${cmd}: ${scrub(err.message)}`);
+ code = 2;
+}
+process.exit(code);
diff --git a/.claude/skills/update-docs/scripts/pw-guard-hook.sh b/.claude/skills/update-docs/scripts/pw-guard-hook.sh
new file mode 100755
index 000000000..1254a238d
--- /dev/null
+++ b/.claude/skills/update-docs/scripts/pw-guard-hook.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Claude Code Bash hook for the update-docs skill (wired in .claude/settings.json).
+#
+# pre — PreToolUse: direct `playwright-cli` calls may only target the guarded
+# "glific-docs" session and only use navigation/inspection commands.
+# Everything that could open an unguarded browser, drop the guard,
+# bypass redaction or leak tokens must go through gdocs.mjs.
+# post — PostToolUse: after any glific-docs browser command, check whether the
+# read-only guard blocked a write and, if so, tell Claude to stop.
+#
+# Exit 2 = block (pre) / feed stderr back to Claude (post). Anything else = allow.
+# Must never break unrelated Bash calls, so every failure path exits 0.
+
+mode="${1:-pre}"
+input="$(cat)"
+
+# Cheap filter: almost every Bash call ends here.
+case "$input" in
+ *playwright-cli*|*GLIFIC_PASSWORD*) ;;
+ *) exit 0 ;;
+esac
+
+cmd="$(printf '%s' "$input" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' 2>/dev/null)" \
+ || cmd="$(printf '%s' "$input" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).tool_input.command||"")}catch{}})' 2>/dev/null)" \
+ || cmd="$input"
+
+session="glific-docs"
+allowed=" goto click dblclick hover snapshot find eval press type fill select check uncheck go-back go-forward reload resize tab-list tab-new tab-select tab-close console requests mousewheel mousemove generate-locator highlight dialog-dismiss set-color-scheme clear-color-scheme close list "
+
+deny() {
+ echo "update-docs guard: $1" >&2
+ echo "Use: node .claude/skills/update-docs/scripts/gdocs.mjs " >&2
+ exit 2
+}
+
+if [ "$mode" = "pre" ]; then
+ case "$cmd" in
+ *GLIFIC_PASSWORD*) deny "commands must not reference GLIFIC_PASSWORD; gdocs.mjs reads credentials itself." ;;
+ esac
+
+ while IFS= read -r inv; do
+ [ -z "$inv" ] && continue
+ set -f
+ # shellcheck disable=SC2086
+ set -- $inv
+ shift # drop "playwright-cli"
+ sess=""
+ sub=""
+ for tok in "$@"; do
+ case "$tok" in
+ -s=*) sess="${tok#-s=}" ;;
+ --session=*) sess="${tok#--session=}" ;;
+ --version|--help|-h) [ -z "$sub" ] && sub="$tok" ;;
+ -*) ;;
+ *) [ -z "$sub" ] && sub="$tok" ;;
+ esac
+ done
+ sess="${sess//\"/}"; sess="${sess//\'/}"
+ case "$sub" in ""|--version|--help|-h|list) continue ;; esac
+ [ "$sess" = "$session" ] || deny "playwright-cli must use -s=$session (the only read-only, guarded session). Got: '${sess:-default}'."
+ case "$allowed" in *" $sub "*) continue ;; esac
+ case "$sub" in
+ screenshot|pdf|video-*|tracing-*) deny "'$sub' is not allowed directly — use 'gdocs.mjs shot' so images are redacted." ;;
+ open|attach) deny "'$sub' would create an unguarded browser — use 'gdocs.mjs start'." ;;
+ *) deny "'playwright-cli $sub' is not allowed in the docs session (it can bypass the read-only guard or expose credentials)." ;;
+ esac
+ # Only invocations in command position (start, after ; & | ( ` or npx/env),
+ # so `grep "playwright-cli open"` etc. is not mistaken for a call.
+ done < <(printf '%s\n' "$cmd" \
+ | grep -oE '(^|[;&|(`]|npx|env|sudo|exec|time|xargs|then|do|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)[[:space:]]*playwright-cli([[:space:]]+[^[:space:];&|)]+)*' 2>/dev/null \
+ | sed -E 's/^.*playwright-cli/playwright-cli/')
+ exit 0
+fi
+
+if [ "$mode" = "post" ]; then
+ case "$cmd" in
+ *"-s=$session"*|*"--session=$session"*) ;;
+ *) exit 0 ;;
+ esac
+ dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ out="$(node "$dir/gdocs.mjs" blocked --quiet 2>&1)"
+ if [ $? -eq 3 ]; then
+ printf '%s\n' "$out" >&2
+ exit 2
+ fi
+ exit 0
+fi
+
+exit 0
diff --git a/.claude/skills/update-docs/sections.md b/.claude/skills/update-docs/sections.md
new file mode 100644
index 000000000..ce3128b21
--- /dev/null
+++ b/.claude/skills/update-docs/sections.md
@@ -0,0 +1,85 @@
+# Glific Docs Sections
+
+The sidebar is autogenerated from the `docs/` directory tree (`sidebars.js`). Number prefixes on folder and file names set the order.
+
+## Section map
+
+| Folder | Audience | Purpose | Content type |
+|--------|----------|---------|--------------|
+| `2. Pre Onboarding/` | Implementation partners, technical staff | Steps to complete *before* Glific onboarding, such as setting up external platforms | Procedural guides (numbered steps, external links) |
+| `3. Starter Kit/` | NGO managers | Checklists and best practices before going live | Checklists, short guides |
+| `4. Product Features/` | Day-to-day users | Reference docs for every UI feature in Glific | Feature reference pages |
+| `5. Integrations/` | Technical staff | How to connect and use third-party tools (OpenAI, Sheets, etc.) | Setup and usage guides |
+| `6. WhatsApp Groups Automation/` | NGOs using WhatsApp Groups | Features specific to WhatsApp Groups (not 1:1 chats) | Feature and setup guides |
+| `7. Use Cases/` | All users | Guides that start from a real problem and show the solution | Narrative walkthroughs |
+| `8. FAQ/` | All users | Quick answers to specific how-to questions | Short Q&A pages |
+
+## Deciding where a new page belongs
+
+1. Is it about a UI screen or button in the Glific web app? → **4. Product Features/**
+2. Is it about connecting an external service to Glific? → **5. Integrations/**
+3. Is it specific to WhatsApp Groups (not regular chats)? → **6. WhatsApp Groups Automation/**
+4. Is it a step required *before* going live (Gupshup, GCS, Facebook)? → **2. Pre Onboarding/**
+5. Is it a real-world scenario showing how to reach a goal? → **7. Use Cases/**
+6. Is it a specific question that doesn't fit anywhere else? → **8. FAQ/**
+
+## Product Features sub-structure
+
+Check the current layout with `ls "docs/4. Product Features/"` before choosing a number. At the time of writing:
+
+```
+01. Chats.md
+02. Speed Sends.md
+03. Flows/ ← folder when a feature has several pages
+04. Triggers.md
+05. Searches.md
+06. HSM Templates.md ← note: two spaces in the existing filename
+07. Interactive Messages.md
+08. Notifications.md
+09. Reporting & Dashboard/
+10. Others/
+11. Link Google Sheets/
+Copy and Paste Node.md ← unnumbered = legacy
+...
+```
+
+Rules:
+- A top-level feature gets a numbered `.md` file, using the next free number (`12. Feature Name.md`).
+- A feature with several pages gets a numbered folder (`12. Feature Name/`) with numbered pages inside and a `_category_.json`:
+ ```json
+ {
+ "link": {
+ "type": "generated-index"
+ }
+ }
+ ```
+ Add `"label"` or `"position"` only if the folder name doesn't already give the right label and order.
+- Unnumbered files at the end are legacy. New pages must be numbered. Don't renumber or rename existing files unless a restructure was approved, because the filename is part of the URL.
+
+## Route → section mapping
+
+Confirm routes against the live sidebar or `src/routes/AuthenticatedRoute/AuthenticatedRoute.tsx` in `glific/glific-frontend` (see `browsing.md`).
+
+| Route | Feature | Section |
+|-------|---------|---------|
+| `/chat` | Chats | 4. Product Features |
+| `/speed-send` | Speed Sends | 4. Product Features |
+| `/flow` | Flows | 4. Product Features |
+| `/trigger` | Triggers | 4. Product Features |
+| `/search` | Searches | 4. Product Features |
+| `/template` | HSM Templates | 4. Product Features |
+| `/interactive-message` | Interactive Messages | 4. Product Features |
+| `/notifications` | Notifications | 4. Product Features |
+| `/analytics` | Reporting & Dashboard | 4. Product Features > 09. Reporting & Dashboard |
+| `/collection` | Collections | 4. Product Features > Others |
+| `/contact-management` | Contact Management | 4. Product Features > Others |
+| `/staff-management` | Staff Management | 4. Product Features > Others |
+| `/settings` | Settings | 4. Product Features > Others |
+| `/sheet-integration` | Sheet Integration | 4. Product Features > 11. Link Google Sheets |
+| `/whatsapp-forms` | WhatsApp Forms | 4. Product Features |
+| `/certificates` | Custom Certificates | 4. Product Features |
+| `/ticket` | Tickets | 4. Product Features |
+| `/webhook-logs` | Webhook logs | 4. Product Features > 03. Flows |
+| `/assistants` | AI Assistants | 5. Integrations |
+| `/ai-evaluations` | AI Evaluations | 5. Integrations |
+| `/group`, `/group-details` | WhatsApp Groups | 6. WhatsApp Groups Automation |
diff --git a/.claude/skills/update-docs/style-images.md b/.claude/skills/update-docs/style-images.md
new file mode 100644
index 000000000..411317889
--- /dev/null
+++ b/.claude/skills/update-docs/style-images.md
@@ -0,0 +1,92 @@
+# Style: Images
+
+## How images are made
+
+Every new or replaced screenshot is taken with `gdocs shot` from the live app (see `browsing.md`). It redacts PII and records the image for `gdocs lint`. Don't add images from anywhere else. A local screenshot tool, a copied GitHub asset or an AI-generated mock-up all fail the provenance lint, and they may contain real contact data.
+
+## Storage policy
+
+| Image type | Where to store |
+|------------|----------------|
+| New screenshots (new pages, new sections, replaced shots) | `static/img/{feature}/` in this repo |
+| Existing GitHub CDN images on pages you don't otherwise touch | Leave them as they are |
+| Existing GitHub CDN image whose screenshot you are replacing | New local shot in `static/img/{feature}/` |
+
+**Never add new GitHub CDN image URLs to a page.**
+
+## Directory mapping
+
+One directory per feature area under `static/img/`:
+
+| Feature | Directory |
+|---------|-----------|
+| Chats | `static/img/chats/` |
+| Speed Sends | `static/img/speed-sends/` |
+| Flows | `static/img/flows/` |
+| Triggers | `static/img/triggers/` |
+| Searches | `static/img/searches/` |
+| HSM Templates | `static/img/hsm-templates/` |
+| Interactive Messages | `static/img/interactive-messages/` |
+| Notifications | `static/img/notifications/` |
+| Reporting & Dashboard / Analytics | `static/img/analytics/` |
+| Collections | `static/img/collections/` |
+| Contact Management | `static/img/contact-management/` |
+| Staff Management | `static/img/staff-management/` |
+| Settings | `static/img/settings/` |
+| Integrations (OpenAI) | `static/img/integrations/openai/` |
+| Integrations (Sheets) | `static/img/integrations/sheets/` |
+| WhatsApp Groups | `static/img/wa-groups/` |
+
+If a feature has no directory yet, `gdocs shot` creates it. Use a lowercase, hyphenated name. Check `ls static/img/` first so you don't create a near-duplicate.
+
+## File naming
+
+Format: `{feature}_{description}.png`. Use lowercase and underscores, with no spaces.
+
+Examples:
+- `flows_list.png`: the list of flows
+- `flows_create_dialog.png`: the create-flow dialog
+- `hsm-templates_new_template_form.png`: the new template form
+- `chats_session_timer.png`: the session timer in the chat window
+
+When you replace a screenshot, **keep its filename** so existing links keep working.
+
+## Markdown syntax
+
+Docusaurus serves `static/` at the site root. Always use the `/img/` prefix, never `static/img/`:
+
+```markdown
+
+```
+
+Don't use:
+- `import Image from ...` with JSX
+- Absolute GitHub CDN URLs for new images
+- Relative paths such as `../../static/img/...`
+
+The alt text should say what the image shows ("Template list with the Status filter open"), not the filename.
+
+## Framing
+
+- **Crop to the relevant element** with `--target` (a dialog, form, list or card). Use a full-viewport shot only when the layout itself matters, such as navigation tabs or where something sits on the screen.
+- Show the state the step describes: the dropdown open, the tab selected, the form empty.
+- Prefer views with little personal data, such as lists, empty states and forms, over chat threads and contact profiles.
+
+## Screenshot placement
+
+Put the screenshot **after** the step it shows, never before. Use one screenshot per major step or UI state.
+
+## Missing screenshots
+
+If you can't capture a state on production (the flow editor, the simulator, success states after saving; see `safety.md`), or the feature isn't live yet:
+
+```markdown
+:::info Screenshot coming soon
+:::
+```
+
+Never use HTML comment placeholders (``).
+
+## Image dimensions
+
+The session captures at a **1440×900 viewport** (set in `scripts/cli.config.json`). Don't resize or compress images. Docusaurus serves them at natural size and the browser scales them.
diff --git a/.claude/skills/documentation/style-page.md b/.claude/skills/update-docs/style-page.md
similarity index 52%
rename from .claude/skills/documentation/style-page.md
rename to .claude/skills/update-docs/style-page.md
index 568d1bd65..3af97a604 100644
--- a/.claude/skills/documentation/style-page.md
+++ b/.claude/skills/update-docs/style-page.md
@@ -1,19 +1,17 @@
# Style: Page Structure
-Different sections use different templates. Match the template to the section type.
+Each section uses its own template. Pick the template that matches the section. `{HEADER}` means the header block from `style-writing.md` (read time, level, Last Updated).
---
## Template 1: Product Feature page (`4. Product Features/`)
```markdown
-###**{N} minute read {padding} `{Level}`**
-
----
+{HEADER}
**{One-sentence summary of what this feature does.}**
-{1–2 sentences of context: when you'd use this, how it fits into Glific.}
+{1–2 sentences of context: when you'd use it and how it fits into Glific.}
## {First capability or action}
@@ -32,24 +30,23 @@ You can read more about related features here:
```
**Rules:**
-- H2 headings are task-focused: "Creating a Flow", "Editing a Template", "Viewing Reports"
-- One screenshot per major action, placed after the step it shows
-- End with cross-links to related Product Feature pages
+- H2 headings name a task: "Creating a Flow", "Editing a Template", "Viewing Reports".
+- One screenshot per major action, placed after the step it shows.
+- End with links to related Product Feature pages.
+- If the feature has tabs in the UI, use one H2 per tab, in the same order as the UI.
---
## Template 2: Integration page (`5. Integrations/`)
```markdown
-###**{N} minute read {padding} `{Level}`**
-
----
+{HEADER}
**{What this integration does and why you'd use it.}**
## Prerequisites
-- {List what the user needs before starting: API keys, accounts, etc.}
+- {What the user needs before starting: API keys, accounts, etc.}
## Setup
@@ -70,9 +67,7 @@ You can read more about related features here:
## Template 3: FAQ page (`8. FAQ/`)
```markdown
-###**{N} minute read {padding} `{Level}`**
-
----
+{HEADER}
**{Restate the question as a one-sentence answer.}**
@@ -91,9 +86,7 @@ FAQ pages are short. If the answer needs more than 5 steps or 2 screenshots, it
## Template 4: Use Case page (`7. Use Cases/`)
```markdown
-###**{N} minute read {padding} `{Level}`**
-
----
+{HEADER}
**{The problem this use case solves, in one sentence.}**
@@ -103,7 +96,7 @@ FAQ pages are short. If the answer needs more than 5 steps or 2 screenshots, it
## Solution
-{Walk through the solution. Reference existing Product Feature pages rather than re-explaining how each feature works.}
+{Walk through the solution. Link to existing Product Feature pages instead of explaining again how each feature works.}
## Steps
@@ -116,15 +109,13 @@ FAQ pages are short. If the answer needs more than 5 steps or 2 screenshots, it
## Template 5: Pre Onboarding guide (`2. Pre Onboarding/`)
```markdown
-###**{N} minute read {padding} `{Level}`**
-
----
+{HEADER}
-**{What this guide helps the user accomplish.}**
+**{What this guide helps the user do.}**
## Prerequisites
-- {External account or info needed before starting.}
+- {External account or information needed before starting.}
## Steps
@@ -137,6 +128,10 @@ FAQ pages are short. If the answer needs more than 5 steps or 2 screenshots, it
---
-## Read-time estimation
+## Sibling consistency
+
+Pages in the same folder (for example every `2. Flow Actions/` page) should share the same H2 pattern. Before you write or refresh one of them, read two siblings and copy their headings. Typical headings are "What is it used for", "How to use it" and "Example". `workflow-review.md` audits this across a whole set of pages.
+
+## Tabs inside a page
-Count images × 10 seconds + word count ÷ 200 wpm. Round to nearest minute. Minimum: 1 minute.
+Use Docusaurus tabs only when the reader picks **one** of several paths, for example a BSP, a platform or a setup method. Don't use them to split up content everyone should read. For the syntax, see `workflow-restructure.md`.
diff --git a/.claude/skills/update-docs/style-writing.md b/.claude/skills/update-docs/style-writing.md
new file mode 100644
index 000000000..bf2c12f1d
--- /dev/null
+++ b/.claude/skills/update-docs/style-writing.md
@@ -0,0 +1,105 @@
+# Style: Writing
+
+Before you write a new page, read at least two existing pages in the same section and match their tone and density.
+
+## Audience
+
+Primary: **NGO program managers and field workers** who use Glific every day. They are not technical. They need to know *what* to do, not *why* the code works.
+
+Secondary: **Implementation partners** who set up Glific for NGOs. They are more technical, but still prefer clear step-by-step instructions.
+
+**Plain language rules:**
+- High-school reading level.
+- One idea per sentence.
+- Explain what things do, not how they work internally.
+- If you can't avoid a Glific term such as "flow", "template" or "collection", explain it the first time you use it, or link to its page.
+- Use "you" and "your". Use active voice and the present tense.
+
+## Voice
+
+| Don't | Do instead |
+|---|---|
+| "Click the button" | "Select **Create Flow**" |
+| "The user should navigate to" | "Open **Flows** from the left menu" |
+| "It is possible to" | "You can" |
+| "In order to" | "To" |
+| Passive voice | Active voice, second person, present tense |
+| "Please note that" | Just say it |
+
+## UI labels
+
+- Copy every label **exactly as the live app shows it**, including capitals and "+" prefixes. Check it with `snapshot`/`find` in the session. Don't trust old docs or memory.
+- Write menu paths with `>`: **Settings** > **Organization**.
+- If the UI renamed something, use the new name everywhere on the page. Mention the old name once only if users will still look for it ("**Dashboards** (previously **Reports**)").
+
+## Page header
+
+Every page starts with a read-time, level and last-updated header. Copy this block exactly, because most pages use it:
+
+```mdx
+
+
+
+
3 minutes read
+
Level: Beginner
+
Last Updated: October 2026
+
+
+
+```
+
+- **Read time**: count words ÷ 200, plus 10 seconds per image, and round to the nearest minute (minimum 1). Write "1 minute read" or "N minutes read".
+- **Level**:
+ - `Beginner`: anyone can follow it with no prior Glific knowledge
+ - `Intermediate`: assumes the reader knows the basic Glific features
+ - `Advanced`: needs an understanding of flows, APIs or technical setup
+- **Last Updated**: the month and year of your change. Update it whenever you change a page's content.
+- Old pages may use other header variants (`###**N minute read …`, `paddingLeft: 40`, `
`). When you refresh such a page, replace its header with the block above.
+
+## Instructions format
+
+Use numbered steps for actions done in order. One action per step.
+
+**Rules:**
+1. **Bold every UI element** the user interacts with: button labels, menu items, tab names and field labels.
+2. Place a screenshot *after* the step it shows, not before.
+3. Keep steps small: one click or one field per step.
+4. Use "Select", not "Click", so the wording also works on touchscreens.
+5. Use quotes for exact text the user types, and bold for UI labels they select.
+
+**Example:**
+```markdown
+1. Open **Flows** from the left menu.
+
+
+
+2. Select **+ Create Flow**.
+3. Enter a name for your flow and select **Save**.
+```
+
+## Section intro pattern
+
+After the header, start with a **bold one-liner** that says exactly what the feature does:
+
+```markdown
+**Flows let you build automated conversation sequences that run without staff involvement.**
+```
+
+Then add 1–2 sentences of context if needed. Then go straight into the steps or sub-sections.
+
+## Examples and sample data
+
+- Example values in text and screenshots must be made up: "Sample Flow", "+91 XXXXX XXXXX", "user@example.org".
+- Never copy real contact names, phone numbers, message text or org names from the live app into a page.
+
+## Anti-patterns
+
+| Don't | Why |
+|---|---|
+| GitHub CDN image URLs in new or rewritten content | The images can break. Use `static/img/` instead |
+| `` tags pointing to GitHub asset URLs | Use standard markdown `` |
+| Long intros before the first action | Use header → bold one-liner → steps |
+| Repeating WhatsApp or Meta documentation | Link to the official docs instead |
+| Explaining GraphQL, Apollo or internal architecture | The audience doesn't need it |
+| `Note:` in plain text | Use the `:::note` admonition |
+| Documenting UI you couldn't see in the live app | It may be wrong. Ask the user, or mark the screenshot as coming soon |
diff --git a/.claude/skills/update-docs/workflow-new.md b/.claude/skills/update-docs/workflow-new.md
new file mode 100644
index 000000000..a804fee23
--- /dev/null
+++ b/.claude/skills/update-docs/workflow-new.md
@@ -0,0 +1,40 @@
+# Workflow: write a new page
+
+Example: `/update-docs write a new doc page for HSM v2 page`
+
+## 1. Make sure the feature exists and isn't documented yet
+
+- Find it in the live app: the sidebar `snapshot`, then `goto` its route. If you can't find it, ask the user for the menu path or route. The feature may be behind a flag that this org doesn't have.
+- Search for existing coverage: `grep -ril "\|" docs`. If a page already covers it, propose a **refresh** instead, or a new page that the old one links to.
+
+## 2. Understand the feature
+
+- In the live app, walk through every tab, list, filter, empty state and **Create/Add** form. Open forms only to look at them. Never submit.
+- Read the frontend code for labels, help text and validation rules: `../glific-frontend/src/containers//`, or the same files via `gh api` (see `browsing.md`).
+- Note what you **can't** capture safely (see `safety.md`, "Things you can't capture on production").
+
+## 3. Placement plan → confirm
+
+Use [sections.md](sections.md) to pick the section. For `4. Product Features/`, check the existing numbered prefixes with `ls` and choose the next number, or use a sub-folder with a `_category_.json` if it has several pages.
+
+```
+Placement plan:
+- Doc: docs//.md (new)
+- Images: static/img// —
+- Linked from:
+- Can't capture: <...>
+```
+
+Wait for confirmation.
+
+## 4. Write
+
+- Start with the page header block from [style-writing.md](style-writing.md#page-header) (read time, Level, Last Updated). Set Last Updated to the current month and year, then work out the read time once the page is written.
+- Use the template for the section from [style-page.md](style-page.md): a bold one-sentence summary, task-focused H2s (“Creating a template”, “Checking template status”), numbered steps, and a screenshot after each major step.
+- Take each screenshot with `gdocs shot` as you write the step it shows. Read each PNG.
+- Use `:::info Screenshot coming soon` only for states you can't capture on production. Never invent UI you didn't see.
+- Add cross-links both ways: from this page to related pages, and from the closest existing pages to this one.
+
+## 5. Validate
+
+Run `gdocs lint`. Run `yarn build` if `node_modules/` exists. Then write the summary (SKILL.md Step 4).
diff --git a/.claude/skills/update-docs/workflow-refresh.md b/.claude/skills/update-docs/workflow-refresh.md
new file mode 100644
index 000000000..2e3ec19cd
--- /dev/null
+++ b/.claude/skills/update-docs/workflow-refresh.md
@@ -0,0 +1,47 @@
+# Workflow: refresh an existing page
+
+Example: `/update-docs refresh the analytics page with new navigation tabs`
+
+Goal: make an existing page match today's live UI. Keep its structure, voice and URL unless the user asks for more.
+
+## 1. Find the page(s)
+
+```bash
+grep -ril "" docs --include=*.md | head -20
+ls "docs/4. Product Features/"
+```
+
+Pick the page or pages the request means. If there are several candidates, list them and ask. Read each page in full, and note every image it references and the header block (read time, level, **Last Updated**).
+
+## 2. Find what changed
+
+- Use the user's hint (e.g. "new navigation tabs").
+- **If the input is a PR** (`#1234`, `glific-frontend#1234`, or a PR URL): `gh pr view --repo glific/glific-frontend` and `gh pr diff --repo glific/glific-frontend`. Map the changed `src/containers//` paths to features, and through [sections.md](sections.md) to pages. One PR can affect several pages. List them in the plan. Only document what is live on production: if the change isn't visible in the live app yet, tell the user and stop.
+- Optionally, check recent frontend PRs for the feature: `gh pr list --repo glific/glific-frontend --state merged --search "" --limit 10`, then `gh pr view --repo glific/glific-frontend`.
+- Open the route in the live app (see `browsing.md`). Use `snapshot` to list every tab, section, button label and empty state.
+
+## 3. Diff page vs live UI
+
+Make a table and show it to the user as part of the plan:
+
+| Doc says / shows | Live UI | Action |
+|---|---|---|
+| "Click **Reports**" | Tab is now **Dashboards** | update text |
+| `analytics_home.png` (old layout) | new tab bar | retake |
+| — | new **Exports** tab | add section |
+| "Filters panel on the right" | no longer exists | remove |
+
+Confirm the plan (SKILL.md Step 2).
+
+## 4. Update
+
+- Edit only what the diff calls for. Keep headings, anchors and filenames stable, because other pages link to them. Check with `grep -rn "" docs`.
+- Retake changed screenshots **with the same filenames**. Add new ones for new sections. Follow the naming in [style-images.md](style-images.md).
+- If a page still uses old GitHub CDN images (`user-images.githubusercontent.com`) and you are replacing that screenshot, move it to `static/img//` with a new local shot. Leave CDN images you don't touch as they are.
+- Use UI labels exactly as they appear: bold for buttons and tabs, and `>` for paths (**Settings** > **Organization**).
+- Set **Last Updated** in the header to the current month and year (e.g. `October 2026`). Recalculate the read time. If the page uses a legacy header variant, replace it with the standard block from [style-writing.md](style-writing.md#page-header).
+- Follow [style-writing.md](style-writing.md): plain language for NGO staff, imperative steps, and a screenshot after the step it shows. Keep the page's template from [style-page.md](style-page.md).
+
+## 5. Validate
+
+Run `gdocs lint`, Read every new PNG, and write the summary (SKILL.md Step 4).
diff --git a/.claude/skills/update-docs/workflow-restructure.md b/.claude/skills/update-docs/workflow-restructure.md
new file mode 100644
index 000000000..9b93e97b1
--- /dev/null
+++ b/.claude/skills/update-docs/workflow-restructure.md
@@ -0,0 +1,71 @@
+# Workflow: restructure docs
+
+Example: `/update-docs restructure docs with tabs like ...`
+
+Restructuring changes URLs and navigation. Plan everything and get approval before you move anything.
+
+## 1. Understand the request
+
+Typical kinds:
+- **Tabs inside a page**: show alternatives side by side (e.g. "Using Gupshup" vs "Using Meta Cloud API", or "Web" vs "Mobile").
+- **Merge/split pages**: combine thin pages, or split a long page into a folder.
+- **Move/reorder**: change the section or the order in the sidebar.
+
+If the request is vague ("restructure with tabs like …"), ask for, or propose, one concrete before/after example first.
+
+## 2. Map the current structure
+
+- The sidebar is autogenerated from `docs/` (`sidebars.js`). Order comes from numeric filename prefixes and `_category_.json` (`position`, `label`).
+- List the affected files, their URLs (the path under `docs/` without the number prefixes; check the page frontmatter for `slug`/`id`) and inbound links: `grep -rn "" docs src`.
+
+## 3. Plan → confirm
+
+```
+Restructure plan:
+- docs/a.md + docs/b.md → docs/x/01. A.md (tabs: "Gupshup" | "Meta")
+- moved: docs/old/path.md → docs/new/path.md (URL changes: /old/path → /new/path)
+- inbound links to update: N files
+- _category_.json changes: ...
+- redirects: none available (no redirect plugin) — URL changes will 404 for external links
+```
+
+Point out every URL change. Wait for approval.
+
+## 4. Apply
+
+Tabs syntax (pages are MDX, so imports work in `.md`):
+
+```mdx
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+
+
+
+ Steps for Gupshup…
+
+ 
+
+
+
+
+ Steps for Meta…
+
+
+
+```
+
+- Leave a blank line after `` and before `` so the markdown inside renders.
+- Use the same `groupId` on related pages so the reader's choice carries over.
+- Put the imports after the frontmatter and before the header block.
+- Use `git mv` for moves so history is kept. Update `_category_.json` where needed.
+- Update every inbound link that you found.
+- If the new layout needs new screenshots (e.g. one per tab), take them with `gdocs shot`.
+
+## 5. Validate
+
+- `gdocs lint $(git ls-files 'docs/*.md' 'docs/**/*.md')`: this checks every page, including unchanged pages that link to moved files. Ignore failures that `main` already has; fix any that mention the paths you moved.
+- `grep -rn "" docs src` must return nothing.
+- `gdocs lint` checks relative `.md` links in changed pages. `onBrokenLinks` is `ignore` in `docusaurus.config.js`, so the build won't catch broken links.
+- Run `yarn build` if `node_modules/` exists, to catch MDX errors such as unclosed ``. Otherwise say that CI will check it.
+- In the summary, list every URL that changed.
diff --git a/.claude/skills/update-docs/workflow-review.md b/.claude/skills/update-docs/workflow-review.md
new file mode 100644
index 000000000..b3d115e5a
--- /dev/null
+++ b/.claude/skills/update-docs/workflow-review.md
@@ -0,0 +1,42 @@
+# Workflow: review a set of pages for consistency
+
+Example: `/update-docs review document structure for all webhooks and ensure consistency of writing`
+
+Goal: make a group of related pages read as one set, with the same structure, terms, header and depth, and make sure they are still accurate.
+
+## 1. Collect the set
+
+```bash
+grep -ril "webhook" docs --include=*.md
+```
+
+List the pages. Group them as **core** (about the topic) or **mentions** (only refer to it), and confirm the scope with the user if there are more than 10 pages. Read every core page in full.
+
+## 2. Audit
+
+Build a matrix of the core pages and report it:
+
+| Page | Header block | Last Updated | Summary line | H2 pattern | Steps style | Terms used | Images (local/CDN/missing) | Broken links | Issues |
+|---|---|---|---|---|---|---|---|---|---|
+
+Check:
+- **Structure**: the right section template ([style-page.md](style-page.md)), the same H2 pattern across sibling pages (see "Sibling consistency"), and an intro → steps → related-links order.
+- **Images**: [style-images.md](style-images.md) naming, `/img/` paths, alt text, and placement after the step.
+- **Terminology**: one name per concept, matching the live UI label (e.g. "Call a webhook" vs "Webhook node" vs "API call"). Pick the term the UI uses.
+- **Header**: the standard block from [style-writing.md](style-writing.md#page-header). Flag legacy variants, wrong read times and stale Last Updated dates.
+- **Voice**: [style-writing.md](style-writing.md) (second person, imperative, plain language, "Select" not "Click", bold UI labels, no internal or technical jargon for Product Features pages).
+- **Accuracy**: spot-check the claims that most affect users against the live app (labels, menu paths, field names). Opening the flow editor isn't possible (see `safety.md`), so for flow node pages check the node lists and labels from the frontend/floweditor code instead.
+- **Duplication**: the same how-to explained on several pages. Suggest one canonical page with links to it.
+- **Links and images**: relative links that resolve, `/img/` paths that exist, and alt text present.
+
+## 3. Propose → confirm
+
+Present a prioritised list of edits: a style guide decision (e.g. the canonical term and the H2 pattern), then a per-page change list. Ask which ones to apply.
+
+## 4. Apply
+
+Make the agreed edits page by page. Keep filenames and anchors unless the user approved renames. If you renamed any, update inbound links with `grep -rn`. Retake screenshots only where they are wrong, and only with `gdocs shot`. Update Last Updated on pages whose content changed.
+
+## 5. Validate
+
+Run `gdocs lint`, then write a summary that includes the audit matrix before and after.
diff --git a/.gitignore b/.gitignore
index 292f939f1..997099d58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+
+# update-docs skill (playwright-cli session output, snapshots, redaction manifest)
+.playwright-cli/
diff --git a/docs/4. Product Features/Data Analytics.md b/docs/4. Product Features/Data Analytics.md
index d10b76941..8a346068f 100644
--- a/docs/4. Product Features/Data Analytics.md
+++ b/docs/4. Product Features/Data Analytics.md
@@ -1,20 +1,21 @@
-
3 minutes read
+
4 minutes read
Level: Beginner
-
Last Updated: July 2026
+
Last Updated: October 2026
# Data Analytics Page
-The Data Analytics page (accessible from the left navigation under "Data Analytics") gives a bird's-eye view of how contacts are growing, how engaged they are, and how reliably messages are being delivered. The page is organized as a single dashboard with a shared control bar at the top and three tabs underneath:
+The Data Analytics page (accessible from the left navigation under "Data Analytics") gives a bird's-eye view of how contacts are growing, how engaged they are, and how reliably messages are being delivered. The page is organized as a single dashboard with a shared control bar at the top and four tabs underneath:
- Reach & Growth,
-- Engagement Quality, and
+- Engagement Quality,
+- Messages Overview, and
- Message Errors.
-
+
Every tab responds to the same global `Time` and `Time Grain` settings, so changing them once updates the charts across whichever tab you're viewing.
@@ -80,6 +81,20 @@ This tab answers "of the people I'm reaching, how many are actually engaging bac
Reading this chart top to bottom shows the engagement funnel — for example, only 17 out of 83 broadcast contacts responded the same day, which is a same-day response rate you could use to gauge broadcast effectiveness. This chart does reflect the Time filter, but note it is not time-grain-bucketed like the trend charts — it's a single snapshot of totals for the selected date range.
+## Messages Overview
+This tab answers "how many messages am I exchanging, and what kind are they?"
+
+
+
+1. **The Messages Exchanged** card splits all messages in the selected time range into three types. The date under the title ("Data available since ...") shows the earliest date for which message data exists.
+- Inbound, (messages your contacts sent to you)
+- Template Outbound, (approved HSM template messages you sent, for example in broadcasts)
+- Service Outbound, (non-template messages you sent, such as replies from flows or staff within the 24-hour session window). This number is marked **Billable from 1st Oct**.
+
+2. **The service message notice** below the card explains that from 1st October 2026, Meta charges for service messages. It estimates the extra cost of the service messages sent in your selected time range at the rate for Indian numbers. For other countries, click **Whatsapp Business Platform Rates** to see Meta's pricing.
+
+3. **The Message Composition** stacked bar chart shows, for each time bucket in your selected range and grain, how many Service Outbound, Inbound and Template Outbound messages were exchanged. The number above each bar is the total for that bucket. Like the charts on the Message Errors tab, it has legend toggles, **All** / **Inv** buttons and a range slider underneath to zoom into part of the timeline. A large share of Service Outbound in a bar shows where most of your new service message costs would come from.
+
## Message Errors
This tab answers "are messages actually getting delivered, and where are the failures?"
diff --git a/static/img/analytics/analytics_dashboard_tabs.png b/static/img/analytics/analytics_dashboard_tabs.png
new file mode 100644
index 000000000..0ed82513b
Binary files /dev/null and b/static/img/analytics/analytics_dashboard_tabs.png differ
diff --git a/static/img/analytics/analytics_messages_overview.png b/static/img/analytics/analytics_messages_overview.png
new file mode 100644
index 000000000..fae6799f1
Binary files /dev/null and b/static/img/analytics/analytics_messages_overview.png differ