diff --git a/.cursor/skills/alert-manager/SKILL.md b/.cursor/skills/alert-manager/SKILL.md
new file mode 100644
index 0000000000..3cece47ca5
--- /dev/null
+++ b/.cursor/skills/alert-manager/SKILL.md
@@ -0,0 +1,337 @@
+---
+name: alert-manager
+description: 'Configure SEO alerts for ranking drops, traffic changes, technical issues, competitor movements. SEO预警/排名监控'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when setting up monitoring alerts for rankings, traffic, backlinks, technical issues, or AI visibility changes."
+argument-hint: " [metric]"
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "low"
+ tags:
+ - seo
+ - geo
+ - seo-alerts
+ - ranking-alerts
+ - traffic-monitoring
+ - competitor-alerts
+ - automated-monitoring
+ - anomaly-detection
+ - SEO预警
+ - SEOアラート
+ - SEO알림
+ - alertas-seo
+ triggers:
+ # EN-formal
+ - "set up SEO alerts"
+ - "monitor rankings"
+ - "ranking notifications"
+ - "traffic alerts"
+ - "competitor alerts"
+ - "automated monitoring"
+ # EN-casual
+ - "notify me when rankings drop"
+ - "alert me if rankings drop"
+ - "notify me of traffic changes"
+ - "watch competitor changes"
+ - "watch my keywords for changes"
+ - "alert me about changes"
+ # EN-question
+ - "how to monitor my rankings"
+ - "how to set up SEO alerts"
+ # ZH-pro
+ - "SEO预警"
+ - "排名监控"
+ - "流量报警"
+ - "竞品变动提醒"
+ # ZH-casual
+ - "排名掉了提醒我"
+ - "流量异常"
+ - "有变化通知我"
+ # JA
+ - "SEOアラート"
+ - "ランキング監視"
+ # KO
+ - "SEO 알림"
+ - "순위 모니터링"
+ # ES
+ - "alertas SEO"
+ - "monitoreo de rankings"
+ # PT
+ - "alertas de SEO"
+ # Misspellings
+ - "SEO allerts"
+---
+
+# Alert Manager
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+Sets up proactive monitoring alerts for critical SEO and GEO metrics. Triggers notifications when rankings drop, traffic changes significantly, technical issues occur, or competitors make moves.
+
+**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
+
+- Setting up SEO monitoring systems
+- Creating ranking drop alerts
+- Monitoring technical SEO health
+- Tracking competitor movements
+- Alerting on content performance changes
+- Monitoring GEO/AI visibility changes
+- Setting up brand mention alerts
+
+## What This Skill Does
+
+1. **Alert Configuration**: Sets up custom alert thresholds
+2. **Multi-Metric Monitoring**: Tracks rankings, traffic, technical issues
+3. **Threshold Management**: Defines when alerts trigger
+4. **Priority Classification**: Categorizes alerts by severity
+5. **Notification Setup**: Configures how alerts are delivered
+6. **Alert Response Plans**: Creates action plans for each alert type
+7. **Alert History**: Tracks alert patterns over time
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Set Up Alerts
+
+```
+Set up SEO monitoring alerts for [domain]
+```
+
+```
+Create ranking drop alerts for my top 20 keywords
+```
+
+### Configure Specific Alerts
+
+```
+Alert me when [specific condition]
+```
+
+```
+Set up competitor monitoring for [competitor domains]
+```
+
+### Review Alert System
+
+```
+Review and optimize my current SEO alerts
+```
+
+## Skill Contract
+
+**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
+
+- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
+- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when a change needs action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~search console + ~~web crawler connected:**
+Automatically monitor real-time metric feeds for ranking changes via ~~SEO tool API, indexing and coverage alerts from ~~search console, and technical health alerts from ~~web crawler. Set up automated threshold-based alerts with notification delivery.
+
+**With manual data only:**
+Ask the user to provide:
+1. Current baseline metrics for alert thresholds (rankings, traffic, backlinks)
+2. Critical keywords or pages to monitor
+3. Alert priority levels and notification preferences
+4. Historical data to understand normal fluctuation ranges
+5. Manual reporting on metric changes when they check their tools
+
+Proceed with the alert configuration using provided parameters. User will need to manually check metrics and report changes for alert triggers.
+
+## Instructions
+
+When a user requests alert setup:
+
+1. **Define Alert Categories**
+
+ ```markdown
+ ## SEO Alert System Configuration
+
+ **Domain**: [domain]
+ **Configured Date**: [date]
+
+ ### Alert Categories
+
+ | Category | Description | Typical Urgency |
+ |----------|-------------|-----------------|
+ | Ranking Alerts | Keyword position changes | Medium-High |
+ | Traffic Alerts | Organic traffic fluctuations | High |
+ | Technical Alerts | Site health issues | Critical |
+ | Backlink Alerts | Link profile changes | Medium |
+ | Competitor Alerts | Competitor movements | Low-Medium |
+ | GEO Alerts | AI visibility changes | Medium |
+ | Brand Alerts | Brand mentions and reputation | Medium |
+ ```
+
+2. **Configure Alert Rules by Category**
+
+ For each relevant category (Rankings, Traffic, Technical, Backlinks, Competitors, GEO/AI, Brand), define alert name, trigger condition, threshold, and priority level.
+
+ > **Reference**: See [references/alert-configuration-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/references/alert-configuration-templates.md) for complete alert tables, threshold examples, and response plan templates for all 7 categories.
+
+3. **Define Alert Response Plans**
+
+ Map each priority level (Critical, High, Medium, Low) to a response time and immediate action steps.
+
+4. **Set Up Alert Delivery**
+
+ Configure notification channels (Email, SMS, Slack), recipient routing by role, suppression rules (duplicate cooldown, maintenance windows), and escalation paths.
+
+5. **Create Alert Summary**
+
+ ```markdown
+ # SEO Alert System Summary
+
+ **Domain**: [domain]
+ **Configured**: [date]
+ **Total Active Alerts**: [X]
+
+ ## Alert Count by Category
+
+ | Category | Critical | High | Medium | Low | Total |
+ |----------|----------|------|--------|-----|-------|
+ | Rankings | [X] | [X] | [X] | [X] | [X] |
+ | Traffic | [X] | [X] | [X] | [X] | [X] |
+ | Technical | [X] | [X] | [X] | [X] | [X] |
+ | Backlinks | [X] | [X] | [X] | [X] | [X] |
+ | Competitors | [X] | [X] | [X] | [X] | [X] |
+ | GEO | [X] | [X] | [X] | [X] | [X] |
+ | **Total** | **[X]** | **[X]** | **[X]** | **[X]** | **[X]** |
+
+ ## Quick Reference
+
+ ### If You Get a Critical Alert
+
+ 1. Don't panic
+ 2. Check alert details
+ 3. Follow response plan
+ 4. Document actions taken
+ 5. Update stakeholders
+
+ ### Weekly Alert Review Checklist
+
+ - [ ] Review all alerts triggered
+ - [ ] Identify patterns
+ - [ ] Adjust thresholds if needed
+ - [ ] Update response plans
+ - [ ] Clean up false positives
+ ```
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Alert thresholds are based on realistic baseline data
+- [ ] Critical keywords and pages clearly identified
+- [ ] Response plans defined for each alert priority level
+- [ ] Notification channels configured with appropriate recipients
+
+### Output Validation
+- [ ] Every metric cites its data source and collection date
+- [ ] Alert thresholds account for normal metric fluctuations
+- [ ] Response plans are specific and time-bound
+- [ ] Source of each alert trigger clearly stated (~~SEO tool API alert, ~~search console notification, ~~web crawler alert, or manual user check)
+
+## Example
+
+**User**: "Set up ranking drop alerts for my top keywords"
+
+**Output**:
+
+```markdown
+## Ranking Alert Configuration
+
+### Critical Keywords (Immediate Alert)
+
+| Keyword | Current | Alert If | Priority |
+|---------|---------|----------|----------|
+| best project management software | 2 | Drops to 5+ | 🔴 Critical |
+| project management tools | 4 | Drops to 8+ | 🔴 Critical |
+| team collaboration software | 1 | Any drop | 🔴 Critical |
+
+### Important Keywords (Same-Day Alert)
+
+| Keyword | Current | Alert If | Priority |
+|---------|---------|----------|----------|
+| agile project management | 7 | Drops out of top 10 | 🔴 High |
+| kanban software | 9 | Drops out of top 10 | 🔴 High |
+
+### Alert Response Plan
+
+**If Critical Keyword Drops**:
+1. Check if page is still indexed (site:url)
+2. Look for algorithm update announcements
+3. Analyze what changed in SERP
+4. Review competitor ranking changes
+5. Check for technical issues on page
+6. Create recovery action plan within 24 hours
+
+**Notification**: Email + Slack to SEO team immediately
+```
+
+## Tips for Success
+
+1. **Start simple** - Don't create too many alerts initially
+2. **Tune thresholds** - Adjust based on normal fluctuations
+3. **Avoid alert fatigue** - Too many alerts = ignored alerts
+4. **Document response plans** - Know what to do when alerts fire
+5. **Review regularly** - Alerts need maintenance as your SEO matures
+6. **Include positive alerts** - Track wins, not just problems
+
+## Alert Threshold Quick Reference
+
+| Metric | Warning | Critical | Frequency |
+|--------|---------|----------|-----------|
+| Organic traffic | -15% WoW | -30% WoW | Daily |
+| Keyword positions | >3 position drop | >5 position drop | Daily |
+| Pages indexed | -5% change | -20% change | Weekly |
+| Crawl errors | >10 new/day | >50 new/day | Daily |
+| Core Web Vitals | "Needs Improvement" | "Poor" | Weekly |
+| Backlinks lost | >5% in 1 week | >15% in 1 week | Weekly |
+| AI citation loss | Any key query | >20% queries | Weekly |
+| Security issues | Any detected | Any detected | Daily |
+
+> **Reference**: See [references/alert-threshold-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/references/alert-threshold-guide.md) for baseline establishment, threshold setting methodology, fatigue prevention, escalation paths, and response playbooks.
+
+
+### Save Results
+
+After delivering monitoring data or reports to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-.md` containing:
+- One-line headline finding or status change
+- Top 3-5 actionable items
+- Open loops or anomalies requiring follow-up
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Alert Threshold Guide](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/references/alert-threshold-guide.md) — Recommended thresholds by metric, fatigue prevention strategies, and escalation path templates
+
+## Next Best Skill
+
+- **Primary**: [rank-tracker](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/rank-tracker/SKILL.md) — pair alerts with a baseline measurement workflow.
diff --git a/.cursor/skills/alert-manager/references/alert-configuration-templates.md b/.cursor/skills/alert-manager/references/alert-configuration-templates.md
new file mode 100644
index 0000000000..c962aed99a
--- /dev/null
+++ b/.cursor/skills/alert-manager/references/alert-configuration-templates.md
@@ -0,0 +1,293 @@
+# Alert Configuration Templates
+
+Detailed alert configuration templates for each alert category. Use these templates when setting up a new alert system for a domain.
+
+---
+
+## Ranking Alerts
+
+### Position Drop Alerts
+
+| Alert Name | Condition | Threshold | Priority | Action |
+|------------|-----------|-----------|----------|--------|
+| Critical Drop | Any top 3 keyword drops 5+ positions | Position change >=5 | Critical | Immediate investigation |
+| Major Drop | Top 10 keyword drops out of top 10 | Position >10 | High | Same-day review |
+| Moderate Drop | Any keyword drops 10+ positions | Position change >=10 | Medium | Weekly review |
+| Competitor Overtake | Competitor passes you for key term | Comp position < yours | Medium | Analysis needed |
+
+### Position Improvement Alerts
+
+| Alert Name | Condition | Threshold | Priority |
+|------------|-----------|-----------|----------|
+| New Top 3 | Keyword enters top 3 | Position <=3 | Positive |
+| Page 1 Entry | Keyword enters top 10 | Position <=10 | Positive |
+| Significant Climb | Keyword improves 10+ positions | Change >=+10 | Positive |
+
+### SERP Feature Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Snippet Lost | Lost featured snippet ownership | High |
+| Snippet Won | Won new featured snippet | Positive |
+| AI Overview Change | Appeared/disappeared in AI Overview | Medium |
+
+### Keywords to Monitor
+
+| Keyword | Current Rank | Alert Threshold | Priority |
+|---------|--------------|-----------------|----------|
+| [keyword 1] | [X] | Drop >=3 | Critical |
+| [keyword 2] | [X] | Drop >=5 | High |
+| [keyword 3] | [X] | Drop >=10 | Medium |
+
+---
+
+## Traffic Alerts
+
+### Traffic Decline Alerts
+
+| Alert Name | Condition | Threshold | Priority |
+|------------|-----------|-----------|----------|
+| Traffic Crash | Day-over-day decline | >=50% drop | Critical |
+| Significant Drop | Week-over-week decline | >=30% drop | High |
+| Moderate Decline | Month-over-month decline | >=20% drop | Medium |
+| Trend Warning | 3 consecutive weeks decline | Any decline | Medium |
+
+### Traffic Anomaly Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Traffic Spike | Unusual increase | Investigate |
+| Zero Traffic | Page receiving 0 visits | High |
+| Bot Traffic | Unusual traffic pattern | Medium |
+
+### Page-Level Alerts
+
+| Page Type | Alert Condition | Priority |
+|-----------|-----------------|----------|
+| Homepage | Any 20%+ decline | Critical |
+| Top 10 pages | Any 30%+ decline | High |
+| Conversion pages | Any 25%+ decline | High |
+| Blog posts | Any 40%+ decline | Medium |
+
+### Conversion Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Conversion Drop | Organic conversions down 30%+ | Critical |
+| CVR Decline | Conversion rate drops 20%+ | High |
+
+---
+
+## Technical SEO Alerts
+
+### Critical Technical Alerts
+
+| Alert Name | Condition | Priority | Response Time |
+|------------|-----------|----------|---------------|
+| Site Down | HTTP 5xx errors | Critical | Immediate |
+| SSL Expiry | Certificate expiring in 14 days | Critical | Same day |
+| Robots.txt Block | Important pages blocked | Critical | Same day |
+| Index Dropped | Pages dropping from index | Critical | Same day |
+
+### Crawl & Index Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Crawl Errors Spike | Errors increase 50%+ | High |
+| New 404 Pages | 404 errors on important pages | Medium |
+| Redirect Chains | 3+ redirect hops detected | Medium |
+| Duplicate Content | New duplicates detected | Medium |
+| Index Coverage Drop | Indexed pages decline 10%+ | High |
+
+### Performance Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Core Web Vitals Fail | CWV drops to "Poor" | High |
+| Page Speed Drop | Load time increases 50%+ | Medium |
+| Mobile Issues | Mobile usability errors | High |
+
+### Security Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Security Issue | GSC security warning | Critical |
+| Manual Action | Google manual action | Critical |
+| Malware Detected | Site flagged for malware | Critical |
+
+---
+
+## Backlink Alerts
+
+### Link Loss Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| High-Value Link Lost | DA 70+ link removed | High |
+| Multiple Links Lost | 10+ links lost in a day | Medium |
+| Referring Domain Lost | Lost entire domain's links | Medium |
+
+### Link Gain Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| High-Value Link | New DA 70+ link | Positive |
+| Suspicious Links | Many low-quality links | Review |
+| Negative SEO | Spam link attack pattern | High |
+
+### Link Profile Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Toxic Score Increase | Toxic score up 20%+ | High |
+| Anchor Over-Optimization | Exact match anchors >30% | Medium |
+
+---
+
+## Competitor Monitoring Alerts
+
+### Ranking Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Competitor Overtake | Competitor passes you | Medium |
+| Competitor Top 3 | Competitor enters top 3 on key term | Medium |
+| Competitor Content | Competitor publishes on your topic | Info |
+
+### Activity Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| New Backlinks | Competitor gains high-DA link | Info |
+| Content Update | Competitor updates ranking content | Info |
+| New Content | Competitor publishes new content | Info |
+
+### Competitors to Monitor
+
+| Competitor | Domain | Monitor Keywords | Alert Priority |
+|------------|--------|------------------|----------------|
+| [Competitor 1] | [domain] | [X] keywords | High |
+| [Competitor 2] | [domain] | [X] keywords | Medium |
+| [Competitor 3] | [domain] | [X] keywords | Low |
+
+---
+
+## GEO (AI Visibility) Alerts
+
+### AI Citation Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Citation Lost | Lost AI Overview citation | Medium |
+| Citation Won | New AI Overview citation | Positive |
+| Citation Position Drop | Dropped from 1st to 3rd+ source | Medium |
+| New AI Overview | AI Overview appears for tracked keyword | Info |
+
+### GEO Trend Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Citation Rate Drop | AI citation rate drops 20%+ | High |
+| GEO Competitor | Competitor cited where you're not | Medium |
+
+---
+
+## Brand Monitoring Alerts
+
+### Mention Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Brand Mention | New brand mention online | Info |
+| Negative Mention | Negative sentiment mention | High |
+| Review Alert | New review on key platforms | Medium |
+| Unlinked Mention | Brand mention without link | Opportunity |
+
+### Reputation Alerts
+
+| Alert Name | Condition | Priority |
+|------------|-----------|----------|
+| Review Rating Drop | Average rating drops | High |
+| Negative Press | Negative news article | High |
+| Competitor Comparison | Named in competitor comparison | Medium |
+
+---
+
+## Alert Response Plans
+
+### Critical Alert Response
+
+**Response Time**: Immediate (within 1 hour)
+
+| Alert Type | Immediate Actions |
+|------------|-------------------|
+| Site Down | 1. Check server status 2. Contact hosting 3. Check DNS |
+| Traffic Crash | 1. Check for algorithm update 2. Review GSC errors 3. Check competitors |
+| Manual Action | 1. Review GSC message 2. Identify issue 3. Begin remediation |
+| Critical Rank Drop | 1. Check if page indexed 2. Review SERP 3. Analyze competitors |
+
+### High Priority Response
+
+**Response Time**: Same day
+
+| Alert Type | Actions |
+|------------|---------|
+| Major Rank Drops | Analyze cause, create recovery plan |
+| Traffic Decline | Investigate source, check technical issues |
+| Backlink Loss | Attempt recovery outreach |
+| CWV Failure | Diagnose and fix performance issues |
+
+### Medium Priority Response
+
+**Response Time**: Within 48 hours
+
+| Alert Type | Actions |
+|------------|---------|
+| Moderate Rank Changes | Monitor trend, plan content updates |
+| Competitor Movement | Analyze competitor changes |
+| New 404s | Set up redirects, update internal links |
+
+### Low Priority
+
+**Response Time**: Weekly review
+
+| Alert Type | Actions |
+|------------|---------|
+| Positive Changes | Document wins, understand cause |
+| Info Alerts | Log for trend analysis |
+
+---
+
+## Alert Notification Setup
+
+### Notification Channels
+
+| Priority | Channels | Frequency |
+|----------|----------|-----------|
+| Critical | Email + SMS + Slack | Immediate |
+| High | Email + Slack | Immediate |
+| Medium | Email + Slack | Daily digest |
+| Low | Email | Weekly digest |
+
+### Alert Recipients
+
+| Role | Critical | High | Medium | Low |
+|------|----------|------|--------|-----|
+| SEO Manager | Yes | Yes | Yes | Yes |
+| Dev Team | Yes | Yes (tech only) | No | No |
+| Marketing Lead | Yes | Yes | No | No |
+| Executive | Yes | No | No | No |
+
+### Alert Suppression
+
+- Suppress duplicate alerts for 24 hours
+- Don't alert on known issues (maintenance windows)
+- Batch low-priority alerts into digests
+
+### Alert Escalation
+
+| If No Response In | Escalate To |
+|-------------------|-------------|
+| 1 hour (Critical) | SEO Manager -> Director |
+| 4 hours (High) | Team Lead -> Manager |
+| 24 hours (Medium) | Team -> Lead |
diff --git a/.cursor/skills/alert-manager/references/alert-threshold-guide.md b/.cursor/skills/alert-manager/references/alert-threshold-guide.md
new file mode 100644
index 0000000000..9b4099872e
--- /dev/null
+++ b/.cursor/skills/alert-manager/references/alert-threshold-guide.md
@@ -0,0 +1,377 @@
+# Alert Threshold Guide
+
+Complete reference for configuring SEO/GEO alert thresholds. Covers baseline establishment, threshold setting methodology, tuning process, alert routing configuration, notification channel setup, and response playbooks for each alert type.
+
+---
+
+## 1. Baseline Establishment Process
+
+Before setting any alert thresholds, you must establish a baseline that represents normal metric behavior for your site. Without a baseline, you will either set thresholds too tight (causing alert fatigue) or too loose (missing real problems).
+
+### Baseline Collection Timeline
+
+| Metric Category | Minimum Baseline Period | Ideal Baseline Period | Why |
+|----------------|------------------------|----------------------|-----|
+| Organic traffic | 4 weeks | 8-12 weeks | Accounts for weekly cycles and monthly patterns |
+| Keyword rankings | 2-4 weeks | 4-8 weeks | Rankings fluctuate daily; need to establish normal range |
+| Backlink metrics | 4 weeks | 8 weeks | Link acquisition is lumpy; need to see natural cadence |
+| Technical metrics | 2 weeks | 4 weeks | Most technical metrics are relatively stable |
+| Core Web Vitals | 4 weeks (28-day rolling) | 8 weeks | CrUX data is 28-day rolling average |
+| AI citations | 4 weeks | 8 weeks | AI answer composition changes frequently |
+
+### Baseline Data Collection Steps
+
+| Step | Action | Output |
+|------|--------|--------|
+| 1 | Record daily metric values for the baseline period | Raw data spreadsheet |
+| 2 | Calculate mean (average) for each metric | Central tendency |
+| 3 | Calculate standard deviation for each metric | Normal variation range |
+| 4 | Identify outliers (values > 2 standard deviations from mean) | Anomaly list |
+| 5 | Remove known outliers (holidays, outages, one-time events) | Clean baseline |
+| 6 | Recalculate mean and standard deviation on clean data | Final baseline values |
+| 7 | Document seasonal patterns if baseline covers enough time | Seasonal adjustment notes |
+
+### Baseline Metrics to Record
+
+| Metric | Daily | Weekly | Monthly |
+|--------|-------|--------|---------|
+| Organic sessions | Record | Calculate WoW % change | Calculate MoM % change |
+| Keyword positions (top 20) | Record | Calculate average movement | Calculate net position change |
+| Keywords in top 10 | Record | Calculate weekly count | Calculate monthly trend |
+| Crawl errors | Record | Calculate weekly new errors | Calculate monthly trend |
+| New backlinks | N/A | Record weekly count | Calculate monthly velocity |
+| Lost backlinks | N/A | Record weekly count | Calculate monthly velocity |
+| Core Web Vitals | N/A | Record from CrUX | Calculate monthly trend |
+| AI citations | N/A | Record weekly count | Calculate monthly trend |
+| Pages indexed | N/A | Record weekly count | Calculate monthly change |
+| Server response time | Record | Calculate weekly average | Calculate monthly average |
+
+---
+
+## 2. Threshold Setting Methodology
+
+### The Standard Deviation Method
+
+For most metrics, set thresholds based on standard deviations from your baseline mean.
+
+| Threshold Level | Formula | Meaning |
+|----------------|---------|---------|
+| **Info** | Mean +/- 1 standard deviation | Normal fluctuation range; log but do not alert |
+| **Warning** | Mean +/- 1.5 standard deviations | Unusual but not necessarily problematic |
+| **Critical** | Mean +/- 2 standard deviations | Statistically significant anomaly; investigate |
+| **Emergency** | Mean +/- 3 standard deviations | Extreme anomaly; immediate action required |
+
+**Example calculation:**
+
+```
+Metric: Daily organic sessions
+Baseline mean: 10,000 sessions/day
+Standard deviation: 800 sessions/day
+
+Info range: 8,200 - 11,800 (normal)
+Warning: < 8,800 or > 11,200
+Critical: < 8,400 or > 11,600
+Emergency: < 7,600 or > 12,400
+```
+
+### The Percentage Method
+
+For metrics where standard deviation is not practical, use percentage-based thresholds.
+
+| Metric | Warning Threshold | Critical Threshold | Comparison Period |
+|--------|------------------|-------------------|-------------------|
+| Organic traffic | -15% vs. comparison | -30% vs. comparison | Week over week |
+| Keyword positions | >3 position average drop | >5 position average drop | Week over week |
+| Pages indexed | -5% change | -20% change | Week over week |
+| Referring domains | -5% loss | -15% loss | Month over month |
+| Crawl error rate | >2x baseline rate | >5x baseline rate | Day over day |
+| Conversion rate | -20% drop | -40% drop | Week over week |
+
+### The Absolute Value Method
+
+For binary or count-based metrics, use absolute thresholds.
+
+| Metric | Warning Threshold | Critical Threshold |
+|--------|------------------|-------------------|
+| New crawl errors | >10 new errors/day | >50 new errors/day |
+| Server 5xx errors | Any occurrence | >5 occurrences/hour |
+| Security issues | N/A | Any detection |
+| Manual penalties | N/A | Any notification |
+| SSL certificate expiry | <30 days to expiry | <7 days to expiry |
+| Robots.txt changes | Any unexpected change | Key pages blocked |
+
+---
+
+## 3. Threshold Configuration by Metric Category
+
+### Traffic Thresholds
+
+| Metric | Comparison | Warning | Critical | Emergency |
+|--------|-----------|---------|----------|-----------|
+| Total organic sessions | WoW | -15% | -30% | -50% |
+| Total organic sessions | DoD | -25% (weekday) | -40% | Site appears down |
+| Non-brand sessions | WoW | -20% | -35% | -50% |
+| Organic conversions | WoW | -20% | -40% | -60% |
+| Organic revenue | WoW | -15% | -30% | -50% |
+| Bounce rate | WoW | +10pp | +20pp | +30pp |
+| Page-level traffic (top 10 pages) | WoW | -25% | -40% | -60% |
+
+**Note:** Day-over-day traffic thresholds need day-of-week adjustment. Monday traffic typically differs from Saturday traffic. Compare Monday to Monday, not Monday to Sunday.
+
+### Ranking Thresholds
+
+| Metric | Scope | Warning | Critical |
+|--------|-------|---------|----------|
+| Position change (Tier 1 keywords) | Individual keyword | Drop >= 3 | Drop >= 5 |
+| Position change (Tier 2 keywords) | Individual keyword | Drop >= 5 | Drop >= 10 |
+| Position change (Tier 3 keywords) | Individual keyword | Drop >= 10 | Drop off page 3 |
+| Average position (all keywords) | Aggregate | +2.0 (worsening) | +5.0 (worsening) |
+| Keywords in top 10 | Count | -10% of count | -20% of count |
+| Keywords in top 3 | Count | Any decrease | -3 or more |
+| Brand keyword position | Individual | Any drop from #1 | Drops below #3 |
+| Featured snippet lost | Individual | Any loss | Loss of 3+ snippets |
+
+### Technical Thresholds
+
+| Metric | Warning | Critical | Emergency |
+|--------|---------|----------|-----------|
+| New 4xx errors | >5/day | >20/day | >100/day |
+| New 5xx errors | >1/day | >5/day | >20/day |
+| Crawl rate change | -30% vs. baseline | -60% vs. baseline | Near-zero crawl |
+| Index coverage drop | -5% | -15% | -30% |
+| Average server response time | >500ms | >1000ms | >2000ms |
+| LCP (mobile) | Moves to "Needs Improvement" | Moves to "Poor" | >6s |
+| CLS | >0.1 | >0.25 | >0.5 |
+| INP | >200ms | >500ms | >1000ms |
+| Robots.txt change | Any unexpected edit | Pages blocked | Entire site blocked |
+| Sitemap errors | New errors | Sitemap inaccessible | Sitemap returning 5xx |
+
+### Backlink Thresholds
+
+| Metric | Warning | Critical |
+|--------|---------|----------|
+| Referring domains lost (weekly) | >5% of total | >15% of total |
+| High-authority link lost (DR 60+) | Any loss | Loss of 3+ in one week |
+| Toxic link spike | >10 new toxic links/week | >50 new toxic links/week |
+| Anchor text over-optimization | Exact match reaches 20% | Exact match reaches 30% |
+| Negative SEO pattern | Unusual link velocity from low-DR sites | Massive spam link spike |
+
+### GEO / AI Visibility Thresholds
+
+| Metric | Warning | Critical |
+|--------|---------|----------|
+| AI citation rate | Drops 10+ percentage points | Drops below 10% |
+| Key query citation lost | Any Tier 1 query | 3+ Tier 1 queries |
+| Citation position degradation | Average position worsens by 2+ | Dropped from citations entirely |
+| Competitor gains citation you lost | 1 instance | Pattern across queries |
+
+---
+
+## 4. Alert Routing Configuration
+
+### Routing Matrix
+
+| Alert Category | P0 (Emergency) | P1 (Urgent) | P2 (Important) | P3 (Monitor) |
+|---------------|----------------|-------------|----------------|--------------|
+| **Traffic** | SEO Lead + Eng Manager + VP | SEO Lead + Marketing Mgr | SEO Team | Weekly digest |
+| **Rankings** | SEO Lead + Content Lead | SEO Team | SEO Team | Weekly digest |
+| **Technical** | SEO Lead + Eng Lead + DevOps | SEO Lead + Eng Team | SEO Team + Eng | Weekly digest |
+| **Backlinks** | SEO Lead | SEO Team | SEO Team | Weekly digest |
+| **Competitor** | N/A | SEO Lead | SEO Team | Weekly digest |
+| **GEO/AI** | SEO Lead + Content Lead | SEO Team | SEO Team | Weekly digest |
+| **Security** | SEO Lead + Eng Manager + VP + Legal | All above | N/A | N/A |
+
+### Role-Based Alert Filtering
+
+| Role | Receives | Does Not Receive |
+|------|---------|-----------------|
+| SEO Lead | All P0, P1, P2 alerts | P3 (weekly digest only) |
+| SEO Analyst | P1, P2 in their area | P0 (escalation only), other areas |
+| Content Lead | P0-P1 ranking + GEO alerts | Technical alerts, backlink alerts |
+| Engineering Lead | P0-P1 technical alerts | Ranking, content, backlink alerts |
+| Marketing VP | P0 only | P1-P3 (receives weekly summary) |
+| DevOps | P0 technical + security | All non-infrastructure alerts |
+
+---
+
+## 5. Notification Channel Setup
+
+### Channel Selection by Priority
+
+| Priority | Primary Channel | Secondary Channel | Escalation Channel |
+|----------|----------------|-------------------|-------------------|
+| P0 | SMS + Phone call | Slack (#seo-emergencies) | PagerDuty / on-call rotation |
+| P1 | Slack (#seo-alerts) | Email | SMS (if not acknowledged in 4h) |
+| P2 | Email | Slack (#seo-daily) | Auto-escalate to P1 after 1 week |
+| P3 | Weekly digest email | Dashboard | Auto-escalate to P2 after 1 month |
+
+### Notification Content Requirements
+
+Every alert notification should include:
+
+| Field | Required | Example |
+|-------|----------|---------|
+| Alert name | Yes | "Critical Ranking Drop" |
+| Priority level | Yes | "P0 — Emergency" |
+| Metric affected | Yes | "Position for 'project management software'" |
+| Current value | Yes | "Position 12" |
+| Previous value | Yes | "Position 3 (yesterday)" |
+| Threshold breached | Yes | "Dropped >5 positions" |
+| Timestamp | Yes | "2025-01-15 09:00 UTC" |
+| Affected URL | Yes (if applicable) | "yoursite.com/blog/pm-guide" |
+| Quick action link | Yes | Link to relevant tool/dashboard |
+| Suggested first step | Recommended | "Check if page is still indexed: site:yoursite.com/blog/pm-guide" |
+
+### Notification Suppression Rules
+
+| Rule | Configuration | Reason |
+|------|-------------|--------|
+| Duplicate cooldown | Do not re-alert on same metric for 24 hours | Prevent alert storms |
+| Maintenance window | Suppress non-security alerts during scheduled maintenance | Avoid known-cause alerts |
+| Weekend adjustment | Increase traffic thresholds by 20% on weekends | Weekend traffic naturally lower |
+| Holiday adjustment | Suppress traffic alerts on major holidays | Known seasonal impact |
+| Recovery auto-close | Auto-close alert if metric returns to normal within 48h | Reduce stale alerts |
+| Batch related alerts | Group multiple ranking drops into single "Ranking Alert" | Reduce notification volume |
+
+---
+
+## 6. Threshold Tuning Guide
+
+### When to Tune Thresholds
+
+| Signal | Action |
+|--------|--------|
+| Too many false positives (>30% of alerts are noise) | Widen thresholds by 0.5 standard deviations |
+| Missed a real problem | Tighten the specific threshold that should have caught it |
+| Seasonal change approaching | Adjust baselines for known seasonal patterns |
+| Major site change (redesign, migration) | Re-establish baseline from scratch (2-4 week observation) |
+| New competitor enters market | Add competitor monitoring, adjust ranking sensitivity |
+| After algorithm update | Let metrics stabilize for 2-4 weeks, then recalibrate |
+
+### Monthly Threshold Review Checklist
+
+| Check | Action |
+|-------|--------|
+| Review all alerts fired in the past month | Count true positives vs. false positives |
+| Calculate false positive rate | If >30%, thresholds are too tight |
+| Check for missed events | If a real issue was not alerted, threshold is too loose |
+| Review metric baselines | Recalculate mean and standard deviation with latest data |
+| Adjust seasonal baselines | Incorporate seasonal patterns from year-over-year data |
+| Update keyword tiers | Promote/demote keywords based on current business priority |
+| Verify notification routing | Confirm all recipients are still in the correct roles |
+| Test alert delivery | Send a test alert through each channel to verify delivery |
+
+### Threshold Evolution Over Time
+
+| Site Maturity | Threshold Approach | Rationale |
+|-------------|-------------------|-----------|
+| New site (0-6 months) | Wide thresholds, few alerts | Metrics are volatile; avoid noise |
+| Growing (6-18 months) | Moderate thresholds, expand coverage | Enough data for meaningful baselines |
+| Established (18+ months) | Tight thresholds, comprehensive | Stable baselines, can detect subtle changes |
+| Post-migration | Reset to wide, re-tighten over 4-8 weeks | Old baselines are invalid |
+
+---
+
+## 7. Playbook Templates by Alert Type
+
+### Playbook: Organic Traffic Emergency (P0)
+
+**Trigger:** Organic traffic drops >50% day-over-day
+
+| Step | Time | Action | Tool |
+|------|------|--------|------|
+| 1 | 0 min | Verify site is accessible from multiple locations | Manual browser check, uptime monitor |
+| 2 | 5 min | Check Google Search Status Dashboard for outages | Google Status Dashboard |
+| 3 | 10 min | Check Search Console for manual actions or security issues | ~~search console |
+| 4 | 15 min | Check robots.txt for accidental blocking | Direct URL check |
+| 5 | 20 min | Check for noindex tags added to key pages | Crawl or manual page inspection |
+| 6 | 30 min | Review recent deployments or CMS changes | Deploy log, git history |
+| 7 | 45 min | Check server logs for unusual patterns | Server access logs |
+| 8 | 60 min | If unresolved, escalate to Engineering Manager | Slack/phone |
+
+### Playbook: Security Alert (P0)
+
+**Trigger:** Google Search Console security issue or manual action
+
+| Step | Time | Action |
+|------|------|--------|
+| 1 | 0 min | Read the exact message in Search Console |
+| 2 | 5 min | Notify Engineering Manager and VP Marketing |
+| 3 | 15 min | Scan site for malware or injected content |
+| 4 | 30 min | If compromised: take affected pages offline, rotate all credentials |
+| 5 | 1 hour | Identify attack vector and patch vulnerability |
+| 6 | 2 hours | Clean all affected pages, submit for re-review |
+| 7 | 24 hours | Verify resolution in Search Console |
+| 8 | 1 week | Post-incident review and security hardening |
+
+### Playbook: Algorithm Update Impact (P1-P2)
+
+**Trigger:** Confirmed Google algorithm update + ranking/traffic changes
+
+| Step | Time | Action |
+|------|------|--------|
+| 1 | Day 0 | Confirm update via Google Search Status Dashboard or official channels |
+| 2 | Day 0 | Document pre-update baseline metrics (rankings, traffic, visibility) |
+| 3 | Day 1-3 | Monitor daily — do not make changes while update is rolling out |
+| 4 | Day 7 | First analysis: which pages/keywords improved, which declined |
+| 5 | Day 7 | Analyze pattern: content quality? link profile? technical? YMYL? |
+| 6 | Day 14 | Develop action plan based on analysis |
+| 7 | Day 14-60 | Implement improvements (content quality, E-E-A-T signals, technical fixes) |
+| 8 | Next update | Re-evaluate impact after next core update |
+
+### Playbook: Backlink Attack / Negative SEO (P1)
+
+**Trigger:** Unusual spike in low-quality backlinks (>100 new links from spam domains in one week)
+
+| Step | Time | Action |
+|------|------|--------|
+| 1 | Day 0 | Verify the spike in ~~link database |
+| 2 | Day 0 | Identify the pattern (same anchor text? same link network? same country?) |
+| 3 | Day 1 | Export all new toxic links |
+| 4 | Day 1 | Create disavow file with identified spam domains |
+| 5 | Day 2 | Upload disavow to Google Search Console |
+| 6 | Day 2 | Document the attack pattern for future reference |
+| 7 | Day 7 | Re-check for continued spam link activity |
+| 8 | Day 14 | Verify disavow processed, monitor rankings for impact |
+
+### Playbook: Core Web Vitals Degradation (P2)
+
+**Trigger:** Any CWV metric moves from "Good" to "Needs Improvement" or "Poor"
+
+| Step | Time | Action |
+|------|------|--------|
+| 1 | Day 0 | Identify which metric degraded and which page groups are affected |
+| 2 | Day 1 | Run PageSpeed Insights on representative pages |
+| 3 | Day 1 | Check recent deployments for potential cause (new scripts, images, layout changes) |
+| 4 | Day 2 | Create engineering ticket with diagnosis and fix recommendations |
+| 5 | Day 3-14 | Engineering implements fix |
+| 6 | Day 14 | Verify improvement in lab data (PageSpeed Insights) |
+| 7 | Day 42 | Verify improvement in field data (CrUX — 28-day rolling window) |
+
+---
+
+## 8. Alert System Maintenance
+
+### Quarterly System Review
+
+| Task | Frequency | Owner |
+|------|-----------|-------|
+| Recalculate all baselines with latest data | Quarterly | SEO Lead |
+| Review and update keyword tier assignments | Quarterly | SEO Team |
+| Audit notification routing (team changes, role changes) | Quarterly | SEO Lead |
+| Test all notification channels (SMS, Slack, email) | Quarterly | SEO Lead |
+| Review alert response times (are SLAs being met?) | Quarterly | SEO Lead |
+| Archive resolved alerts older than 90 days | Quarterly | SEO Analyst |
+| Update playbooks based on lessons learned | Quarterly | SEO Team |
+
+### Alert Effectiveness Metrics
+
+Track these metrics about your alerting system itself:
+
+| Metric | Target | Meaning |
+|--------|--------|---------|
+| False positive rate | <30% | % of alerts that were not actionable |
+| Mean time to acknowledge (MTTA) | P0: <15min, P1: <4h | Time from alert to first human response |
+| Mean time to resolve (MTTR) | P0: <2h, P1: <24h | Time from alert to resolution |
+| Missed incident rate | 0% | Real problems that were not alerted |
+| Alert volume per week | Manageable for team size | If overwhelming, thresholds need tuning |
diff --git a/.cursor/skills/backlink-analyzer/SKILL.md b/.cursor/skills/backlink-analyzer/SKILL.md
new file mode 100644
index 0000000000..ce0a2e7977
--- /dev/null
+++ b/.cursor/skills/backlink-analyzer/SKILL.md
@@ -0,0 +1,301 @@
+---
+name: backlink-analyzer
+description: 'Analyze backlink profiles: link authority, toxic links, building opportunities, competitor link gaps. 外链分析/反向链接'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when analyzing backlink profiles, link quality, toxic links, referring domains, or anchor text distribution."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "low"
+ tags:
+ - seo
+ - backlinks
+ - link-building
+ - link-profile
+ - toxic-links
+ - off-page-seo
+ - link-audit
+ - referring-domains
+ - disavow
+ - ahrefs-alternative
+ - 外链分析
+ - 被リンク
+ - 백링크
+ - backlinks-seo
+ triggers:
+ # EN-formal
+ - "analyze backlinks"
+ - "check link profile"
+ - "find toxic links"
+ - "link building opportunities"
+ - "link profile analysis"
+ - "backlink audit"
+ - "link quality"
+ # EN-casual
+ - "who links to me"
+ - "I have spammy links"
+ - "how do I get more backlinks"
+ - "how do I get more links"
+ - "disavow links"
+ - "link building outreach"
+ - "disavow file"
+ # EN-question
+ - "how to build backlinks"
+ - "how to find toxic backlinks"
+ # ZH-pro
+ - "外链分析"
+ - "反向链接"
+ - "有毒链接"
+ - "链接建设"
+ # ZH-casual
+ - "外链怎么做"
+ - "有垃圾外链"
+ - "谁链接到我"
+ - "友链"
+ - "互换友链"
+ - "外链建设"
+ # JA
+ - "被リンク分析"
+ - "バックリンク"
+ - "リンク構築"
+ # KO
+ - "백링크 분석"
+ - "링크 빌딩"
+ - "누가 내 사이트 링크해?"
+ - "백링크 어떻게 늘려?"
+ # ES
+ - "análisis de backlinks"
+ - "enlaces entrantes"
+ # PT
+ - "análise de backlinks"
+ # Misspellings
+ - "backlink anaylsis"
+ - "backlnk analysis"
+---
+
+# Backlink Analyzer
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+Analyzes, monitors, and optimizes backlink profiles. Identifies link quality, discovers opportunities, and tracks competitor link building activities.
+
+**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
+
+- Auditing your current backlink profile
+- Identifying toxic or harmful links
+- Discovering link building opportunities
+- Analyzing competitor backlink strategies
+- Monitoring new and lost links
+- Evaluating link quality for outreach
+- Preparing for link disavow
+
+## What This Skill Does
+
+1. **Profile Analysis**: Comprehensive backlink profile overview
+2. **Quality Assessment**: Evaluates link authority and relevance
+3. **Toxic Link Detection**: Identifies harmful links
+4. **Competitor Analysis**: Compares link profiles across competitors
+5. **Opportunity Discovery**: Finds link building prospects
+6. **Trend Monitoring**: Tracks link acquisition over time
+7. **Disavow Guidance**: Helps create disavow files
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Analyze Your Profile
+
+```
+Analyze backlink profile for [domain]
+```
+
+### Find Opportunities
+
+```
+Find link building opportunities by analyzing [competitor domains]
+```
+
+### Detect Issues
+
+```
+Check for toxic backlinks on [domain]
+```
+
+### Compare Profiles
+
+```
+Compare backlink profiles: [your domain] vs [competitor domains]
+```
+
+## Skill Contract
+
+**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
+
+- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
+- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when a change needs action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~link database + ~~SEO tool connected:**
+Automatically pull comprehensive backlink profiles including referring domains, anchor text distribution, link quality metrics (DA/DR), link velocity, and toxic link detection from ~~link database. Competitor backlink data from ~~SEO tool for gap analysis.
+
+**With manual data only:**
+Ask the user to provide:
+1. Backlink export CSV (with source domains, anchor text, link type)
+2. Referring domains list with authority metrics
+3. Competitor domains for comparison
+4. Recent link gains/losses if tracking changes
+5. Any known toxic or spammy links
+
+Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests backlink analysis:
+
+1. **Generate Profile Overview** -- Key metrics (total backlinks, referring domains, DA/DR, dofollow ratio), link velocity (30d/90d/year), authority distribution chart, profile health score.
+
+2. **Analyze Link Quality** -- Top quality backlinks table, link type distribution, anchor text analysis (brand/exact/partial/URL/generic), geographic distribution.
+
+3. **Identify Toxic Links** -- Toxic score, risk indicators by type (spam, PBN, link farms, irrelevant), high-risk links to review, disavow recommendations (domain-level and URL-level).
+
+4. **Compare Against Competitors** -- Profile comparison table (referring domains, DA/DR, velocity, avg link DA), unique referring domains, link intersection analysis, competitor content attracting most links.
+
+5. **Find Link Building Opportunities** -- Link intersection prospects, broken link opportunities, unlinked mentions, resource page opportunities, guest post prospects, priority matrix (effort vs impact).
+
+6. **Track Link Changes** -- New and lost links for last 30 days with DA, type, anchor, dates. Net change and links to recover.
+
+7. **Generate Backlink Report** -- Executive summary, strengths, concerns, opportunities, competitive position, recommended actions (immediate/short-term/long-term), KPIs to track.
+
+ > **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/analysis-templates.md) for complete output templates for all 7 steps above.
+
+### CITE Item Mapping
+
+When running `domain-authority-auditor` after this analysis, the following data feeds directly into CITE scoring:
+
+| Backlink Metric | CITE Item | Dimension |
+|----------------|-----------|-----------|
+| Referring domains count | C01 (Referring Domain Volume) | Citation |
+| Authority distribution (DA breakdown) | C02 (Referring Domains Quality) | Citation |
+| Link velocity | C04 (Link Velocity) | Citation |
+| Geographic distribution | C10 (Link Source Diversity) | Citation |
+| Dofollow/Nofollow ratio | T02 (Dofollow Ratio Normality) | Trust |
+| Toxic link analysis | T01 (Link Profile Naturalness), T03 (Link-Traffic Coherence) | Trust |
+| Competitive link intersection | T05 (Profile Uniqueness) | Trust |
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Target domain backlink data is complete and current
+- [ ] Competitor domains specified for comparison analysis
+- [ ] Backlink data includes necessary fields (source domain, anchor text, link type)
+- [ ] Authority metrics available (DA/DR or equivalent)
+
+### Output Validation
+- [ ] Every metric cites its data source and collection date
+- [ ] Toxic link assessments include risk justification
+- [ ] Link opportunity recommendations are specific and actionable
+- [ ] Source of each data point clearly stated (~~link database data, ~~SEO tool data, user-provided, or estimated)
+
+## Example
+
+**User**: "Find link building opportunities by analyzing HubSpot, Salesforce, and Mailchimp"
+
+**Output**:
+
+```markdown
+## Link Intersection Analysis
+
+### Sites linking to 2+ competitors (not you)
+
+| Domain | DA | HubSpot | Salesforce | Mailchimp | Opportunity |
+|--------|-----|---------|------------|-----------|-------------|
+| g2.com | 91 | ✅ | ✅ | ✅ | Get listed/reviewed |
+| capterra.com | 89 | ✅ | ✅ | ✅ | Submit for review |
+| entrepreneur.com | 92 | ✅ | ✅ | ❌ | Pitch guest post |
+| techcrunch.com | 94 | ✅ | ❌ | ✅ | PR/news pitch |
+
+### Top 5 Immediate Opportunities
+
+1. **G2.com** (DA 91) - All competitors listed
+ - Action: Create detailed G2 profile
+ - Effort: Low
+ - Impact: High authority + referral traffic
+
+2. **Entrepreneur.com** (DA 92) - 2 competitors have links
+ - Action: Pitch contributed article
+ - Effort: High
+ - Impact: High authority + brand exposure
+
+3. **MarketingProfs** (DA 75) - All competitors featured
+ - Action: Apply for expert contribution
+ - Effort: Medium
+ - Impact: Relevant audience + quality link
+
+### Estimated Impact
+
+If you acquire links from top 10 opportunities:
+- New referring domains: +10
+- Average DA of new links: 82
+- Estimated ranking impact: +2-5 positions for competitive keywords
+```
+
+## Tips for Success
+
+1. **Quality over quantity** - One DA 80 link beats ten DA 20 links
+2. **Monitor regularly** - Catch lost links and toxic links early
+3. **Study competitors** - Learn from their link building success
+4. **Diversify your profile** - Mix of link types and anchors
+5. **Disavow carefully** - Only disavow clearly toxic links
+
+## Link Quality and Strategy Reference
+
+> **Reference**: See [references/link-quality-rubric.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/link-quality-rubric.md) for the complete link quality scoring matrix (6 weighted factors), toxic link identification criteria, link profile health benchmarks, and disavow file guidance.
+
+> **Reference**: See [references/outreach-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/outreach-templates.md) for email outreach frameworks, subject line formulas, response rate benchmarks, follow-up sequences, and templates for each link building strategy.
+
+
+### Save Results
+
+After delivering monitoring data or reports to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-.md` containing:
+- One-line headline finding or status change
+- Top 3-5 actionable items
+- Open loops or anomalies requiring follow-up
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+
+**Gate check recommended**: If toxic link ratio exceeds 15%, recommend running domain-authority-auditor to assess overall domain trust impact.
+
+## Reference Materials
+
+- [Link Quality Rubric](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/link-quality-rubric.md) — Quality scoring matrix with weighted factors and toxic link identification criteria
+- [Outreach Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/outreach-templates.md) — Email frameworks, subject line formulas, and response rate benchmarks
+
+## Next Best Skill
+
+- **Primary**: [domain-authority-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/SKILL.md) — translate link findings into a domain-level trust view.
diff --git a/.cursor/skills/backlink-analyzer/references/analysis-templates.md b/.cursor/skills/backlink-analyzer/references/analysis-templates.md
new file mode 100644
index 0000000000..d1809dc291
--- /dev/null
+++ b/.cursor/skills/backlink-analyzer/references/analysis-templates.md
@@ -0,0 +1,354 @@
+# Backlink Analysis Output Templates
+
+Detailed output templates for each step of the backlink analysis workflow. Use these templates when generating analysis deliverables.
+
+---
+
+## 1. Profile Overview Template
+
+```markdown
+## Backlink Profile Overview
+
+**Domain**: [domain]
+**Analysis Date**: [date]
+
+### Key Metrics
+
+| Metric | Value | Industry Avg | Status |
+|--------|-------|--------------|--------|
+| Total Backlinks | [X] | [Y] | [Above/Below avg] |
+| Referring Domains | [X] | [Y] | [status] |
+| Domain Authority | [X] | [Y] | [status] |
+| Domain Rating | [X] | [Y] | [status] |
+| Dofollow Links | [X] ([Y]%) | [Z]% | [status] |
+| Nofollow Links | [X] ([Y]%) | [Z]% | [status] |
+
+### Link Velocity
+
+| Period | New Links | Lost Links | Net Change |
+|--------|-----------|------------|------------|
+| Last 30 days | [X] | [Y] | [+/-Z] |
+| Last 90 days | [X] | [Y] | [+/-Z] |
+| Last year | [X] | [Y] | [+/-Z] |
+
+### Authority Distribution
+
+```
+DA 80-100: [X]%
+DA 60-79: [X]%
+DA 40-59: [X]%
+DA 20-39: [X]%
+DA 0-19: [X]%
+```
+
+**Profile Health Score**: [X]/100
+```
+
+---
+
+## 2. Link Quality Analysis Template
+
+```markdown
+## Link Quality Analysis
+
+### Top Quality Backlinks
+
+| Source Domain | DA | Link Type | Anchor | Target Page |
+|---------------|-----|-----------|--------|-------------|
+| [domain 1] | [DA] | Editorial | [anchor] | [page] |
+| [domain 2] | [DA] | Guest Post | [anchor] | [page] |
+| [domain 3] | [DA] | Resource | [anchor] | [page] |
+
+### Link Type Distribution
+
+| Type | Count | Percentage | Assessment |
+|------|-------|------------|------------|
+| Editorial | [X] | [Y]% | High quality |
+| Guest posts | [X] | [Y]% | Good |
+| Resource pages | [X] | [Y]% | Good |
+| Directory | [X] | [Y]% | Moderate |
+| Forum/Comments | [X] | [Y]% | Low quality |
+| Sponsored/Paid | [X] | [Y]% | Risky |
+
+### Anchor Text Analysis
+
+| Anchor Type | Count | Percentage | Status |
+|-------------|-------|------------|--------|
+| Brand name | [X] | [Y]% | Natural |
+| Exact match | [X] | [Y]% | [Warning if >30%] |
+| Partial match | [X] | [Y]% | Natural |
+| URL/Naked | [X] | [Y]% | Natural |
+| Generic | [X] | [Y]% | Natural |
+
+**Top Anchor Texts**:
+1. "[anchor 1]" - [X] links
+2. "[anchor 2]" - [X] links
+3. "[anchor 3]" - [X] links
+
+### Geographic Distribution
+
+| Country | Links | Percentage |
+|---------|-------|------------|
+| [Country 1] | [X] | [Y]% |
+| [Country 2] | [X] | [Y]% |
+| [Country 3] | [X] | [Y]% |
+```
+
+---
+
+## 3. Toxic Link Analysis Template
+
+```markdown
+## Toxic Link Analysis
+
+### Risk Summary
+
+**Toxic Score**: [X]/100
+**High Risk Links**: [X]
+**Medium Risk Links**: [X]
+**Action Required**: [Yes/No]
+
+### Toxic Link Indicators
+
+| Risk Type | Count | Examples |
+|-----------|-------|----------|
+| Spammy domains | [X] | [domains] |
+| Link farms | [X] | [domains] |
+| PBN suspected | [X] | [domains] |
+| Irrelevant sites | [X] | [domains] |
+| Foreign language spam | [X] | [domains] |
+| Penalized domains | [X] | [domains] |
+
+### High-Risk Links to Review
+
+| Source Domain | Risk Score | Issue | Recommendation |
+|---------------|------------|-------|----------------|
+| [domain 1] | 95/100 | Link farm | Disavow |
+| [domain 2] | 85/100 | Spam site | Disavow |
+| [domain 3] | 72/100 | PBN | Investigate |
+
+### Disavow Recommendations
+
+**Domains to disavow** ([X] total):
+```
+domain:[spam-site-1.com]
+domain:[spam-site-2.com]
+domain:[link-farm.com]
+```
+
+**Individual URLs to disavow** ([X] total):
+```
+[specific-url-1]
+[specific-url-2]
+```
+```
+
+---
+
+## 4. Competitive Backlink Analysis Template
+
+```markdown
+## Competitive Backlink Analysis
+
+### Profile Comparison
+
+| Metric | You | Competitor 1 | Competitor 2 | Competitor 3 |
+|--------|-----|--------------|--------------|--------------|
+| Referring Domains | [X] | [X] | [X] | [X] |
+| Domain Authority | [X] | [X] | [X] | [X] |
+| Domain Rating | [X] | [X] | [X] | [X] |
+| Link Velocity (30d) | [X] | [X] | [X] | [X] |
+| Avg Link DA | [X] | [X] | [X] | [X] |
+
+### Unique Referring Domains
+
+**Links only you have**: [X] domains
+**Links competitors share**: [X] domains
+**Links competitors have, you don't**: [X] domains -- Opportunity
+
+### Link Intersection Analysis
+
+**Sites linking to competitors but not you**:
+
+| Domain | DA | Links to Comp 1 | Comp 2 | Comp 3 | Opportunity |
+|--------|-----|-----------------|--------|--------|-------------|
+| [domain 1] | [DA] | Yes | Yes | Yes | High - All competitors |
+| [domain 2] | [DA] | Yes | Yes | No | High - 2 competitors |
+| [domain 3] | [DA] | Yes | No | No | Medium - 1 competitor |
+
+### Content Getting Most Links (Competitor Analysis)
+
+| Competitor | Content | Backlinks | Content Type |
+|------------|---------|-----------|--------------|
+| [Comp 1] | [Title/URL] | [X] | [Type] |
+| [Comp 2] | [Title/URL] | [X] | [Type] |
+| [Comp 3] | [Title/URL] | [X] | [Type] |
+
+**Insight**: [What content types attract most links in this niche]
+```
+
+---
+
+## 5. Link Building Opportunities Template
+
+```markdown
+## Link Building Opportunities
+
+### High-Priority Opportunities
+
+#### 1. Link Intersection Prospects
+
+Sites linking to multiple competitors but not you:
+
+| Domain | DA | Why Link | Contact Approach |
+|--------|-----|----------|------------------|
+| [domain 1] | [DA] | [resource page about X] | Suggest your resource |
+| [domain 2] | [DA] | [links to similar tools] | Pitch your tool |
+| [domain 3] | [DA] | [industry roundup] | Request inclusion |
+
+#### 2. Broken Link Opportunities
+
+| Source Page | Broken Link | Suggested Replacement |
+|-------------|-------------|----------------------|
+| [URL] | [broken URL] | [your relevant page] |
+
+#### 3. Unlinked Mentions
+
+| Site | Mention | Your Page to Link |
+|------|---------|-------------------|
+| [domain] | Mentioned your brand | [homepage] |
+| [domain] | Referenced your data | [research page] |
+
+#### 4. Resource Page Opportunities
+
+| Resource Page | Topic | Your Relevant Content |
+|---------------|-------|----------------------|
+| [URL] | [topic] | [your content] |
+
+#### 5. Guest Post Prospects
+
+| Site | DA | Topic Fit | Contact |
+|------|-----|-----------|---------|
+| [domain] | [DA] | [relevance] | [contact info/page] |
+
+### Link Building Priority Matrix
+
+| Opportunity Type | Effort | Impact | Priority |
+|------------------|--------|--------|----------|
+| Link intersection | Medium | High | Highest |
+| Broken links | Low | Medium | High |
+| Unlinked mentions | Low | Medium | High |
+| Resource pages | Medium | High | High |
+| Guest posts | High | High | Medium |
+```
+
+---
+
+## 6. Link Change Tracking Template
+
+```markdown
+## Link Change Tracking
+
+### New Links (Last 30 Days)
+
+| Source | DA | Type | Anchor | Date |
+|--------|-----|------|--------|------|
+| [domain 1] | [DA] | [type] | [anchor] | [date] |
+
+**Total new links**: [X]
+**Average DA of new links**: [X]
+**Best new link**: [domain] (DA [X])
+
+### Lost Links (Last 30 Days)
+
+| Source | DA | Reason | Action |
+|--------|-----|--------|--------|
+| [domain 1] | [DA] | Page removed | Reach out |
+| [domain 2] | [DA] | Link removed | Investigate |
+
+**Total lost links**: [X]
+**Net change**: [+/-X]
+
+### Links to Recover
+
+| Lost Link | Value | Recovery Strategy |
+|-----------|-------|-------------------|
+| [domain 1] | High | Contact webmaster |
+| [domain 2] | High | Update content they linked to |
+```
+
+---
+
+## 7. Backlink Report Template
+
+```markdown
+# Backlink Analysis Report
+
+**Domain**: [domain]
+**Report Date**: [date]
+**Period Analyzed**: [period]
+
+## Executive Summary
+
+Your backlink profile is [healthy/needs attention/concerning].
+
+**Key Stats**:
+- Referring domains: [X] ([+/-Y] vs last month)
+- Average link authority: [X] DA
+- Link velocity: [X] new links/month
+- Toxic link percentage: [X]%
+
+## Profile Strengths
+
+1. [Strength 1]
+2. [Strength 2]
+3. [Strength 3]
+
+## Areas of Concern
+
+1. [Concern 1]
+2. [Concern 2]
+
+## Opportunities Identified
+
+| Opportunity | Potential Links | Effort | Priority |
+|-------------|-----------------|--------|----------|
+| Link intersection | [X] sites | Medium | High |
+| Broken links | [X] sites | Low | High |
+| Resource pages | [X] sites | Medium | Medium |
+
+## Competitive Position
+
+Your referring domains rank #[X] among [Y] competitors.
+
+| Rank | Domain | Referring Domains |
+|------|--------|-------------------|
+| 1 | [domain] | [X] |
+| 2 | [domain] | [X] |
+| 3 | [domain] | [X] |
+
+## Recommended Actions
+
+### Immediate (This Week)
+- [ ] Disavow [X] toxic links identified
+- [ ] Reach out to [X] unlinked mentions
+
+### Short-term (This Month)
+- [ ] Pursue [X] link intersection opportunities
+- [ ] Fix [X] broken link opportunities
+- [ ] Recover [X] recently lost links
+
+### Long-term (This Quarter)
+- [ ] Create linkable asset targeting [topic]
+- [ ] Launch guest posting campaign
+- [ ] Build [X] resource page links
+
+## KPIs to Track
+
+| Metric | Current | 3-Month Target |
+|--------|---------|----------------|
+| Referring domains | [X] | [Y] |
+| Average DA of new links | [X] | [Y] |
+| Link velocity | [X]/mo | [Y]/mo |
+| Toxic link % | [X]% | <5% |
+```
diff --git a/.cursor/skills/backlink-analyzer/references/link-quality-rubric.md b/.cursor/skills/backlink-analyzer/references/link-quality-rubric.md
new file mode 100644
index 0000000000..03e801a492
--- /dev/null
+++ b/.cursor/skills/backlink-analyzer/references/link-quality-rubric.md
@@ -0,0 +1,292 @@
+# Link Quality Rubric
+
+Comprehensive reference for evaluating backlink quality. Use this rubric to assess individual links, audit entire link profiles, perform competitive link gap analysis, and prepare disavow files.
+
+---
+
+## 1. Individual Link Quality Evaluation
+
+### Scoring Methodology
+
+Evaluate each link across six factors. Multiply score (1-5) by factor weight to produce a weighted score. Sum all weighted scores for a final Link Quality Score (LQS).
+
+**Rating Scale:**
+- **LQS 4.0-5.0**: Premium link — high authority, topically relevant, editorial placement
+- **LQS 2.5-3.9**: Acceptable link — provides value, typical of healthy profiles
+- **LQS 1.0-2.4**: Low quality — minimal value, review for potential risk
+
+### Factor 1: Domain Authority (25% weight)
+
+| Score | DR / DA Range | Characteristics | Examples |
+|-------|-------------|-----------------|---------|
+| 5 | DR 70+ | Major publication, established authority | NYTimes, Forbes, BBC, major university sites |
+| 4 | DR 50-69 | Strong domain, recognized in industry | Industry publications, large blogs, government sites |
+| 3 | DR 30-49 | Moderate authority, established site | Mid-tier blogs, regional publications, niche authorities |
+| 2 | DR 15-29 | Low authority, newer or smaller site | Small blogs, newer companies, personal sites |
+| 1 | DR <15 | Very low authority | New sites, abandoned sites, thin content sites |
+
+**Notes:**
+- DR/DA is a proxy, not the sole indicator. A DR 30 site that is highly relevant to your niche may be more valuable than a DR 70 site in an unrelated field.
+- Check if the domain's authority is organic (earned over time) or inflated (bought links, PBN).
+
+### Factor 2: Topical Relevance (25% weight)
+
+| Score | Relevance Level | Description |
+|-------|----------------|-------------|
+| 5 | Exact match | Same niche, same subtopic. A link from a CRM review site to your CRM product. |
+| 4 | Closely related | Same industry, adjacent topic. A marketing blog linking to your email tool. |
+| 3 | Broadly related | Same general field. A business blog linking to your SaaS product. |
+| 2 | Tangentially related | Loose connection. A general news site mentioning your product in a tech roundup. |
+| 1 | Unrelated | No topical connection. A cooking blog linking to your B2B software. |
+
+**How to assess relevance:**
+1. Read the linking page content. Is it about your topic?
+2. Check the linking site's overall focus. Is it in your industry?
+3. Look at the surrounding content. Does the link make editorial sense?
+4. Check the site's other outbound links. Are they topically coherent?
+
+### Factor 3: Traffic to Linking Page (15% weight)
+
+| Score | Estimated Monthly Traffic | Characteristics |
+|-------|--------------------------|-----------------|
+| 5 | 10,000+ visits/month | High-traffic page, likely drives referral traffic |
+| 4 | 1,000-9,999 visits/month | Solid traffic, some referral value |
+| 3 | 100-999 visits/month | Moderate traffic, primarily SEO value |
+| 2 | 10-99 visits/month | Low traffic, SEO value only |
+| 1 | <10 visits/month | No meaningful traffic, minimal value |
+
+**Why traffic matters:**
+- Links from pages with real traffic are more likely to be genuine editorial placements.
+- Google likely weights links from pages that receive traffic more highly.
+- Referral traffic from the link provides direct business value beyond SEO.
+
+### Factor 4: Link Position (15% weight)
+
+| Score | Position | Description |
+|-------|----------|-------------|
+| 5 | In-content, editorial | Naturally placed within the article body as a citation or resource |
+| 4 | In-content, contextual | Within the body text but in a "resources" or "further reading" section |
+| 3 | Author bio or about section | Part of a contributor's bio or about page |
+| 2 | Sidebar or dedicated links section | Widget, blogroll, or sidebar placement |
+| 1 | Footer, sitewide, or hidden | Footer link, sitewide template link, or visually obscured |
+
+**Key principle:** Editorial in-content links carry the most weight because they represent a genuine endorsement. Footer and sitewide links are devalued by search engines.
+
+### Factor 5: Anchor Text (10% weight)
+
+| Score | Anchor Type | Example (for a CRM product) |
+|-------|------------|----------------------------|
+| 5 | Descriptive, natural | "this customer relationship management platform" |
+| 4 | Partial match, natural | "CRM tools for small businesses" |
+| 3 | Brand name | "Acme CRM" |
+| 2 | Naked URL | "https://acmecrm.com" |
+| 1 | Generic | "click here", "read more", "this website" |
+
+**Important nuance:** A natural link profile has a MIX of all anchor types. Too many exact-match anchors (score 5) can signal manipulation. The ideal distribution is:
+- Brand anchors: 30-40%
+- Naked URLs: 15-25%
+- Generic anchors: 10-20%
+- Descriptive/partial match: 15-25%
+- Exact match: 5-15%
+
+### Factor 6: Follow Status (10% weight)
+
+| Score | Status | Description |
+|-------|--------|-------------|
+| 5 | Dofollow, editorial | Standard followed link from editorial content |
+| 4 | Dofollow, non-editorial | Followed link from directory, profile, or user-generated content |
+| 3 | Sponsored (rel="sponsored") | Properly disclosed sponsored/paid link |
+| 2 | UGC (rel="ugc") | User-generated content link (forums, comments) |
+| 1 | Nofollow (rel="nofollow") | Explicitly nofollowed link |
+
+**Notes:**
+- Google treats nofollow as a "hint" rather than a directive since 2019.
+- Nofollow links from high-authority sites (e.g., Wikipedia) still provide brand value and referral traffic.
+- A healthy profile naturally includes a mix of followed and nofollowed links. Typical ratio: 60-80% dofollow, 20-40% nofollow.
+
+---
+
+## 2. Example Link Profile Assessments
+
+### Example A: Strong Link Profile
+
+| Characteristic | Value | Assessment |
+|---------------|-------|-----------|
+| Total referring domains | 1,200 | Healthy for a mid-size SaaS company |
+| Dofollow ratio | 72% | Natural distribution |
+| Average linking domain DR | 38 | Solid average authority |
+| Top anchor: brand name | 35% | Natural brand dominance |
+| Exact match anchors | 8% | Within safe range |
+| Topical relevance (sampled) | 75% related | Strong relevance signal |
+| Link velocity | +25/month net | Steady organic growth |
+| Toxic link estimate | 3% | Below 5% threshold — healthy |
+
+**Verdict:** Healthy profile with natural link distribution. Continue current strategy.
+
+### Example B: At-Risk Link Profile
+
+| Characteristic | Value | Assessment |
+|---------------|-------|-----------|
+| Total referring domains | 800 | Adequate but thin for competitive niche |
+| Dofollow ratio | 92% | Suspiciously high — may indicate link manipulation |
+| Average linking domain DR | 18 | Low average authority |
+| Top anchor: exact match keyword | 42% | Over-optimized — risk of penalty |
+| Exact match anchors | 42% | Far above safe threshold (>15%) |
+| Topical relevance (sampled) | 30% related | Many irrelevant links |
+| Link velocity | +80/month net | Unnaturally high — investigate |
+| Toxic link estimate | 18% | Above 10% threshold — action needed |
+
+**Verdict:** Profile shows signs of manipulation. Immediate actions needed: disavow toxic links, diversify anchor text, slow down link acquisition pace.
+
+### Example C: New Site Link Profile
+
+| Characteristic | Value | Assessment |
+|---------------|-------|-----------|
+| Total referring domains | 45 | Expected for a 6-month-old site |
+| Dofollow ratio | 65% | Natural |
+| Average linking domain DR | 28 | Reasonable for early-stage outreach |
+| Top anchor: brand name | 40% | Healthy |
+| Exact match anchors | 5% | Conservative and safe |
+| Topical relevance (sampled) | 80% related | Well-targeted outreach |
+| Link velocity | +8/month net | Appropriate for new site |
+| Toxic link estimate | 1% | Clean profile |
+
+**Verdict:** Healthy foundation. Focus on scaling link acquisition while maintaining quality standards.
+
+---
+
+## 3. Competitive Link Gap Analysis Methodology
+
+### Step-by-Step Process
+
+**Step 1: Identify competitors**
+Select 3-5 direct competitors who rank for your target keywords.
+
+**Step 2: Pull referring domain data**
+Export the full referring domain list for each competitor from ~~link database.
+
+**Step 3: Create intersection matrix**
+
+| Referring Domain | You | Comp 1 | Comp 2 | Comp 3 | Overlap Count |
+|-----------------|-----|--------|--------|--------|---------------|
+| example-a.com | No | Yes | Yes | Yes | 3 |
+| example-b.com | No | Yes | Yes | No | 2 |
+| example-c.com | No | Yes | No | No | 1 |
+| example-d.com | Yes | Yes | Yes | Yes | 3 (already have) |
+
+**Step 4: Prioritize opportunities**
+
+| Priority | Criteria | Rationale |
+|----------|---------|-----------|
+| Highest | Links to 3+ competitors, DR 50+, relevant | If all competitors have it, it is likely linkable |
+| High | Links to 2+ competitors, DR 30+, relevant | Strong signal of willingness to link in niche |
+| Medium | Links to 1 competitor, DR 50+, relevant | May be less accessible but high value |
+| Lower | Links to 1 competitor, DR <30, or low relevance | Diminishing returns |
+
+**Step 5: Analyze link context**
+For each high-priority opportunity, visit the actual linking page to understand:
+- Why did they link to your competitor? (resource page, mention, guest post, etc.)
+- What content on your site could replace or complement that link?
+- What outreach angle would work? (broken link, better resource, relationship)
+
+**Step 6: Create outreach plan**
+Build a prioritized list with contact information, outreach angle, and template selection.
+
+---
+
+## 4. Disavow File Format Guide
+
+### When to Disavow
+
+Only disavow links when you have clear evidence of risk. Unnecessary disavow can hurt your rankings.
+
+| Situation | Disavow? | Reasoning |
+|-----------|----------|-----------|
+| Obvious PBN links | Yes | Clear manipulation signal |
+| Paid links you cannot get removed | Yes | After attempting removal |
+| Spam attack (negative SEO) | Yes | Protect from third-party manipulation |
+| Low-quality directory links | Maybe | Only if pattern is excessive |
+| Foreign language spam | Yes | If clearly unnatural |
+| Low-DA sites with real content | No | Low quality is not toxic |
+| Nofollow links from any source | No | Already nofollowed; no risk |
+
+### Disavow File Format
+
+The disavow file is a plain text file (.txt) uploaded to Google Search Console.
+
+```
+# Disavow file for example.com
+# Generated: [date]
+# Reason: Toxic link cleanup
+
+# Individual URLs to disavow
+https://spam-site.com/page-with-link
+https://another-spam.com/toxic-page
+
+# Entire domains to disavow (use for sites with multiple toxic links)
+domain:link-farm-example.com
+domain:pbn-network-site.com
+domain:spam-directory.net
+```
+
+### Disavow File Best Practices
+
+| Practice | Why |
+|----------|-----|
+| Comment every entry or group | Future auditors need to understand why |
+| Use `domain:` for sites with multiple bad links | More thorough than individual URLs |
+| Use individual URLs when only one page is toxic | Avoid disavowing good links from the same domain |
+| Keep a changelog | Track what was added and when |
+| Review quarterly | Remove entries if domains have been cleaned up |
+| Never disavow your own domain | Common mistake that causes severe damage |
+| Back up before uploading | Keep previous version in case of errors |
+
+### Disavow Review Workflow
+
+| Step | Action | Tool |
+|------|--------|------|
+| 1 | Export full backlink profile | ~~link database |
+| 2 | Filter for known toxic patterns | Spam score, DR <10, foreign spam |
+| 3 | Manual review of flagged links | Visit each flagged domain |
+| 4 | Attempt removal via email first | Contact webmasters |
+| 5 | Wait 2 weeks for removal responses | Track outreach results |
+| 6 | Add non-removed toxic links to disavow | Format as .txt file |
+| 7 | Upload to Google Search Console | Disavow Links tool |
+| 8 | Document all actions and dates | Internal records |
+| 9 | Re-check in 4-6 weeks | Verify processing |
+
+---
+
+## 5. Link Profile Health Benchmarks
+
+### Healthy Profile Indicators
+
+| Metric | Healthy Range | Warning Sign | Critical |
+|--------|-------------|--------------|----------|
+| Dofollow ratio | 60-80% | >90% | >95% |
+| Exact match anchor % | <15% | 15-25% | >25% |
+| Brand anchor % | 25-45% | <15% | <5% |
+| Toxic link % | <5% | 5-10% | >10% |
+| Referring domain growth | Positive, steady | Flat | Declining |
+| Average linking DR | 25+ | 15-25 | <15 |
+| Link diversity (unique domains / total links) | >0.3 | 0.1-0.3 | <0.1 |
+| Topical relevance (sampled) | >60% | 40-60% | <40% |
+
+### Industry-Specific Benchmarks
+
+Authority expectations vary significantly by industry vertical.
+
+| Industry | Typical DR Range (Top 10 Sites) | Typical Referring Domains | Link Difficulty |
+|----------|-------------------------------|--------------------------|----------------|
+| Finance / Insurance | DR 60-90 | 5,000-50,000+ | Very High |
+| Health / Medical | DR 50-85 | 3,000-30,000+ | Very High |
+| Technology / SaaS | DR 40-80 | 1,000-20,000+ | High |
+| E-commerce (general) | DR 35-75 | 500-15,000+ | High |
+| Legal | DR 40-70 | 1,000-10,000+ | High |
+| Education | DR 50-90 | 2,000-25,000+ | Medium-High |
+| Local services | DR 15-45 | 50-500 | Medium |
+| B2B niche | DR 25-60 | 200-5,000+ | Medium |
+| Blog / Content site | DR 20-70 | 100-10,000+ | Medium |
+| New startup | DR 5-25 | 10-200 | Starting point |
+
+_Note: These are general ranges. Actual requirements depend on your specific keyword competition._
diff --git a/.cursor/skills/backlink-analyzer/references/outreach-templates.md b/.cursor/skills/backlink-analyzer/references/outreach-templates.md
new file mode 100644
index 0000000000..c6c25cf4b9
--- /dev/null
+++ b/.cursor/skills/backlink-analyzer/references/outreach-templates.md
@@ -0,0 +1,578 @@
+# Outreach Templates
+
+Copy-ready outreach email templates for each link building strategy. Includes subject line variations, follow-up sequences, personalization tips, and response handling scripts.
+
+---
+
+## General Outreach Principles
+
+Before using any template, follow these rules:
+
+| Principle | Why It Matters |
+|-----------|---------------|
+| **Personalize every email** | Generic emails get deleted. Reference specific content. |
+| **Lead with value** | Explain what is in it for them before making your ask. |
+| **Keep it short** | 100-150 words maximum for initial outreach. |
+| **One clear ask** | Do not combine multiple requests in one email. |
+| **Professional sender address** | Use name@yourdomain.com, not a free email provider. |
+| **No attachments on first email** | Attachments trigger spam filters and reduce trust. |
+| **Follow up (once or twice)** | Most responses come from follow-ups, not initial emails. |
+| **Track everything** | Log outreach in a spreadsheet or CRM for accountability. |
+
+---
+
+## 1. Broken Link Building Templates
+
+### Template 1A: Broken Link — Direct Approach
+
+**Subject:** Found a broken link on your [topic] page
+
+**Body:**
+
+Hi [Name],
+
+I was reading your article on [specific article title] — great resource on [brief compliment about content].
+
+I noticed the link to [description of broken link resource] in the [section name] section seems to be broken (returns a 404).
+
+I recently published a guide on [your topic] that covers similar ground: [Your URL]
+
+It might work as a replacement if you are updating the page. Either way, wanted to give you a heads-up about the broken link.
+
+Best,
+[Your Name]
+
+---
+
+### Template 1B: Broken Link — Roundup Page
+
+**Subject:** Quick heads-up about [their page title]
+
+**Body:**
+
+Hi [Name],
+
+Your [topic] resource page is one of my go-to references — really well curated.
+
+I noticed [X] links on the page are no longer working:
+- [Broken URL 1] — returns 404
+- [Broken URL 2] — domain expired
+
+If you are updating the page, I have a [content type] that covers [topic]: [Your URL]
+
+Happy to suggest other replacement resources too if that would help.
+
+Thanks for maintaining such a useful list,
+[Your Name]
+
+---
+
+### Follow-Up (5-7 days after initial email)
+
+**Subject:** Re: [original subject]
+
+**Body:**
+
+Hi [Name],
+
+Just bumping this up in case it got buried. I spotted a broken link on your [page title] and thought you would want to know.
+
+No worries if the page is staying as-is — just wanted to make sure you saw it.
+
+[Your Name]
+
+---
+
+## 2. Guest Post Pitching Templates
+
+### Template 2A: Guest Post — Topic Pitch
+
+**Subject:** Guest post idea: [proposed title]
+
+**Body:**
+
+Hi [Name],
+
+I have been following [their site name] for a while — your recent piece on [specific article] was especially [specific compliment].
+
+I would love to contribute a guest post on [proposed topic]. Here is what I have in mind:
+
+**Title:** [Proposed title]
+**Angle:** [1-2 sentences describing the unique angle]
+**Why your readers would care:** [1 sentence on value to their audience]
+
+I have written for [1-2 relevant publications] previously. Here are a couple of samples:
+- [Sample URL 1]
+- [Sample URL 2]
+
+Would this be a fit for [their site name]?
+
+Best,
+[Your Name]
+[Your title / credentials]
+
+---
+
+### Template 2B: Guest Post — Expertise Pitch
+
+**Subject:** [Your expertise area] contributor for [their site]
+
+**Body:**
+
+Hi [Name],
+
+I am [Your Name], a [your role/expertise] with [X years] experience in [field]. I noticed [their site] covers [topic area] extensively and thought I could contribute something useful.
+
+Three topic ideas that might work for your audience:
+
+1. [Title idea 1] — [one-line description]
+2. [Title idea 2] — [one-line description]
+3. [Title idea 3] — [one-line description]
+
+Happy to adjust angles or pitch different ideas. I can also share writing samples if helpful.
+
+Thanks,
+[Your Name]
+
+---
+
+### Follow-Up (7-10 days after initial email)
+
+**Subject:** Re: [original subject]
+
+**Body:**
+
+Hi [Name],
+
+Checking back on my guest post pitch. I know editorial calendars fill up fast, so no worries if the timing does not work.
+
+If any of the topics I suggested could work down the road, happy to keep in touch for a future slot.
+
+[Your Name]
+
+---
+
+## 3. Resource Page Outreach Templates
+
+### Template 3A: Resource Page — Direct Suggestion
+
+**Subject:** Resource for your [topic] page
+
+**Body:**
+
+Hi [Name],
+
+I found your [topic] resource page while researching [related topic] — it is a really well-organized collection.
+
+I thought [your resource title] might be a good addition to the [specific section] section. It covers [brief description of what it covers and why it is useful].
+
+Here is the link: [Your URL]
+
+No pressure at all — just thought it might be useful for your readers.
+
+Best,
+[Your Name]
+
+---
+
+### Template 3B: Resource Page — Value-Add Approach
+
+**Subject:** A few resources for your [topic] page
+
+**Body:**
+
+Hi [Name],
+
+I have been using your [topic] resource page as a reference and really appreciate the curation work.
+
+I wanted to suggest a few resources you might consider adding (not all mine):
+
+1. [External resource title] — [URL] — Great for [reason]
+2. [Your resource title] — [Your URL] — Covers [topic]
+3. [Another external resource] — [URL] — Useful for [reason]
+
+I included a couple of third-party resources alongside mine since they genuinely complement your page.
+
+Thanks for maintaining such a useful list,
+[Your Name]
+
+---
+
+## 4. Unlinked Mention Outreach Templates
+
+### Template 4A: Unlinked Brand Mention
+
+**Subject:** Thanks for mentioning [brand name]
+
+**Body:**
+
+Hi [Name],
+
+Just came across your article [article title] — thanks for mentioning [your brand/product]. Really appreciate the kind words about [specific thing they said].
+
+Quick request: would you be able to add a link to [Your URL] where you mention us? It would help your readers find us directly and help us out with attribution.
+
+Totally understand if it is not possible. Either way, glad you found [product/service] useful.
+
+Best,
+[Your Name]
+
+---
+
+### Template 4B: Unlinked Data/Research Mention
+
+**Subject:** Thanks for citing our [research/data]
+
+**Body:**
+
+Hi [Name],
+
+I noticed you referenced our [study/statistic/data point] in your article [article title]. Thanks for citing our research.
+
+If you are able to add a link to the original source, it would help your readers verify the data and access the full [study/report]: [Your URL]
+
+We actually just updated it with [new data point] that might be relevant to your piece as well.
+
+Thanks,
+[Your Name]
+
+---
+
+### Follow-Up (5-7 days)
+
+**Subject:** Re: [original subject]
+
+**Body:**
+
+Hi [Name],
+
+Just a quick follow-up on adding a link for the mention of [brand/research] in your article. I know updating published content can be low priority, so no rush.
+
+If it helps, the exact URL is: [Your URL]
+
+Thanks again for the mention,
+[Your Name]
+
+---
+
+## 5. Digital PR / Data-Driven Outreach Templates
+
+### Template 5A: Original Research Pitch
+
+**Subject:** New data: [key finding headline]
+
+**Body:**
+
+Hi [Name],
+
+We just published [study/survey/analysis] that found [most surprising or newsworthy finding].
+
+Key findings:
+- [Finding 1 — the headline stat]
+- [Finding 2 — supporting data]
+- [Finding 3 — counterintuitive insight]
+
+Full report: [Your URL]
+
+Given your coverage of [their beat/topic], I thought this might be worth a mention. Happy to provide additional data, quotes, or custom analysis for your audience.
+
+[Your Name]
+[Your title]
+
+---
+
+### Template 5B: Expert Commentary Pitch
+
+**Subject:** Expert source for your [topic] coverage
+
+**Body:**
+
+Hi [Name],
+
+I saw you are covering [topic/trend] and thought I could be a useful source.
+
+I am [Your Name], [your credentials — brief]. I have [specific experience relevant to their coverage].
+
+A couple of angles I could provide commentary on:
+- [Angle 1]
+- [Angle 2]
+
+Happy to jump on a quick call or provide written quotes. Available on short notice.
+
+[Your Name]
+[Your title, company]
+[Phone number for urgent requests]
+
+---
+
+## 6. HARO / Source Request Templates
+
+### Template 6A: HARO Response — Expert Source
+
+**Subject:** [HARO] Re: [original query title]
+
+**Body:**
+
+Hi [Journalist Name],
+
+Re: your query on [topic].
+
+**Source:** [Your Name], [Title] at [Company]
+**Credentials:** [1-2 sentences on relevant expertise]
+
+**Response:**
+
+[2-3 paragraphs directly answering their query. Be specific, quotable, and concise. Lead with the most valuable insight.]
+
+**Key quote:** "[One highly quotable sentence they can use directly]"
+
+Happy to elaborate or provide additional data. Available at [email] or [phone].
+
+[Your Name]
+
+---
+
+### Template 6B: HARO Response — Data Source
+
+**Subject:** [HARO] Re: [original query title] — with data
+
+**Body:**
+
+Hi [Journalist Name],
+
+For your piece on [topic], here is data from our [study/platform/analysis]:
+
+- [Statistic 1]: [data point with context]
+- [Statistic 2]: [data point with context]
+- [Statistic 3]: [data point with context]
+
+Source: [Your URL for attribution]
+
+**Expert quote from [Your Name], [Title]:**
+"[Quotable insight interpreting the data]"
+
+Full dataset available if needed for additional analysis.
+
+[Your Name]
+[Contact info]
+
+---
+
+## 7. Skyscraper Technique Templates
+
+### Template 7A: Skyscraper — Better Resource
+
+**Subject:** Updated resource on [topic]
+
+**Body:**
+
+Hi [Name],
+
+I noticed you linked to [competitor's article title] in your post on [their article title].
+
+We just published an updated version of that topic: [Your URL]
+
+It includes [specific improvements over the original]:
+- [Improvement 1 — e.g., "2025 data instead of 2022"]
+- [Improvement 2 — e.g., "interactive calculator included"]
+- [Improvement 3 — e.g., "covers 3 additional sub-topics"]
+
+If you think it would be a better resource for your readers, I would appreciate you considering swapping the link. No worries either way.
+
+Best,
+[Your Name]
+
+---
+
+## 8. Relationship Building Templates
+
+### Template 8A: Initial Relationship — No Ask
+
+**Subject:** Loved your piece on [topic]
+
+**Body:**
+
+Hi [Name],
+
+Just read your article on [specific article title] and wanted to say it was genuinely one of the best pieces I have read on [topic].
+
+The section on [specific section] was especially useful — I actually shared it with my team and we are implementing [specific takeaway].
+
+No ask here, just wanted to let you know your work is making an impact.
+
+[Your Name]
+
+---
+
+### Template 8B: Follow-Up After Relationship Building (2-4 weeks later)
+
+**Subject:** [New topic] — thought of your readers
+
+**Body:**
+
+Hi [Name],
+
+Following up on our exchange a few weeks back. I just published something your audience might find useful: [Your URL]
+
+It covers [brief description] with a focus on [specific angle].
+
+Would love to hear your thoughts if you get a chance to read it.
+
+Best,
+[Your Name]
+
+---
+
+## 9. Follow-Up Sequences
+
+### Standard Follow-Up Timeline
+
+| Email | Timing | Purpose | Tone |
+|-------|--------|---------|------|
+| Initial outreach | Day 0 | Make the ask | Professional, value-forward |
+| Follow-up 1 | Day 5-7 | Gentle reminder | Casual, brief |
+| Follow-up 2 | Day 14 | Final attempt | Short, no-pressure close |
+| Stop | After follow-up 2 | Do not send more | Move on |
+
+### Follow-Up 2 Template (Final Attempt)
+
+**Subject:** Re: [original subject]
+
+**Body:**
+
+Hi [Name],
+
+Last follow-up on this — I know you are busy.
+
+[One-sentence reminder of what you asked]
+
+If the timing is not right, no worries at all. Feel free to bookmark [Your URL] for whenever it might be useful.
+
+Best,
+[Your Name]
+
+---
+
+## 10. Response Handling Scripts
+
+### Positive Response — They Will Add the Link
+
+**Reply:**
+
+That is great, [Name] — really appreciate it.
+
+The exact URL is: [Your URL]
+Preferred anchor text (just a suggestion): [anchor text]
+
+Let me know if you need anything else. Happy to return the favor anytime.
+
+Best,
+[Your Name]
+
+---
+
+### Positive Response — They Want a Guest Post
+
+**Reply:**
+
+Wonderful, I would love to contribute. Here is what I am thinking:
+
+**Title:** [Proposed title]
+**Outline:**
+1. [Section 1]
+2. [Section 2]
+3. [Section 3]
+
+**Estimated length:** [word count]
+**Delivery timeline:** [date]
+
+Do you have any editorial guidelines or specific requirements I should follow?
+
+Best,
+[Your Name]
+
+---
+
+### Neutral Response — They Are Interested but Noncommittal
+
+**Reply:**
+
+Totally understand, [Name]. No rush at all.
+
+I will keep [Your URL] updated with [latest data/content], so it will be here whenever the timing works.
+
+Feel free to reach out if I can ever be a source or contributor for [their site].
+
+Best,
+[Your Name]
+
+---
+
+### Negative Response — They Decline
+
+**Reply:**
+
+Completely understand, [Name]. Thanks for letting me know.
+
+If anything changes or if I can ever be helpful as a source, do not hesitate to reach out.
+
+Best,
+[Your Name]
+
+_Note: Never argue, push back, or send additional follow-ups after a decline. Keep the relationship positive for potential future opportunities._
+
+---
+
+### No Response — After Full Sequence
+
+Do not send further emails. Add to a "re-engage in 3-6 months" list. When you re-engage, use a completely new angle or piece of content. Never reference the fact that they did not respond previously.
+
+---
+
+## 11. Personalization Tips
+
+### Research Checklist Before Sending
+
+| Check | Where to Find | How to Use |
+|-------|-------------|-----------|
+| Their name (correct spelling) | About page, LinkedIn, byline | Use in greeting and body |
+| Recent article they wrote | Their blog, Google "[name] + [site]" | Reference in opening line |
+| Their social media | Twitter/X, LinkedIn | Mention a recent post or shared interest |
+| Their publication's audience | About page, media kit | Tailor your value proposition |
+| Content they have linked to before | ~~link database, their recent articles | Match the type of content they prefer |
+| How they prefer to be contacted | Website contact page, social bio | Some prefer Twitter DMs over email |
+
+### Personalization Levels
+
+| Level | Effort | Response Rate Impact | When to Use |
+|-------|--------|---------------------|-------------|
+| **Zero** (template only) | Minimal | Baseline (lowest) | Never recommended |
+| **Basic** (name + site name) | Low | +20-30% | Bulk outreach to lower-priority targets |
+| **Moderate** (+ specific article reference) | Medium | +50-80% | Standard outreach to mid-tier targets |
+| **Deep** (+ shared interest, social reference, mutual connection) | High | +100-200% | High-priority targets (DR 60+ sites, key relationships) |
+
+---
+
+## 12. Subject Line Variations by Strategy
+
+### High-Performing Subject Line Patterns
+
+| Strategy | Subject Lines (pick one) |
+|----------|------------------------|
+| **Broken link** | "Found a broken link on your [topic] page" / "Quick heads-up about [page title]" / "Broken resource on [their site]" |
+| **Guest post** | "Guest post idea: [title]" / "[Topic] contributor for [their site]" / "Content pitch for [their site]" |
+| **Resource page** | "Resource for your [topic] page" / "Suggestion for your [topic] list" / "Addition for [page title]?" |
+| **Unlinked mention** | "Thanks for mentioning [brand]" / "Re: your mention of [brand/data]" / "Quick favor re: [their article]" |
+| **Digital PR** | "New data: [headline stat]" / "Exclusive: [finding]" / "[Topic] research for your coverage" |
+| **Skyscraper** | "Updated resource on [topic]" / "Better version of [competitor content]" / "Thought you'd want to see this" |
+
+### Subject Lines to Avoid
+
+| Avoid | Why |
+|-------|-----|
+| "Link exchange opportunity" | Screams spam and violates Google guidelines |
+| "SEO partnership" | Immediately signals manipulative intent |
+| ALL CAPS or excessive punctuation | Triggers spam filters, looks unprofessional |
+| "I'd love a backlink" | Too direct; leads with your need, not their value |
+| Misleading subjects ("Re:" when no prior conversation) | Damages trust immediately |
+| Long subjects (>60 characters) | Gets truncated in inbox, lower open rates |
diff --git a/.cursor/skills/competitor-analysis/SKILL.md b/.cursor/skills/competitor-analysis/SKILL.md
new file mode 100644
index 0000000000..11c1efc917
--- /dev/null
+++ b/.cursor/skills/competitor-analysis/SKILL.md
@@ -0,0 +1,309 @@
+---
+name: competitor-analysis
+description: 'Analyze competitor SEO/GEO: keywords, content, backlinks, AI citations, traffic share gaps. 竞品分析/竞争对手'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when analyzing competitor SEO strategy, comparing domains, benchmarking against competitors, or finding competitor keywords and content gaps."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - geo
+ - competitor-analysis
+ - competitive-intelligence
+ - benchmarking
+ - competitor-keywords
+ - competitor-backlinks
+ - market-analysis
+ - spyfu-alternative
+ - 竞品分析
+ - 競合分析
+ - 경쟁분석
+ - analisis-competitivo
+ triggers:
+ # EN-formal
+ - "analyze competitors"
+ - "competitor SEO"
+ - "competitive analysis"
+ - "competitor keywords"
+ - "competitor backlinks"
+ - "market analysis"
+ - "competitive intelligence"
+ # EN-casual
+ - "what are my competitors doing"
+ - "what are they doing differently"
+ - "why do they rank higher"
+ - "spy on competitor SEO"
+ - "what are they doing better"
+ - "why do they outrank me"
+ # EN-question
+ - "who are my SEO competitors"
+ - "how do I beat my competitors"
+ - "why do competitors rank higher"
+ # EN-competitor
+ - "SpyFu alternative"
+ - "Semrush competitor analysis"
+ - "Ahrefs competitor tool"
+ # ZH-pro
+ - "竞品分析"
+ - "竞争对手分析"
+ - "竞品SEO"
+ - "对标分析"
+ - "竞争情报"
+ # ZH-casual
+ - "竞品怎么做的"
+ - "他们排名为什么比我高"
+ - "看看对手在干什么"
+ - "为什么他们排名好"
+ # JA
+ - "競合分析"
+ - "競合SEO分析"
+ - "ライバル分析"
+ # KO
+ - "경쟁 분석"
+ - "경쟁사 SEO"
+ - "경쟁사 키워드"
+ # ES
+ - "análisis de competidores"
+ - "análisis competitivo SEO"
+ # PT
+ - "análise de concorrentes"
+ # Misspellings
+ - "competitve analysis"
+ - "compeditor analysis"
+---
+
+# Competitor Analysis
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill provides comprehensive analysis of competitor SEO and GEO strategies, revealing what's working in your market and identifying opportunities to outperform the competition.
+
+**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
+
+- Entering a new market or niche
+- Planning content strategy based on competitor success
+- Understanding why competitors rank higher
+- Finding backlink and partnership opportunities
+- Identifying content gaps competitors are missing
+- Analyzing competitor AI citation strategies
+- Benchmarking your SEO performance
+
+## What This Skill Does
+
+1. **Keyword Analysis**: Identifies keywords competitors rank for
+2. **Content Audit**: Analyzes competitor content strategies and formats
+3. **Backlink Profiling**: Reviews competitor link-building approaches
+4. **Technical Assessment**: Evaluates competitor site health
+5. **GEO Analysis**: Identifies how competitors appear in AI responses
+6. **Gap Identification**: Finds opportunities competitors miss
+7. **Strategy Extraction**: Reveals actionable insights from competitor success
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Basic Competitor Analysis
+
+```
+Analyze SEO strategy for [competitor URL]
+```
+
+```
+Compare my site [URL] against [competitor 1], [competitor 2], [competitor 3]
+```
+
+### Specific Analysis
+
+```
+What content is driving the most traffic for [competitor]?
+```
+
+```
+Analyze why [competitor] ranks #1 for [keyword]
+```
+
+### GEO-Focused Analysis
+
+```
+How is [competitor] getting cited in AI responses? What can I learn?
+```
+
+## Skill Contract
+
+**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
+
+- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
+- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
+- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~analytics + ~~AI monitor connected:**
+Automatically pull competitor keyword rankings, backlink profiles, top performing content, domain authority metrics from ~~SEO tool. Compare against your site's metrics from ~~analytics and ~~search console. Check AI citation patterns for both your site and competitors using ~~AI monitor.
+
+**With manual data only:**
+Ask the user to provide:
+1. Competitor URLs to analyze (2-5 recommended)
+2. Your own site URL and current metrics (traffic, rankings if known)
+3. Industry or niche context
+4. Specific aspects to focus on (keywords, content, backlinks, etc.)
+5. Any known competitor strengths or weaknesses
+
+Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests competitor analysis:
+
+1. **Identify Competitors**
+
+ If not specified, help identify competitors:
+
+ ```markdown
+ ### Competitor Identification Framework
+
+ **Direct Competitors** (same product/service)
+ - Search "[your main keyword]" and note top 5 organic results
+ - Check who's advertising for your keywords
+ - Ask: Who do customers compare you to?
+
+ **Indirect Competitors** (different solution, same problem)
+ - Search problem-focused keywords
+ - Look at alternative solutions
+
+ **Content Competitors** (compete for same keywords)
+ - May not sell same product
+ - Rank for your target keywords
+ - Include media sites, blogs, aggregators
+ ```
+
+2. **Gather Competitor Data**
+
+ Collect for each competitor: URL, domain age, estimated traffic, domain authority, business model, target audience, and key offerings.
+
+3. **Analyze Keyword Rankings**
+
+ Document total keywords ranking, top 10/top 3 counts, top performing keywords (with position, volume, traffic, page URL), keyword distribution by intent, and keyword gaps.
+
+4. **Audit Content Strategy**
+
+ Analyze content volume by type, top performing content, content patterns (word count, frequency, formats), content themes, and success factors.
+
+5. **Analyze Backlink Profile**
+
+ Review total backlinks, referring domains, link quality distribution, top linking domains, link acquisition patterns, and linkable assets.
+
+6. **Technical SEO Assessment**
+
+ Evaluate Core Web Vitals, mobile-friendliness, site architecture, internal linking quality, URL structure, and technical strengths/weaknesses.
+
+7. **GEO/AI Citation Analysis**
+
+ Test competitor content in AI systems: document which queries cite them, GEO strategies observed (definitions, statistics, Q&A, authority signals), and GEO opportunities they are missing.
+
+8. **Synthesize Competitive Intelligence**
+
+ Produce a final report with: Executive Summary, Competitive Landscape comparison table, CITE domain authority comparison, Strengths to Learn From, Weaknesses to Exploit, Keyword Opportunities, Content Strategy Recommendations, and Action Plan (Immediate / Short-term / Long-term).
+
+ > **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/analysis-templates.md) for detailed templates for each step.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Competitor URLs verified as relevant to your niche
+- [ ] Analysis scope defined (comprehensive or specific focus area)
+- [ ] Your own site metrics available for comparison
+- [ ] Minimum 2-3 competitors identified for meaningful patterns
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] Competitor strengths backed by measurable evidence (metrics, rankings)
+- [ ] Opportunities based on identifiable gaps, not assumptions
+- [ ] Action plan items are specific and actionable (not vague strategies)
+- [ ] Source of each data point clearly stated (~~SEO tool data, ~~analytics data, ~~AI monitor data, user-provided, or estimated)
+
+## Example
+
+> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/example-report.md) for a complete example analyzing HubSpot's marketing keyword dominance.
+
+## Advanced Analysis Types
+
+### Content Gap Analysis
+
+```
+Show me content [competitor] has that I don't, sorted by traffic potential
+```
+
+### Link Intersection
+
+```
+Find sites linking to [competitor 1] AND [competitor 2] but not me
+```
+
+### SERP Feature Analysis
+
+```
+What SERP features do competitors win? (Featured snippets, PAA, etc.)
+```
+
+### Historical Tracking
+
+```
+How has [competitor]'s SEO strategy evolved over the past year?
+```
+
+## Tips for Success
+
+1. **Analyze 3-5 competitors** for comprehensive view
+2. **Include indirect competitors** - they often have innovative approaches
+3. **Look beyond rankings** - analyze content quality, user experience
+4. **Study their failures** - avoid their mistakes
+5. **Monitor regularly** - competitor strategies evolve
+6. **Focus on actionable insights** - what can you actually implement?
+
+
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/research/competitor-analysis/YYYY-MM-DD-.md` containing:
+- One-line headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Analysis Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/analysis-templates.md) — Detailed templates for each analysis step (profile, keywords, content, backlinks, technical, GEO, synthesis)
+- [Battlecard Template](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/battlecard-template.md) — Quick-reference competitive battlecard for sales and marketing teams
+- [Positioning Frameworks](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/positioning-frameworks.md) — Positioning maps, messaging matrices, narrative analysis, and differentiation frameworks
+- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/example-report.md) — Complete example analyzing HubSpot's marketing keyword dominance
+
+## Next Best Skill
+
+- **Primary**: [content-gap-analysis](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/SKILL.md) — turn competitor findings into a focused opportunity map.
diff --git a/.cursor/skills/competitor-analysis/references/analysis-templates.md b/.cursor/skills/competitor-analysis/references/analysis-templates.md
new file mode 100644
index 0000000000..1e1e157167
--- /dev/null
+++ b/.cursor/skills/competitor-analysis/references/analysis-templates.md
@@ -0,0 +1,285 @@
+# Competitor Analysis — Analysis Templates
+
+Templates for each step of the competitor analysis workflow. Use these to structure your output.
+
+## Competitor Profile Template
+
+```markdown
+## Competitor Profile: [Name]
+
+**Basic Info**
+- URL: [website]
+- Domain Age: [years]
+- Estimated Traffic: [monthly visits]
+- Domain Authority/Rating: [score]
+
+**Business Model**
+- Type: [SaaS/E-commerce/Content/etc.]
+- Target Audience: [description]
+- Key Offerings: [products/services]
+```
+
+## Keyword Analysis Template
+
+```markdown
+### Keyword Analysis: [Competitor]
+
+**Total Keywords Ranking**: [X]
+**Keywords in Top 10**: [X]
+**Keywords in Top 3**: [X]
+
+#### Top Performing Keywords
+
+| Keyword | Position | Volume | Traffic Est. | Page |
+|---------|----------|--------|--------------|------|
+| [kw 1] | [pos] | [vol] | [traffic] | [url] |
+| [kw 2] | [pos] | [vol] | [traffic] | [url] |
+
+#### Keyword Distribution by Intent
+
+- Informational: [X]% ([keywords])
+- Commercial: [X]% ([keywords])
+- Transactional: [X]% ([keywords])
+- Navigational: [X]% ([keywords])
+
+#### Keyword Gaps (They rank, you don't)
+
+| Keyword | Their Position | Volume | Opportunity |
+|---------|----------------|--------|-------------|
+| [kw 1] | [pos] | [vol] | [analysis] |
+```
+
+## Content Analysis Template
+
+```markdown
+### Content Analysis: [Competitor]
+
+**Content Volume**
+- Total Pages: [X]
+- Blog Posts: [X]
+- Landing Pages: [X]
+- Resource Pages: [X]
+
+**Content Performance**
+
+#### Top Performing Content
+
+| Title | URL | Est. Traffic | Keywords | Backlinks |
+|-------|-----|--------------|----------|-----------|
+| [title 1] | [url] | [traffic] | [X] | [X] |
+
+**Content Patterns**
+
+- Average word count: [X] words
+- Publishing frequency: [X] posts/month
+- Content formats used:
+ - Blog posts: [X]%
+ - Guides/tutorials: [X]%
+ - Case studies: [X]%
+ - Tools/calculators: [X]%
+ - Videos: [X]%
+
+**Content Themes**
+
+| Theme | # Articles | Combined Traffic |
+|-------|------------|------------------|
+| [theme 1] | [X] | [traffic] |
+| [theme 2] | [X] | [traffic] |
+
+**What Makes Their Content Successful**
+
+1. [Success factor 1 with example]
+2. [Success factor 2 with example]
+3. [Success factor 3 with example]
+```
+
+## Backlink Analysis Template
+
+```markdown
+### Backlink Analysis: [Competitor]
+
+**Overview**
+- Total Backlinks: [X]
+- Referring Domains: [X]
+- Domain Rating: [X]
+
+**Link Quality Distribution**
+- High Authority (DR 70+): [X]%
+- Medium Authority (DR 30-69): [X]%
+- Low Authority (DR <30): [X]%
+
+**Top Linking Domains**
+
+| Domain | DR | Link Type | Target Page |
+|--------|-----|-----------|-------------|
+| [domain 1] | [DR] | [type] | [page] |
+
+**Link Acquisition Patterns**
+
+- Guest posts: [X]%
+- Editorial/organic: [X]%
+- Resource pages: [X]%
+- Directories: [X]%
+- Other: [X]%
+
+**Linkable Assets (Content attracting links)**
+
+| Asset | Type | Backlinks | Why It Works |
+|-------|------|-----------|--------------|
+| [asset 1] | [type] | [X] | [reason] |
+```
+
+## Technical SEO Assessment Template
+
+```markdown
+### Technical Analysis: [Competitor]
+
+**Site Performance**
+- Core Web Vitals: [Pass/Fail]
+- LCP: [X]s
+- FID: [X]ms
+- CLS: [X]
+- Mobile-friendly: [Yes/No]
+
+**Site Structure**
+- Site architecture depth: [X] levels
+- Internal linking quality: [Rating]
+- URL structure: [Clean/Messy]
+- Sitemap present: [Yes/No]
+
+**Technical Strengths**
+1. [Strength 1]
+2. [Strength 2]
+
+**Technical Weaknesses**
+1. [Weakness 1]
+2. [Weakness 2]
+```
+
+## GEO/AI Citation Analysis Template
+
+```markdown
+### GEO Analysis: [Competitor]
+
+**AI Visibility Assessment**
+
+Test competitor content in AI systems for relevant queries:
+
+| Query | AI Mentions Competitor? | What's Cited | Why |
+|-------|------------------------|--------------|-----|
+| [query 1] | Yes/No | [content] | [reason] |
+| [query 2] | Yes/No | [content] | [reason] |
+
+**GEO Strategies Observed**
+
+1. **Clear Definitions**
+ - Example: [quote from their content]
+ - Effectiveness: [rating]
+
+2. **Quotable Statistics**
+ - Example: [quote from their content]
+ - Effectiveness: [rating]
+
+3. **Q&A Format Content**
+ - Examples found: [X] pages
+ - Topics covered: [list]
+
+4. **Authority Signals**
+ - Expert authorship: [Yes/No]
+ - Citations to sources: [Yes/No]
+ - Original research: [Yes/No]
+
+**GEO Opportunities They're Missing**
+
+| Topic | Why Missing | Your Opportunity |
+|-------|-------------|------------------|
+| [topic 1] | [reason] | [action] |
+```
+
+## Synthesis Report Template
+
+```markdown
+# Competitive Analysis Report
+
+**Analysis Date**: [Date]
+**Competitors Analyzed**: [List]
+**Your Site**: [URL]
+
+## Executive Summary
+
+[2-3 paragraph overview of key findings and recommendations]
+
+## Competitive Landscape
+
+| Metric | You | Competitor 1 | Competitor 2 | Competitor 3 |
+|--------|-----|--------------|--------------|--------------|
+| Domain Authority | [X] | [X] | [X] | [X] |
+| Organic Traffic | [X] | [X] | [X] | [X] |
+| Keywords Top 10 | [X] | [X] | [X] | [X] |
+| Backlinks | [X] | [X] | [X] | [X] |
+| Content Pages | [X] | [X] | [X] | [X] |
+
+**Domain Authority Comparison (Recommended)**
+
+When domain-level comparison is needed, run `domain-authority-auditor` for each competitor to get CITE scores:
+
+| Domain | CITE Score | C (Citation) | I (Identity) | T (Trust) | E (Eminence) | Veto |
+|--------|-----------|-------------|-------------|----------|-------------|------|
+| Your domain | [score] | [score] | [score] | [score] | [score] | [pass/fail] |
+| Competitor 1 | [score] | [score] | [score] | [score] | [score] | [pass/fail] |
+| Competitor 2 | [score] | [score] | [score] | [score] | [score] | [pass/fail] |
+
+This reveals domain authority gaps that inform link building and brand strategy beyond keyword-level competition.
+
+## Competitor Strengths to Learn From
+
+### [Competitor 1]
+- **Strength**: [description]
+- **Why It Works**: [analysis]
+- **How to Apply**: [action item]
+
+[Repeat for each competitor]
+
+## Competitor Weaknesses to Exploit
+
+### Gap 1: [Description]
+- Who's weak: [competitors]
+- Opportunity size: [estimate]
+- Recommended action: [specific steps]
+
+[Repeat for each gap]
+
+## Keyword Opportunities
+
+### Keywords to Target (Competitor overlap)
+| Keyword | Volume | Avg Position | Best Strategy |
+|---------|--------|--------------|---------------|
+| [kw] | [vol] | [pos] | [strategy] |
+
+### Untapped Keywords (No competitor coverage)
+| Keyword | Volume | Difficulty | Opportunity |
+|---------|--------|------------|-------------|
+| [kw] | [vol] | [diff] | [description] |
+
+## Content Strategy Recommendations
+
+Based on competitor analysis:
+
+1. **Create**: [Content type] about [topic] because [reason]
+2. **Improve**: [Existing content] to match/exceed [competitor content]
+3. **Promote**: [Content] to sites like [competitor's link sources]
+
+## Action Plan
+
+### Immediate (This Week)
+1. [Action item]
+2. [Action item]
+
+### Short-term (This Month)
+1. [Action item]
+2. [Action item]
+
+### Long-term (This Quarter)
+1. [Action item]
+2. [Action item]
+```
diff --git a/.cursor/skills/competitor-analysis/references/battlecard-template.md b/.cursor/skills/competitor-analysis/references/battlecard-template.md
new file mode 100644
index 0000000000..66407a53c3
--- /dev/null
+++ b/.cursor/skills/competitor-analysis/references/battlecard-template.md
@@ -0,0 +1,256 @@
+# Competitive Battlecard Template
+
+A fill-in-the-blank template for creating competitive battlecards that equip sales, marketing, and content teams with actionable intelligence. Maintain one battlecard per major competitor and review quarterly.
+
+## Overview
+
+A competitive battlecard is a concise reference document that summarizes a competitor's strengths, weaknesses, positioning, and how to compete against them. Battlecards are living documents that should be updated as competitors evolve.
+
+**Audience:** Sales teams, content strategists, marketing leadership
+**Update frequency:** Quarterly review minimum; update immediately on major competitor changes
+**Length target:** 2-3 pages per competitor (scannable, not exhaustive)
+
+---
+
+## Battlecard Template
+
+### Header
+
+```
+COMPETITIVE BATTLECARD: [Competitor Name]
+Last Updated: [Date]
+Updated By: [Name/Team]
+Confidence Level: [High/Medium/Low] — based on data recency and source quality
+```
+
+---
+
+### Section 1: Competitor Overview
+
+| Field | Details |
+|-------|---------|
+| **Company Name** | [Name] |
+| **Website** | [URL] |
+| **Founded** | [Year] |
+| **Headquarters** | [Location] |
+| **Company Size** | [Employees] |
+| **Funding/Revenue** | [Known funding rounds or estimated revenue] |
+| **Target Customer** | [Primary audience: SMB, mid-market, enterprise, etc.] |
+| **Pricing Model** | [Freemium, subscription, usage-based, custom, etc.] |
+| **Pricing Range** | [Entry price → Enterprise price] |
+
+**One-Sentence Summary:**
+> [Competitor] is a [category] that helps [target audience] to [primary benefit] by [mechanism].
+
+---
+
+### Section 2: Their Pitch
+
+**Tagline/Slogan:**
+> [Their exact tagline from website]
+
+**Top 3 Claimed Differentiators:**
+1. [Differentiator 1 — their claim, not your assessment]
+2. [Differentiator 2]
+3. [Differentiator 3]
+
+**Positioning Statement (Reverse-Engineered):**
+> For [their target audience], [product] is the [category] that [key benefit] because [reason to believe].
+
+**Key Messages They Repeat:**
+- [Message 1 — found in blog, ads, sales decks]
+- [Message 2]
+- [Message 3]
+
+---
+
+### Section 3: Strengths (Be Honest)
+
+Credibility depends on honestly assessing where competitors are genuinely strong. Sugarcoating weaknesses or ignoring strengths reduces trust in the battlecard.
+
+| Strength | Evidence | Impact on Deals |
+|----------|---------|----------------|
+| [Strength 1] | [Where you observed this — reviews, demos, customer feedback] | [How this affects your competitive win rate] |
+| [Strength 2] | [Evidence] | [Impact] |
+| [Strength 3] | [Evidence] | [Impact] |
+
+---
+
+### Section 4: Weaknesses
+
+| Weakness | Evidence | How to Exploit |
+|----------|---------|---------------|
+| [Weakness 1] | [Sources: G2 reviews, customer complaints, technical limitations] | [Talking point or demo moment that highlights this gap] |
+| [Weakness 2] | [Evidence] | [How to exploit] |
+| [Weakness 3] | [Evidence] | [How to exploit] |
+
+**Common Complaints (from review sites):**
+- "[Exact quote from G2/Capterra/TrustRadius]" — [Source, Date]
+- "[Exact quote]" — [Source, Date]
+- "[Exact quote]" — [Source, Date]
+
+---
+
+### Section 5: Your Differentiators
+
+For each differentiator, provide the claim AND the proof. Unsupported claims erode credibility.
+
+| Differentiator | Your Advantage | Proof Point | How to Demo/Show |
+|---------------|---------------|------------|-----------------|
+| [Differentiator 1] | [What you do better] | [Data, testimonial, or demo evidence] | [Specific demo step or slide] |
+| [Differentiator 2] | [What you do better] | [Proof] | [How to show] |
+| [Differentiator 3] | [What you do better] | [Proof] | [How to show] |
+
+---
+
+### Section 6: Feature Comparison
+
+| Feature/Capability | You | [Competitor] | Notes |
+|-------------------|-----|-------------|-------|
+| [Feature 1] | [Yes/No/Partial + detail] | [Yes/No/Partial + detail] | [Context] |
+| [Feature 2] | [Yes/No/Partial] | [Yes/No/Partial] | [Context] |
+| [Feature 3] | [Yes/No/Partial] | [Yes/No/Partial] | [Context] |
+| [Feature 4] | [Yes/No/Partial] | [Yes/No/Partial] | [Context] |
+| [Feature 5] | [Yes/No/Partial] | [Yes/No/Partial] | [Context] |
+| [Integration A] | [Yes/No] | [Yes/No] | [Context] |
+| [Integration B] | [Yes/No] | [Yes/No] | [Context] |
+
+---
+
+### Section 7: Pricing Comparison
+
+| Tier | You | [Competitor] | Advantage |
+|------|-----|-------------|-----------|
+| Entry/Free | [Price + what's included] | [Price + what's included] | [Who wins at this tier and why] |
+| Mid-tier | [Price + what's included] | [Price + what's included] | [Who wins] |
+| Enterprise | [Price + what's included] | [Price + what's included] | [Who wins] |
+
+**Hidden Costs to Highlight:**
+- [Competitor charges extra for X, which you include]
+- [Competitor requires Y add-on for common use case]
+- [Competitor's pricing scales poorly because Z]
+
+---
+
+### Section 8: Objection Handling
+
+| When They Say... | You Respond With... | Supporting Evidence |
+|-----------------|--------------------|--------------------|
+| "[Competitor] has more features" | "[Your response — focus on outcomes, not feature count]" | [Case study, data point, or demo] |
+| "[Competitor] is cheaper" | "[Your response — focus on total value, ROI, or hidden costs]" | [TCO comparison, customer quote] |
+| "[Competitor] is more established" | "[Your response — focus on innovation, agility, or support quality]" | [Growth metrics, customer satisfaction data] |
+| "We're already using [Competitor]" | "[Your response — focus on switching ease, quick wins, or pain points]" | [Migration case study, time-to-value data] |
+| "[Competitor] integrates with [tool]" | "[Your response — confirm your integration or alternative]" | [Integration docs, workaround, or roadmap] |
+
+---
+
+### Section 9: Landmine Questions
+
+Questions to ask prospects early in the sales process that highlight your advantages and surface competitor weaknesses. Use these in discovery calls.
+
+| Landmine Question | What It Exposes | Your Advantage |
+|------------------|----------------|---------------|
+| "How important is [capability you have, they don't] to your workflow?" | Creates need for your differentiator | [Your capability] |
+| "Have you experienced [common pain point with competitor]?" | Surfaces known competitor weakness | [Your solution to that pain] |
+| "What's your timeline for seeing ROI from this tool?" | Highlights time-to-value differences | [Your faster time-to-value] |
+| "How does your team currently handle [use case you excel at]?" | Opens discussion where you shine | [Your approach to this use case] |
+
+---
+
+### Section 10: Win/Loss Analysis
+
+| Theme | Win Reasons (Why We Won) | Loss Reasons (Why We Lost) |
+|-------|------------------------|---------------------------|
+| Product | [What product features drove the win] | [What product gaps caused the loss] |
+| Pricing | [Price advantage or value perception] | [Price disadvantage or perceived poor value] |
+| Relationship | [Sales process, support quality] | [Better existing relationship with competitor] |
+| Brand | [Brand trust, market perception] | [Competitor brand stronger in this segment] |
+
+**Recent Win Story:**
+> [Brief narrative: who was the customer, what were they comparing, why did they choose you]
+
+**Recent Loss Story:**
+> [Brief narrative: who was the customer, what were they comparing, why did they choose competitor]
+
+---
+
+### Section 11: SEO/Content Intelligence
+
+| Dimension | [Competitor] | Your Position |
+|-----------|-------------|--------------|
+| Domain Authority/Rating | [Score] | [Score] |
+| Organic traffic (est.) | [Monthly visits] | [Monthly visits] |
+| Keywords in top 10 | [Count] | [Count] |
+| Top-performing content | [URL + topic] | [Your equivalent or gap] |
+| Content publishing cadence | [Posts/month] | [Posts/month] |
+| Backlink count | [Count] | [Count] |
+| AI citation frequency | [High/Medium/Low] | [High/Medium/Low] |
+
+**Content Strategy Observations:**
+- [What content themes drive their traffic]
+- [What formats they use effectively]
+- [Where their content is weak or outdated]
+
+---
+
+## Battlecard Maintenance Guidelines
+
+### Update Triggers
+
+Update the battlecard immediately when any of these occur:
+
+| Trigger | What to Update |
+|---------|---------------|
+| Competitor launches new feature | Feature comparison, differentiators, landmine questions |
+| Competitor changes pricing | Pricing comparison, objection handling |
+| Competitor raises funding / IPO | Overview section, positioning |
+| Major review trends change | Weaknesses, objection handling, win/loss |
+| You launch competing feature | Differentiators, feature comparison |
+| Significant win or loss against them | Win/loss analysis, objection handling |
+| Competitor publishes major content | SEO/content intelligence |
+
+### Quarterly Review Checklist
+
+- [ ] Verify all pricing information is current
+- [ ] Check for new features launched by competitor
+- [ ] Review latest G2/Capterra/TrustRadius reviews for new themes
+- [ ] Update win/loss analysis with recent deals
+- [ ] Refresh SEO/content metrics
+- [ ] Confirm differentiators are still accurate
+- [ ] Test all landmine questions with sales team for relevance
+- [ ] Archive outdated information (don't delete -- move to "Historical" section)
+
+### Data Sources for Battlecard Updates
+
+| Source | What to Gather | Update Frequency |
+|--------|---------------|-----------------|
+| Competitor website | Pricing, features, messaging, positioning | Monthly |
+| G2/Capterra reviews | Strengths, weaknesses, customer sentiment | Quarterly |
+| LinkedIn/social media | Team changes, company news, content strategy | Monthly |
+| Industry press/blogs | Funding, partnerships, market perception | As published |
+| Sales team feedback | Win/loss themes, objection patterns | Monthly |
+| SEO tools | Traffic, rankings, backlinks, content changes | Monthly |
+| AI system queries | AI citation patterns, brand mentions | Quarterly |
+| Customer feedback | Competitive switching reasons | Quarterly |
+
+---
+
+## Example: Populated Battlecard Header
+
+```
+COMPETITIVE BATTLECARD: SEMrush
+Last Updated: 2026-02-01
+Updated By: Content Strategy Team
+Confidence Level: High
+
+One-Sentence Summary:
+SEMrush is an all-in-one SEO and digital marketing platform that helps marketing
+professionals manage SEO, PPC, content, and social media campaigns from a single dashboard.
+
+Tagline: "Online Marketing Can Be Easy"
+
+Positioning Statement:
+For digital marketing professionals and agencies, SEMrush is the all-in-one marketing
+toolkit that provides competitive intelligence and workflow automation because it combines
+55+ tools across SEO, PPC, content, and social in one platform.
+```
diff --git a/.cursor/skills/competitor-analysis/references/example-report.md b/.cursor/skills/competitor-analysis/references/example-report.md
new file mode 100644
index 0000000000..4ef522d40a
--- /dev/null
+++ b/.cursor/skills/competitor-analysis/references/example-report.md
@@ -0,0 +1,75 @@
+# Competitor Analysis — Example Report
+
+This is a complete example output showing the expected format for competitor analysis deliverables.
+
+## Prompt
+
+**User**: "Analyze why HubSpot ranks so well for marketing-related keywords"
+
+## Example Output
+
+```markdown
+# Competitive Analysis: HubSpot
+
+## SEO Strategy Overview
+
+HubSpot dominates marketing keywords through a combination of:
+1. **Massive content moat** - 10,000+ blog posts
+2. **Free tools as linkbait** - Website grader, email signature generator
+3. **Educational brand** - Academy, certifications, courses
+4. **Topic cluster model** - Pioneered the pillar/cluster approach
+
+## What Makes Them Successful
+
+### Content Strategy
+
+**Publishing Volume**: 50-100 posts/month
+**Average Word Count**: 2,500+ words
+**Content Types**:
+- In-depth guides (35%)
+- How-to tutorials (25%)
+- Templates & examples (20%)
+- Data/research (10%)
+- Tools & calculators (10%)
+
+**Top Performing Content Pattern**:
+1. Ultimate guides on broad topics
+2. Free templates with email gate
+3. Statistics roundup posts
+4. Definition posts ("What is [term]")
+
+### GEO Success Factors
+
+HubSpot appears in AI responses frequently because:
+
+1. **Clear definitions** at the start of every post
+ > "Inbound marketing is a business methodology that attracts customers by creating valuable content and experiences tailored to them."
+
+2. **Quotable statistics**
+ > "Companies that blog get 55% more website visitors"
+
+3. **Comprehensive coverage** - AI trusts their authority
+
+### Linkable Assets
+
+| Asset | Backlinks | Why It Works |
+|-------|-----------|--------------|
+| Website Grader | 45,000+ | Free, instant value |
+| Marketing Statistics | 12,000+ | Quotable reference |
+| Blog Ideas Generator | 8,500+ | Solves real problem |
+
+## Weaknesses to Exploit
+
+1. **Content becoming dated** - Many posts 3+ years old
+2. **Generic advice** - Lacks industry-specific depth
+3. **Enterprise focus** - Underserves solopreneurs
+4. **Slow innovation** - Same formats for years
+
+## Your Opportunities
+
+1. Create more specific, niche content they can't cover
+2. Target long-tail keywords they ignore
+3. Build interactive tools in emerging areas
+4. Add original research they don't have
+5. Focus on GEO-optimized definitions in your niche
+```
diff --git a/.cursor/skills/competitor-analysis/references/positioning-frameworks.md b/.cursor/skills/competitor-analysis/references/positioning-frameworks.md
new file mode 100644
index 0000000000..42cba0ac50
--- /dev/null
+++ b/.cursor/skills/competitor-analysis/references/positioning-frameworks.md
@@ -0,0 +1,320 @@
+# Positioning Frameworks
+
+Comprehensive frameworks for analyzing competitor positioning, developing your own positioning strategy, and identifying differentiation opportunities. Use these frameworks to map competitive landscapes, craft positioning statements, and find messaging vulnerabilities.
+
+## Overview
+
+Positioning is how your product or brand occupies a distinct place in the mind of your target audience relative to competitors. Strong positioning makes your value proposition clear, your differentiation obvious, and your target audience feel understood.
+
+This reference covers:
+1. Positioning statement templates
+2. 2x2 positioning map methodology
+3. Category strategy options
+4. Differentiation playbooks
+5. Messaging vulnerability analysis
+
+---
+
+## 1. Positioning Statement Templates
+
+### Classic Positioning Statement
+
+The foundational template used across industries:
+
+```
+For [target audience],
+[product/brand] is the [category]
+that [key benefit / point of difference]
+because [reason to believe / proof point].
+```
+
+**Example (SEO tool):**
+> For growth-stage SaaS marketers, Clearscope is the content optimization platform that ensures every article ranks on page one because it uses real-time SERP data and NLP to grade content against ranking competitors.
+
+### Extended Positioning Statement
+
+Adds competitive context and emotional resonance:
+
+```
+For [target audience] who [situation/need],
+[product/brand] is the [category]
+that [functional benefit]
+unlike [competitive alternative]
+because [unique capability / proof].
+This matters because [emotional benefit / outcome].
+```
+
+**Example:**
+> For content teams who struggle to consistently rank new articles, MarketMuse is the content planning platform that identifies exactly which topics to cover, unlike manual keyword research tools, because it uses AI-driven topic models trained on ranking content. This matters because teams stop guessing and start publishing with confidence.
+
+### Before/After/Bridge Positioning
+
+Focuses on transformation:
+
+```
+BEFORE: [Current painful state for target audience]
+AFTER: [Desired improved state]
+BRIDGE: [Your product] makes this possible by [mechanism].
+```
+
+### Problem-Agitation-Solution Positioning
+
+Focuses on pain point amplification:
+
+```
+PROBLEM: [What the target audience struggles with]
+AGITATION: [Why this problem is worse than they think — consequences, hidden costs]
+SOLUTION: [How your product solves it differently than alternatives]
+```
+
+---
+
+## 2. Positioning Map (2x2 Matrix) Methodology
+
+### How to Build a Positioning Map
+
+A positioning map plots competitors on two key dimensions to visualize the competitive landscape and identify open positioning space.
+
+#### Step 1: Choose Your Axes
+
+Select two dimensions that matter most to your target audience. The axes should be:
+- **Meaningful to buyers** (not internal metrics)
+- **Differentiating** (competitors should spread across the map)
+- **Independent** (the two dimensions should not be correlated)
+
+#### Common Axis Pairs for SEO/Marketing
+
+| Axis Pair | X-Axis | Y-Axis | Best For |
+|-----------|--------|--------|---------|
+| Value Positioning | Price (Low → High) | Capability (Basic → Advanced) | Understanding market tiers |
+| UX Positioning | Ease of Use (Complex → Simple) | Power (Limited → Comprehensive) | Evaluating UX/capability tradeoffs |
+| Audience Positioning | SMB Focus ← → Enterprise Focus | Point Solution ← → Full Platform | Identifying segment gaps |
+| Innovation Positioning | Established/Stable ← → Innovative/Cutting-Edge | Niche ← → Broad | Timing market positioning |
+| Content Positioning | Data-Driven ← → Opinion-Driven | Beginner ← → Expert | Content strategy differentiation |
+
+#### Step 2: Plot Competitors
+
+Place each competitor (including yourself) on the map based on objective assessment.
+
+```
+ HIGH CAPABILITY
+ │
+ Enterprise │ All-in-One
+ Suites │ Platforms
+ │
+ LOW ───────────────┼─────────────── HIGH
+ PRICE │ PRICE
+ │
+ Free/Basic │ Premium
+ Tools │ Specialists
+ │
+ LOW CAPABILITY
+```
+
+#### Step 3: Identify White Space
+
+Look for quadrants or areas where:
+- **No competitor exists** (market opportunity)
+- **Only weak competitors exist** (displacement opportunity)
+- **Crowded clusters exist** (differentiation challenge -- avoid or reframe)
+
+#### Step 4: Choose Your Position
+
+Select a position that:
+- Aligns with your actual product capabilities
+- Serves an underserved audience segment
+- Is defensible (hard for competitors to copy)
+- Connects to a clear value narrative
+
+### Positioning Map Anti-Patterns
+
+| Anti-Pattern | Description | Fix |
+|-------------|------------|-----|
+| Aspiration mapping | Plotting yourself where you want to be, not where you are | Be honest about current position; create a roadmap to desired position |
+| Vanity axes | Choosing dimensions that make you look good but don't matter to buyers | Validate axes with customer research |
+| Missing competitors | Only plotting direct competitors, missing substitutes | Include indirect competitors and alternative solutions |
+| Static mapping | Building the map once and never updating | Refresh quarterly as market shifts |
+
+---
+
+## 3. Category Strategy Options
+
+How you define your category determines your competitive set and positioning flexibility.
+
+### Strategy 1: Win the Existing Category
+
+**When to use:** You have a genuinely superior product in an established category.
+**Approach:** Accept the existing category definition and compete on being the best.
+**Messaging:** "The best [category] for [audience]"
+**Risk:** Head-to-head competition with established players.
+
+**Example:** "The best SEO tool for content marketers" (competing within "SEO tools")
+
+### Strategy 2: Create a New Sub-Category
+
+**When to use:** You have a genuine differentiator that deserves its own label.
+**Approach:** Segment the existing category and own the new segment.
+**Messaging:** "The first [new sub-category]"
+**Risk:** Market may not recognize the sub-category.
+
+**Example:** "Content optimization platform" (sub-category of "SEO tools" focused on content)
+
+### Strategy 3: Create a New Category
+
+**When to use:** Your product genuinely doesn't fit existing categories.
+**Approach:** Define a new category and position yourself as the category creator.
+**Messaging:** "Introducing [new category]: [definition]"
+**Risk:** High education cost; market may not adopt the category.
+
+**Example:** "GEO optimization platform" (new category combining SEO + AI visibility)
+
+### Strategy 4: Reframe the Category
+
+**When to use:** The existing category frames competition in a way that disadvantages you.
+**Approach:** Change how buyers think about the problem, shifting the evaluation criteria.
+**Messaging:** "Stop thinking about [old category]. Start thinking about [new frame]."
+**Risk:** Confusing prospects who use existing category to search/evaluate.
+
+**Example:** "Not another SEO tool -- a revenue intelligence platform" (reframing from SEO to revenue)
+
+### Category Strategy Decision Matrix
+
+| Factor | Win Existing | New Sub-Category | New Category | Reframe |
+|--------|-------------|-----------------|-------------|---------|
+| Product differentiation | Low-Medium | Medium-High | Very High | Medium |
+| Market education cost | Low | Medium | Very High | High |
+| Competitive intensity | High | Medium | Low (initially) | Medium |
+| SEO/search difficulty | High (competitive terms) | Medium (new terms) | Low (no competition) | Medium |
+| Sales cycle impact | Short (known category) | Medium | Long (education needed) | Medium-Long |
+
+---
+
+## 4. Differentiation Playbooks
+
+### Types of Differentiation
+
+Not all differentiation is equal. Stronger forms are harder to copy.
+
+| Type | Strength | Durability | Examples |
+|------|---------|-----------|---------|
+| **Feature** | Low | Low (easily copied) | "We have feature X" |
+| **Integration** | Medium | Medium | "We connect to Y ecosystem" |
+| **Experience** | Medium-High | Medium-High | "Our UX is designed for Z workflow" |
+| **Data/Network** | High | High | "Our data from N users makes results better" |
+| **Methodology** | High | High | "We invented the X framework" |
+| **Mission/Values** | High | Very High | "We're the only [category] focused on [mission]" |
+
+### Differentiation Audit Template
+
+For each competitor, assess your differentiation strength:
+
+| Dimension | Your Approach | Competitor's Approach | Differentiation Strength | Defensibility |
+|-----------|-------------|---------------------|------------------------|--------------|
+| Core technology | [Your tech] | [Their tech] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+| Target audience | [Your audience] | [Their audience] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+| Pricing model | [Your model] | [Their model] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+| Content/education | [Your approach] | [Their approach] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+| Customer support | [Your approach] | [Their approach] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+| Data advantage | [Your data] | [Their data] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+| Brand/community | [Your brand] | [Their brand] | [Weak/Medium/Strong] | [Easy/Hard to copy] |
+
+### The "Only We" Test
+
+For each claimed differentiator, apply this test:
+
+> "Only [your company] [does X] because [unique reason]."
+
+If a competitor could truthfully make the same claim, it is not a true differentiator. Revise until the statement is uniquely yours.
+
+**Strong example:**
+> "Only Clearscope grades content against real-time SERP competitor analysis because we built a proprietary NLP model trained on ranking content patterns."
+
+**Weak example:**
+> "Only we offer great customer support." (Every company claims this.)
+
+### Differentiation Messaging Formula
+
+```
+We're the only [category] that [unique capability]
+which means [customer benefit]
+so you can [desired outcome].
+```
+
+---
+
+## 5. Messaging Vulnerability Analysis
+
+### Identifying Competitor Messaging Vulnerabilities
+
+A messaging vulnerability is a gap between what a competitor claims and what they actually deliver, or a message that sounds good but collapses under scrutiny.
+
+#### Vulnerability Types
+
+| Vulnerability Type | Description | How to Exploit |
+|-------------------|------------|---------------|
+| **Promise-Reality Gap** | They claim X but reviews say Y | Reference review data; offer proof of your delivery |
+| **Specificity Gap** | They use vague claims ("best-in-class") without proof | Be specific with your claims; use data |
+| **Audience Mismatch** | Their messaging targets one audience but product serves another | Speak directly to the underserved audience |
+| **Legacy Positioning** | Their positioning is outdated; product has evolved | Position against their OLD story, which is stuck in market perception |
+| **Feature Overload** | They list features without connecting to outcomes | Lead with outcomes, not features |
+| **Price Sensitivity** | They avoid discussing price, suggesting it's high or confusing | Be transparent about pricing; create TCO comparisons |
+
+#### Competitor Messaging Audit Worksheet
+
+For each competitor, analyze their messaging across touchpoints:
+
+| Touchpoint | Their Message | Vulnerability? | Your Counter-Message |
+|-----------|-------------|---------------|---------------------|
+| Homepage hero | [Their headline] | [Yes/No — what's weak?] | [Your alternative angle] |
+| Pricing page | [How they frame pricing] | [Yes/No — what's hidden?] | [Your transparency approach] |
+| Blog/content | [Content themes and tone] | [Yes/No — what's missing?] | [Your content differentiation] |
+| Social media | [Social messaging and engagement] | [Yes/No — what's performative?] | [Your authentic approach] |
+| Sales emails/outreach | [Known sales messaging] | [Yes/No — what's pushy/generic?] | [Your consultative approach] |
+| Customer reviews | [What customers actually say] | [Yes/No — gap from their claims?] | [Highlight alignment of your claims + reviews] |
+
+### Counter-Positioning Strategies
+
+| Strategy | When to Use | Messaging Pattern |
+|----------|-----------|-----------------|
+| **Contrast** | When competitor is dominant but has clear weakness | "Unlike [competitor], we [your strength]" |
+| **Flanking** | When competitor ignores a valuable segment | "Built specifically for [underserved segment]" |
+| **Reframing** | When competitor's strength is actually a weakness in disguise | "[Competitor's feature] sounds good until you realize [negative consequence]" |
+| **Elevation** | When competing on features is a losing game | "Stop comparing features. What matters is [higher-level outcome]" |
+| **Specificity** | When competitor uses vague claims | "[Specific metric] for [specific audience] in [specific timeframe]" |
+
+### Messaging Strength Test
+
+Rate each competitor's messaging on these dimensions (1-5):
+
+| Dimension | What to Evaluate | Score |
+|-----------|-----------------|-------|
+| Clarity | Can you understand their value prop in 5 seconds? | [1-5] |
+| Specificity | Do they use concrete numbers and claims? | [1-5] |
+| Differentiation | Is their message distinct from competitors? | [1-5] |
+| Proof | Do they back claims with evidence? | [1-5] |
+| Audience fit | Does the message resonate with their target? | [1-5] |
+| Consistency | Is messaging consistent across all touchpoints? | [1-5] |
+| Emotional resonance | Does it connect with real pain or aspiration? | [1-5] |
+
+**Total Score** = Sum / 35
+
+| Score Range | Assessment |
+|------------|-----------|
+| 0.8-1.0 | Very strong messaging -- find a niche angle to compete |
+| 0.6-0.79 | Solid messaging -- exploit specific gaps |
+| 0.4-0.59 | Average messaging -- multiple attack vectors available |
+| Below 0.4 | Weak messaging -- opportunity to dominate with clarity |
+
+---
+
+## Applying These Frameworks Together
+
+### Recommended Workflow
+
+1. **Start with Positioning Maps** to understand the landscape visually
+2. **Analyze Category Strategy** to decide how to frame your competitive set
+3. **Build Positioning Statement** using the templates
+4. **Audit Differentiation** to ensure claims are defensible
+5. **Identify Messaging Vulnerabilities** to find attack angles
+6. **Create Battlecards** (see [battlecard-template.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/battlecard-template.md)) incorporating all insights
+7. **Review quarterly** as the competitive landscape shifts
diff --git a/.cursor/skills/content-gap-analysis/SKILL.md b/.cursor/skills/content-gap-analysis/SKILL.md
new file mode 100644
index 0000000000..0e4e0e7883
--- /dev/null
+++ b/.cursor/skills/content-gap-analysis/SKILL.md
@@ -0,0 +1,287 @@
+---
+name: content-gap-analysis
+description: 'Find content gaps: topics and keywords competitors cover that you don''t, with editorial calendar. 内容缺口/选题规划'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when finding content gaps between two domains, discovering missing topics, or identifying coverage holes versus competitors."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - geo
+ - content-gaps
+ - topic-analysis
+ - content-strategy
+ - editorial-calendar
+ - competitive-gap
+ - content-opportunities
+ - 内容缺口
+ - コンテンツギャップ
+ - 콘텐츠갭
+ - brechas-contenido
+ triggers:
+ # EN-formal
+ - "find content gaps"
+ - "content opportunities"
+ - "topic analysis"
+ - "content strategy gaps"
+ - "editorial calendar"
+ - "untapped topics"
+ # EN-casual
+ - "what am I missing"
+ - "topics to cover"
+ - "what do competitors write about"
+ - "what should I cover next"
+ - "topics I haven't written about"
+ - "they cover this but I don't"
+ # EN-question
+ - "what topics am I missing"
+ - "what content should I create"
+ # ZH-pro
+ - "内容缺口分析"
+ - "选题规划"
+ - "内容机会"
+ - "竞品话题"
+ # ZH-casual
+ - "缺什么内容"
+ - "竞品写了什么"
+ - "还应该写什么"
+ # JA
+ - "コンテンツギャップ"
+ - "コンテンツ機会"
+ # KO
+ - "콘텐츠 갭 분석"
+ - "콘텐츠 기회"
+ # ES
+ - "brechas de contenido"
+ - "oportunidades de contenido"
+ # PT
+ - "lacunas de conteúdo"
+ # Misspellings
+ - "content gab analysis"
+---
+
+# Content Gap Analysis
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+Identifies content opportunities by analyzing gaps between a site's content and competitors'. Surfaces missing topics, untapped keywords, and content formats worth creating.
+
+**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
+
+- Planning content strategy and editorial calendar
+- Finding quick-win content opportunities
+- Understanding where competitors outperform you
+- Identifying underserved topics in your niche
+- Expanding into adjacent topic areas
+- Prioritizing content creation efforts
+- Finding GEO opportunities competitors miss
+
+## What This Skill Does
+
+1. **Keyword Gap Analysis**: Finds keywords competitors rank for that you don't
+2. **Topic Coverage Mapping**: Identifies topic areas needing more content
+3. **Content Format Gaps**: Reveals missing content types (videos, tools, guides)
+4. **Audience Need Mapping**: Matches gaps to audience journey stages
+5. **GEO Opportunity Detection**: Finds AI-answerable topics you're missing
+6. **Priority Scoring**: Ranks gaps by impact and effort
+7. **Content Calendar Creation**: Plans gap-filling content schedule
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Basic Gap Analysis
+
+```
+Find content gaps between my site [URL] and [competitor URLs]
+```
+
+```
+What content am I missing compared to my top 3 competitors?
+```
+
+### Topic-Specific Analysis
+
+```
+Find content gaps in [topic area] compared to industry leaders
+```
+
+```
+What [content type] do competitors have that I don't?
+```
+
+### Audience-Focused
+
+```
+What content gaps exist for [audience segment] in my niche?
+```
+
+## Skill Contract
+
+**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
+
+- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
+- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
+- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~search console + ~~analytics + ~~AI monitor connected:**
+Automatically pull your site's content inventory from ~~search console and ~~analytics (indexed pages, traffic per page, keywords ranking), competitor content data from ~~SEO tool (ranking keywords, top pages, backlink counts), and AI citation patterns from ~~AI monitor. Keyword overlap analysis and gap identification can be automated.
+
+**With manual data only:**
+Ask the user to provide:
+1. Your site URL and content inventory (list of published content with topics)
+2. Competitor URLs (3-5 sites)
+3. Your current traffic and keyword performance (if available)
+4. Known content strengths and weaknesses
+5. Industry context and business goals
+
+Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests content gap analysis:
+
+1. **Define Analysis Scope**
+
+ Clarify parameters:
+
+ ```markdown
+ ### Analysis Parameters
+
+ **Your Site**: [URL]
+ **Competitors to Analyze**: [URLs or "identify for me"]
+ **Topic Focus**: [specific area or "all"]
+ **Content Types**: [blogs, guides, tools, videos, or "all"]
+ **Audience**: [target audience]
+ **Business Goals**: [traffic, leads, authority, etc.]
+ ```
+
+2. **Audit Your Existing Content**
+
+ Document total indexed pages, content by type and topic cluster, top performing content, and content strengths/weaknesses.
+
+3. **Analyze Competitor Content**
+
+ For each competitor: document content volume, monthly traffic, content distribution by type, topic coverage vs. yours, and unique content they have.
+
+4. **Identify Keyword Gaps**
+
+ Find keywords competitors rank for that you do not. Categorize into High Priority (high volume, achievable difficulty), Quick Wins (lower volume, low difficulty), and Long-term (high volume, high difficulty). Include keyword overlap analysis.
+
+5. **Map Topic Gaps**
+
+ Create a topic coverage comparison matrix across all competitors. For each missing topic cluster, document business relevance, competitor coverage, opportunity size, sub-topics, and recommended pillar/cluster approach.
+
+6. **Identify Content Format Gaps**
+
+ Compare format distribution (guides, tutorials, comparisons, case studies, tools, templates, video, infographics, research) against competitors and industry averages. For each gap, assess effort and expected impact.
+
+7. **Analyze GEO/AI Gaps**
+
+ Identify topics where competitors get AI citations but you do not. Document missing Q&A content, definition/explanation content, and comparison content. Score each by traditional SEO value and GEO value.
+
+8. **Map to Audience Journey**
+
+ Compare funnel stage coverage (Awareness, Consideration, Decision, Retention) against competitor averages. Detail specific gaps at each stage.
+
+9. **Prioritize and Create Action Plan**
+
+ Produce a final report with: Executive Summary, Prioritized Gap List (Tier 1 Quick Wins, Tier 2 Strategic Builds, Tier 3 Long-term), Content Calendar, and Success Metrics.
+
+ > **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/analysis-templates.md) for detailed templates for each step.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Your content inventory is complete or representative sample provided
+- [ ] Competitor URLs identified (minimum 2-3 competitors)
+- [ ] Analysis scope defined (specific topics or comprehensive)
+- [ ] Business goals and priorities clarified
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] Gap analysis compares like-to-like content (topic clusters to topic clusters)
+- [ ] Priority scoring based on measurable criteria (volume, difficulty, business fit)
+- [ ] Content calendar maps gaps to realistic timeframes
+- [ ] Source of each data point clearly stated (~~SEO tool data, ~~analytics data, ~~AI monitor data, user-provided, or estimated)
+
+## Example
+
+> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/example-report.md) for a complete example analyzing SaaS marketing blog gaps vs. HubSpot and Drift.
+
+## Advanced Analysis
+
+### Competitive Cluster Comparison
+
+```
+Compare our topic cluster coverage for [topic] vs top 5 competitors
+```
+
+### Temporal Gap Analysis
+
+```
+What content have competitors published in the last 6 months that we haven't covered?
+```
+
+### Intent-Based Gaps
+
+```
+Find gaps in our [commercial/informational] intent content
+```
+
+## Tips for Success
+
+1. **Focus on actionable gaps** - Not all gaps are worth filling
+2. **Consider your resources** - Prioritize based on ability to execute
+3. **Quality over quantity** - Better to fill 5 gaps well than 20 poorly
+4. **Track what works** - Measure gap-filling success
+5. **Update regularly** - Gaps change as competitors publish
+6. **Include GEO opportunities** - Don't just optimize for traditional search
+
+
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/research/content-gap-analysis/YYYY-MM-DD-.md` containing:
+- One-line headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Analysis Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/analysis-templates.md) — Detailed templates for each analysis step (inventory, competitor content, keyword gaps, topic gaps, format gaps, GEO gaps, journey, prioritized report)
+- [Gap Analysis Frameworks](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/gap-analysis-frameworks.md) — Content audit matrices, funnel mapping, and gap prioritization scoring methodologies
+- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/example-report.md) — Complete example analyzing SaaS marketing blog gaps vs. HubSpot and Drift
+
+## Next Best Skill
+
+- **Primary**: [seo-content-writer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/SKILL.md) — turn missing topics into a draft or content roadmap.
diff --git a/.cursor/skills/content-gap-analysis/references/analysis-templates.md b/.cursor/skills/content-gap-analysis/references/analysis-templates.md
new file mode 100644
index 0000000000..487919a67e
--- /dev/null
+++ b/.cursor/skills/content-gap-analysis/references/analysis-templates.md
@@ -0,0 +1,335 @@
+# Content Gap Analysis — Analysis Templates
+
+Templates for each step of the content gap analysis workflow. Use these to structure your output.
+
+## Content Inventory Template
+
+```markdown
+## Your Content Inventory
+
+**Total Indexed Pages**: [X]
+**Content by Type**:
+- Blog posts: [X]
+- Landing pages: [X]
+- Resource pages: [X]
+- Tools/calculators: [X]
+- Case studies: [X]
+
+**Content by Topic Cluster**:
+
+| Topic | Articles | Keywords Ranking | Traffic |
+|-------|----------|------------------|---------|
+| [topic 1] | [X] | [X] | [X] |
+| [topic 2] | [X] | [X] | [X] |
+| [topic 3] | [X] | [X] | [X] |
+
+**Top Performing Content**:
+1. [Title] - [traffic] visits - [keywords] keywords
+2. [Title] - [traffic] visits - [keywords] keywords
+3. [Title] - [traffic] visits - [keywords] keywords
+
+**Content Strengths**:
+- [Strength 1]
+- [Strength 2]
+
+**Content Weaknesses**:
+- [Weakness 1]
+- [Weakness 2]
+```
+
+## Competitor Content Analysis Template
+
+```markdown
+## Competitor Content Analysis
+
+### Competitor 1: [Name/URL]
+
+**Content Volume**: [X] pages
+**Monthly Traffic**: [X] visits
+
+**Content Distribution**:
+| Type | Count | Est. Traffic |
+|------|-------|--------------|
+| Blog posts | [X] | [X] |
+| Guides | [X] | [X] |
+| Tools | [X] | [X] |
+| Videos | [X] | [X] |
+
+**Topic Coverage**:
+| Topic | Articles | Your Coverage |
+|-------|----------|---------------|
+| [topic] | [X] | [X or "None"] |
+
+**Unique Content They Have**:
+1. [Content piece] - [traffic] - [why it works]
+2. [Content piece] - [traffic] - [why it works]
+
+[Repeat for each competitor]
+```
+
+## Keyword Gap Analysis Template
+
+```markdown
+## Keyword Gap Analysis
+
+### Keywords Competitors Rank For (You Don't)
+
+**High Priority Gaps** (High volume, achievable difficulty)
+
+| Keyword | Volume | Difficulty | Competitor | Their Position |
+|---------|--------|------------|------------|----------------|
+| [kw 1] | [vol] | [diff] | [comp] | [pos] |
+| [kw 2] | [vol] | [diff] | [comp] | [pos] |
+| [kw 3] | [vol] | [diff] | [comp] | [pos] |
+
+**Quick Win Gaps** (Lower volume, low difficulty)
+
+| Keyword | Volume | Difficulty | Competitor | Their Position |
+|---------|--------|------------|------------|----------------|
+| [kw 1] | [vol] | [diff] | [comp] | [pos] |
+
+**Long-term Gaps** (High volume, high difficulty)
+
+| Keyword | Volume | Difficulty | Competitor | Their Position |
+|---------|--------|------------|------------|----------------|
+| [kw 1] | [vol] | [diff] | [comp] | [pos] |
+
+### Keyword Overlap Analysis
+
+```
+Venn Diagram Representation:
+
+ You Competitor 1
+ ○ ○
+ / \ / \
+ / \ / \
+ / A \ B / C \
+ / \ / \
+ ○─────────○─────○─────────○
+ Competitor 2
+
+A: Keywords only you rank for: [X]
+B: Overlap with Comp 1: [X]
+C: Keywords all competitors share: [X]
+Gap: Keywords they all have, you don't: [X]
+```
+
+**Unique Keywords (Your Advantage)**:
+| Keyword | Your Position | Volume |
+|---------|---------------|--------|
+| [kw] | [pos] | [vol] |
+```
+
+## Topic Gap Analysis Template
+
+```markdown
+## Topic Gap Analysis
+
+### Topic Coverage Comparison
+
+| Topic Area | You | Comp 1 | Comp 2 | Comp 3 | Gap? |
+|------------|-----|--------|--------|--------|------|
+| [Topic 1] | ✅ [X] | ✅ [X] | ✅ [X] | ✅ [X] | No |
+| [Topic 2] | ❌ 0 | ✅ [X] | ✅ [X] | ✅ [X] | **Yes** |
+| [Topic 3] | ✅ [X] | ✅ [X] | ❌ 0 | ✅ [X] | Partial |
+| [Topic 4] | ❌ 0 | ✅ [X] | ✅ [X] | ❌ 0 | **Yes** |
+
+### Missing Topic Clusters
+
+#### Gap 1: [Topic Area]
+
+**Why it matters**: [Business relevance]
+**Competitor coverage**: [Who covers it and how]
+**Opportunity size**: [Traffic/keyword potential]
+
+**Sub-topics to cover**:
+1. [Sub-topic] - [X] search volume
+2. [Sub-topic] - [X] search volume
+3. [Sub-topic] - [X] search volume
+
+**Recommended approach**:
+- Pillar content: [topic]
+- Cluster articles: [list]
+- Supporting content: [list]
+```
+
+## Content Format Gap Template
+
+```markdown
+## Content Format Gap Analysis
+
+### Format Distribution Comparison
+
+| Format | You | Comp 1 | Comp 2 | Industry Avg |
+|--------|-----|--------|--------|--------------|
+| Long-form guides | [X] | [X] | [X] | [X] |
+| Tutorials | [X] | [X] | [X] | [X] |
+| Comparison posts | [X] | [X] | [X] | [X] |
+| Case studies | [X] | [X] | [X] | [X] |
+| Tools/calculators | [X] | [X] | [X] | [X] |
+| Templates | [X] | [X] | [X] | [X] |
+| Video content | [X] | [X] | [X] | [X] |
+| Infographics | [X] | [X] | [X] | [X] |
+| Original research | [X] | [X] | [X] | [X] |
+
+### Format Gaps to Fill
+
+#### Gap: [Format Type]
+
+**Current state**: You have [X], competitors average [Y]
+**Best examples**: [Competitor content examples]
+**Opportunity**: [Description]
+**Effort to create**: [Low/Medium/High]
+**Expected impact**: [Low/Medium/High]
+
+**Recommended first project**:
+[Specific content idea]
+```
+
+## GEO Content Gap Template
+
+```markdown
+## GEO Content Gap Analysis
+
+### AI-Answerable Topics Assessment
+
+**Topics where competitors get AI citations (you don't)**:
+
+| Topic | AI Cites | Why They're Cited | Your Gap |
+|-------|----------|-------------------|----------|
+| [topic 1] | [Comp] | [reason] | [what you need] |
+| [topic 2] | [Comp] | [reason] | [what you need] |
+
+### GEO-Optimized Content Gaps
+
+**Missing Q&A Content**:
+| Question | Search Volume | Currently Answered By |
+|----------|---------------|----------------------|
+| [question] | [vol] | [competitor] |
+
+**Missing Definition/Explanation Content**:
+| Term | Search Volume | Best Current Source |
+|------|---------------|---------------------|
+| [term] | [vol] | [source] |
+
+**Missing Comparison Content**:
+| Comparison | Search Volume | Best Current Source |
+|------------|---------------|---------------------|
+| [A vs B] | [vol] | [source] |
+
+### GEO Opportunity Score
+
+| Topic | Traditional SEO Value | GEO Value | Combined Priority |
+|-------|----------------------|-----------|-------------------|
+| [topic] | [score] | [score] | [priority] |
+```
+
+## Audience Journey Gap Template
+
+```markdown
+## Audience Journey Gap Analysis
+
+### Funnel Stage Coverage
+
+| Stage | Your Content | Competitor Avg | Gap |
+|-------|--------------|----------------|-----|
+| Awareness | [X] articles | [X] articles | [+/-X] |
+| Consideration | [X] articles | [X] articles | [+/-X] |
+| Decision | [X] articles | [X] articles | [+/-X] |
+| Retention | [X] articles | [X] articles | [+/-X] |
+
+### Journey Gap Details
+
+#### Awareness Stage Gaps
+- Missing: [topics/content]
+- Opportunity: [description]
+
+#### Consideration Stage Gaps
+- Missing: [topics/content]
+- Opportunity: [description]
+
+#### Decision Stage Gaps
+- Missing: [topics/content]
+- Opportunity: [description]
+```
+
+## Prioritized Report Template
+
+```markdown
+# Content Gap Analysis Report
+
+## Executive Summary
+
+**Analysis Date**: [Date]
+**Sites Analyzed**: [Your site] vs [Competitors]
+
+**Key Findings**:
+1. [Most significant gap]
+2. [Second significant gap]
+3. [Third significant gap]
+
+**Total Opportunity**:
+- Keywords gaps identified: [X]
+- Estimated traffic opportunity: [X]/month
+- Quick wins available: [X] pieces
+
+---
+
+## Prioritized Gap List
+
+### Tier 1: Quick Wins (Do Now)
+
+| Content to Create | Target Keyword | Volume | Difficulty | Impact |
+|-------------------|----------------|--------|------------|--------|
+| [Title idea] | [keyword] | [vol] | [diff] | High |
+| [Title idea] | [keyword] | [vol] | [diff] | High |
+
+**Why prioritize**: Low effort, immediate ranking potential
+
+### Tier 2: Strategic Builds (This Quarter)
+
+| Content to Create | Target Keyword | Volume | Difficulty | Impact |
+|-------------------|----------------|--------|------------|--------|
+| [Title idea] | [keyword] | [vol] | [diff] | High |
+
+**Why prioritize**: High value, requires more resources
+
+### Tier 3: Long-term Investments (This Year)
+
+| Content to Create | Target Keyword | Volume | Difficulty | Impact |
+|-------------------|----------------|--------|------------|--------|
+| [Title idea] | [keyword] | [vol] | [diff] | High |
+
+**Why prioritize**: Builds authority, competitive differentiator
+
+---
+
+## Content Calendar Recommendation
+
+### Month 1
+| Week | Content | Type | Target Keyword | Status |
+|------|---------|------|----------------|--------|
+| 1 | [Title] | [Type] | [Keyword] | Planned |
+| 2 | [Title] | [Type] | [Keyword] | Planned |
+| 3 | [Title] | [Type] | [Keyword] | Planned |
+| 4 | [Title] | [Type] | [Keyword] | Planned |
+
+### Month 2
+[Continue...]
+
+### Month 3
+[Continue...]
+
+---
+
+## Success Metrics
+
+Track these to measure gap-filling success:
+
+| Metric | Current | 3-Month Target | 6-Month Target |
+|--------|---------|----------------|----------------|
+| Keyword coverage | [X] | [X] | [X] |
+| Topic clusters complete | [X] | [X] | [X] |
+| Traffic from new content | [X] | [X] | [X] |
+| AI citations | [X] | [X] | [X] |
+```
diff --git a/.cursor/skills/content-gap-analysis/references/example-report.md b/.cursor/skills/content-gap-analysis/references/example-report.md
new file mode 100644
index 0000000000..0a4438dce6
--- /dev/null
+++ b/.cursor/skills/content-gap-analysis/references/example-report.md
@@ -0,0 +1,47 @@
+# Content Gap Analysis — Example Report
+
+This is a complete example output showing the expected format for content gap analysis deliverables.
+
+## Prompt
+
+**User**: "Find content gaps for my SaaS marketing blog compared to HubSpot and Drift"
+
+## Example Output
+
+```markdown
+# Content Gap Analysis: SaaS Marketing Blog
+
+## Executive Summary
+
+Compared to HubSpot and Drift, your blog has significant gaps in:
+1. **Interactive tools** - They have 15+, you have 0
+2. **Comparison content** - Missing "[Your Tool] vs [Competitor]" pages
+3. **GEO-optimized definitions** - No glossary or term definitions
+
+Total opportunity: ~25,000 monthly visits from 45 keyword gaps
+
+## Top Keyword Gaps
+
+### Quick Wins (Difficulty <40)
+
+| Keyword | Volume | Difficulty | Who Ranks |
+|---------|--------|------------|-----------|
+| saas marketing metrics | 1,200 | 32 | HubSpot #3 |
+| b2b email sequences | 890 | 28 | Drift #5 |
+| saas onboarding emails | 720 | 25 | Neither! |
+| marketing qualified lead definition | 1,800 | 35 | HubSpot #1 |
+
+### Content Format Gaps
+
+**You're missing**:
+- [ ] Interactive ROI calculator (HubSpot gets 15k visits/mo from theirs)
+- [ ] Email template library (Drift's gets 8k visits/mo)
+- [ ] Marketing glossary (HubSpot's definition pages rank for 500+ keywords)
+
+## Recommended Content Calendar
+
+**Week 1**: "SaaS Marketing Metrics: Complete Guide" (Quick win)
+**Week 2**: "What is a Marketing Qualified Lead?" (GEO opportunity)
+**Week 3**: "B2B Email Sequence Templates" (Format gap)
+**Week 4**: "[Your Tool] vs HubSpot" (Comparison gap)
+```
diff --git a/.cursor/skills/content-gap-analysis/references/gap-analysis-frameworks.md b/.cursor/skills/content-gap-analysis/references/gap-analysis-frameworks.md
new file mode 100644
index 0000000000..a9ebc5a462
--- /dev/null
+++ b/.cursor/skills/content-gap-analysis/references/gap-analysis-frameworks.md
@@ -0,0 +1,318 @@
+# Gap Analysis Frameworks
+
+Complete frameworks for systematic keyword gap identification, content format gap analysis, funnel stage gap mapping, opportunity scoring models, and content calendar integration. Use these frameworks to transform gap analysis from ad-hoc observation into repeatable process.
+
+## Overview
+
+Content gap analysis answers three questions:
+1. **What are competitors covering that you are not?** (Keyword and topic gaps)
+2. **What content formats are expected but missing?** (Format gaps)
+3. **Where in the buyer journey does your content fail?** (Funnel gaps)
+
+This reference provides structured methodologies for each question, plus scoring and prioritization systems.
+
+---
+
+## 1. Systematic Keyword Gap Methodology
+
+### Step-by-Step Process
+
+#### Step 1: Define Your Keyword Universe
+
+Before finding gaps, establish what you already rank for:
+
+| Data Source | What It Provides | How to Collect |
+|------------|-----------------|---------------|
+| ~~search console | Keywords driving impressions and clicks to your site | Export Performance report (last 6 months) |
+| ~~SEO tool | Full keyword profile including positions 1-100 | Domain keyword export |
+| Content audit | Topics covered by existing content | Manual or automated URL crawl + categorization |
+
+**Output:** A master list of all keywords you currently rank for (positions 1-100).
+
+#### Step 2: Build Competitor Keyword Profiles
+
+For each competitor (3-5 recommended):
+
+| Competitor | Total Keywords | Keywords in Top 10 | Keywords in Top 3 | Estimated Traffic |
+|-----------|---------------|-------------------|-------------------|------------------|
+| [Competitor A] | [Count] | [Count] | [Count] | [Monthly est.] |
+| [Competitor B] | [Count] | [Count] | [Count] | [Monthly est.] |
+| [Competitor C] | [Count] | [Count] | [Count] | [Monthly est.] |
+| **You** | [Count] | [Count] | [Count] | [Monthly est.] |
+
+#### Step 3: Calculate Keyword Overlap
+
+Identify four keyword segments:
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ │
+│ A: Keywords ONLY you rank for (your unique advantage) │
+│ │
+│ B: Keywords you AND competitors share (competitive) │
+│ │
+│ C: Keywords ONLY competitors rank for (your gaps) │
+│ │
+│ D: Keywords NO ONE ranks for (market opportunity) │
+│ │
+└──────────────────────────────────────────────────────────┘
+```
+
+| Segment | Size | Strategic Meaning | Action |
+|---------|------|------------------|--------|
+| A (Only you) | [Count] | Your content moat | Protect and strengthen |
+| B (Shared) | [Count] | Competitive battleground | Improve rankings |
+| C (Only them) | [Count] | Your content gaps | Prioritize and create |
+| D (No one) | [Count] | Untapped market | Evaluate and pioneer |
+
+#### Step 4: Filter and Categorize Gaps (Segment C)
+
+Not all gaps are worth filling. Filter by:
+
+| Filter | Threshold | Purpose |
+|--------|-----------|---------|
+| Minimum search volume | >100/month (adjust for niche) | Ensure traffic potential |
+| Maximum keyword difficulty | 70% bounce rate | Missing "next step" content or CTAs |
+| Interest → Consideration | Pages per session | <2 pages/session | Missing comparison/evaluation content |
+| Consideration → Intent | Demo/trial requests | Low conversion from comparison pages | Missing trust content (case studies, reviews) |
+| Intent → Purchase | Cart/signup abandonment | High abandonment rate | Missing objection-handling content |
+| Purchase → Retention | Churn rate | High early churn | Missing onboarding/help content |
+
+---
+
+## 4. Opportunity Scoring Model
+
+### Multi-Factor Gap Scoring
+
+Score each identified gap on 5 factors (1-5 scale each):
+
+| Factor | Weight | Score 1 (Low) | Score 3 (Medium) | Score 5 (High) |
+|--------|--------|--------------|-----------------|----------------|
+| **Search Demand** | 25% | <100 monthly searches | 500-2,000 searches | >5,000 searches |
+| **Competitive Density** | 20% | All competitors cover it well | 1-2 competitors cover it | No competitor covers it |
+| **Business Relevance** | 25% | Tangential to your offering | Related to your offering | Core to your offering |
+| **Creation Effort** | 15% | Requires new capabilities | Moderate effort | Quick to create |
+| **Conversion Potential** | 15% | Pure awareness (top-funnel) | Consideration stage | Decision/transactional stage |
+
+**Gap Priority Score** = Σ (Factor Weight x Score)
+
+### Priority Tiers
+
+| Tier | Score Range | Timeline | Action |
+|------|-----------|----------|--------|
+| **P0 -- Fill Immediately** | 4.0-5.0 | This sprint / this week | High demand + low competition + high relevance |
+| **P1 -- Fill This Quarter** | 3.0-3.9 | Next 1-3 months | Good demand + some competition + relevant |
+| **P2 -- Plan for Future** | 2.0-2.9 | Next 3-6 months | Moderate opportunity, requires more resources |
+| **P3 -- Monitor** | 1.0-1.9 | Track quarterly | Low priority but may become relevant |
+
+### Quick-Win Identification
+
+A "quick win" gap scores high on Demand + Relevance and low on Effort + Competition:
+
+```
+Quick Win Score = (Search Demand + Business Relevance) - (Creation Effort + Competitive Density)
+ Higher is better
+```
+
+| Quick Win Score | Assessment |
+|----------------|-----------|
+| 4+ | Strong quick win -- create this content first |
+| 2-3 | Moderate quick win -- include in next content sprint |
+| 0-1 | Not a quick win -- may still be strategically important |
+| Negative | Avoid -- high effort/competition relative to demand |
+
+---
+
+## 5. Content Calendar Integration
+
+### Gap-to-Calendar Workflow
+
+#### Step 1: Group Gaps by Topic Cluster
+
+Group related gaps into clusters rather than addressing them individually:
+
+| Cluster | Related Gaps | Combined Volume | Pillar Needed? | Cluster Pages Needed |
+|---------|-------------|----------------|---------------|---------------------|
+| [Cluster A] | Gap 1, Gap 3, Gap 7 | [Sum of volumes] | [Yes/No] | [Count] |
+| [Cluster B] | Gap 2, Gap 5 | [Sum of volumes] | [Yes/No] | [Count] |
+| [Cluster C] | Gap 4, Gap 6, Gap 8, Gap 9 | [Sum of volumes] | [Yes/No] | [Count] |
+
+#### Step 2: Sequence by Priority and Dependencies
+
+| Order | Content Piece | Gap(s) Addressed | Priority | Dependencies | Target Publish |
+|-------|-------------|-----------------|---------|-------------|---------------|
+| 1 | [Pillar page for Cluster A] | Gap 1, 3, 7 (overview) | P0 | None | Week 1 |
+| 2 | [Quick-win article] | Gap 2 | P0 | None | Week 2 |
+| 3 | [Cluster A subtopic 1] | Gap 3 (deep dive) | P0 | Pillar published | Week 3 |
+| 4 | [Cluster A subtopic 2] | Gap 7 (deep dive) | P1 | Pillar published | Week 4 |
+| 5 | [Comparison page] | Gap 5 | P1 | None | Week 5 |
+
+#### Step 3: Assign Resources and Track
+
+| Content Piece | Writer | Reviewer | Target Date | Status | Gap Closed? |
+|-------------|--------|---------|------------|--------|------------|
+| [Title] | [Name] | [Name] | [Date] | [Draft/Review/Published] | [Yes/No/Partial] |
+
+### Calendar Cadence by Team Size
+
+| Team Size | Monthly Output | Gap Fill Rate | Full Gap Close Timeline |
+|-----------|---------------|--------------|----------------------|
+| Solo | 4-6 pieces/month | 3-4 gaps/month | 6-12 months for 30 gaps |
+| Small (2-3) | 8-15 pieces/month | 6-10 gaps/month | 3-6 months for 30 gaps |
+| Medium (4-6) | 15-25 pieces/month | 10-20 gaps/month | 2-3 months for 30 gaps |
+| Large (7+) | 25+ pieces/month | 20+ gaps/month | 1-2 months for 30 gaps |
+
+### Post-Publication Gap Validation
+
+After publishing gap-filling content, validate that the gap is actually closed:
+
+| Checkpoint | Timeframe | What to Check | Success Criteria |
+|-----------|-----------|--------------|-----------------|
+| Indexing | 1-2 weeks | Is the page indexed? | Appears in Google index |
+| Initial ranking | 2-4 weeks | Is it ranking for target keyword? | Appears in top 100 |
+| Competitive ranking | 2-3 months | Has it reached competitive positions? | Top 20 or improving trend |
+| Traffic impact | 3-6 months | Is it driving meaningful traffic? | Meeting traffic projections |
+| Gap closure | 6 months | Has the gap been effectively closed? | Ranking comparable to competitors |
+
+---
+
+## Framework Selection Guide
+
+| Situation | Primary Framework | Secondary Framework |
+|-----------|------------------|-------------------|
+| "We need more traffic" | Keyword Gap Methodology | Content Calendar Integration |
+| "Competitors outrank us everywhere" | Keyword Gap + Format Gap | Opportunity Scoring |
+| "Our conversion rate is low" | Funnel Stage Gap | Format Gap (decision-stage formats) |
+| "We don't know what to write next" | All three gap types | Opportunity Scoring + Calendar |
+| "We have limited resources" | Opportunity Scoring (quick wins) | Keyword Gap (filtered by difficulty) |
+| "We're entering a new market" | Keyword Gap (comprehensive) | Format Gap + Funnel Gap |
diff --git a/.cursor/skills/content-quality-auditor/SKILL.md b/.cursor/skills/content-quality-auditor/SKILL.md
new file mode 100644
index 0000000000..c3679a8e27
--- /dev/null
+++ b/.cursor/skills/content-quality-auditor/SKILL.md
@@ -0,0 +1,403 @@
+---
+name: content-quality-auditor
+description: 'Publish-readiness gate: 80-item CORE-EEAT audit with weighted scoring, veto checks, and fix plan. 内容质量/EEAT评分'
+version: "6.0.0"
+license: Apache-2.0
+allowed-tools: WebFetch
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when auditing content quality before publishing. Runs CORE-EEAT 80-item scoring with veto checks. Also when the user asks for E-E-A-T analysis or publish readiness."
+argument-hint: " [keyword]"
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "high"
+ tags:
+ - seo
+ - geo
+ - e-e-a-t
+ - core-eeat
+ - content-quality
+ - content-scoring
+ - helpful-content
+ - publish-readiness
+ - 内容质量
+ - コンテンツ品質
+ - 콘텐츠품질
+ - auditoria-eeat
+ triggers:
+ # EN-formal
+ - "audit content quality"
+ - "EEAT score"
+ - "CORE-EEAT audit"
+ - "content quality check"
+ - "content assessment"
+ - "quality score"
+ # EN-casual
+ - "is this ready to publish"
+ - "grade my article"
+ - "check before publishing"
+ - "how good is my content"
+ - "is my content good enough to rank"
+ - "rate my content quality"
+ # EN-question
+ - "is my content ready to publish"
+ - "how do I improve content quality"
+ # ZH-pro
+ - "内容质量审计"
+ - "EEAT评分"
+ - "内容评估"
+ # ZH-casual
+ - "文章能发吗"
+ - "内容打几分"
+ - "文章写得怎么样"
+ # JA
+ - "コンテンツ品質監査"
+ - "E-E-A-T評価"
+ # KO
+ - "콘텐츠 품질 감사"
+ - "EEAT 점수"
+ # ES
+ - "auditoría de calidad de contenido"
+ - "puntuación EEAT"
+ # PT
+ - "auditoria de qualidade"
+ # Misspellings
+ - "EEAT scroe"
+---
+
+# Content Quality Auditor
+
+> Based on [CORE-EEAT Content Benchmark](https://github.com/aaron-he-zhu/core-eeat-content-benchmark). Full benchmark reference: [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill evaluates content quality across 80 standardized criteria organized in 8 dimensions. It produces a comprehensive audit report with per-item scoring, dimension and system scores, weighted totals by content type, and a prioritized action plan.
+
+**System role**: Publish Readiness Gate. It decides whether content is ready to ship, what blocks publication, and what should be promoted into durable project memory.
+
+## When This Must Trigger
+
+Use this when content needs a quality check before publishing — even if the user doesn't use audit terminology:
+
+- User asks "is this ready to publish" or "how good is this"
+- User just finished writing with seo-content-writer or content-refresher
+- **PostToolUse hook auto-triggers**: after content is written or substantially edited, the hook recommends this audit. When hook-triggered, skip setup questions — audit the content that was just produced.
+- Auditing content quality before publishing
+- Evaluating existing content for improvement opportunities
+- Benchmarking content against CORE-EEAT standards
+- Comparing content quality against competitors
+- Assessing both GEO readiness (AI citation potential) and SEO strength (source credibility)
+- Running periodic content quality checks as part of a content maintenance program
+- After writing or optimizing content with seo-content-writer or geo-content-optimizer
+
+## What This Skill Does
+
+1. **Full 80-Item Audit**: Scores every CORE-EEAT check item as Pass/Partial/Fail
+2. **Dimension Scoring**: Calculates scores for all 8 dimensions (0-100 each)
+3. **System Scoring**: Computes GEO Score (CORE) and SEO Score (EEAT)
+4. **Weighted Totals**: Applies content-type-specific weights for final score
+5. **Veto Detection**: Flags critical trust violations (T04, C01, R10)
+6. **Priority Ranking**: Identifies Top 5 improvements sorted by impact
+7. **Action Plan**: Generates specific, actionable improvement steps
+
+## Quick Start
+
+Start with one of these prompts. Finish with a publish verdict and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Audit Content
+
+```
+Audit this content against CORE-EEAT: [content text or URL]
+```
+
+```
+Run a content quality audit on [URL] as a [content type]
+```
+
+### Audit with Content Type
+
+```
+CORE-EEAT audit for this product review: [content]
+```
+
+```
+Score this how-to guide against the 80-item benchmark: [content]
+```
+
+### Comparative Audit
+
+```
+Audit my content vs competitor: [your content] vs [competitor content]
+```
+
+## Skill Contract
+
+**Gate verdict**: **SHIP** (no veto items, dimension scores above threshold) / **FIX** (issues found but no veto) / **BLOCK** (veto item T04, C01, or R10 failed). Always state the verdict prominently at the top of the report.
+
+**Expected output**: a CORE-EEAT audit report, a publish-readiness verdict, and a short handoff summary ready for `memory/audits/content/`.
+
+- **Reads**: the target content, content type, supporting evidence, and any prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing audit report plus a reusable summary that can be stored under `memory/audits/content/`.
+- **Promotes**: veto items and publish blockers to `memory/hot-cache.md` (auto-saved, no user confirmation needed). Top improvement priorities to `memory/open-loops.md`.
+- **Next handoff**: use the `Next Best Skill` below once the verdict is clear.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~web crawler + ~~SEO tool connected:**
+Automatically fetch page content, extract HTML structure, check schema markup, verify internal/external links, and pull competitor content for comparison.
+
+**With manual data only:**
+Ask the user to provide:
+1. Content text, URL, or file path
+2. Content type (if not auto-detectable): Product Review, How-to Guide, Comparison, Landing Page, Blog Post, FAQ Page, Alternative, Best-of, or Testimonial
+3. Optional: competitor content for benchmarking
+
+Proceed with the full 80-item audit using provided data. Note in the output which items could not be fully evaluated due to missing access (e.g., backlink data, schema markup, site-level signals).
+
+## Decision Gates
+
+When stopping to ask, always: (1) state the specific value and threshold, (2) offer numbered options with outcomes.
+
+**Stop and ask the user when:**
+- Content is under minimum word count for its type (blog/guide: 300 words; product/landing page: 150 words; FAQ: fewer than 3 entries with 50+ words each) — state the actual count and offer: (1) expand to minimum, (2) continue audit with Insufficient Data flags, (3) cancel
+- Content type cannot be auto-detected — state what you detected and ask to confirm before proceeding
+- Content is primarily media (video/image) with minimal text — ask whether to audit transcript, alt text, or skip
+- More than 50% of a dimension's items are N/A — name the dimension and ask: (1) provide supplementary data, (2) mark entire dimension as Insufficient Data
+- Any veto item triggers — flag it immediately with the item ID and ask: (1) stop for immediate fix, (2) continue full audit and flag in report
+
+**Continue silently (never stop for):**
+- Individual Partial scores within a dimension
+- Missing SEO tool data (mark items as N/A and continue)
+- Low overall score (the report is the deliverable, not a judgment call)
+- User not specifying content type (auto-detect and state your assumption)
+
+## Instructions
+
+When a user requests a content quality audit:
+
+### Step 1: Preparation
+
+```markdown
+### Audit Setup
+
+**Content**: [title or URL]
+**Content Type**: [auto-detected or user-specified]
+**Dimension Weights**: [loaded from content-type weight table]
+
+#### Veto Check (Emergency Brake)
+
+| Veto Item | Status | Action |
+|-----------|--------|--------|
+| T04: Disclosure Statements | ✅ Pass / ⚠️ VETO | [If VETO: "Add disclosure banner at page top immediately"] |
+| C01: Intent Alignment | ✅ Pass / ⚠️ VETO | [If VETO: "Rewrite title and first paragraph"] |
+| R10: Content Consistency | ✅ Pass / ⚠️ VETO | [If VETO: "Verify all data before publishing"] |
+```
+
+If any veto item triggers, flag it prominently at the top of the report and recommend immediate action before continuing the full audit.
+
+### Step 2: CORE Audit (40 items)
+
+Evaluate each item against the criteria in [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md).
+
+Score each item:
+- **Pass** = 10 points (fully meets criteria)
+- **Partial** = 5 points (partially meets criteria)
+- **Fail** = 0 points (does not meet criteria)
+
+```markdown
+### C — Contextual Clarity
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| C01 | Intent Alignment | Pass/Partial/Fail | [specific observation] |
+| C02 | Direct Answer | Pass/Partial/Fail | [specific observation] |
+| ... | ... | ... | ... |
+| C10 | Semantic Closure | Pass/Partial/Fail | [specific observation] |
+
+**C Score**: [X]/100
+```
+
+Repeat the same table format for **O** (Organization), **R** (Referenceability), and **E** (Exclusivity), scoring all 10 items per dimension.
+
+### Step 3: EEAT Audit (40 items)
+
+```markdown
+### Exp — Experience
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| Exp01 | First-Person Narrative | Pass/Partial/Fail | [specific observation] |
+| ... | ... | ... | ... |
+
+**Exp Score**: [X]/100
+```
+
+Repeat the same table format for **Ept** (Expertise), **A** (Authority), and **T** (Trust), scoring all 10 items per dimension.
+
+See [references/item-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/references/item-reference.md) for the complete 80-item ID lookup table and site-level item handling notes.
+
+### Step 4: Scoring & Report
+
+Calculate scores and generate the final report:
+
+```markdown
+## CORE-EEAT Audit Report
+
+### Overview
+
+- **Content**: [title]
+- **Content Type**: [type]
+- **Audit Date**: [date]
+- **Total Score**: [score]/100 ([rating])
+- **GEO Score**: [score]/100 | **SEO Score**: [score]/100
+- **Veto Status**: ✅ No triggers / ⚠️ [item] triggered
+
+### Dimension Scores
+
+| Dimension | Score | Rating | Weight | Weighted |
+|-----------|-------|--------|--------|----------|
+| C — Contextual Clarity | [X]/100 | [rating] | [X]% | [X] |
+| O — Organization | [X]/100 | [rating] | [X]% | [X] |
+| R — Referenceability | [X]/100 | [rating] | [X]% | [X] |
+| E — Exclusivity | [X]/100 | [rating] | [X]% | [X] |
+| Exp — Experience | [X]/100 | [rating] | [X]% | [X] |
+| Ept — Expertise | [X]/100 | [rating] | [X]% | [X] |
+| A — Authority | [X]/100 | [rating] | [X]% | [X] |
+| T — Trust | [X]/100 | [rating] | [X]% | [X] |
+| **Weighted Total** | | | | **[X]/100** |
+
+**Score Calculation**:
+- GEO Score = (C + O + R + E) / 4
+- SEO Score = (Exp + Ept + A + T) / 4
+- Weighted Score = Σ (dimension_score × content_type_weight)
+
+**Rating Scale**: 90-100 Excellent | 75-89 Good | 60-74 Medium | 40-59 Low | 0-39 Poor
+
+### N/A Item Handling
+
+When an item cannot be evaluated (e.g., A01 Backlink Profile requires site-level data not available):
+
+1. Mark the item as "N/A" with reason
+2. Exclude N/A items from the dimension score calculation
+3. Dimension Score = (sum of scored items) / (number of scored items x 10) x 100
+4. If more than 50% of a dimension's items are N/A, flag the dimension as "Insufficient Data" and exclude it from the weighted total
+5. Recalculate weighted total using only dimensions with sufficient data, re-normalizing weights to sum to 100%
+
+**Example**: Authority dimension with 8 N/A items and 2 scored items (A05=8, A07=5):
+- Dimension score = (8+5) / (2 x 10) x 100 = 65
+- But 8/10 items are N/A (>50%), so flag as "Insufficient Data -- Authority"
+- Exclude A dimension from weighted total; redistribute its weight proportionally to remaining dimensions
+
+### Per-Item Scores
+
+#### CORE — Content Body (40 Items)
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| C01 | Intent Alignment | [Pass/Partial/Fail] | [observation] |
+| C02 | Direct Answer | [Pass/Partial/Fail] | [observation] |
+| ... | ... | ... | ... |
+
+#### EEAT — Source Credibility (40 Items)
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| Exp01 | First-Person Narrative | [Pass/Partial/Fail] | [observation] |
+| ... | ... | ... | ... |
+
+### Top 5 Priority Improvements
+
+Sorted by: weight × points lost (highest impact first)
+
+1. **[ID] [Name]** — [specific modification suggestion]
+ - Current: [Fail/Partial] | Potential gain: [X] weighted points
+ - Action: [concrete step]
+
+2. **[ID] [Name]** — [specific modification suggestion]
+ - Current: [Fail/Partial] | Potential gain: [X] weighted points
+ - Action: [concrete step]
+
+3–5. [Same format]
+
+### Action Plan
+
+#### Quick Wins (< 30 minutes each)
+- [ ] [Action 1]
+- [ ] [Action 2]
+
+#### Medium Effort (1-2 hours)
+- [ ] [Action 3]
+- [ ] [Action 4]
+
+#### Strategic (Requires planning)
+- [ ] [Action 5]
+- [ ] [Action 6]
+
+### Recommended Next Steps
+
+- For full content rewrite: use `seo-content-writer` with CORE-EEAT constraints
+- For GEO optimization: use `geo-content-optimizer` targeting failed GEO-First items
+- For content refresh: use `content-refresher` with weak dimensions as focus
+- For technical fixes: run `/seo:check-technical` for site-level issues
+```
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Content source identified (text, URL, or file path)
+- [ ] Content type confirmed (auto-detected or user-specified)
+- [ ] Content is substantial enough for meaningful audit (≥300 words)
+- [ ] If comparative audit, competitor content also provided
+
+### Output Validation
+- [ ] All 80 items scored (or marked N/A with reason)
+- [ ] All 8 dimension scores calculated correctly
+- [ ] Weighted total matches content-type weight configuration
+- [ ] Veto items checked and flagged if triggered
+- [ ] Top 5 improvements sorted by weighted impact, not arbitrary
+- [ ] Every recommendation is specific and actionable (not generic advice)
+- [ ] Action plan includes concrete steps with effort estimates
+
+## Example
+
+See [references/item-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/references/item-reference.md) for a complete scored example showing the C dimension with all 10 items, priority improvements, and weighted scoring.
+
+## Tips for Success
+
+1. **Start with veto items** — T04, C01, R10 are deal-breakers regardless of total score
+ > These veto items are consistent with the CORE-EEAT benchmark (Section 3), which defines them as items that can override the overall score.
+2. **Focus on high-weight dimensions** — Different content types prioritize different dimensions
+3. **GEO-First items matter most for AI visibility** — Prioritize items tagged GEO 🎯 if AI citation is the goal
+4. **Some EEAT items need site-level data** — Don't penalize content for things only observable at the site level (backlinks, brand recognition)
+5. **Use the weighted score, not just the raw average** — A product review with strong Exclusivity matters more than strong Authority
+6. **Re-audit after improvements** — Run again to verify score improvements and catch regressions
+7. **Pair with CITE for domain-level context** — A high content score on a low-authority domain signals a different priority than the reverse; run [domain-authority-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/SKILL.md) for the full 120-item picture
+
+## Reference Materials
+
+- [CORE-EEAT Content Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md) — Full 80-item benchmark with dimension definitions, scoring criteria, and GEO-First item markers
+- [references/item-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/references/item-reference.md) — All 80 item IDs in a compact lookup table + site-level item handling notes + scored example report
+
+## Next Best Skill
+
+- **Primary**: [content-refresher](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/SKILL.md) — turn failed checks into a concrete rewrite plan.
diff --git a/.cursor/skills/content-quality-auditor/references/item-reference.md b/.cursor/skills/content-quality-auditor/references/item-reference.md
new file mode 100644
index 0000000000..49bf51045a
--- /dev/null
+++ b/.cursor/skills/content-quality-auditor/references/item-reference.md
@@ -0,0 +1,99 @@
+# CORE-EEAT Item Reference
+
+Quick reference for all 80 CORE-EEAT audit items. Full scoring criteria in [core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md).
+
+## Complete Item Reference
+
+| ID | Item | ID | Item |
+|----|------|----|------|
+| C01 | Intent Alignment | Exp01 | First-Person Narrative |
+| C02 | Direct Answer | Exp02 | Sensory Details |
+| C03 | Query Coverage | Exp03 | Process Documentation |
+| C04 | Definition First | Exp04 | Tangible Proof |
+| C05 | Topic Scope | Exp05 | Usage Duration |
+| C06 | Audience Targeting | Exp06 | Problems Encountered |
+| C07 | Semantic Coherence | Exp07 | Before/After Comparison |
+| C08 | Use Case Mapping | Exp08 | Quantified Metrics |
+| C09 | FAQ Coverage | Exp09 | Repeated Testing |
+| C10 | Semantic Closure | Exp10 | Limitations Acknowledged |
+| O01 | Heading Hierarchy | Ept01 | Author Identity |
+| O02 | Summary Box | Ept02 | Credentials Display |
+| O03 | Data Tables | Ept03 | Professional Vocabulary |
+| O04 | List Formatting | Ept04 | Technical Depth |
+| O05 | Schema Markup | Ept05 | Methodology Rigor |
+| O06 | Section Chunking | Ept06 | Edge Case Awareness |
+| O07 | Visual Hierarchy | Ept07 | Historical Context |
+| O08 | Anchor Navigation | Ept08 | Reasoning Transparency |
+| O09 | Information Density | Ept09 | Cross-domain Integration |
+| O10 | Multimedia Structure | Ept10 | Editorial Process |
+| R01 | Data Precision | A01 | Backlink Profile |
+| R02 | Citation Density | A02 | Media Mentions |
+| R03 | Source Hierarchy | A03 | Industry Awards |
+| R04 | Evidence-Claim Mapping | A04 | Publishing Record |
+| R05 | Methodology Transparency | A05 | Brand Recognition |
+| R06 | Timestamp & Versioning | A06 | Social Proof |
+| R07 | Entity Precision | A07 | Knowledge Graph Presence |
+| R08 | Internal Link Graph | A08 | Entity Consistency |
+| R09 | HTML Semantics | A09 | Partnership Signals |
+| R10 | Content Consistency | A10 | Community Standing |
+| E01 | Original Data | T01 | Legal Compliance |
+| E02 | Novel Framework | T02 | Contact Transparency |
+| E03 | Primary Research | T03 | Security Standards |
+| E04 | Contrarian View | T04 | Disclosure Statements |
+| E05 | Proprietary Visuals | T05 | Editorial Policy |
+| E06 | Gap Filling | T06 | Correction & Update Policy |
+| E07 | Practical Tools | T07 | Ad Experience |
+| E08 | Depth Advantage | T08 | Risk Disclaimers |
+| E09 | Synthesis Value | T09 | Review Authenticity |
+| E10 | Forward Insights | T10 | Customer Support |
+
+**Note on site-level items**: Most Authority items (A01-A10) and several Trust items (T01-T03, T05, T07, T10) require site-level or organization-level data that may not be observable from a single page. When auditing a standalone page without site context, mark these as "N/A — requires site-level data" and exclude from the dimension average.
+
+## Example Audit Report
+
+**User**: "Audit this blog post against CORE-EEAT: [paste of 'Best Project Management Tools for Remote Teams 2025']"
+
+**Output** (partial — showing one dimension to demonstrate format):
+
+```markdown
+## CORE-EEAT Audit Report
+
+### Overview
+
+- **Content**: "Best Project Management Tools for Remote Teams 2025"
+- **Content Type**: Blog Post / Comparison
+- **Audit Date**: 2025-06-15
+- **Veto Status**: No triggers
+
+### C -- Contextual Clarity (scored dimension example)
+
+| ID | Check Item | Score | Points | Notes |
+|-----|--------------------|---------|--------|-------------------------------------------------------------|
+| C01 | Intent Alignment | Pass | 10 | Matches "best X" comparison intent; title and body aligned |
+| C02 | Direct Answer | Partial | 5 | Answer appears in first 300 words but no summary box |
+| C03 | Query Coverage | Pass | 10 | Covers "project management tools", "remote team software", "best PM tools" |
+| C04 | Definition First | Pass | 10 | Key terms ("PM tool", "async collaboration") defined on first use |
+| C05 | Topic Scope | Partial | 5 | States what's covered but not what's excluded |
+| C06 | Audience Targeting | Pass | 10 | Explicitly targets "remote team leads and managers" |
+| C07 | Semantic Coherence | Pass | 10 | Logical flow: intro > criteria > tools > comparison > verdict |
+| C08 | Use Case Mapping | Pass | 10 | Decision matrix for team size, budget, and features |
+| C09 | FAQ Coverage | Fail | 0 | No FAQ section despite long-tail potential ("free PM tools for small teams") |
+| C10 | Semantic Closure | Partial | 5 | Conclusion present but doesn't loop back to opening promise |
+
+**C Dimension Score**: 75/100 (Good)
+**Blog Post weight for C**: 25%
+**Weighted contribution**: 18.75
+
+#### Priority Improvements from C Dimension
+
+1. **C09 FAQ Coverage** -- Add FAQ section with 3-5 long-tail questions
+ - Current: Fail (0) | Potential gain: 2.5 weighted points
+ - Action: Add FAQ with "Are there free PM tools for small remote teams?", "How to migrate between PM tools?", etc.
+
+2. **C02 Direct Answer** -- Add a summary box above the fold
+ - Current: Partial (5) | Potential gain: 1.25 weighted points
+ - Action: Insert a "Top 3 Picks" callout box in the first 150 words
+
+[... remaining 7 dimensions (O, R, E, Exp, Ept, A, T) follow the same per-item format ...]
+[... then: Dimension Scores table, Top 5 Priority Improvements, Action Plan, Recommended Next Steps ...]
+```
diff --git a/.cursor/skills/content-refresher/SKILL.md b/.cursor/skills/content-refresher/SKILL.md
new file mode 100644
index 0000000000..5c1967a909
--- /dev/null
+++ b/.cursor/skills/content-refresher/SKILL.md
@@ -0,0 +1,396 @@
+---
+name: content-refresher
+description: 'Refresh outdated posts with current stats, new sections, freshness signals to restore rankings. 内容更新/排名恢复'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when updating outdated content, refreshing old articles, improving declining pages, or adding new information to existing content."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - geo
+ - content-refresh
+ - content-update
+ - content-decay
+ - ranking-recovery
+ - evergreen-content
+ - content-lifecycle
+ - 内容更新
+ - コンテンツ更新
+ - 콘텐츠갱신
+ - actualizar-contenido
+ triggers:
+ # EN-formal
+ - "update old content"
+ - "refresh content"
+ - "content is outdated"
+ - "improve declining rankings"
+ - "revive old blog posts"
+ - "content decay"
+ - "content refresh strategy"
+ # EN-casual
+ - "traffic is dropping"
+ - "ranking dropped"
+ - "this post is outdated"
+ - "traffic is declining on this page"
+ - "rankings dropped for this article"
+ - "my old content needs updating"
+ # EN-question
+ - "how to fix declining traffic"
+ - "why is my content losing rankings"
+ - "how often should I update content"
+ # EN-competitor
+ - "Clearscope content refresh"
+ - "MarketMuse content update"
+ # ZH-pro
+ - "内容更新"
+ - "内容刷新"
+ - "排名恢复"
+ - "内容衰减"
+ - "内容生命周期"
+ # ZH-casual
+ - "排名下降了"
+ - "文章过时了"
+ - "流量掉了"
+ - "老文章怎么办"
+ # JA
+ - "コンテンツ更新"
+ - "コンテンツリフレッシュ"
+ - "記事更新"
+ # KO
+ - "콘텐츠 갱신"
+ - "콘텐츠 업데이트"
+ - "순위 하락"
+ - "순위 하락 원인"
+ - "오래된 글 어떻게 해?"
+ # ES
+ - "actualizar contenido"
+ - "refrescar contenido antiguo"
+ # PT
+ - "atualizar conteúdo"
+ # Misspellings
+ - "content refesh"
+ - "content refreshh"
+---
+
+# Content Refresher
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill helps identify and revitalize outdated content to reclaim lost rankings and traffic. It analyzes content freshness, identifies update opportunities, and guides the refresh process for maximum SEO and GEO impact.
+
+**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
+
+- Content has lost rankings or traffic over time
+- Statistics and information are outdated
+- Competitors have published better content
+- Content needs updating for a new year
+- Industry changes require content updates
+- Adding new sections to existing content
+- Converting old content for GEO optimization
+
+## What This Skill Does
+
+1. **Freshness Analysis**: Identifies outdated content needing updates
+2. **Performance Tracking**: Finds content with declining traffic
+3. **Gap Identification**: Spots missing information competitors have
+4. **Update Prioritization**: Ranks content by refresh potential
+5. **Refresh Recommendations**: Provides specific update guidance
+6. **GEO Enhancement**: Updates content for AI citation potential
+7. **Republishing Strategy**: Advises on date and promotion tactics
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Identify Content to Refresh
+
+```
+Find content on [domain] that needs refreshing
+```
+
+```
+Which of my blog posts have lost the most traffic?
+```
+
+### Refresh Specific Content
+
+```
+Refresh this article for [current year]: [URL/content]
+```
+
+```
+Update this content to outrank [competitor URL]: [your URL]
+```
+
+### Content Refresh Strategy
+
+```
+Create a content refresh strategy for [domain/topic]
+```
+
+## Skill Contract
+
+**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
+
+- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
+- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~analytics + ~~search console + ~~SEO tool connected:**
+Claude can automatically pull historical traffic trends from ~~analytics, fetch impression and ranking data from ~~search console, retrieve keyword position history from ~~SEO tool, and identify content with declining performance. This enables data-driven refresh prioritization.
+
+**With manual data only:**
+Ask the user to provide:
+1. Traffic data or screenshots showing performance trends
+2. Ranking screenshots or history for key pages
+3. Content publish dates and last update dates
+4. List of pages the user suspects need refreshing
+
+Proceed with the analysis using provided data. Note in the output which findings are from automated data vs. manual review.
+
+## Instructions
+
+When a user requests content refresh help:
+
+1. **CORE-EEAT Quick Score — Identify Weak Dimensions**
+
+ Before refreshing, run a quick CORE-EEAT assessment to focus effort on the weakest areas. Reference: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
+
+ ```markdown
+ ### CORE-EEAT Quick Assessment
+
+ **Content**: [title or URL]
+ **Content Type**: [type]
+
+ Rapidly score each dimension (estimate 0-100):
+
+ | Dimension | Quick Score | Key Weakness | Refresh Priority |
+ |-----------|-----------|--------------|-----------------|
+ | C — Contextual Clarity | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | O — Organization | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | R — Referenceability | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | E — Exclusivity | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | Exp — Experience | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | Ept — Expertise | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | A — Authority | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+ | T — Trust | [X]/100 | [main issue] | 🔴/🟡/🟢 |
+
+ **Weakest Dimensions** (focus refresh here):
+ 1. [Dimension] — [what needs fixing]
+ 2. [Dimension] — [what needs fixing]
+
+ **Refresh Strategy**: Focus on 🔴 dimensions first, then 🟡.
+
+ _For full 80-item audit, use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
+ ```
+
+2. **Identify Content Refresh Candidates**
+
+ ```markdown
+ ## Content Refresh Analysis
+
+ ### Refresh Candidate Identification
+
+ **Criteria for Content Refresh**:
+ - Published more than 6 months ago
+ - Contains dated information (years, statistics)
+ - Declining traffic trend
+ - Lost keyword rankings
+ - Outdated references or broken links
+ - Missing topics competitors now cover
+ - No GEO optimization
+
+ ### Content Audit Results
+
+ | Content | Published | Last Updated | Traffic Trend | Priority |
+ |---------|-----------|--------------|---------------|----------|
+ | [Title 1] | [date] | [date] | ↓ -45% | 🔴 High |
+ | [Title 2] | [date] | Never | ↓ -30% | 🔴 High |
+ | [Title 3] | [date] | [date] | ↓ -20% | 🟡 Medium |
+ | [Title 4] | [date] | [date] | → 0% | 🟡 Medium |
+
+ ### Refresh Prioritization Matrix
+
+ ```
+ High Traffic + High Decline = 🔴 Refresh Immediately
+ High Traffic + Low Decline = 🟡 Schedule Refresh
+ Low Traffic + High Decline = 🟡 Evaluate & Decide
+ Low Traffic + Low Decline = 🟢 Low Priority
+ ```
+ ```
+
+3. **Analyze Individual Content for Refresh**
+
+ ```markdown
+ ## Content Refresh Analysis: [Title]
+
+ **URL**: [URL]
+ **Published**: [date]
+ **Last Updated**: [date]
+ **Word Count**: [X]
+
+ ### Performance Metrics
+
+ | Metric | 6 Mo Ago | Current | Change |
+ |--------|----------|---------|--------|
+ | Organic Traffic | [X]/mo | [X]/mo | [+/-X]% |
+ | Avg Position | [X] | [X] | [+/-X] |
+ | Impressions | [X] | [X] | [+/-X]% |
+ | CTR | [X]% | [X]% | [+/-X]% |
+
+ ### Keywords Analysis
+
+ | Keyword | Old Position | Current Position | Change |
+ |---------|--------------|------------------|--------|
+ | [kw 1] | [X] | [X] | ↓ [X] |
+ | [kw 2] | [X] | [X] | ↓ [X] |
+ | [kw 3] | [X] | [X] | ↓ [X] |
+
+ ### Why This Content Needs Refresh
+
+ 1. **Outdated information**: [specific examples]
+ 2. **Competitive gap**: [what competitors added]
+ 3. **Missing topics**: [new subtopics to cover]
+ 4. **SEO issues**: [current optimization problems]
+ 5. **GEO potential**: [AI citation opportunities]
+ ```
+
+4. **Identify Specific Updates Needed**
+
+ ```markdown
+ ## Refresh Requirements
+
+ ### Outdated Elements
+
+ | Element | Current | Update Needed |
+ |---------|---------|---------------|
+ | Year references | "[old year]" | Update to [current year] |
+ | Statistics | "[old stat]" | Find current data |
+ | Tool mentions | "[old tool]" | Add newer tools |
+ | Links | [X] broken | Fix or replace |
+ | Screenshots | Outdated UI | Recapture |
+
+ ### Missing Information
+
+ **Topics competitors now cover that you don't**:
+
+ | Topic | Competitor Coverage | Words Needed | Priority |
+ |-------|---------------------|--------------|----------|
+ | [Topic 1] | 3/5 competitors | ~300 words | High |
+ | [Topic 2] | 2/5 competitors | ~200 words | Medium |
+ | [Topic 3] | 4/5 competitors | ~400 words | High |
+
+ ### SEO Updates Needed
+
+ - [ ] Update title tag with current year
+ - [ ] Refresh meta description
+ - [ ] Add new H2 sections for [topics]
+ - [ ] Update internal links to newer content
+ - [ ] Add FAQ section for featured snippets
+ - [ ] Refresh images and add new alt text
+
+ ### GEO Updates Needed
+
+ - [ ] Add clear definition at start
+ - [ ] Include quotable statistics with sources
+ - [ ] Add Q&A formatted sections
+ - [ ] Update sources with current citations
+ - [ ] Create standalone factual statements
+ ```
+
+5. **Create Refresh Plan** — Structural changes, content additions, statistics/links/images to update
+
+ > **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the full refresh plan template (Step 5).
+
+6. **Write Refresh Content** — Updated introduction, new sections, refreshed statistics, new FAQ section
+
+ > **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the refresh content writing template (Step 6).
+
+7. **Optimize for GEO During Refresh** — Clear definitions, quotable statements, Q&A sections, updated citations
+
+ > **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the GEO enhancement template (Step 7).
+
+8. **Generate Republishing Strategy** — Date strategy (update/add "last updated"/keep original), technical implementation, promotion plan
+
+ > **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the republishing strategy template (Step 8).
+
+9. **Create Refresh Report** — Summary of changes, updates completed, expected outcomes, next review date
+
+ > **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the refresh report template (Step 9).
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Target content URL or title clearly identified
+- [ ] Historical performance data available (traffic trends, rankings)
+- [ ] Content publish/update dates known
+- [ ] If comparing to competitors, competitor URLs provided
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] Outdated elements identified with specific examples and replacement data
+- [ ] All suggested additions include word counts and section locations
+- [ ] Source of each data point clearly stated (~~analytics data, ~~search console, ~~SEO tool, user-provided, or estimated)
+
+## Example
+
+> **Reference**: See [references/refresh-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-example.md) for a full worked example (cloud hosting refresh) and the comprehensive content refresh checklist.
+
+## Tips for Success
+
+1. **Prioritize by ROI** - Refresh high-potential content first
+2. **Don't just add dates** - Make substantial improvements
+3. **Beat competitors** - Add what they have and more
+4. **Track results** - Monitor ranking changes post-refresh
+5. **Schedule regular audits** - Check content health quarterly
+6. **Optimize for GEO** - Every refresh is a GEO opportunity
+
+> **Reference data**: For content decay signal taxonomy, lifecycle stages, refresh vs. rewrite decision framework, and update strategy by content type, see [references/content-decay-signals.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/content-decay-signals.md).
+
+
+### Save Results
+
+After delivering audit or optimization findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/audits/content-refresher/YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+
+**Gate check recommended**: Run content-quality-auditor on refreshed content before republishing.
+
+## Reference Materials
+
+- [Content Decay Signals](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/content-decay-signals.md) — Decay indicators, lifecycle stages, and refresh triggers by content type
+- [Refresh Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) — Detailed output templates for steps 5-9 (refresh plan, content writing, GEO enhancement, republishing, report)
+- [Refresh Example & Checklist](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-example.md) — Full worked example and pre/post-refresh checklist
+
+## Next Best Skill
+
+- **Primary**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) — re-score the refreshed content before shipping.
diff --git a/.cursor/skills/content-refresher/references/content-decay-signals.md b/.cursor/skills/content-refresher/references/content-decay-signals.md
new file mode 100644
index 0000000000..9eb7dd3c55
--- /dev/null
+++ b/.cursor/skills/content-refresher/references/content-decay-signals.md
@@ -0,0 +1,404 @@
+# Content Decay Signals
+
+Comprehensive decay detection system with automated monitoring setup, severity scoring, refresh playbooks by content type, and ROI estimation for content refresh investments.
+
+## Decay Signal Detection System
+
+### Primary Signals (High Reliability)
+
+These signals directly indicate content performance decline and should trigger immediate investigation.
+
+#### 1. Organic Traffic Decline
+
+| Severity | Threshold | Detection Window | Action |
+|----------|-----------|-----------------|--------|
+| Watch | 10-20% decline | Month-over-month | Add to monitoring list |
+| Warning | 20-40% decline | Month-over-month | Schedule refresh within 2 weeks |
+| Critical | 40-60% decline | Month-over-month | Refresh this week |
+| Emergency | >60% decline | Month-over-month | Investigate immediately (may be technical issue) |
+
+**Detection method**: Compare current month's organic sessions to same month previous year (to account for seasonality) and to previous month (for trend detection).
+
+**False positive check**: Before attributing traffic decline to content decay, rule out:
+- Seasonal variations (compare year-over-year, not just month-over-month)
+- Algorithm updates (check if decline coincides with known Google updates)
+- Technical issues (crawl errors, indexation problems, site speed regression)
+- Tracking code changes (analytics misconfiguration)
+
+#### 2. Ranking Position Drops
+
+| Severity | Threshold | Detection Window | Action |
+|----------|-----------|-----------------|--------|
+| Watch | 1-3 positions lost | 2-week average | Monitor |
+| Warning | 3-5 positions lost | 2-week average | Investigate cause |
+| Critical | 5-10 positions lost | 2-week average | Schedule immediate refresh |
+| Emergency | Dropped off page 1 to page 3+ | Any timeframe | Priority refresh or rewrite |
+
+**Detection method**: Track primary keyword positions weekly. Use 2-week rolling averages to smooth daily fluctuations.
+
+#### 3. Click-Through Rate Decline
+
+| Severity | Threshold | Context | Action |
+|----------|-----------|---------|--------|
+| Watch | CTR below expected for position | Position stable, CTR dropping | Review title and meta description |
+| Warning | CTR dropped 20%+ vs. baseline | With stable impressions | Rewrite title tag and meta description |
+| Critical | CTR dropped 40%+ vs. baseline | May indicate stale SERP appearance | Full refresh of title, description, and structured data |
+
+**Expected CTR by position** (organic, desktop, approximate):
+
+| Position | Expected CTR Range | Below This = Investigate |
+|----------|--------------------|-------------------------|
+| 1 | 25-35% | <20% |
+| 2 | 12-18% | <10% |
+| 3 | 8-12% | <6% |
+| 4-5 | 5-8% | <4% |
+| 6-10 | 2-5% | <2% |
+
+---
+
+### Secondary Signals (Moderate Reliability)
+
+These signals suggest potential decay but may have other causes. Use them to corroborate primary signals.
+
+#### 4. Engagement Metric Decline
+
+| Metric | Decay Indicator | Possible Cause |
+|--------|----------------|---------------|
+| Bounce rate increase >15% | Content no longer satisfies intent | Outdated information, better competitor content |
+| Time on page decrease >20% | Users leaving faster | Content not comprehensive enough |
+| Scroll depth decrease | Users not reading full content | Front-loading outdated info, losing interest |
+| Pages per session decrease | Users not exploring further | Poor internal linking, irrelevant content |
+
+#### 5. Content Freshness Indicators
+
+| Indicator | Decay Risk | Detection |
+|-----------|-----------|-----------|
+| Published >12 months ago, never updated | High | CMS date audit |
+| Contains year references 2+ years old | High | Text search for year patterns |
+| Statistics from 3+ years ago | Medium | Manual review or text search for "20XX" |
+| Broken external links (>10% of total) | Medium | Monthly crawl report |
+| Screenshots of outdated UI | Medium | Manual visual review |
+| References to discontinued products/tools | High | Manual review |
+
+#### 6. Competitive Displacement Signals
+
+| Signal | Detection Method | Severity |
+|--------|-----------------|----------|
+| New competitor content ranking above you | SERP monitoring | High |
+| Competitor content is longer and more comprehensive | Manual comparison | Medium |
+| Competitor has more recent publication date displayed in SERP | SERP monitoring | Medium |
+| Featured snippet lost to competitor | SERP monitoring | High |
+| AI overview now answers query without click | SERP monitoring | High |
+
+---
+
+### Tertiary Signals (Low Reliability, Supporting Evidence)
+
+These signals alone do not indicate decay but strengthen the case when combined with primary or secondary signals.
+
+| Signal | What It Suggests |
+|--------|-----------------|
+| Fewer social shares over time | Content less share-worthy (may be stale) |
+| Decrease in backlink acquisition | Content no longer being cited as a resource |
+| Fewer comments or engagement | Community interest waning |
+| Content not appearing in AI responses | Not structured for GEO or information is outdated |
+
+---
+
+## Automated Monitoring Setup
+
+### Monitoring Dashboard Configuration
+
+Set up these automated checks to catch decay early.
+
+#### Weekly Checks
+
+| Check | Data Source | Alert Threshold |
+|-------|-----------|----------------|
+| Keyword position changes | Rank tracker | Any target keyword drops >3 positions |
+| Crawl errors on key pages | Search Console | Any new crawl error on monitored pages |
+| Index coverage changes | Search Console | Any page drops from index |
+
+#### Monthly Checks
+
+| Check | Data Source | Alert Threshold |
+|-------|-----------|----------------|
+| Traffic comparison (MoM) | Analytics | >15% decline on any monitored page |
+| CTR comparison | Search Console | >20% CTR decline for any target keyword |
+| Broken link scan | Crawler | Any new broken links on monitored pages |
+| Competitor SERP changes | SERP tracker | New competitor enters top 5 |
+
+#### Quarterly Checks
+
+| Check | Data Source | Process |
+|-------|-----------|---------|
+| Content freshness audit | CMS + manual | Review all content older than 6 months |
+| Statistics accuracy check | Manual | Verify top 20 pages have current data |
+| Engagement trend review | Analytics | Compare engagement metrics across quarters |
+| Full competitive content gap | SEO tool | Identify new competitor content opportunities |
+
+### Alert Priority Matrix
+
+When multiple signals fire simultaneously, use this matrix to determine response urgency.
+
+| Primary Signal + Secondary Signal | Priority | Response |
+|----------------------------------|----------|----------|
+| Traffic decline + Position drop | P1 (Critical) | Refresh within 48 hours |
+| Traffic decline + CTR decline | P1 (Critical) | Rewrite title/description immediately, schedule content refresh |
+| Position drop + Competitor displacement | P2 (High) | Refresh within 1 week |
+| Traffic decline + Engagement decline | P2 (High) | Refresh within 1 week |
+| CTR decline only | P3 (Medium) | Rewrite title and meta description this week |
+| Freshness indicators only | P3 (Medium) | Schedule refresh within 2 weeks |
+| Engagement decline only | P4 (Low) | Investigate and schedule if confirmed |
+
+---
+
+## Decay Severity Scoring
+
+### Composite Decay Score
+
+Calculate a 0-100 decay severity score by summing weighted signal scores.
+
+| Signal Category | Weight | Score Range |
+|----------------|--------|-------------|
+| Traffic decline | 30% | 0 = no decline, 100 = >60% decline |
+| Position drops | 25% | 0 = stable, 100 = dropped off page 1 |
+| CTR decline | 15% | 0 = stable, 100 = >40% decline |
+| Content freshness | 15% | 0 = updated this quarter, 100 = >2 years stale |
+| Competitive displacement | 15% | 0 = no new competitors, 100 = displaced from top 3 |
+
+### Score Interpretation
+
+| Composite Score | Decay Stage | Action |
+|----------------|-------------|--------|
+| 0-20 | Healthy | Continue monitoring |
+| 21-40 | Early decay | Add to refresh queue (next month) |
+| 41-60 | Active decay | Schedule refresh (this week) |
+| 61-80 | Significant decay | Immediate refresh or rewrite decision |
+| 81-100 | Terminal decay | Rewrite, redirect, or retire |
+
+---
+
+## Refresh Playbooks by Content Type
+
+### Blog Post / Article Refresh Playbook
+
+| Step | Action | Time Estimate |
+|------|--------|--------------|
+| 1 | Update title with current year or hook | 10 min |
+| 2 | Rewrite introduction with fresh angle | 20 min |
+| 3 | Update all statistics with current sources | 30-60 min |
+| 4 | Add 1-2 new sections covering gaps | 60-90 min |
+| 5 | Update screenshots and images | 30 min |
+| 6 | Add or update FAQ section | 20 min |
+| 7 | Refresh internal links | 15 min |
+| 8 | Update meta description | 5 min |
+| 9 | Add/update schema markup | 10 min |
+| 10 | Update dateModified and republish | 5 min |
+| **Total** | | **3-4 hours** |
+
+### Product/Service Page Refresh Playbook
+
+| Step | Action | Time Estimate |
+|------|--------|--------------|
+| 1 | Update pricing, features, specifications | 30 min |
+| 2 | Add new customer testimonials/reviews | 20 min |
+| 3 | Update product images | 30 min |
+| 4 | Refresh comparison tables | 20 min |
+| 5 | Update internal links to related products | 15 min |
+| 6 | Verify and update schema markup | 10 min |
+| **Total** | | **2-2.5 hours** |
+
+### Statistics/Data Roundup Refresh Playbook
+
+| Step | Action | Time Estimate |
+|------|--------|--------------|
+| 1 | Verify every statistic is still current | 60-90 min |
+| 2 | Replace outdated stats with current data | 60 min |
+| 3 | Add new statistics from recent studies | 30 min |
+| 4 | Update source links and citations | 30 min |
+| 5 | Update year references throughout | 15 min |
+| 6 | Add new visualization if data changed significantly | 30 min |
+| 7 | Update title, meta description with year | 10 min |
+| **Total** | | **4-5 hours** |
+
+### How-To Guide Refresh Playbook
+
+| Step | Action | Time Estimate |
+|------|--------|--------------|
+| 1 | Verify all steps are still accurate | 30 min |
+| 2 | Update screenshots for UI changes | 60 min |
+| 3 | Add new methods or alternative approaches | 30 min |
+| 4 | Update tool recommendations | 15 min |
+| 5 | Add troubleshooting section if missing | 20 min |
+| 6 | Update FAQ with new common questions | 15 min |
+| 7 | Test all links and embedded resources | 15 min |
+| **Total** | | **3-3.5 hours** |
+
+---
+
+## ROI Estimation for Content Refresh
+
+### Cost-Benefit Framework
+
+| Factor | Measurement |
+|--------|------------|
+| **Cost of refresh** | Writer hours x hourly rate + tool costs |
+| **Current monthly traffic value** | Organic sessions x conversion rate x avg order value |
+| **Projected traffic recovery** | Based on decay stage and content potential |
+| **Time to recover** | Typically 4-8 weeks for rankings to respond |
+
+### Traffic Recovery Benchmarks
+
+Based on industry data for content refreshes (not rewrites):
+
+| Decay Stage at Refresh | Typical Traffic Recovery | Recovery Timeline |
+|------------------------|------------------------|-------------------|
+| Early decay | 90-110% of peak (often exceeds) | 2-4 weeks |
+| Active decay | 70-90% of peak | 4-8 weeks |
+| Significant decay | 40-70% of peak | 6-12 weeks |
+| Terminal decay | 10-40% of peak (rewrite may be better) | 8-16 weeks |
+
+### ROI Calculation Template
+
+```
+Refresh Cost:
+ Writer time: [X hours] x [$Y/hour] = $[Z]
+ Tool costs: $[A] (one-time crawl, research tools)
+ Total cost: $[Z + A]
+
+Monthly Traffic Value (before decay):
+ Peak monthly organic sessions: [N]
+ Conversion rate: [X]%
+ Average conversion value: $[Y]
+ Peak monthly value: [N] x [X]% x $[Y] = $[V]
+
+Expected Recovery:
+ Projected recovery: [%] of peak = $[V x %] per month
+ Current monthly value: $[current]
+ Monthly value increase: $[V x % - current]
+
+ROI:
+ Payback period: $[total cost] / $[monthly value increase] = [months]
+ 12-month ROI: ($[monthly value increase] x 12 - $[total cost]) / $[total cost] x 100 = [X]%
+```
+
+### Refresh Priority Scoring
+
+When choosing which content to refresh first, score each candidate:
+
+| Factor | Weight | Score (1-10) |
+|--------|--------|-------------|
+| Current traffic value | 25% | Higher traffic = higher score |
+| Decay severity | 20% | More decay = more urgency |
+| Competitive opportunity | 20% | Weaker competition = higher score |
+| Refresh difficulty | 15% | Easier refresh = higher score |
+| Strategic importance | 10% | Aligns with business goals = higher score |
+| Backlink equity | 10% | More backlinks = more worth preserving |
+
+**Priority formula**: Weighted score total. Refresh highest-scoring content first.
+
+---
+
+## Content Retirement Decision
+
+Not all decaying content should be refreshed. Use this checklist to decide when to retire content instead.
+
+### Retire When
+
+- [ ] Content targets a keyword with zero search volume
+- [ ] Topic is no longer relevant to your business
+- [ ] No backlinks worth preserving
+- [ ] Content never ranked well even when fresh
+- [ ] Cost to refresh exceeds projected 12-month value recovery
+- [ ] Content cannibalizes a better-performing page on the same topic
+
+### Retirement Options
+
+| Option | When to Use | Implementation |
+|--------|------------|---------------|
+| 301 redirect | Content has backlinks or residual traffic | Redirect to best related page |
+| Consolidate | Multiple weak pages on same topic | Merge into one strong page, redirect others |
+| Noindex | Page has internal utility but should not rank | Add noindex, keep page accessible |
+| Delete (410) | Content has no value, no links, no traffic | Return 410 Gone status |
+
+### Post-Retirement Monitoring
+
+After retiring content, monitor for 4 weeks:
+- Verify redirects are working (no 404 errors)
+- Check that target pages are receiving redirected traffic
+- Monitor rankings of consolidated/target pages
+- Ensure no orphan pages were created by removing internal links
+
+---
+
+## Content Decay Signal Taxonomy
+
+### Decay Indicators
+
+| Signal | Source | Severity | Detection Method |
+|--------|--------|----------|-----------------|
+| Traffic decline >20% MoM | Analytics | High | Monthly traffic comparison |
+| Position drop >5 positions | Rank tracker | High | Weekly rank monitoring |
+| Outdated statistics/dates | Manual review | Medium | Annual content audit |
+| Broken external links | Crawler | Medium | Monthly crawl reports |
+| Decreased CTR | Search Console | Medium | Quarterly CTR analysis |
+| Competitor new content | SERP monitoring | Medium | Monthly SERP checks |
+| User engagement drop | Analytics | Low | Quarterly engagement review |
+| Index coverage issues | Search Console | High | Weekly coverage monitoring |
+
+### Content Decay Stages
+
+| Stage | Symptoms | Urgency | Recommended Action |
+|-------|---------|---------|-------------------|
+| **Early decay** | Slight traffic/position dip | Low | Monitor for 2-4 weeks |
+| **Active decay** | Consistent decline across 2+ months | Medium | Schedule refresh within 2 weeks |
+| **Significant decay** | 50%+ traffic loss, page 2+ | High | Immediate refresh or rewrite |
+| **Terminal decay** | No organic traffic, deindexed | Critical | Rewrite, redirect, or retire |
+
+## Refresh vs. Rewrite Decision Framework
+
+| Factor | Refresh (Update) | Rewrite (New version) |
+|--------|-----------------|---------------------|
+| Content quality | Good foundation, needs updating | Fundamentally flawed or outdated approach |
+| Position | Was ranking well, now dropping | Never ranked well despite optimization |
+| URL age | 1+ years, has earned backlinks | Young URL with no backlink equity |
+| Backlinks | Has external links pointing to it | No backlinks worth preserving |
+| Scope of changes needed | <50% of content changing | >50% needs rewriting |
+| Search intent | Intent hasn't changed | Search intent has evolved |
+
+**Decision rule:** If the URL has backlinks and was ranking, REFRESH. If not, consider REWRITE at a new URL (with 301 redirect if old URL has any equity).
+
+## Content Lifecycle Model
+
+```
+CREATE → PROMOTE → MAINTAIN → REFRESH → [REFRESH again] or RETIRE
+ │ │ │ │ │
+ │ Month 1 Month 2-6 Month 6-12 When terminal
+ │ Social, Monitor Update facts, 301 redirect
+ │ outreach, rankings, add new sections, to related
+ │ email fix issues improve depth content
+```
+
+### Lifecycle Actions by Phase
+
+| Phase | Duration | Key Actions | Metrics to Track |
+|-------|----------|------------|-----------------|
+| Create | Week 1 | Publish, submit to Search Console | Indexation |
+| Promote | Month 1 | Social shares, email, outreach | Referral traffic, backlinks |
+| Maintain | Months 2-6 | Monitor, fix broken links, respond to comments | Rankings, traffic trend |
+| Refresh | Months 6-12+ | Update data, add sections, improve structure | Traffic recovery, new keywords |
+| Retire | When terminal | 301 redirect to best alternative | Redirect traffic recovery |
+
+## Update Strategy by Content Type
+
+| Content Type | Refresh Frequency | Key Updates | Shelf Life |
+|-------------|-------------------|------------|-----------|
+| Statistics roundups | Every 6 months | Replace old stats, add new sources | 6-12 months |
+| Tool comparisons | Every 3-6 months | Update pricing, features, screenshots | 3-6 months |
+| How-to guides | Annually | Update steps, screenshots, links | 12-18 months |
+| Evergreen guides | Every 12-18 months | Add new sections, update examples | 18-24 months |
+| News/trend content | Don't refresh | Archive or redirect | 1-3 months |
+| Case studies | Rarely | Update results if available | 2-3 years |
+| Glossary/definitions | As needed | Update when definitions evolve | 2-5 years |
+
diff --git a/.cursor/skills/content-refresher/references/refresh-example.md b/.cursor/skills/content-refresher/references/refresh-example.md
new file mode 100644
index 0000000000..40dcc1887d
--- /dev/null
+++ b/.cursor/skills/content-refresher/references/refresh-example.md
@@ -0,0 +1,143 @@
+# Content Refresher — Worked Example & Checklist
+
+Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/SKILL.md).
+
+---
+
+## Worked Example
+
+**User**: "Refresh my blog post about 'best cloud hosting providers'"
+
+**Output**:
+
+```markdown
+### CORE-EEAT Quick Assessment
+
+**Content**: "Best Cloud Hosting Providers 2023"
+**Content Type**: Commercial comparison / listicle
+
+| Dimension | Quick Score | Key Weakness | Refresh Priority |
+|-----------|-----------|--------------|-----------------|
+| C — Contextual Clarity | 60/100 | Title says 2023 — stale intent signal | 🔴 |
+| O — Organization | 75/100 | Good comparison tables but no summary box | 🟡 |
+| R — Referenceability | 35/100 | Pricing from Q1 2023, 3 broken links | 🔴 |
+| E — Exclusivity | 50/100 | No original benchmarks or test data | 🟡 |
+| Exp — Experience | 30/100 | No first-person testing narrative | 🔴 |
+| Ept — Expertise | 65/100 | Author bio present but lacks credentials | 🟡 |
+| A — Authority | 55/100 | 12 backlinks, was ranking page 1 | 🟢 |
+| T — Trust | 60/100 | Affiliate links present but not disclosed | 🔴 |
+
+**Weakest Dimensions** (focus refresh here):
+1. **Experience** — Add hands-on testing results ("We migrated a test site to each provider")
+2. **Referenceability** — Replace all 2023 pricing/uptime data with current figures
+
+## Content Refresh Analysis: Best Cloud Hosting Providers 2023
+
+**URL**: cloudhosting.com/best-cloud-hosting
+**Published**: 2023-02-14
+**Last Updated**: Never
+**Word Count**: 2,100
+
+### Performance Metrics
+
+| Metric | 6 Mo Ago | Current | Change |
+|--------|----------|---------|--------|
+| Organic Traffic | 3,200/mo | 1,400/mo | -56% |
+| Avg Position | 4.2 | 14.8 | ↓ 10.6 |
+| Impressions | 18,000 | 9,500 | -47% |
+| CTR | 6.1% | 2.3% | -3.8% |
+
+### Content Decay Signals Identified
+
+1. **Outdated year in title and H1** — "2023" signals stale content to users and search engines
+2. **Pricing data 18+ months old** — AWS Lightsail listed at $3.50/mo (now $5/mo), DigitalOcean at $4/mo (now $6/mo)
+3. **Missing new entrants** — No mention of Hetzner Cloud or Vultr, which 4/5 top competitors now cover
+4. **3 broken outbound links** — Provider comparison pages that have moved or been retired
+
+### Refresh vs. Rewrite Decision
+
+| Factor | Assessment |
+|--------|-----------|
+| Content quality | Good structure, solid comparison tables — foundation is sound |
+| URL equity | 12 referring domains, 18 months old |
+| Scope of changes | ~40% of content needs updating |
+| Search intent | Unchanged — still commercial comparison |
+
+**Decision**: **REFRESH** — The URL has earned backlinks, the structure is solid, and less than 50% needs rewriting. Keep the URL, update in place.
+
+## Content Refresh Plan
+
+**Current Title**: "Best Cloud Hosting Providers 2023"
+**Refreshed Title**: "Best Cloud Hosting Providers 2024: 7 Platforms Tested & Compared"
+
+### Specific Refresh Actions
+
+1. **Update all pricing and specs** (~30 min)
+ - Replace 2023 pricing for all 5 listed providers with current data
+ - Add uptime stats from the last 12 months (source: UptimeRobot public status pages)
+ - Update feature comparison table with current plan tiers
+
+2. **Add 2 missing providers + testing narrative** (~600 words)
+ - Add Hetzner Cloud and Vultr sections with same comparison format
+ - Write intro paragraph: "We deployed a WordPress benchmark site to each provider and measured TTFB, uptime, and support response times over 30 days"
+
+3. **Add affiliate disclosure and FAQ section** (~200 words)
+ - Add disclosure statement below introduction: "This post contains affiliate links. See our editorial policy."
+ - Add FAQ with 4 questions targeting People Also Ask (e.g., "What is the cheapest cloud hosting?", "Is cloud hosting faster than shared hosting?")
+ - Implement FAQ schema markup for rich result eligibility
+
+4. **Fix broken links and update internal links** (~15 min)
+ - Replace 3 broken outbound links with current provider URLs
+ - Add internal links to cloudhosting.com/vps-vs-cloud and cloudhosting.com/hosting-speed-test
+
+### Republishing Strategy
+
+**Recommendation**: Update Published Date — this is a major overhaul (40%+ new content, new providers, fresh test data). Update `dateModified` in Article schema, resubmit URL in Search Console, and share on social as "Updated for 2024."
+
+### Expected Outcomes
+
+| Metric | Current | 30-Day Target | 90-Day Target |
+|--------|---------|---------------|---------------|
+| Avg Position | 14.8 | 8-10 | 3-6 |
+| Organic Traffic | 1,400/mo | 2,200/mo | 3,500/mo |
+| Featured Snippets | 0 | 1 (FAQ) | 2+ |
+```
+
+---
+
+## Content Refresh Checklist
+
+```markdown
+### Pre-Refresh
+- [ ] Analyze current performance metrics
+- [ ] Identify outdated information
+- [ ] Research competitor updates
+- [ ] Note missing topics
+
+### Content Updates
+- [ ] Update year references
+- [ ] Refresh statistics with sources
+- [ ] Add new examples and case studies
+- [ ] Expand thin sections
+- [ ] Add new relevant sections
+- [ ] Create FAQ section
+
+### SEO Updates
+- [ ] Update title tag
+- [ ] Refresh meta description
+- [ ] Optimize headers
+- [ ] Update internal links
+- [ ] Add new images with alt text
+
+### GEO Updates
+- [ ] Add clear definition
+- [ ] Include quotable statements
+- [ ] Add Q&A formatted content
+- [ ] Update source citations
+
+### Technical
+- [ ] Update schema dateModified
+- [ ] Clear page cache
+- [ ] Update sitemap
+- [ ] Test page speed
+```
diff --git a/.cursor/skills/content-refresher/references/refresh-templates.md b/.cursor/skills/content-refresher/references/refresh-templates.md
new file mode 100644
index 0000000000..8b9e52a826
--- /dev/null
+++ b/.cursor/skills/content-refresher/references/refresh-templates.md
@@ -0,0 +1,253 @@
+# Content Refresh Templates
+
+Detailed output templates for content-refresher steps 5-9. Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/SKILL.md).
+
+---
+
+## Step 5: Create Refresh Plan
+
+```markdown
+## Content Refresh Plan
+
+### Title/URL
+**Current**: [current title]
+**Refreshed**: [updated title with year/hook]
+
+### Structural Changes
+
+**Keep As-Is**:
+- [Section 1] - Still relevant and accurate
+- [Section 2] - Still relevant and accurate
+
+**Update/Expand**:
+- [Section 3] - Update statistics, add [X] words
+- [Section 4] - Add new examples from [current year]
+
+**Add New Sections**:
+- [New Section 1] - [description, ~X words]
+- [New Section 2] - [description, ~X words]
+- FAQ Section - [X questions for featured snippets]
+
+**Remove/Consolidate**:
+- [Section 5] - Outdated, remove or redirect topic
+
+### Content Additions
+
+**New Word Count Target**: [X] words (+[Y] from current)
+
+| Section | Current | After Refresh | Notes |
+|---------|---------|---------------|-------|
+| Introduction | [X] | [X] | Add hook, update context |
+| [Section 1] | [X] | [X] | Keep |
+| [Section 2] | [X] | [X] | Update stats |
+| [New Section] | 0 | [X] | Add entirely |
+| FAQ | 0 | [X] | Add for GEO |
+| Conclusion | [X] | [X] | Update CTA |
+
+### Specific Updates
+
+**Statistics to Update**:
+
+| Old Statistic | New Statistic | Source |
+|---------------|---------------|--------|
+| "[old stat]" | "[find current]" | [source] |
+| "[old stat]" | "[find current]" | [source] |
+
+**Links to Update**:
+
+| Anchor Text | Old URL | New URL | Reason |
+|-------------|---------|---------|--------|
+| "[anchor]" | [old] | [new] | Broken |
+| "[anchor]" | [old] | [new] | Better resource |
+
+**Images to Update**:
+
+| Image | Action | New Alt Text |
+|-------|--------|--------------|
+| [img 1] | Replace | "[keyword-rich alt]" |
+| [img 2] | Keep | Update alt text |
+```
+
+---
+
+## Step 6: Write Refresh Content
+
+```markdown
+## Refreshed Content Sections
+
+### Updated Introduction
+
+[Write new introduction with:]
+- Updated hook for current year
+- Fresh statistics
+- Clear value proposition
+- Primary keyword in first 100 words
+
+### New Section: [Title]
+
+[Write new section covering:]
+- [Topic competitors now cover]
+- Current information and examples
+- GEO-optimized with quotable statements
+
+### Updated Statistics Section
+
+**Replace**:
+> "[Old statement with outdated stat]"
+
+**With**:
+> "[New statement with current stat] (Source, [current year])"
+
+### New FAQ Section
+
+## Frequently Asked Questions
+
+### [Question matching PAA/common query]?
+
+[Direct answer in 40-60 words, optimized for featured snippets]
+
+### [Question 2]?
+
+[Direct answer]
+
+### [Question 3]?
+
+[Direct answer]
+```
+
+---
+
+## Step 7: Optimize for GEO During Refresh
+
+```markdown
+## GEO Enhancement Opportunities
+
+### Add Clear Definitions
+
+**Add at start of article**:
+> **[Topic]** is [clear, quotable definition in 40-60 words that
+> AI systems can cite directly].
+
+### Add Quotable Statements
+
+**Transform**:
+> "Email marketing is effective for businesses."
+
+**Into**:
+> "Email marketing delivers an average ROI of $42 for every $1
+> invested, making it the highest-ROI digital marketing channel
+> according to the Data & Marketing Association ([current year])."
+
+### Add Q&A Sections
+
+Structure content with questions AI might answer:
+- What is [topic]?
+- How does [topic] work?
+- Why is [topic] important?
+- What are the benefits of [topic]?
+
+### Update Citations
+
+- Add sources for all statistics
+- Link to authoritative references
+- Include publication dates
+- Use recent sources (last 2 years)
+```
+
+---
+
+## Step 8: Generate Republishing Strategy
+
+```markdown
+## Republishing Strategy
+
+### Date Strategy
+
+**Options**:
+
+1. **Update Published Date**
+ - Use when: Major overhaul (50%+ new content)
+ - Pros: Signals freshness to Google
+ - Cons: Loses "original" authority
+
+2. **Add "Last Updated" Date**
+ - Use when: Moderate updates (20-50% new)
+ - Pros: Shows both original and fresh
+ - Cons: Original date visible
+
+3. **Keep Original Date**
+ - Use when: Minor updates (<20% new)
+ - Pros: Maintains authority
+ - Cons: Doesn't signal update
+
+**Recommendation**: [Option X] because [reason]
+
+### Technical Implementation
+
+- [ ] Update `dateModified` in schema
+- [ ] Update sitemap lastmod
+- [ ] Clear cache after publishing
+- [ ] Resubmit to ~~search console
+
+### Promotion Strategy
+
+**Immediately after refresh**:
+- [ ] Share on social media as "updated for [current year]"
+- [ ] Send to email list if significant update
+- [ ] Update internal links with fresh anchors
+- [ ] Reach out for new backlinks
+
+**Track Results**:
+- [ ] Monitor rankings for 4-6 weeks
+- [ ] Track traffic changes
+- [ ] Watch for featured snippet capture
+- [ ] Check AI citation improvements
+```
+
+---
+
+## Step 9: Create Refresh Report
+
+```markdown
+# Content Refresh Report
+
+## Summary
+
+**Content**: [Title]
+**Refresh Date**: [Date]
+**Refresh Level**: [Major/Moderate/Minor]
+
+## Changes Made
+
+| Element | Before | After |
+|---------|--------|-------|
+| Word count | [X] | [Y] (+[Z]%) |
+| Sections | [X] | [Y] |
+| Statistics | [X] outdated | [Y] current |
+| Internal links | [X] | [Y] |
+| Images | [X] | [Y] |
+| FAQ questions | 0 | [X] |
+
+## Updates Completed
+
+- [x] Updated title with current year
+- [x] Refreshed meta description
+- [x] Added [X] new sections
+- [x] Updated [X] statistics with sources
+- [x] Fixed [X] broken links
+- [x] Added FAQ section for GEO
+- [x] Implemented FAQ schema
+- [x] Updated images and alt text
+
+## Expected Outcomes
+
+| Metric | Current | 30-Day Target | 90-Day Target |
+|--------|---------|---------------|---------------|
+| Avg Position | [X] | [Y] | [Z] |
+| Organic Traffic | [X]/mo | [Y]/mo | [Z]/mo |
+| Featured Snippets | 0 | 1+ | 2+ |
+
+## Next Review
+
+Schedule next refresh review: [Date - 6 months from now]
+```
diff --git a/.cursor/skills/domain-authority-auditor/SKILL.md b/.cursor/skills/domain-authority-auditor/SKILL.md
new file mode 100644
index 0000000000..1840cfd38e
--- /dev/null
+++ b/.cursor/skills/domain-authority-auditor/SKILL.md
@@ -0,0 +1,394 @@
+---
+name: domain-authority-auditor
+description: '40-item CITE domain audit: citation, impact, trust, entity scoring with veto checks. 域名权威/网站可信度'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when auditing domain trust and authority. Runs CITE 40-item scoring with veto checks. Also when the user asks about domain credibility or citation trustworthiness."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - geo
+ - domain-authority
+ - domain-rating
+ - domain-trust
+ - cite-framework
+ - site-authority
+ - 域名权威
+ - ドメイン権威
+ - 도메인권위
+ - autoridad-dominio
+ triggers:
+ # EN-formal
+ - "audit domain authority"
+ - "CITE audit"
+ - "domain trust score"
+ - "domain credibility check"
+ - "domain rating"
+ - "site authority"
+ # EN-casual
+ - "how trustworthy is my site"
+ - "is my domain credible"
+ - "is my domain trustworthy"
+ - "domain credibility score"
+ - "Google penalty recovery"
+ - "my site got penalized"
+ # EN-question
+ - "how authoritative is my site"
+ - "what is my domain authority"
+ # ZH-pro
+ - "域名权威审计"
+ - "网站可信度"
+ - "域名评分"
+ # ZH-casual
+ - "域名可信吗"
+ - "权威度多少"
+ - "网站可信度怎么样"
+ # JA
+ - "ドメイン権威"
+ - "ドメイン評価"
+ # KO
+ - "도메인 권위"
+ - "도메인 신뢰도"
+ # ES
+ - "autoridad de dominio"
+ - "auditoría de dominio"
+ # PT
+ - "autoridade de domínio"
+ # Misspellings
+ - "domain autority"
+---
+
+# Domain Authority Auditor
+
+> Based on [CITE Domain Rating](https://github.com/aaron-he-zhu/cite-domain-rating). Full benchmark reference: [references/cite-domain-rating.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md)
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill evaluates domain authority across 40 standardized criteria organized in 4 dimensions. It produces a comprehensive audit report with per-item scoring, dimension and weighted scores by domain type, veto item checks, and a prioritized action plan.
+
+**Sister skill**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) evaluates content at the page level (80 items). This skill evaluates the domain behind the content (40 items). Together they provide a complete 120-item assessment.
+
+> **Namespace note**: CITE uses C01-C10 for Citation items; CORE-EEAT uses C01-C10 for Contextual Clarity items. In combined 120-item assessments, prefix with the framework name (e.g., CITE-C01 vs CORE-C01) to avoid confusion.
+
+**System role**: Citation Trust Gate. It decides whether a domain is credible enough to support ranking, citation, and brand authority work.
+
+## When This Must Trigger
+
+Use this when domain credibility or citation trustworthiness is in question — even if the user doesn't use audit terminology:
+
+- User asks "how trustworthy is my site" or "is my domain credible"
+- When backlink-analyzer finds toxic link ratio above 15%, its handoff summary recommends this gate check
+- Evaluating domain authority before a GEO campaign
+- Benchmarking your domain against competitors
+- Assessing whether a domain is trustworthy as a citation source
+- Running periodic domain health checks or after link building campaigns
+- Identifying manipulation red flags (PBNs, link farms, penalty history)
+- Cross-referencing with content-quality-auditor for full 120-item assessment
+
+## What This Skill Does
+
+1. **Full 40-Item Audit**: Scores every CITE check item as Pass/Partial/Fail
+2. **Dimension Scoring**: Calculates scores for all 4 dimensions (0-100 each)
+3. **Weighted Totals**: Applies domain-type-specific weights for CITE Score
+4. **Veto Detection**: Flags critical manipulation signals (T03, T05, T09)
+5. **Priority Ranking**: Identifies Top 5 improvements sorted by impact
+6. **Action Plan**: Generates specific, actionable improvement steps
+7. **Cross-Reference**: Optionally pairs with CORE-EEAT for combined diagnosis
+
+## Quick Start
+
+Start with one of these prompts. Finish with a citation-trust verdict and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Audit Your Domain
+
+```
+Audit domain authority for [domain]
+Run a CITE domain audit on [domain] as a [domain type]
+```
+
+### Audit with Domain Type
+
+```
+CITE audit for example.com as an e-commerce site
+Score this SaaS domain against the 40-item benchmark: [domain]
+```
+
+### Comparative Audit
+
+```
+Compare domain authority: [your domain] vs [competitor 1] vs [competitor 2]
+```
+
+### Combined Assessment
+
+```
+Run full 120-item assessment on [domain]: CITE domain audit + CORE-EEAT content audit on [sample pages]
+```
+
+## Skill Contract
+
+**Gate verdict**: **TRUSTED** (no veto items, scores above threshold) / **CAUTIOUS** (issues found but no veto) / **UNTRUSTED** (veto item T03, T05, or T09 failed). Always state the verdict prominently at the top of the report.
+
+**Expected output**: a CITE audit report, a citation-trust verdict, and a short handoff summary ready for `memory/audits/domain/`.
+
+- **Reads**: the target domain, supporting authority signals, comparison domains, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing authority report plus a reusable summary that can be stored under `memory/audits/domain/`.
+- **Promotes**: veto items and domain risks to `memory/hot-cache.md` (auto-saved). Authority context to `memory/audits/domain/`. Results feed into entity-optimizer as authority input for brand's canonical profile.
+- **Next handoff**: use the `Next Best Skill` below once the trust picture is clear.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+**With ~~link database + ~~SEO tool + ~~AI monitor + ~~knowledge graph + ~~brand monitor connected:**
+Automatically pull backlink profiles and link quality metrics from ~~link database, domain authority scores and keyword rankings from ~~SEO tool, AI citation data from ~~AI monitor, entity presence from ~~knowledge graph, and brand mention data from ~~brand monitor.
+
+**With manual data only:**
+Ask the user to provide:
+1. Domain to evaluate
+2. Domain type (if not auto-detectable): Content Publisher, Product & Service, E-commerce, Community & UGC, Tool & Utility, or Authority & Institutional
+3. Backlink data: referring domains count, domain authority, top linking domains
+4. Traffic estimates (from any SEO tool or SimilarWeb)
+5. Competitor domains for comparison (optional)
+
+Proceed with the full 40-item audit using provided data. Note in the output which items could not be fully evaluated due to missing access (e.g., AI citation data, knowledge graph queries, WHOIS history).
+
+## Instructions
+
+When a user requests a domain authority audit:
+
+### Step 1: Preparation
+
+```markdown
+### Audit Setup
+
+**Domain**: [domain]
+**Domain Type**: [auto-detected or user-specified]
+**Dimension Weights**: [from domain-type weight table below]
+
+#### Domain-Type Weight Table
+
+> Canonical source: `references/cite-domain-rating.md`. This inline copy is for convenience.
+
+| Dim | Default | Content Publisher | Product & Service | E-commerce | Community & UGC | Tool & Utility | Authority & Institutional |
+|-----|:-------:|:-:|:-:|:-:|:-:|:-:|:-:|
+| C | 35% | **40%** | 25% | 20% | 35% | 25% | **45%** |
+| I | 20% | 15% | **30%** | 20% | 10% | **30%** | 20% |
+| T | 25% | 20% | 25% | **35%** | 25% | 25% | 20% |
+| E | 20% | 25% | 20% | 25% | **30%** | 20% | 15% |
+
+#### Veto Check (Emergency Brake)
+
+| Veto Item | Status | Action |
+|-----------|--------|--------|
+| T03: Link-Traffic Coherence | ✅ Pass / ⚠️ VETO | [If VETO: "Audit backlink profile; disavow toxic links"] |
+| T05: Backlink Profile Uniqueness | ✅ Pass / ⚠️ VETO | [If VETO: "Flag as manipulation network; investigate link sources"] |
+| T09: Penalty & Deindex History | ✅ Pass / ⚠️ VETO | [If VETO: "Address penalty first; all other optimization is futile"] |
+```
+
+If any veto item triggers, flag it prominently at the top of the report. CITE Score is capped at 39 (Poor) regardless of other scores.
+
+### Step 2: C + I Audit (20 items)
+
+Evaluate each item against the criteria in [references/cite-domain-rating.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md).
+
+Score each item:
+- **Pass** = 10 points (fully meets criteria)
+- **Partial** = 5 points (partially meets criteria)
+- **Fail** = 0 points (does not meet criteria)
+
+```markdown
+### C — Citation
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| C01 | Referring Domains Volume | Pass/Partial/Fail | [specific observation] |
+| C02 | Referring Domains Quality | Pass/Partial/Fail | [specific observation] |
+| ... | ... | ... | ... |
+| C10 | Link Source Diversity | Pass/Partial/Fail | [specific observation] |
+
+**C Score**: [X]/100
+
+### I — Identity
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| I01 | Knowledge Graph Presence | Pass/Partial/Fail | [specific observation] |
+| ... | ... | ... | ... |
+
+**I Score**: [X]/100
+```
+
+### Step 3: T + E Audit (20 items)
+
+Same format for Trust and Eminence dimensions.
+
+```markdown
+### T — Trust
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| T01 | Link Profile Naturalness | Pass/Partial/Fail | [specific observation] |
+| ... | ... | ... | ... |
+
+**T Score**: [X]/100
+
+### E — Eminence
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| E01 | Organic Search Visibility | Pass/Partial/Fail | [specific observation] |
+| ... | ... | ... | ... |
+
+**E Score**: [X]/100
+```
+
+**Note**: Some items require specialized data (C05-C08 AI citation data, I01 knowledge graph queries, T04-T05 IP/profile analysis). Score what is observable; mark unverifiable items as "N/A — requires [data source]" and exclude from dimension average.
+
+### Step 4: Scoring & Report
+
+Calculate scores and generate the final report:
+
+```markdown
+## CITE Domain Authority Report
+
+### Overview
+
+- **Domain**: [domain]
+- **Domain Type**: [type]
+- **Audit Date**: [date]
+- **CITE Score**: [score]/100 ([rating])
+- **Veto Status**: ✅ No triggers / ⚠️ [item] triggered — Score capped at 39
+
+### Dimension Scores
+
+| Dimension | Score | Rating | Weight | Weighted |
+|-----------|-------|--------|--------|----------|
+| C — Citation | [X]/100 | [rating] | [X]% | [X] |
+| I — Identity | [X]/100 | [rating] | [X]% | [X] |
+| T — Trust | [X]/100 | [rating] | [X]% | [X] |
+| E — Eminence | [X]/100 | [rating] | [X]% | [X] |
+| **CITE Score** | | | | **[X]/100** |
+
+**Score Calculation**: CITE Score = C × [w_C] + I × [w_I] + T × [w_T] + E × [w_E]
+
+**Rating Scale**: 90-100 Excellent | 75-89 Good | 60-74 Medium | 40-59 Low | 0-39 Poor
+
+### Per-Item Scores
+
+| ID | Check Item | Score | Notes |
+|----|-----------|-------|-------|
+| C01 | Referring Domains Volume | [Pass/Partial/Fail] | [observation] |
+| C02 | Referring Domains Quality | [Pass/Partial/Fail] | [observation] |
+| ... | ... | ... | ... |
+| E10 | Industry Share of Voice | [Pass/Partial/Fail] | [observation] |
+
+### Top 5 Priority Improvements
+
+Sorted by: weight × points lost (highest impact first)
+
+1. **[ID] [Name]** — [specific modification suggestion]
+ - Current: [Fail/Partial] | Potential gain: [X] weighted points
+ - Action: [concrete step]
+2. **[ID] [Name]** — [specific modification suggestion]
+ - Current: [Fail/Partial] | Potential gain: [X] weighted points
+ - Action: [concrete step]
+3–5. [Same format]
+
+### Action Plan
+
+#### Quick Wins (< 1 week)
+- [ ] [Action 1]
+- [ ] [Action 2]
+#### Medium Effort (1-4 weeks)
+- [ ] [Action 3]
+- [ ] [Action 4]
+#### Strategic (1-3 months)
+- [ ] [Action 5]
+- [ ] [Action 6]
+
+### Cross-Reference with CORE-EEAT
+
+For a complete assessment, pair this CITE audit with a CORE-EEAT content audit:
+
+| Assessment | Score | Rating |
+|-----------|-------|--------|
+| CITE (Domain) | [X]/100 | [rating] |
+| CORE-EEAT (Content) | [Run content-quality-auditor on sample pages] | — |
+
+**Diagnosis Matrix**:
+- High CITE + High CORE-EEAT → Maintain and expand
+- High CITE + Low CORE-EEAT → Prioritize content quality
+- Low CITE + High CORE-EEAT → Build domain authority
+- Low CITE + Low CORE-EEAT → Start with content, then domain
+
+### Recommended Next Steps
+
+- For domain authority building: focus on top 5 priorities above
+- For content improvement: use `content-quality-auditor` on key pages
+- For backlink strategy: use `backlink-analyzer` for detailed link analysis
+- For competitor benchmarking: use `competitor-analysis` with CITE scores
+- For tracking progress: run `/seo:report` with CITE score trends
+```
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Domain identified and accessible
+- [ ] Domain type confirmed (auto-detected or user-specified)
+- [ ] Backlink data available (at minimum: referring domains count, DA/DR)
+- [ ] If comparative audit, competitor domains also specified
+
+### Output Validation
+- [ ] All 40 items scored (or marked N/A with reason)
+- [ ] All 4 dimension scores calculated correctly
+- [ ] Weighted CITE Score matches domain-type weight configuration
+- [ ] All 3 veto items checked first and flagged if triggered
+- [ ] Top 5 improvements sorted by weighted impact, not arbitrary
+- [ ] Every recommendation is specific and actionable (not generic advice)
+- [ ] Action plan includes concrete steps with effort estimates
+
+## Example
+
+See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/references/example-report.md) for a complete CITE audit of cloudhosting.com showing veto check, dimension scores, top 5 improvements, action plan, and cross-reference with CORE-EEAT.
+
+## Tips for Success
+
+1. **Start with veto items** — T03, T05, T09 can invalidate the entire score
+2. **Identify domain type first** — Different types have very different weight profiles
+3. **AI citation items (C05-C08) matter most for GEO** — Test by querying AI engines with niche-relevant questions
+4. **Some items need specialized tools** — Knowledge graph queries, AI citation monitoring, and IP diversity analysis may require manual research if tools aren't connected
+5. **Pair with CORE-EEAT for full picture** — Domain authority without content quality (or vice versa) tells only half the story
+
+## Reference Materials
+
+- [CITE Domain Rating](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md) — Full 40-item benchmark with dimension definitions, scoring criteria, domain-type weight tables, and veto items
+- [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/references/example-report.md) — Complete CITE audit example with scored dimensions, top 5 improvements, action plan, and CORE-EEAT cross-reference
+
+## Next Best Skill
+
+- **Primary**: [backlink-analyzer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/SKILL.md) — turn trust or citation issues into link-level investigation.
diff --git a/.cursor/skills/domain-authority-auditor/references/example-report.md b/.cursor/skills/domain-authority-auditor/references/example-report.md
new file mode 100644
index 0000000000..fe20e30132
--- /dev/null
+++ b/.cursor/skills/domain-authority-auditor/references/example-report.md
@@ -0,0 +1,99 @@
+# CITE Domain Authority Report — Example
+
+Full example audit output for reference. See the [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/SKILL.md) for the complete workflow.
+
+## Example
+
+**User**: "Audit domain authority for cloudhosting.com as a content publisher"
+
+**Output**:
+
+```markdown
+## CITE Domain Authority Report
+
+### Overview
+
+- **Domain**: cloudhosting.com
+- **Domain Type**: Content Publisher
+- **Audit Date**: 2025-02-03
+- **CITE Score**: 69.9/100 (Medium)
+- **Veto Status**: ✅ No triggers
+
+#### Veto Check (Emergency Brake)
+
+| Veto Item | Status | Action |
+|-----------|--------|--------|
+| T03: Link-Traffic Coherence | ✅ Pass | Link growth correlates with traffic growth |
+| T05: Backlink Profile Uniqueness | ✅ Pass | No PBN patterns detected; diverse link sources |
+| T09: Penalty & Deindex History | ✅ Pass | No manual actions; clean penalty history |
+
+### Dimension Scores
+
+| Dimension | Score | Rating | Weight | Weighted |
+|-----------|-------|--------|--------|----------|
+| C — Citation | 72/100 | Medium | 40% | 28.8 |
+| I — Identity | 58/100 | Low | 15% | 8.7 |
+| T — Trust | 81/100 | Good | 20% | 16.2 |
+| E — Eminence | 65/100 | Medium | 25% | 16.25 |
+| **CITE Score** | | | | **69.9/100** |
+
+**Score Calculation**:
+- CITE Score = 72 × 0.40 + 58 × 0.15 + 81 × 0.20 + 65 × 0.25 = 69.9
+
+**Rating Scale**: 90-100 Excellent | 75-89 Good | 60-74 Medium | 40-59 Low | 0-39 Poor
+
+### Top 5 Priority Improvements
+
+Sorted by: weight × points lost (highest impact first)
+
+1. **I01 Knowledge Graph Presence** — Create entity entry in Google Knowledge Graph
+ - Current: Fail | Potential gain: 1.5 weighted points
+ - Action: Create Wikidata entry for CloudHost Inc. with P856 (website), P452 (industry), P571 (inception)
+
+2. **C05 AI Citation Volume** — Increase citations in AI-generated answers
+ - Current: Partial | Potential gain: 2.0 weighted points
+ - Action: Optimize top 10 pages for GEO; add definitive statements AI can quote directly
+
+3. **I03 Brand SERP Control** — Branded SERP shows only 4 of 10 results from owned properties
+ - Current: Partial | Potential gain: 0.75 weighted points
+ - Action: Claim Google Business Profile; build out social profiles; create CrunchBase entry
+
+4. **E04 Content Freshness Cadence** — 40% of content is >12 months without update
+ - Current: Partial | Potential gain: 1.25 weighted points
+ - Action: Establish monthly content refresh schedule; prioritize top 20 traffic pages
+
+5. **I05 Schema.org Completeness** — Organization schema missing sameAs, founder, foundingDate
+ - Current: Partial | Potential gain: 0.75 weighted points
+ - Action: Add complete Organization schema with sameAs links to Wikidata, LinkedIn, CrunchBase
+
+### Action Plan
+
+#### Quick Wins (< 1 week)
+- [ ] Add sameAs, founder, and foundingDate to Organization schema
+- [ ] Claim Google Business Profile for branded SERP control
+
+#### Medium Effort (1-4 weeks)
+- [ ] Create Wikidata entry with complete properties and references
+- [ ] Optimize top 10 pages with GEO-friendly definitive statements
+- [ ] Create or complete CrunchBase, LinkedIn company page profiles
+
+#### Strategic (1-3 months)
+- [ ] Launch monthly content refresh program targeting stale pages
+- [ ] Build topical authority through 3-4 pillar content clusters
+- [ ] Pursue digital PR to earn mentions on industry publications (TechCrunch, G2)
+
+### Cross-Reference with CORE-EEAT
+
+| Assessment | Score | Rating |
+|-----------|-------|--------|
+| CITE (Domain) | 69.9/100 | Medium |
+| CORE-EEAT (Content) | Run content-quality-auditor on sample pages | — |
+
+**Diagnosis**: Low CITE + unknown CORE-EEAT → Run `/seo:audit-page` on top 5 landing pages to determine whether to prioritize content quality or domain authority first.
+
+### Recommended Next Steps
+
+- For entity building: run `entity-optimizer` to strengthen I-dimension signals
+- For content audit: use `content-quality-auditor` on key pages
+- For tracking progress: run `/seo:report` with CITE score trends quarterly
+```
diff --git a/.cursor/skills/entity-optimizer/SKILL.md b/.cursor/skills/entity-optimizer/SKILL.md
new file mode 100644
index 0000000000..7a2a6c5029
--- /dev/null
+++ b/.cursor/skills/entity-optimizer/SKILL.md
@@ -0,0 +1,376 @@
+---
+name: entity-optimizer
+description: 'Build entity presence in Knowledge Graph, Wikidata, AI systems for brand recognition and citations. 实体优化/知识图谱'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when optimizing entity presence for Knowledge Graph, Wikidata, or AI engine disambiguation. Also for brand entity canonicalization."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "high"
+ tags:
+ - seo
+ - geo
+ - entity-optimization
+ - knowledge-graph
+ - knowledge-panel
+ - brand-entity
+ - wikidata
+ - entity-disambiguation
+ - 实体优化
+ - エンティティ
+ - 엔티티
+ - entidad-seo
+ triggers:
+ # EN-formal
+ - "optimize entity presence"
+ - "build knowledge graph"
+ - "improve knowledge panel"
+ - "entity audit"
+ - "establish brand entity"
+ - "entity disambiguation"
+ # EN-casual
+ - "Google doesn't know my brand"
+ - "no knowledge panel"
+ - "establish my brand"
+ - "establish my brand as an entity"
+ - "get a Google knowledge card"
+ - "no Wikipedia entry"
+ # EN-question
+ - "how to get a knowledge panel"
+ - "how to build brand entity"
+ # ZH-pro
+ - "实体优化"
+ - "知识图谱"
+ - "品牌实体"
+ - "知识面板"
+ - "品牌词"
+ - "品牌词优化"
+ # ZH-casual
+ - "品牌搜不到"
+ - "没有知识面板"
+ - "Google不认识我的品牌"
+ # JA
+ - "エンティティ最適化"
+ - "ナレッジパネル"
+ # KO
+ - "엔티티 최적화"
+ - "지식 패널"
+ - "구글이 내 브랜드 모르는데?"
+ - "지식 패널 만들려면?"
+ # ES
+ - "optimización de entidad"
+ - "panel de conocimiento"
+ # PT
+ - "otimização de entidade"
+ # Misspellings
+ - "knowlege panel"
+ - "enity optimization"
+---
+
+# Entity Optimizer
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+Audits, builds, and maintains entity identity across search engines and AI systems. Entities — the people, organizations, products, and concepts that search engines and AI systems recognize as distinct things — are the foundation of how both Google and LLMs decide *what a brand is* and *whether to cite it*.
+
+**Why entities matter for SEO + GEO:**
+
+- **SEO**: Google's Knowledge Graph powers Knowledge Panels, rich results, and entity-based ranking signals. A well-defined entity earns SERP real estate.
+- **GEO**: AI systems resolve queries to entities before generating answers. If an AI cannot identify an entity, it cannot cite it — no matter how good the content is.
+
+**System role**: Canonical Entity Profile. It acts as the source of truth for entity identity, associations, and disambiguation across the library.
+
+## When This Must Trigger
+
+Use this when brand or entity identity needs to be established or verified — even if the user doesn't use entity terminology:
+
+- User says "Google doesn't know my brand" or "no knowledge panel"
+- Auto-recommended when `memory/entities/candidates.md` accumulates 3 or more uncanonized entity candidates from other skills
+- Establishing a new brand/person/product as a recognized entity
+- Auditing current entity presence across Knowledge Graph, Wikidata, and AI systems
+- Improving or correcting a Knowledge Panel
+- Building entity associations (entity ↔ topic, entity ↔ industry)
+- Resolving entity disambiguation issues (your entity confused with another)
+- Strengthening entity signals for AI citation
+- After launching a new brand, product, or organization
+- Preparing for a site migration (preserving entity identity)
+- Running periodic entity health checks
+
+## What This Skill Does
+
+1. **Entity Audit**: Evaluates current entity presence across search and AI systems
+2. **Knowledge Graph Analysis**: Checks Google Knowledge Graph, Wikidata, and Wikipedia status
+3. **AI Entity Resolution Test**: Queries AI systems to see how they identify and describe the entity
+4. **Entity Signal Mapping**: Identifies all signals that establish entity identity
+5. **Gap Analysis**: Finds missing or weak entity signals
+6. **Entity Building Plan**: Creates actionable plan to establish or strengthen entity presence
+7. **Disambiguation Strategy**: Resolves confusion with similarly-named entities
+
+## Quick Start
+
+Start with one of these prompts. Finish with a canonical entity profile and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Entity Audit
+
+```
+Audit entity presence for [brand/person/organization]
+```
+
+```
+How well do search engines and AI systems recognize [entity name]?
+```
+
+### Build Entity Presence
+
+```
+Build entity presence for [new brand] in the [industry] space
+```
+
+```
+Establish [person name] as a recognized expert in [topic]
+```
+
+### Fix Entity Issues
+
+```
+My Knowledge Panel shows incorrect information — fix entity signals for [entity]
+```
+
+```
+AI systems confuse [my entity] with [other entity] — help me disambiguate
+```
+
+## Skill Contract
+
+**Expected output**: an entity audit, a canonical entity profile, and a short handoff summary ready for `memory/entities/`.
+
+- **Reads**: the entity name, primary domain, known profiles, topic associations, and prior brand context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing entity report plus a reusable profile that can be stored under `memory/entities/`.
+- **Promotes**: canonical names, sameAs links, disambiguation notes, and entity gaps to `CLAUDE.md`, `memory/entities/`, and `memory/open-loops.md`.
+
+This skill is the sole writer of canonical entity profiles at `memory/entities/.md`. Other skills write entity candidates to `memory/entities/candidates.md` only. When 3+ candidates accumulate, this skill should be recommended.
+
+- **Next handoff**: use the `Next Best Skill` below once the entity truth is clear.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~knowledge graph + ~~SEO tool + ~~AI monitor + ~~brand monitor connected:**
+Query Knowledge Graph API for entity status, pull branded search data from ~~SEO tool, test AI citation with ~~AI monitor, track brand mentions with ~~brand monitor.
+
+**With manual data only:**
+Ask the user to provide:
+1. Entity name, type (Person, Organization, Brand, Product, Creative Work, Event)
+2. Primary website / domain
+3. Known existing profiles (Wikipedia, Wikidata, social media, industry directories)
+4. Top 3-5 topics/industries the entity should be associated with
+5. Any known disambiguation issues (other entities with same/similar name)
+
+Without tools, Claude provides entity optimization strategy and recommendations based on information the user provides. The user must run search queries, check Knowledge Panels, and test AI responses to supply the raw data for analysis.
+
+Proceed with the audit using public search results, AI query testing, and SERP analysis. Note which items require tool access for full evaluation.
+
+## Instructions
+
+When a user requests entity optimization:
+
+### Step 1: Entity Discovery
+
+Establish the entity's current state across all systems.
+
+```markdown
+### Entity Profile
+
+**Entity Name**: [name]
+**Entity Type**: [Person / Organization / Brand / Product / Creative Work / Event]
+**Primary Domain**: [URL]
+**Target Topics**: [topic 1, topic 2, topic 3]
+
+#### Current Entity Presence
+
+| Platform | Status | Details |
+|----------|--------|---------|
+| Google Knowledge Panel | ✅ Present / ❌ Absent / ⚠️ Incorrect | [details] |
+| Wikidata | ✅ Listed / ❌ Not listed | [QID if exists] |
+| Wikipedia | ✅ Article / ⚠️ Mentioned only / ❌ Absent | [notability assessment] |
+| Google Knowledge Graph API | ✅ Entity found / ❌ Not found | [entity ID, types, score] |
+| Schema.org on site | ✅ Complete / ⚠️ Partial / ❌ Missing | [Organization/Person/Product schema] |
+
+#### AI Entity Resolution Test
+
+**Note**: Claude cannot directly query other AI systems or perform real-time web searches without tool access. When running without ~~AI monitor or ~~knowledge graph tools, ask the user to run these test queries and report the results, or use the user-provided information to assess entity presence.
+
+Test how AI systems identify this entity by querying:
+- "What is [entity name]?"
+- "Who founded [entity name]?" (for organizations)
+- "What does [entity name] do?"
+- "[entity name] vs [competitor]"
+
+| AI System | Recognizes Entity? | Description Accuracy | Cites Entity's Content? |
+|-----------|-------------------|---------------------|------------------------|
+| ChatGPT | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
+| Claude | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
+| Perplexity | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
+| Google AI Overview | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
+```
+
+### Step 2: Entity Signal Audit
+
+Evaluate entity signals across 6 categories. For the detailed 47-signal checklist with verification methods, see [references/entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md).
+
+Evaluate each signal as Pass / Fail / Partial with a specific action for each gap. The 6 categories are:
+
+1. **Structured Data Signals** -- Organization/Person schema, sameAs links, @id consistency, author schema
+2. **Knowledge Base Signals** -- Wikidata, Wikipedia, CrunchBase, industry directories
+3. **Consistent NAP+E Signals** -- Name/description/logo/social consistency across platforms
+4. **Content-Based Entity Signals** -- About page, author pages, topical authority, branded backlinks
+5. **Third-Party Entity Signals** -- Authoritative mentions, co-citation, reviews, press coverage
+6. **AI-Specific Entity Signals** -- Clear definitions, disambiguation, verifiable claims, crawlability
+
+> **Reference**: Use the audit template in [references/entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md) for the full 47-signal checklist with verification methods for each category.
+
+### Step 3: Report & Action Plan
+
+```markdown
+## Entity Optimization Report
+
+### Overview
+
+- **Entity**: [name]
+- **Entity Type**: [type]
+- **Audit Date**: [date]
+
+### Signal Category Summary
+
+| Category | Status | Key Findings |
+|----------|--------|-------------|
+| Structured Data | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
+| Knowledge Base | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
+| Consistency (NAP+E) | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
+| Content-Based | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
+| Third-Party | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
+| AI-Specific | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
+
+### Critical Issues
+
+[List any issues that severely impact entity recognition — disambiguation problems, incorrect Knowledge Panel, missing from Knowledge Graph entirely]
+
+### Top 5 Priority Actions
+
+Sorted by: impact on entity recognition × effort required
+
+1. **[Signal]** — [specific action]
+ - Impact: [High/Medium] | Effort: [Low/Medium/High]
+ - Why: [explanation of how this improves entity recognition]
+
+2. **[Signal]** — [specific action]
+ - Impact: [High/Medium] | Effort: [Low/Medium/High]
+ - Why: [explanation]
+
+3–5. [Same format]
+
+### Entity Building Roadmap
+
+#### Week 1-2: Foundation (Structured Data + Consistency)
+- [ ] Implement/fix Organization or Person schema with full properties
+- [ ] Add sameAs links to all authoritative profiles
+- [ ] Audit and fix NAP+E consistency across all platforms
+- [ ] Ensure About page is entity-rich and well-structured
+
+#### Month 1: Knowledge Bases
+- [ ] Create or update Wikidata entry with complete properties
+- [ ] Ensure CrunchBase / industry directory profiles are complete
+- [ ] Build Wikipedia notability (or plan path to notability)
+- [ ] Submit to relevant authoritative directories
+
+#### Month 2-3: Authority Building
+- [ ] Secure mentions on authoritative industry sites
+- [ ] Build co-citation signals with established entities
+- [ ] Create topical content clusters that reinforce entity-topic associations
+- [ ] Pursue PR opportunities that generate entity mentions
+
+#### Ongoing: AI-Specific Optimization
+- [ ] Test AI entity resolution quarterly
+- [ ] Update factual claims to remain current and verifiable
+- [ ] Monitor AI systems for incorrect entity information
+- [ ] Ensure new content reinforces entity identity signals
+
+### Cross-Reference
+
+- **CORE-EEAT relevance**: Items A07 (Knowledge Graph Presence) and A08 (Entity Consistency) directly overlap — entity optimization strengthens Authority dimension
+- **CITE relevance**: CITE I01-I10 (Identity dimension) measures entity signals at domain level — entity optimization feeds these scores
+- For content-level audit: `content-quality-auditor`
+- For domain-level audit: `domain-authority-auditor`
+```
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Entity name and type identified
+- [ ] Primary domain/website confirmed
+- [ ] Target topics/industries specified
+- [ ] Disambiguation context provided (if entity name is common)
+
+### Output Validation
+- [ ] All 6 signal categories evaluated
+- [ ] AI entity resolution tested with at least 3 queries
+- [ ] Knowledge Panel status checked
+- [ ] Wikidata/Wikipedia status verified
+- [ ] Schema.org markup on primary site audited
+- [ ] Every recommendation is specific and actionable
+- [ ] Roadmap includes concrete steps with timeframes
+- [ ] Cross-reference with CORE-EEAT A07/A08 and CITE I01-I10 noted
+
+## Example
+
+> **Reference**: See [references/example-audit-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/example-audit-report.md) for a complete example entity audit report for a B2B SaaS company (CloudMetrics), including AI entity resolution test results, entity health summary, top 3 priority actions, and CORE-EEAT/CITE cross-references.
+
+## Tips for Success
+
+1. **Start with Wikidata** — It's the single most influential editable knowledge base; a complete Wikidata entry with references often triggers Knowledge Panel creation within weeks
+2. **sameAs is your most powerful Schema.org property** — It directly tells search engines "I am this entity in the Knowledge Graph"; always include Wikidata URL first
+3. **Test AI recognition before and after** — Query ChatGPT, Claude, Perplexity, and Google AI Overview before optimizing, then again after; this is the most direct GEO metric
+4. **Entity signals compound** — Unlike content SEO, entity signals from different sources reinforce each other; 5 weak signals together are stronger than 1 strong signal alone
+5. **Consistency beats completeness** — A consistent entity name and description across 10 platforms beats a perfect profile on just 2
+6. **Don't neglect disambiguation** — If your entity name is shared with anything else, disambiguation is the first priority; all other signals are wasted if they're attributed to the wrong entity
+7. **Pair with CITE I-dimension for domain context** — Entity audit tells you how well the entity is recognized; CITE Identity (I01-I10) tells you how well the domain represents that entity; use both together
+
+## Entity Type Reference
+
+> **Reference**: See [references/entity-type-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-type-reference.md) for entity types with key signals, schemas, and disambiguation strategies by situation.
+
+## Knowledge Panel & Wikidata Optimization
+
+> **Reference**: See [references/knowledge-panel-wikidata-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/knowledge-panel-wikidata-guide.md) for Knowledge Panel claiming/editing, common issues and fixes, Wikidata entry creation, key properties by entity type, and AI entity resolution optimization.
+
+## Reference Materials
+
+Detailed guides for entity optimization:
+- [references/entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md) — Complete signal checklist with verification methods
+- [references/knowledge-graph-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/knowledge-graph-guide.md) — Wikidata, Wikipedia, and Knowledge Graph optimization playbook
+
+## Next Best Skill
+
+- **Primary**: [schema-markup-generator](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/SKILL.md) — turn entity truth into machine-readable implementation.
diff --git a/.cursor/skills/entity-optimizer/references/entity-signal-checklist.md b/.cursor/skills/entity-optimizer/references/entity-signal-checklist.md
new file mode 100644
index 0000000000..b6b772fe59
--- /dev/null
+++ b/.cursor/skills/entity-optimizer/references/entity-signal-checklist.md
@@ -0,0 +1,137 @@
+# Entity Signal Checklist
+
+> Part of [entity-optimizer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/SKILL.md). See also: [knowledge-graph-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/knowledge-graph-guide.md)
+
+Complete checklist of entity signals organized by priority and verification method. Use this as a systematic audit guide — work through each signal, verify its status, and note actions needed.
+
+## Priority 1: Foundation Signals (Must-Have)
+
+These signals form the minimum viable entity identity. Without them, search engines and AI systems cannot reliably identify the entity.
+
+### On-Site Structured Data
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 1 | Organization or Person schema on homepage | Run Google Rich Results Test on homepage | Schema present with name, url, logo, description |
+| 2 | sameAs property links to all authoritative profiles | Inspect schema markup | Links to Wikipedia, Wikidata, LinkedIn, social profiles |
+| 3 | Consistent @id used across all pages | Inspect schema on 5+ pages | Same @id (typically homepage URL + #organization) on every page |
+| 4 | About page exists with entity-rich content | Manual review | First paragraph defines entity clearly; includes founding date, key people, mission |
+| 5 | Contact page with verifiable information | Manual review | Physical address, phone, email — matches other directory listings |
+
+### Key External Profiles
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 6 | Wikidata entry exists | Search wikidata.org | Entry with label, description, key properties, and references |
+| 7 | Google Business Profile (if applicable) | Search "[entity] Google Business" | Claimed, verified, complete profile |
+| 8 | LinkedIn company/person page | Search LinkedIn | Complete profile matching entity name and description |
+| 9 | CrunchBase profile (for companies/products) | Search crunchbase.com | Entry with description, founding info, key people |
+| 10 | Primary industry directory listing | Search top 3 industry directories | Listed with correct entity information |
+
+### Branded Search Presence
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 11 | Branded search returns correct entity | Google "[entity name]" | Entity's website is #1; Knowledge Panel appears or SERP clearly identifies entity |
+| 12 | No disambiguation confusion | Google "[entity name]" | No other prominent entity dominates results for the same name |
+| 13 | Branded search volume exists | Check ~~SEO tool | Measurable branded search volume (any amount > 0) |
+
+## Priority 2: Authority Signals (Should-Have)
+
+These signals establish the entity as recognized and authoritative. They separate a "registered entity" from a "known entity."
+
+### Knowledge Graph Depth
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 14 | Google Knowledge Panel present | Google "[entity name]" | Knowledge Panel displayed with correct information |
+| 15 | Knowledge Panel attributes complete | Review Knowledge Panel | Key attributes filled (founded, CEO, location, industry, etc.) |
+| 16 | Knowledge Panel image correct | Review Knowledge Panel | Preferred image displayed |
+| 17 | Wikipedia article (or strong notability path) | Search Wikipedia | Article exists, or entity has 3+ independent reliable sources for future article |
+| 18 | Wikidata properties complete | Review Wikidata entry | 10+ properties with references |
+
+### Third-Party Validation
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 19 | Authoritative media mentions | Google News search for entity | 3+ mentions in recognized publications |
+| 20 | Industry awards or recognitions | Search "[entity] award" | At least 1 verifiable award or recognition |
+| 21 | Co-citation with established entities | Search for entity alongside competitors | Appears in "X vs Y" comparisons, listicles, or industry roundups |
+| 22 | Speaking engagements or publications | Search event/conference sites | Appears as speaker, author, or contributor |
+| 23 | Reviews on third-party platforms | Check G2, Trustpilot, Yelp, etc. | Reviews exist with reasonable volume and rating |
+
+### Content Authority
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 24 | Topical content depth in target areas | Site search for target topics | 10+ pages covering target topics in depth |
+| 25 | Author pages with credentials | Review author pages | Author schema, credentials, sameAs to external profiles |
+| 26 | Original research or data published | Review content | At least 1 piece of original data/research cited by others |
+| 27 | Entity mentioned in own content naturally | Search site for entity name | Entity name appears contextually (not just in header/footer) |
+
+## Priority 3: AI-Specific Signals (Must-Have for GEO)
+
+These signals specifically help AI systems recognize, understand, and cite the entity.
+
+### AI Recognition
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 28 | ChatGPT recognizes entity | Ask "What is [entity]?" | Correct description returned |
+| 29 | Perplexity recognizes entity | Ask "What is [entity]?" | Correct description with source citations |
+| 30 | Google AI Overview mentions entity | Search branded + topical queries | Entity appears in AI-generated overview |
+| 31 | AI description is accurate | Compare AI output to entity's self-description | No factual errors in AI's response |
+| 32 | AI associates entity with correct topics | Ask "[entity] expertise areas" | Correct topic associations returned |
+
+### AI Optimization
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 33 | Entity definition quotable in first paragraph | Review About page and key pages | Clear, factual, self-contained definition suitable for AI quotation |
+| 34 | Factual claims are verifiable | Cross-reference claims with external sources | All claims about entity can be verified via third-party sources |
+| 35 | Entity name used consistently | Audit all platforms | Identical name format everywhere (no abbreviations in some places, full name in others) |
+| 36 | Content is crawlable by AI systems | Check robots.txt for AI bot access | Not blocking GPTBot, ClaudeBot, or other AI crawlers (unless intentional) |
+| 37 | Fresh information available | Check update dates | Key entity pages updated within last 6 months |
+
+## Priority 4: Advanced Signals (Nice-to-Have)
+
+These signals provide marginal gains but demonstrate thoroughness and maturity.
+
+### Extended Knowledge Base Presence
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 38 | Multiple language entries in Wikidata | Check Wikidata labels | Labels and descriptions in languages matching target markets |
+| 39 | DBpedia entry | Search dbpedia.org | Entry exists (auto-generated from Wikipedia) |
+| 40 | Google Knowledge Graph ID known | Search Google Knowledge Graph API | Entity has a kg: identifier |
+| 41 | ISNI or VIAF identifier (for persons) | Search isni.org or viaf.org | Identifier exists and links correctly |
+
+### Social Entity Signals
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 42 | Social profiles bidirectionally linked | Check website links to social AND social links to website | Both directions verified on all platforms |
+| 43 | Consistent entity description across social | Compare bios on all platforms | Same core description, adapted for platform length limits |
+| 44 | Social engagement demonstrates real audience | Review engagement metrics | Engagement patterns consistent with genuine audience (not bot-like) |
+
+### Technical Entity Signals
+
+| # | Signal | Verification Method | Pass Criteria |
+|---|--------|-------------------|---------------|
+| 45 | Entity homepage has strong backlink profile | Check ~~link database | Homepage DR/DA above industry median |
+| 46 | Branded anchor text in backlinks | Analyze anchor text distribution | Entity name appears naturally in inbound link anchor text |
+| 47 | Entity subdomain consistency | Check all subdomains | Same entity schema and branding across all subdomains |
+
+## How to Use This Checklist
+
+Work through signals by priority tier. For each signal, mark status as ✅ (present and correct), ⚠️ (present but incomplete), or ❌ (absent). Focus on completing each priority tier before moving to the next.
+
+### Priority Action Matrix
+
+| Current State | Focus Area | Expected Timeline |
+|--------------|-----------|-------------------|
+| Most Priority 1 signals ❌ | Priority 1 foundation signals only | 2-4 weeks |
+| Priority 1 mostly ✅, Priority 2 mixed | Priority 2 authority signals | 1-2 months |
+| Priority 1-2 mostly ✅ | Priority 3 AI-specific signals | 2-3 months |
+| Priority 1-3 mostly ✅ | Selective Priority 4 for completeness | Ongoing |
+| All tiers mostly ✅ | Maintenance + quarterly re-audit | Quarterly review |
diff --git a/.cursor/skills/entity-optimizer/references/entity-type-reference.md b/.cursor/skills/entity-optimizer/references/entity-type-reference.md
new file mode 100644
index 0000000000..1bffe7d948
--- /dev/null
+++ b/.cursor/skills/entity-optimizer/references/entity-type-reference.md
@@ -0,0 +1,24 @@
+# Entity Type Reference
+
+Reference tables for entity types, key signals, and disambiguation strategies.
+
+## Entity Types and Key Signals
+
+| Entity Type | Primary Signals | Secondary Signals | Key Schema |
+|-------------|----------------|-------------------|------------|
+| **Person** | Author pages, social profiles, publication history | Speaking, awards, media mentions | Person, ProfilePage |
+| **Organization** | Registration records, Wikidata, industry listings | Press coverage, partnerships, awards | Organization, Corporation |
+| **Brand** | Trademark, branded search volume, social presence | Reviews, brand mentions, visual identity | Brand, Organization |
+| **Product** | Product pages, reviews, comparison mentions | Awards, expert endorsements, market share | Product, SoftwareApplication |
+| **Creative Work** | Publication record, citations, reviews | Awards, adaptations, cultural impact | CreativeWork, Book, Movie |
+| **Event** | Event listings, press coverage, social buzz | Sponsorships, speaker profiles, attendance | Event |
+
+## Disambiguation Strategy by Situation
+
+| Situation | Strategy |
+|-----------|----------|
+| **Common name, unique entity** | Strengthen all signals; let signal volume resolve ambiguity |
+| **Name collision with larger entity** | Add qualifier consistently (e.g., "Acme Software" not just "Acme"); use sameAs extensively; build topic-specific authority that differentiates |
+| **Name collision with similar entity** | Geographic, industry, or product qualifiers; ensure Schema @id is unique and consistent; prioritize Wikidata disambiguation |
+| **Abbreviation/acronym conflict** | Prefer full name in structured data; use abbreviation only in contexts where entity is already established |
+| **Merged or renamed entity** | Redirect old entity signals; update all structured data; create explicit "formerly known as" content; update Wikidata |
diff --git a/.cursor/skills/entity-optimizer/references/example-audit-report.md b/.cursor/skills/entity-optimizer/references/example-audit-report.md
new file mode 100644
index 0000000000..dc3648167f
--- /dev/null
+++ b/.cursor/skills/entity-optimizer/references/example-audit-report.md
@@ -0,0 +1,59 @@
+# Example: Entity Optimization Report
+
+Full example output for an entity audit request.
+
+**User**: "Audit entity presence for CloudMetrics, our B2B SaaS analytics platform at cloudmetrics.io"
+
+**Output**:
+
+```markdown
+## Entity Optimization Report
+
+### Entity Profile
+
+**Entity Name**: CloudMetrics
+**Entity Type**: Organization (B2B SaaS)
+**Primary Domain**: cloudmetrics.io
+**Target Topics**: analytics platform, business intelligence, enterprise analytics
+
+### AI Entity Resolution Test
+
+Queries tested with results reported by user:
+
+| Query | Result | Assessment |
+|-------|--------|------------|
+| "What is CloudMetrics?" | Described as "an analytics tool" with no further detail | Partial recognition -- generic description, no mention of B2B focus or key features |
+| "Best analytics platforms for enterprises" | CloudMetrics not mentioned in any AI response | Not recognized as a player in the enterprise analytics space |
+| "CloudMetrics vs Datadog" | Correctly identified as a competitor to Datadog, but feature comparison was incomplete and partially inaccurate | Partial -- entity is associated with the right category but attributes are thin |
+| "Who founded CloudMetrics?" | No answer found by any AI system tested | Entity leadership not present in AI knowledge bases |
+
+### Entity Health Summary
+
+| Signal Category | Status | Key Findings |
+|-----------------|--------|--------------|
+| Knowledge Graph | Missing | No Wikidata entry exists; no Google Knowledge Panel triggers for branded queries |
+| Structured Data | Partial | Organization schema present on homepage with name, url, and logo; missing Person schema for CEO and leadership team; no sameAs links to external profiles |
+| Web Presence | Strong | Consistent NAP across LinkedIn, Twitter/X, G2, and Crunchbase; social profiles link back to cloudmetrics.io; branded search returns owned properties in top 5 |
+| Content-Based | Partial | About page exists but opens with marketing copy rather than an entity-defining statement; no dedicated author pages for leadership |
+| Third-Party | Partial | Listed on G2 and Crunchbase; 2 industry publication mentions found; no awards or analyst coverage |
+| AI-Specific | Weak | AI systems have only surface-level awareness; entity definition is not quotable from any authoritative source |
+
+### Top 3 Priority Actions
+
+1. **Create Wikidata entry** with key properties: instance of (P31: business intelligence software company), official website (P856: cloudmetrics.io), inception (P571), country (P17)
+ - Impact: High | Effort: Low
+ - Why: Wikidata is the foundational knowledge base that feeds Google Knowledge Graph, Bing, and AI training pipelines; without it, the entity cannot be formally resolved
+
+2. **Add Person schema for leadership team** on the About/Team page, including name, jobTitle, sameAs links to LinkedIn profiles, and worksFor pointing to the Organization entity
+ - Impact: High | Effort: Low
+ - Why: Addresses the "Who founded CloudMetrics?" gap directly; Person schema for key people creates bidirectional entity associations that strengthen organizational identity
+
+3. **Build Wikipedia notability through independent press coverage** -- target 3-5 articles in industry publications (TechCrunch, VentureBeat, Analytics India Magazine) that mention CloudMetrics by name with verifiable claims
+ - Impact: High | Effort: High
+ - Why: Wikipedia notability requires coverage in independent reliable sources; press mentions simultaneously feed AI training data, build third-party entity signals, and create the citation foundation for a future Wikipedia article
+
+### Cross-Reference
+
+- **CORE-EEAT**: A07 (Knowledge Graph Presence) scored Fail, A08 (Entity Consistency) scored Pass -- entity optimization should focus on knowledge base gaps rather than consistency
+- **CITE**: I-dimension weakest area is I01 (Knowledge Graph Presence) -- completing Wikidata entry and earning Knowledge Panel directly improves domain identity score
+```
diff --git a/.cursor/skills/entity-optimizer/references/knowledge-graph-guide.md b/.cursor/skills/entity-optimizer/references/knowledge-graph-guide.md
new file mode 100644
index 0000000000..ed7ad0aeb4
--- /dev/null
+++ b/.cursor/skills/entity-optimizer/references/knowledge-graph-guide.md
@@ -0,0 +1,399 @@
+# Knowledge Graph Optimization Guide
+
+> Part of [entity-optimizer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/SKILL.md). See also: [entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md)
+
+Comprehensive playbook for establishing and maintaining entity presence across Google Knowledge Graph, Wikidata, Wikipedia, and other knowledge bases.
+
+## How Knowledge Graphs Work
+
+### The Entity Web
+
+Knowledge graphs are interconnected databases of entities and their relationships. Search engines and AI systems use them as ground truth for entity understanding.
+
+```
+Your Entity
+├── is described by → Wikidata entry
+├── is described by → Wikipedia article
+├── is described by → Schema.org markup on your site
+├── is linked to → Social profiles (LinkedIn, X, etc.)
+├── is mentioned by → News articles, industry sites
+├── is associated with → Topics, industries, other entities
+└── is recognized by → Google Knowledge Graph, Bing Satori, AI training data
+```
+
+### Which Knowledge Graphs Matter
+
+| Knowledge Graph | Who Uses It | Impact |
+|----------------|-------------|--------|
+| **Google Knowledge Graph** | Google Search, Google AI | Powers Knowledge Panels, rich results, entity understanding in search |
+| **Wikidata** | Google, Bing, Apple, Amazon, AI systems | Open data feeds multiple knowledge graphs; primary structured data source |
+| **Wikipedia** | Google, all AI systems | Training data for every major LLM; Knowledge Panel descriptions often sourced here |
+| **Bing Satori** | Bing, Copilot | Powers Bing's entity understanding and Microsoft Copilot |
+| **Schema.org (your site)** | All search engines, AI crawlers | First-party structured data you control directly |
+| **DBpedia** | Research, some AI systems | Auto-extracted from Wikipedia; relevant for academic/research entities |
+
+### Data Flow
+
+```
+Your Website (Schema.org) ─┐
+Wikidata ──────────────────┤
+Wikipedia ─────────────────┼──→ Google Knowledge Graph ──→ Knowledge Panel
+Industry Directories ──────┤ AI Search Results
+News/Media Mentions ───────┤ Rich Results
+Social Profiles ───────────┘
+```
+
+Understanding this flow is key: you influence the Knowledge Graph by controlling the **source signals** that feed it.
+
+## Google Knowledge Graph
+
+### Getting Into the Knowledge Graph
+
+There is no "submit to Knowledge Graph" form. Google builds its Knowledge Graph from multiple sources. To get included:
+
+1. **Have a Wikidata entry** — This is the most direct path
+2. **Earn a Wikipedia article** — Strongest single signal
+3. **Implement Schema.org markup** — Provides structured self-description
+4. **Get mentioned on authoritative sites** — Third-party validation
+5. **Build branded search demand** — Signals that users look for your entity
+
+### Checking Your Knowledge Graph Status
+
+**Method 1: Google Search**
+Search for your entity name in quotes. If a Knowledge Panel appears on the right, you're in the Knowledge Graph.
+
+**Method 2: Knowledge Graph API**
+```
+GET https://kgsearch.googleapis.com/v1/entities:search?query=[entity]&key=[API_KEY]
+```
+
+Response includes:
+- `@id`: Your Knowledge Graph ID (e.g., `kg:/m/0wrt4g`)
+- `name`: Entity name as Google understands it
+- `description`: Short entity description
+- `detailedDescription`: Longer description (usually from Wikipedia)
+- `resultScore`: Confidence score (higher = more established entity)
+
+**Method 3: ~~knowledge graph**
+If connected, query directly for entity status and attributes.
+
+### Claiming Your Knowledge Panel
+
+1. Search for your entity on Google
+2. If Knowledge Panel appears, look for "Claim this knowledge panel" link at bottom
+3. Verify via official website, Search Console, YouTube, or other Google property
+4. Once claimed, you can suggest edits (but Google has final say)
+
+### Common Knowledge Panel Fixes
+
+| Problem | Solution |
+|---------|----------|
+| **No Knowledge Panel** | Build Wikidata entry + Schema.org + authoritative mentions. Timeline: 2-6 months. |
+| **Wrong image** | Update preferred image on: Wikidata (P18), About page, social profiles. Claim panel and suggest preferred image. |
+| **Wrong description** | Edit Wikidata description. Update first paragraph of About page and Wikipedia article. |
+| **Missing attributes** | Add properties to Wikidata and Schema.org. Claim panel and suggest additions. |
+| **Outdated information** | Update Wikidata, About page, Wikipedia, and social profiles. Request refresh via claimed panel. |
+| **Wrong entity shown** | Disambiguation needed. See Wikidata section below for disambiguation strategy. |
+
+## Wikidata
+
+### Why Wikidata Is Critical
+
+Wikidata is the **single most influential editable knowledge base** for entity optimization:
+- Google uses it as a primary source for Knowledge Panels
+- Bing uses it for Satori knowledge graph
+- AI systems reference it during entity resolution
+- It's open and you can edit it (within their guidelines)
+
+### Creating a Wikidata Entry
+
+#### Step 1: Check Eligibility
+
+Wikidata requires "notability" — the entity must be referenced in at least one external source. Unlike Wikipedia, the notability bar is lower: a company mentioned in a news article, a product with reviews, or a person with published work typically qualifies.
+
+#### Step 2: Create the Item
+
+1. Go to https://www.wikidata.org/wiki/Special:NewItem
+2. Fill in:
+ - **Label**: Official entity name
+ - **Description**: Short description (e.g., "American software company" or "SEO optimization tool")
+ - **Aliases**: Alternative names, abbreviations, former names
+
+#### Step 3: Add Core Statements
+
+Essential properties for each entity type:
+
+**Organizations:**
+| Property | Code | Example |
+|----------|------|---------|
+| instance of | P31 | business (Q4830453) or specific type |
+| official website | P856 | https://example.com |
+| inception | P571 | 2020-01-15 |
+| country | P17 | United States (Q30) |
+| headquarters location | P159 | San Francisco (Q62) |
+| industry | P452 | software industry (Q638608) |
+| founded by | P112 | [founder's Wikidata item] |
+| CEO | P169 | [CEO's Wikidata item] |
+
+**Persons:**
+| Property | Code | Example |
+|----------|------|---------|
+| instance of | P31 | human (Q5) |
+| occupation | P106 | software engineer (Q183888) |
+| employer | P108 | [company Wikidata item] |
+| educated at | P69 | [university Wikidata item] |
+| country of citizenship | P27 | [country item] |
+| official website | P856 | https://example.com |
+
+**Products/Software:**
+| Property | Code | Example |
+|----------|------|---------|
+| instance of | P31 | software (Q7397) or web application (Q189210) |
+| developer | P178 | [company Wikidata item] |
+| official website | P856 | https://example.com |
+| programming language | P277 | Python (Q28865) |
+| operating system | P306 | Linux (Q388) |
+| software license | P275 | Apache-2.0 (Q13785927) |
+| inception | P571 | 2023-06-01 |
+
+#### Step 4: Add External Identifiers
+
+These link your Wikidata item to other knowledge bases:
+
+| Identifier | Code | Purpose |
+|-----------|------|---------|
+| official website | P856 | Primary web presence |
+| X (Twitter) username | P2002 | Social presence |
+| LinkedIn organization ID | P4264 | Professional presence |
+| GitHub username | P2037 | Technical presence |
+| CrunchBase ID | P2087 | Business data |
+| Google Knowledge Graph ID | P2671 | Google entity link |
+| App Store ID | P3861 | Mobile presence |
+
+#### Step 5: Add References
+
+**Every statement must have a reference.** Unreferenced statements may be removed.
+
+Good reference sources:
+- Official website (for factual claims like founding date)
+- News articles (for events, milestones)
+- Industry reports (for market position)
+- Government registries (for legal entity information)
+
+### Wikidata Maintenance
+
+| Task | Frequency | Why |
+|------|-----------|-----|
+| Review existing statements | Quarterly | Ensure accuracy; update changed information |
+| Add new properties | When new information available | Keep entry comprehensive |
+| Check for vandalism | Monthly | Others can edit your entry |
+| Add new references | When new coverage appears | Strengthen statement credibility |
+| Update identifiers | When new profiles created | Keep links current |
+
+## Wikipedia
+
+### Notability Requirements
+
+Wikipedia requires entities to meet "general notability guidelines" (GNG):
+- **Significant coverage** in **reliable, independent sources**
+- Coverage must be **non-trivial** (not just a mention or directory listing)
+- Sources must be **independent** of the entity (not press releases, not entity's own content)
+
+### Building Toward Notability
+
+If the entity doesn't have a Wikipedia article yet:
+
+1. **Audit existing coverage**: Search Google News, academic databases, and industry publications for mentions
+2. **Identify gaps**: What kinds of coverage are missing?
+3. **Build coverage first, then article**: The article is the last step, not the first
+
+Coverage-building strategies:
+| Strategy | Timeline | Notability Impact |
+|----------|----------|-------------------|
+| Industry report mentions | 3-6 months | Medium — depends on report authority |
+| News article coverage | 1-3 months | High — especially from recognized publications |
+| Conference speaking + coverage | 3-12 months | Medium — needs post-event coverage |
+| Academic paper citations | 6-12+ months | High — very strong for GNG |
+| Award recognition | Variable | Medium — depends on award authority |
+| Book publication or feature | 6-12+ months | High — strong independent source |
+
+### Wikipedia Article Best Practices
+
+**DO:**
+- Write in neutral, encyclopedic tone
+- Use only independent, reliable sources as references
+- Follow Wikipedia's Manual of Style
+- Disclose any conflict of interest on your Talk page
+- Let the community review and improve the article
+
+**DO NOT:**
+- Write promotional content
+- Use the entity's own website as a primary source
+- Create the article from a company account without disclosure
+- Remove criticism or negative but sourced information
+- Pay someone to write the article without disclosure (violates Wikipedia policy)
+
+### Wikipedia's Impact on AI
+
+Wikipedia is disproportionately important for AI systems because:
+- It's in the training data of every major LLM
+- AI systems treat it as a high-trust source
+- Wikipedia's structured format makes it easy for AI to extract and cite
+- The first paragraph of a Wikipedia article often becomes the AI's entity definition
+
+This makes Wikipedia presence one of the highest-impact entity optimization actions for GEO.
+
+## Schema.org Entity Markup
+
+### Minimum Viable Entity Schema
+
+Every entity should have at minimum this markup on the homepage:
+
+**Organization:**
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ "@id": "https://example.com/#organization",
+ "name": "Example Corp",
+ "url": "https://example.com",
+ "logo": "https://example.com/logo.png",
+ "description": "Example Corp is a [what it is] that [what it does].",
+ "foundingDate": "2020-01-15",
+ "founder": {
+ "@type": "Person",
+ "name": "Jane Smith",
+ "@id": "https://example.com/about/jane-smith#person"
+ },
+ "sameAs": [
+ "https://www.wikidata.org/wiki/Q12345678",
+ "https://en.wikipedia.org/wiki/Example_Corp",
+ "https://www.linkedin.com/company/example-corp",
+ "https://x.com/examplecorp",
+ "https://www.crunchbase.com/organization/example-corp"
+ ]
+}
+```
+
+**Person:**
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Person",
+ "@id": "https://example.com/about/jane-smith#person",
+ "name": "Jane Smith",
+ "url": "https://example.com/about/jane-smith",
+ "image": "https://example.com/photos/jane-smith.jpg",
+ "jobTitle": "CEO",
+ "worksFor": {
+ "@type": "Organization",
+ "@id": "https://example.com/#organization"
+ },
+ "description": "Jane Smith is [who they are] specializing in [expertise areas].",
+ "sameAs": [
+ "https://www.wikidata.org/wiki/Q87654321",
+ "https://www.linkedin.com/in/janesmith",
+ "https://x.com/janesmith"
+ ]
+}
+```
+
+### sameAs Best Practices
+
+The `sameAs` property is the **primary entity disambiguation signal** in Schema.org. It tells search engines "this is the same entity as the one on these other platforms."
+
+**Must include (when available):**
+1. Wikidata URL (most important for Knowledge Graph)
+2. Wikipedia URL
+3. LinkedIn URL
+4. Official social media profiles
+
+**Include when relevant:**
+5. CrunchBase URL
+6. GitHub URL
+7. IMDb URL (for people in entertainment)
+8. Industry directory URLs
+
+**Common mistakes:**
+- Linking to generic pages instead of entity-specific URLs
+- Inconsistent: Schema says "Example Corp" but LinkedIn says "Example Corporation"
+- Missing Wikidata link (this is the single most impactful sameAs)
+- Including dead or redirecting URLs
+
+### Cross-Page Entity Consistency
+
+Every page on the site should reference the same entity with the same `@id`:
+
+```json
+{
+ "@type": "WebPage",
+ "publisher": {
+ "@type": "Organization",
+ "@id": "https://example.com/#organization"
+ }
+}
+```
+
+For articles:
+```json
+{
+ "@type": "Article",
+ "author": {
+ "@type": "Person",
+ "@id": "https://example.com/about/jane-smith#person"
+ },
+ "publisher": {
+ "@type": "Organization",
+ "@id": "https://example.com/#organization"
+ }
+}
+```
+
+This creates a consistent entity graph that search engines can confidently map to Knowledge Graph entries.
+
+## Monitoring Entity Health
+
+### Quarterly Entity Health Check
+
+| Check | How | What to Look For |
+|-------|-----|-----------------|
+| Knowledge Panel accuracy | Google entity name | Correct info, image, attributes |
+| Wikidata entry | Visit Wikidata page | No vandalism, info still current |
+| AI entity resolution | Query 3+ AI systems | Accurate recognition and description |
+| Schema.org validation | Google Rich Results Test | No errors, complete entity data |
+| Branded search SERP | Google "[entity name]" | Clean SERP, no disambiguation issues |
+| Social profile consistency | Visit all profiles | Same name, description, links |
+
+### Entity Health Metrics to Track
+
+| Metric | Tool | Target |
+|--------|------|--------|
+| Knowledge Panel presence | Google Search | Present and accurate |
+| Branded search CTR | ~~search console | > 50% for exact brand name |
+| AI recognition rate | Manual testing | Recognized by 3/3 major AI systems |
+| Wikidata completeness | Wikidata | 15+ properties with references |
+| Schema.org error count | Google Search Console | 0 errors |
+| Brand mention volume | ~~brand monitor | Stable or growing trend |
+
+### Recovery Playbooks
+
+**Entity disappeared from Knowledge Graph:**
+1. Check if Wikidata entry was deleted or merged
+2. Verify Schema.org markup hasn't changed
+3. Look for major algorithm updates that might have affected entity recognition
+4. Rebuild signals: start with Wikidata, then Schema.org, then external mentions
+5. Timeline: 2-8 weeks for recovery
+
+**AI systems giving incorrect entity info:**
+1. Identify which sources have incorrect information
+2. Correct information at source (Wikidata, Wikipedia, About page)
+3. AI systems will update over time (training data refresh + live search)
+4. For urgent issues, some AI systems have feedback mechanisms
+5. Timeline: weeks to months depending on AI system update cycles
+
+**Knowledge Panel showing wrong entity:**
+1. Claim the Knowledge Panel (if you haven't already)
+2. Strengthen disambiguation signals (see SKILL.md Disambiguation Strategy)
+3. Add qualifier to entity name if needed
+4. Build more unique entity signals (original content, specific topic associations)
+5. Timeline: 1-3 months
diff --git a/.cursor/skills/entity-optimizer/references/knowledge-panel-wikidata-guide.md b/.cursor/skills/entity-optimizer/references/knowledge-panel-wikidata-guide.md
new file mode 100644
index 0000000000..b718f4ef88
--- /dev/null
+++ b/.cursor/skills/entity-optimizer/references/knowledge-panel-wikidata-guide.md
@@ -0,0 +1,81 @@
+# Knowledge Panel & Wikidata Optimization Guide
+
+Detailed instructions for Knowledge Panel optimization, Wikidata entry management, and AI entity resolution.
+
+## Knowledge Panel Optimization
+
+### Claiming and Editing
+
+1. **Google Knowledge Panel**: Claim via Google's verification process (search for entity -> click "Claim this knowledge panel")
+2. **Bing Knowledge Panel**: Driven by Wikidata and LinkedIn -- update those sources
+3. **AI Knowledge**: Driven by training data -- ensure authoritative sources describe entity correctly
+
+### Common Knowledge Panel Issues
+
+| Issue | Root Cause | Fix |
+|-------|-----------|-----|
+| No panel appears | Entity not in Knowledge Graph | Build Wikidata entry + structured data + authoritative mentions |
+| Wrong image | Image sourced from incorrect page | Update Wikidata image; ensure preferred image on About page and social profiles |
+| Wrong description | Description pulled from wrong source | Edit Wikidata description; ensure About page has clear entity description in first paragraph |
+| Missing attributes | Incomplete structured data | Add properties to Schema.org markup and Wikidata entry |
+| Wrong entity shown | Disambiguation failure | Strengthen unique signals; add qualifiers; resolve Wikidata disambiguation |
+| Outdated info | Source data not updated | Update Wikidata, About page, and all profile pages |
+
+## Wikidata Best Practices
+
+### Creating a Wikidata Entry
+
+1. **Check notability**: Entity must have at least one authoritative reference
+2. **Create item**: Add label, description, and aliases in relevant languages
+3. **Add statements**: instance of, official website, social media links, founding date, founders, industry
+4. **Add identifiers**: official website (P856), social media IDs, CrunchBase ID, ISNI, VIAF
+5. **Add references**: Every statement should have a reference to an authoritative source
+
+**Important**: Wikipedia's Conflict of Interest (COI) policy prohibits individuals and organizations from creating or editing articles about themselves. Instead of directly editing Wikipedia: (1) Focus on building notability through independent reliable sources (press coverage, industry publications, academic citations); (2) If you believe a Wikipedia article is warranted, consider engaging an independent Wikipedia editor through the Requested Articles process; (3) Ensure all claims about the entity are verifiable through third-party sources before any Wikipedia involvement.
+
+### Key Wikidata Properties by Entity Type
+
+| Property | Code | Person | Org | Brand | Product |
+|----------|------|:------:|:---:|:-----:|:-------:|
+| instance of | P31 | human | organization type | brand | product type |
+| official website | P856 | yes | yes | yes | yes |
+| occupation / industry | P106/P452 | yes | yes | -- | -- |
+| founded by | P112 | -- | yes | yes | -- |
+| inception | P571 | -- | yes | yes | yes |
+| country | P17 | yes | yes | -- | -- |
+| social media | various | yes | yes | yes | yes |
+| employer | P108 | yes | -- | -- | -- |
+| developer | P178 | -- | -- | -- | yes |
+
+## AI Entity Optimization
+
+### How AI Systems Resolve Entities
+
+```
+User query -> Entity extraction -> Entity resolution -> Knowledge retrieval -> Answer generation
+```
+
+AI systems follow this pipeline:
+1. **Extract** entity mentions from the query
+2. **Resolve** each mention to a known entity (or fail -> "I'm not sure")
+3. **Retrieve** associated knowledge about the entity
+4. **Generate** response citing sources that confirmed the entity's attributes
+
+### Signals AI Systems Use for Entity Resolution
+
+| Signal Type | What AI Checks | How to Optimize |
+|-------------|---------------|-----------------|
+| **Training data presence** | Was entity in pre-training corpus? | Get mentioned in high-quality, widely-crawled sources |
+| **Retrieval augmentation** | Does entity appear in live search results? | Strong SEO presence for branded queries |
+| **Structured data** | Can entity be matched to Knowledge Graph? | Complete Wikidata + Schema.org |
+| **Contextual co-occurrence** | What topics/entities appear alongside? | Build consistent topic associations across content |
+| **Source authority** | Are sources about entity trustworthy? | Get mentioned by authoritative, well-known sources |
+| **Recency** | Is information current? | Keep all entity profiles and content updated |
+
+### Entity-Specific GEO Tactics
+
+1. **Define clearly**: First paragraph of About page and key pages should define the entity in a way AI can quote directly
+2. **Be consistent**: Use identical entity description across all platforms
+3. **Build associations**: Create content that explicitly connects entity to target topics
+4. **Earn mentions**: Third-party authoritative mentions are stronger entity signals than self-description
+5. **Stay current**: Outdated entity information causes AI to lose confidence and stop citing
diff --git a/.cursor/skills/geo-content-optimizer/SKILL.md b/.cursor/skills/geo-content-optimizer/SKILL.md
new file mode 100644
index 0000000000..5ee0a58407
--- /dev/null
+++ b/.cursor/skills/geo-content-optimizer/SKILL.md
@@ -0,0 +1,420 @@
+---
+name: geo-content-optimizer
+description: 'Optimize content for AI citations in ChatGPT, Perplexity, AI Overviews, Gemini, Claude. AI引用优化/GEO优化/AI搜索'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when optimizing content for AI engines like ChatGPT, Perplexity, AI Overviews, Gemini, Claude, or Copilot. Also for AI citation optimization and generative engine visibility."
+argument-hint: " [target AI engine]"
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "high"
+ tags:
+ - geo
+ - ai-citations
+ - chatgpt-optimization
+ - perplexity-optimization
+ - google-ai-overview
+ - gemini
+ - generative-engine-optimization
+ - llm-citations
+ - ai-seo
+ - quotable-content
+ - AI引用优化
+ - GEO优化
+ - AI最適化
+ - AI최적화
+ - optimizacion-ia
+ triggers:
+ # EN-formal
+ - "optimize for AI"
+ - "get cited by ChatGPT"
+ - "AI optimization"
+ - "appear in AI answers"
+ - "GEO optimization"
+ - "generative engine optimization"
+ - "AI-friendly content"
+ - "LLM citations"
+ # EN-casual
+ - "get cited by AI"
+ - "show up in ChatGPT answers"
+ - "AI doesn't mention my brand"
+ - "make content AI-quotable"
+ - "how do I get AI to mention me"
+ - "ChatGPT doesn't know my brand"
+ # EN-question
+ - "how to appear in AI answers"
+ - "how to get cited by AI"
+ - "why doesn't AI mention my brand"
+ # EN-competitor
+ - "Perplexity optimization"
+ - "how to rank in ChatGPT answers"
+ - "AI Overview optimization tool"
+ # ZH-pro
+ - "AI引用优化"
+ - "GEO优化"
+ - "生成式引擎优化"
+ - "AI搜索优化"
+ - "AI可引用内容"
+ # ZH-casual
+ - "让AI引用我"
+ - "ChatGPT搜不到我"
+ - "AI不提我的品牌"
+ - "怎么让AI推荐我"
+ - "AI搜不到"
+ # JA
+ - "AIコンテンツ最適化"
+ - "AI引用されるコンテンツ"
+ - "ChatGPT対策"
+ - "AI検索最適化"
+ # KO
+ - "AI 최적화"
+ - "AI 검색 최적화"
+ - "ChatGPT 인용"
+ # ES
+ - "optimización para IA"
+ - "aparecer en respuestas de IA"
+ - "citación de IA"
+ # PT
+ - "otimização para IA"
+ - "aparecer nas respostas da IA"
+ # Misspellings
+ - "generative engine optimisation"
+ - "GEO optimisation"
+---
+
+# GEO Content Optimizer
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill optimizes content to appear in AI-generated responses. As AI systems increasingly answer user queries directly, getting cited by these systems directly impacts visibility.
+
+**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
+
+- Optimizing existing content for AI citations
+- Creating new content designed for both SEO and GEO
+- Improving chances of appearing in AI Overviews
+- Making content more quotable by AI systems
+- Adding authority signals that AI systems trust
+- Structuring content for AI comprehension
+- Competing for visibility in the AI-first search era
+
+## What This Skill Does
+
+1. **Citation Optimization**: Makes content more likely to be quoted by AI
+2. **Structure Enhancement**: Formats content for AI comprehension
+3. **Authority Building**: Adds signals that AI systems trust
+4. **Factual Enhancement**: Improves accuracy and verifiability
+5. **Quote Creation**: Creates memorable, citeable statements
+6. **Source Attribution**: Adds proper citations that AI can verify
+7. **GEO Scoring**: Evaluates content's AI-friendliness
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Optimize Existing Content
+
+```
+Optimize this content for GEO/AI citations: [content or URL]
+```
+
+```
+Make this article more likely to be cited by AI systems
+```
+
+### Create GEO-Optimized Content
+
+```
+Write content about [topic] optimized for both SEO and GEO
+```
+
+### GEO Audit
+
+```
+Audit this content for GEO readiness and suggest improvements
+```
+
+## Skill Contract
+
+**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
+
+- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
+- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
+- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+**With ~~AI monitor + ~~SEO tool connected:**
+Automatically pull AI citation patterns (which content is being cited by ChatGPT, Claude, Perplexity), current AI visibility scores, competitor citation frequency, and AI Overview appearance tracking.
+
+**With manual data only:**
+Ask the user to provide:
+1. Target queries where they want AI citations
+2. Current content URL or full content text
+3. Any known instances where competitors are being cited by AI
+
+Proceed with the full workflow using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests GEO optimization:
+
+1. **Load CORE-EEAT GEO-First Optimization Targets**
+
+ Before optimizing, load GEO-critical items from the [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md):
+
+ ```markdown
+ ### CORE-EEAT GEO-First Targets
+
+ These items have the highest impact on AI engine citation. Use as optimization checklist:
+
+ **Top 6 Priority Items**:
+ | Rank | ID | Standard | Why It Matters |
+ |------|----|----------|---------------|
+ | 1 | C02 | Direct Answer in first 150 words | All engines extract from first paragraph |
+ | 2 | C09 | Structured FAQ with Schema | Directly matches AI follow-up queries |
+ | 3 | O03 | Data in tables, not prose | Most extractable structured format |
+ | 4 | O05 | JSON-LD Schema Markup | Helps AI understand content type |
+ | 5 | E01 | Original first-party data | AI prefers exclusive, verifiable sources |
+ | 6 | O02 | Key Takeaways / Summary Box | First choice for AI summary citations |
+
+ **All GEO-First Items** (optimize for all when possible):
+ C02, C04, C05, C07, C08, C09 | O02, O03, O04, O05, O06, O09
+ R01, R02, R03, R04, R05, R07, R09 | E01, E02, E03, E04, E06, E08, E09, E10
+ Exp10 | Ept05, Ept08 | A08
+
+ **AI Engine Preferences**:
+ | Engine | Priority Items |
+ |--------|----------------|
+ | Google AI Overview | C02, O03, O05, C09 |
+ | ChatGPT Browse | C02, R01, R02, E01 |
+ | Perplexity AI | E01, R03, R05, Ept05 |
+ | Claude | R04, Ept08, Exp10, R03 |
+
+ _Full benchmark: [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)_
+ ```
+
+2. **Analyze Current Content**
+
+ ```markdown
+ ## GEO Analysis: [Content Title]
+
+ ### Current State Assessment
+
+ | GEO Factor | Current Score (1-10) | Notes |
+ |------------|---------------------|-------|
+ | Clear definitions | [X] | [notes] |
+ | Quotable statements | [X] | [notes] |
+ | Factual density | [X] | [notes] |
+ | Source citations | [X] | [notes] |
+ | Q&A format | [X] | [notes] |
+ | Authority signals | [X] | [notes] |
+ | Content freshness | [X] | [notes] |
+ | Structure clarity | [X] | [notes] |
+ | **GEO Readiness** | **[avg]/10** | **Average across factors** |
+
+ **Primary Weaknesses**:
+ 1. [Weakness 1]
+ 2. [Weakness 2]
+ 3. [Weakness 3]
+
+ **Quick Wins**:
+ 1. [Quick improvement 1]
+ 2. [Quick improvement 2]
+ ```
+
+3. **Apply GEO Optimization Techniques**
+
+ > **GEO fundamentals**: AI systems prioritize content that is authoritative (expert credentials, proper citations), accurate (verifiable, up-to-date), clear (well-structured, unambiguous), and quotable (standalone answers, specific data). See [references/geo-optimization-techniques.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/geo-optimization-techniques.md) for details.
+
+ Apply the six core optimization techniques: definition optimization, quotable statement creation, authority signal enhancement, structure optimization, factual density improvement, and FAQ schema implementation.
+
+ > **Reference**: See [references/geo-optimization-techniques.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/geo-optimization-techniques.md) for detailed before/after examples, templates, and checklists for each technique.
+
+ Key principles:
+ - **Definitions**: 25-50 words, standalone, starting with the term
+ - **Quotable statements**: Specific statistics with sources, verifiable facts
+ - **Authority signals**: Expert quotes with credentials, proper source citations
+ - **Structure**: Q&A format, comparison tables, numbered lists
+ - **Factual density**: Replace vague claims with specific data points
+ - **FAQ schema**: JSON-LD FAQPage markup matching visible content
+
+4. **Generate GEO-Optimized Output**
+
+ ```markdown
+ ## GEO Optimization Report
+
+ ### Changes Made
+
+ **Definitions Added/Improved**:
+ 1. [Definition 1] - [location in content]
+ 2. [Definition 2] - [location in content]
+
+ **Quotable Statements Created**:
+ 1. "[Statement 1]"
+ 2. "[Statement 2]"
+
+ **Authority Signals Added**:
+ 1. [Expert quote/citation]
+ 2. [Source attribution]
+
+ **Structural Improvements**:
+ 1. [Change 1]
+ 2. [Change 2]
+
+ ### Before/After GEO Score
+
+ | GEO Factor | Before (1-10) | After (1-10) | Change |
+ |------------|---------------|--------------|--------|
+ | Clear definitions | [X] | [X] | +[X] |
+ | Quotable statements | [X] | [X] | +[X] |
+ | Factual density | [X] | [X] | +[X] |
+ | Source citations | [X] | [X] | +[X] |
+ | Q&A format | [X] | [X] | +[X] |
+ | Authority signals | [X] | [X] | +[X] |
+ | **Overall GEO Score** | **[avg]/10** | **[avg]/10** | **+[X]** |
+
+ ### AI Query Coverage
+
+ This content is now optimized to answer:
+ - "What is [topic]?" ✅
+ - "How does [topic] work?" ✅
+ - "Why is [topic] important?" ✅
+ - "[Topic] vs [alternative]" ✅
+ - "Best [topic] for [use case]" ✅
+ ```
+
+5. **CORE-EEAT GEO Self-Check**
+
+ After optimization, verify GEO-First items:
+
+ ```markdown
+ ### CORE-EEAT GEO Post-Optimization Check
+
+ | ID | Standard | Status | Notes |
+ |----|----------|--------|-------|
+ | C02 | Direct Answer in first 150 words | ✅/⚠️/❌ | [notes] |
+ | C04 | Key terms defined on first use | ✅/⚠️/❌ | [notes] |
+ | C09 | Structured FAQ with Schema | ✅/⚠️/❌ | [notes] |
+ | O02 | Summary Box / Key Takeaways | ✅/⚠️/❌ | [notes] |
+ | O03 | Comparisons in tables | ✅/⚠️/❌ | [notes] |
+ | O05 | JSON-LD Schema Markup | ✅/⚠️/❌ | [notes] |
+ | O06 | Section chunking (3–5 sentences) | ✅/⚠️/❌ | [notes] |
+ | R01 | ≥5 precise data points with units | ✅/⚠️/❌ | [notes] |
+ | R02 | ≥1 citation per 500 words | ✅/⚠️/❌ | [notes] |
+ | R04 | Claims backed by evidence | ✅/⚠️/❌ | [notes] |
+ | R07 | Full entity names | ✅/⚠️/❌ | [notes] |
+ | E01 | Original first-party data | ✅/⚠️/❌ | [notes] |
+ | Exp10 | Limitations acknowledged | ✅/⚠️/❌ | [notes] |
+ | Ept08 | Reasoning transparency | ✅/⚠️/❌ | [notes] |
+
+ **Items Needing Attention**: [list any ⚠️/❌ items]
+
+ _For full 80-item audit, use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
+ ```
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Content source identified (URL, full text, or content draft)
+- [ ] Target AI queries or topics clearly defined
+- [ ] Current GEO baseline assessed (if optimizing existing content)
+
+### Output Validation
+- [ ] At least 3 clear, quotable definitions added
+- [ ] Factual density improved with at least 5 verifiable statistics
+- [ ] All claims have source citations from authoritative sources
+- [ ] Q&A format sections cover top 5 user queries
+- [ ] GEO score improvement of at least 50% from baseline
+- [ ] Source of each data point clearly stated (~~AI monitor data, user-provided, or estimated)
+
+## Example
+
+**User**: "Optimize this paragraph for GEO: 'Email marketing is a good way to reach customers. It's been around for a while and many businesses use it. You can send newsletters, promotions, and other messages.'"
+
+**Output**:
+
+```markdown
+## GEO-Optimized Version
+
+**Email marketing** is a digital marketing strategy that uses email to promote
+products, build customer relationships, and drive conversions. According to
+the Data & Marketing Association, email marketing delivers an average ROI
+of $42 for every $1 invested, making it the highest-performing digital
+marketing channel.
+
+### Key email marketing formats include:
+
+1. **Newsletters**: Regular updates sharing company news, industry insights,
+ and valuable content to maintain subscriber engagement
+2. **Promotional emails**: Time-sensitive offers, discounts, and product
+ announcements designed to drive immediate sales
+3. **Transactional emails**: Order confirmations, shipping updates, and
+ account notifications with 8x higher open rates than marketing emails
+4. **Automated sequences**: Pre-scheduled email series triggered by user
+ actions, such as welcome series or abandoned cart reminders
+
+> **Key statistic**: 81% of small businesses rely on email as their primary
+> customer acquisition channel (Emarsys, 2024).
+
+---
+
+### Changes Made:
+
+Changes: added clear definition, 2 stats with sources, structured list, DMA/Emarsys citations, 3 quotable facts. **GEO Score**: 1/10 → 8/10.
+```
+
+## GEO Optimization Checklist
+
+> **Reference**: See the GEO Readiness Checklist in [references/geo-optimization-techniques.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/geo-optimization-techniques.md) for the full checklist covering definitions, quotable content, authority, structure, and technical elements.
+
+## Tips for Success
+
+1. **Answer the question first** - Put the answer in the first sentence
+2. **Be specific** - Vague content doesn't get cited
+3. **Cite sources** - AI systems trust verifiable information
+4. **Stay current** - Update statistics and facts regularly
+5. **Match query format** - Questions deserve direct answers
+6. **Build authority** - Expert credentials increase citation likelihood
+
+
+### Save Results
+
+After delivering content or optimization output to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/content/YYYY-MM-DD-.md` containing:
+- One-line description of what was created
+- Target keyword and content type
+- Open loops or items needing review
+- Source data references
+
+**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [AI Citation Patterns](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/ai-citation-patterns.md) - How Google AI Overviews, ChatGPT, Perplexity, and Claude select and cite sources
+- [Quotable Content Examples](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/quotable-content-examples.md) - Before/after examples of content optimized for AI citation
+
+## Next Best Skill
+
+- **Primary**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) — verify the optimized content is strong enough to ship and cite.
diff --git a/.cursor/skills/geo-content-optimizer/references/ai-citation-patterns.md b/.cursor/skills/geo-content-optimizer/references/ai-citation-patterns.md
new file mode 100644
index 0000000000..6430c20a9c
--- /dev/null
+++ b/.cursor/skills/geo-content-optimizer/references/ai-citation-patterns.md
@@ -0,0 +1,504 @@
+# AI Citation Patterns
+
+How different AI systems select and cite content. Understanding these patterns helps optimize content for AI visibility.
+
+## Google AI Overviews
+
+### Citation Behavior
+
+**Format preferences**:
+- Prefers structured, factual content
+- Cites multiple sources per overview
+- Shows source links as footnotes
+- Displays "Sources" section at bottom
+
+**What gets cited**:
+- Clear, direct answers to queries
+- Statistics with recent dates
+- Step-by-step instructions
+- Comparison tables
+- Definition blocks
+- List-formatted content
+
+**Content structure preferences**:
+- Short paragraphs (2-3 sentences)
+- Bullet points and numbered lists
+- Clear headings matching query intent
+- Tables for comparison data
+- FAQ formats
+
+**Authority signals**:
+- Domain authority (trusted sites favored)
+- E-E-A-T signals (expertise, authoritativeness, trustworthiness)
+- Recent publication/update dates
+- Author credentials visible
+- Citations to other authoritative sources
+
+**Citation frequency**: Typically cites 3-8 sources per AI Overview
+
+---
+
+## ChatGPT (with Browsing)
+
+### Citation Behavior
+
+**Format preferences**:
+- Inline citations with numbers [1], [2]
+- "Sources" list at end of response
+- Clickable source links
+- Sometimes quotes directly with quotation marks
+
+**What gets cited**:
+- Specific facts and statistics
+- Expert quotes
+- Technical explanations
+- Recent information (prioritizes freshness)
+- Authoritative domain content
+- Well-structured, scannable content
+
+**Source selection patterns**:
+- Favors .edu, .gov, .org domains
+- Prioritizes recognized brands/publishers
+- Values comprehensive content over thin pages
+- Prefers content with clear attribution
+- Looks for consensus across multiple sources
+
+**Quoting behavior**:
+- Pulls exact quotes when information is distinctive
+- Paraphrases general information
+- Combines information from multiple sources
+- Attributes specific claims to sources
+
+**Citation frequency**: 1-6 sources per response depending on complexity
+
+---
+
+## Perplexity AI
+
+### Citation Behavior
+
+**Format preferences**:
+- Superscript numbers [1] inline
+- Numbered source list with snippets
+- Shows brief excerpt from each source
+- Displays domain name and publish date
+
+**What gets cited**:
+- Recent content (strong freshness bias)
+- Authoritative sources
+- Content with clear, quotable statements
+- Statistical data with sources
+- Primary sources over secondary
+- Content matching query intent precisely
+
+**Content structure preferences**:
+- Extremely well-structured content
+- Clear topic sentences
+- Quotable, standalone statements
+- Factual density (stats, data, specifics)
+- Headings that match question formats
+
+**Authority signals**:
+- Domain credibility
+- Author expertise
+- Publication reputation
+- Recency of content
+- Depth of coverage
+
+**Citation frequency**: Typically 5-10 sources per response (more than others)
+
+**Unique behavior**: Often shows "Follow-up Questions" that can reveal additional citation opportunities
+
+---
+
+## Claude (Knowledge-Based Responses)
+
+### Citation Behavior
+
+**Note**: Claude typically relies on training data rather than live web access, but understanding preferences helps create citeable content.
+
+**Format preferences**:
+- When citing, uses clear attribution phrases
+- "According to [source]..."
+- "Research from [source] shows..."
+- May reference general knowledge without specific citations
+
+**What gets remembered/prioritized**:
+- Clear, authoritative definitions
+- Widely-accepted facts and statistics
+- Well-established methodologies
+- Consensus information
+- Content from recognized authorities
+
+**Content characteristics valued**:
+- Factual accuracy and precision
+- Logical structure and clarity
+- Comprehensive explanations
+- Technical accuracy
+- Unambiguous language
+
+---
+
+## Common Traits Across All AI Systems
+
+### Universal Citation Factors
+
+**Content quality**:
+- Factual accuracy (incorrect info won't be cited)
+- Clear, unambiguous language
+- Proper grammar and spelling
+- Comprehensive coverage
+- Up-to-date information
+
+**Structure**:
+- Scannable format (headings, lists, tables)
+- Logical organization
+- Clear topic segmentation
+- Short paragraphs
+- Visual hierarchy
+
+**Authority**:
+- Domain credibility
+- Author credentials
+- Source citations in content
+- Expertise signals
+- Editorial quality
+
+**Relevance**:
+- Precise match to query intent
+- Topic focus (not meandering)
+- Keyword-topic alignment
+- Depth of coverage on specific topic
+
+---
+
+## Optimal Content Structures for Citation
+
+### 1. Definition Blocks
+
+AI systems love clear, quotable definitions.
+
+**Structure**:
+```markdown
+**[Term]** is [clear category] that [primary function], [key characteristic].
+```
+
+**Example**:
+> **Search Engine Optimization (SEO)** is a digital marketing practice that improves website visibility in organic search results by optimizing content, technical elements, and authority signals.
+
+**Why it works**: Standalone, complete, unambiguous, proper scope.
+
+---
+
+### 2. Statistic Blocks
+
+Facts with sources are highly citeable.
+
+**Structure**:
+```markdown
+According to [Source], [specific statistic] as of [timeframe].
+```
+
+**Example**:
+> According to HubSpot's 2024 State of Marketing Report, 82% of marketers actively invest in content marketing, making it the most widely adopted digital marketing strategy.
+
+**Why it works**: Specific, attributed, recent, verifiable.
+
+---
+
+### 3. Q&A Pairs
+
+Question-answer formats match AI query patterns.
+
+**Structure**:
+```markdown
+### [Question matching common query]?
+
+[Direct answer in 40-60 words]
+
+[Optional supporting detail]
+```
+
+**Example**:
+> ### How long does SEO take to show results?
+>
+> SEO typically takes 3-6 months to show significant results for new websites, though this varies based on competition, domain authority, and strategy. Established sites may see improvements in 1-3 months for less competitive keywords.
+
+**Why it works**: Matches query format, provides concise answer, includes qualifiers.
+
+---
+
+### 4. Comparison Tables
+
+Structured comparisons are easy for AI to parse and cite.
+
+**Structure**:
+```markdown
+| Feature | Option A | Option B |
+|---------|----------|----------|
+| [Factor 1] | [Specific value] | [Specific value] |
+| [Factor 2] | [Specific value] | [Specific value] |
+| **Best for** | [Use case] | [Use case] |
+```
+
+**Example**:
+| Factor | Technical SEO | On-Page SEO |
+|--------|---------------|-------------|
+| Focus | Site infrastructure | Content optimization |
+| Timeframe | 1-3 months | Ongoing |
+| Complexity | High | Medium |
+| **Best for** | Site-wide issues | Individual page improvements |
+
+**Why it works**: Clear comparison, specific values, scannable format.
+
+---
+
+### 5. Step-by-Step Processes
+
+Numbered lists for "how to" queries.
+
+**Structure**:
+```markdown
+1. **[Action]** - [Brief explanation]
+2. **[Action]** - [Brief explanation]
+3. **[Action]** - [Brief explanation]
+```
+
+**Example**:
+> To conduct keyword research:
+> 1. **Identify seed keywords** - List 5-10 topics your audience searches for
+> 2. **Use keyword research tools** - Expand seed keywords into hundreds of variations
+> 3. **Analyze search intent** - Determine what content format each keyword requires
+> 4. **Evaluate competition** - Assess ranking difficulty for each keyword
+> 5. **Prioritize keywords** - Choose based on volume, difficulty, and relevance
+
+**Why it works**: Clear process, actionable steps, logical sequence.
+
+---
+
+### 6. List-Based Content
+
+Curated lists with brief explanations.
+
+**Structure**:
+```markdown
+**[Item name]**: [Clear description with key benefit]
+```
+
+**Example**:
+> Top on-page SEO factors:
+> - **Title tags**: Most important on-page element; include primary keyword within first 60 characters
+> - **Header tags**: Structure content hierarchically; use one H1, multiple H2s for main sections
+> - **Meta descriptions**: Don't directly impact rankings but affect CTR; keep under 160 characters
+> - **URL structure**: Use descriptive, keyword-rich URLs without unnecessary parameters
+
+**Why it works**: Scannable, specific, actionable.
+
+---
+
+### 7. Before/After Examples
+
+Concrete examples showing transformation.
+
+**Structure**:
+```markdown
+**Before**: [Weak example]
+**After**: [Strong example]
+**Why it's better**: [Explanation]
+```
+
+**Example**:
+> **Before**: "Email marketing is pretty effective."
+> **After**: "Email marketing delivers an average ROI of $42 for every $1 spent, according to the Data & Marketing Association."
+> **Why it's better**: Specific statistic, attributed source, quantifiable claim.
+
+**Why it works**: Shows concrete improvement, demonstrates principle.
+
+---
+
+### 8. Key Insight Callouts
+
+Highlighted important points.
+
+**Structure**:
+```markdown
+> **Key insight**: [Memorable, quotable statement]
+```
+
+**Example**:
+> **Key insight**: According to Google's John Mueller, internal linking is one of the most underutilized SEO tactics, with properly structured internal links often delivering faster ranking improvements than external link building.
+
+**Why it works**: Visually distinct, authoritative, quotable.
+
+---
+
+## Content Optimization by Query Type
+
+### Informational Queries ("What is...", "How does...", "Why...")
+
+**AI citation priorities**:
+1. Clear definitions
+2. Comprehensive explanations
+3. Expert perspectives
+4. Supporting statistics
+5. Real-world examples
+
+**Optimal structure**:
+- Definition in first paragraph
+- "Why it matters" section
+- How it works explanation
+- Common use cases
+- Expert quotes or citations
+
+---
+
+### Comparison Queries ("[A] vs [B]", "Best [category]")
+
+**AI citation priorities**:
+1. Comparison tables
+2. Clear pros/cons lists
+3. Use case recommendations
+4. Specific differentiators
+5. Verdict or recommendation
+
+**Optimal structure**:
+- Quick comparison table upfront
+- Individual descriptions
+- Feature-by-feature comparison
+- "Choose X if..." recommendations
+- Summary verdict
+
+---
+
+### How-To Queries ("How to...", "Steps to...")
+
+**AI citation priorities**:
+1. Numbered step-by-step processes
+2. Required tools/prerequisites
+3. Time estimates
+4. Success indicators
+5. Troubleshooting tips
+
+**Optimal structure**:
+- Prerequisites listed first
+- Clear numbered steps
+- Sub-steps where needed
+- Visual indicators of progress
+- Common problems and solutions
+
+---
+
+### Statistical Queries ("How much...", "How many...", "Statistics about...")
+
+**AI citation priorities**:
+1. Specific numbers with sources
+2. Recent data (within 1-2 years)
+3. Multiple data points
+4. Context for statistics
+5. Trend information
+
+**Optimal structure**:
+- Lead with key statistic
+- Source attribution immediately after
+- Context and interpretation
+- Related statistics
+- Takeaways from data
+
+---
+
+## Citation Likelihood Factors
+
+### High Citation Likelihood
+
+- [ ] Content from recognized authority domains
+- [ ] Published or updated within 12 months
+- [ ] Clear, standalone statements
+- [ ] Proper source attribution
+- [ ] Specific statistics with dates
+- [ ] Structured with headings/lists/tables
+- [ ] Comprehensive topic coverage
+- [ ] Author credentials visible
+- [ ] Technical accuracy verified
+- [ ] Consensus with other sources
+
+### Medium Citation Likelihood
+
+- [ ] Content from less-known but quality domains
+- [ ] Published 1-2 years ago
+- [ ] Clear but requires slight context
+- [ ] General industry claims
+- [ ] Good structure but less scannable
+- [ ] Moderate depth of coverage
+- [ ] No author listed but quality content
+- [ ] Some supporting evidence
+
+### Low Citation Likelihood
+
+- [ ] Content from unknown/low-authority domains
+- [ ] Published 3+ years ago without updates
+- [ ] Vague or ambiguous statements
+- [ ] No sources cited
+- [ ] Poor content structure (walls of text)
+- [ ] Thin or superficial coverage
+- [ ] Promotional or biased tone
+- [ ] Factual inconsistencies
+- [ ] No expertise signals
+
+---
+
+## AI System Comparison Summary
+
+| Factor | Google AI Overviews | ChatGPT | Perplexity | Claude |
+|--------|---------------------|---------|------------|--------|
+| **Freshness bias** | High | Medium | Very high | N/A (training data) |
+| **Authority weight** | Very high | High | High | High |
+| **Structure importance** | High | Medium | Very high | Medium |
+| **Citation count** | 3-8 | 1-6 | 5-10 | N/A |
+| **Quotable focus** | High | Medium | Very high | High |
+| **Domain trust** | Very high | High | Medium | High |
+| **Factual density** | High | High | Very high | Very high |
+
+---
+
+## Tracking AI Citations
+
+### Manual Monitoring
+
+**Check if your content appears in**:
+- Google AI Overviews for target keywords
+- ChatGPT responses (search your domain in ChatGPT)
+- Perplexity results for relevant queries
+- Other AI search engines
+
+**Test queries**:
+- Exact-match questions from your FAQ
+- Definitions of terms you've defined
+- Statistics you've cited with attribution
+- Processes you've documented
+
+### Indicators of AI Visibility
+
+- Increased direct traffic (AI users clicking sources)
+- Traffic spikes from unusual referrers
+- Engagement metrics: low bounce rate, high time-on-page
+- Return visitors (AI users coming back for more depth)
+
+---
+
+## Optimization Checklist for AI Citations
+
+Content ready for AI citation should have:
+
+- [ ] At least 3 clear, quotable definitions
+- [ ] 5+ specific statistics with sources and dates
+- [ ] Q&A format sections covering top queries
+- [ ] Comparison tables where relevant
+- [ ] Numbered lists for processes
+- [ ] Content published or updated within 12 months
+- [ ] Author credentials visible
+- [ ] External citations to authoritative sources
+- [ ] Structured with clear H2/H3 headings
+- [ ] Short paragraphs (2-4 sentences)
+- [ ] No promotional language
+- [ ] Technical accuracy verified
+- [ ] Mobile-friendly formatting
diff --git a/.cursor/skills/geo-content-optimizer/references/geo-optimization-techniques.md b/.cursor/skills/geo-content-optimizer/references/geo-optimization-techniques.md
new file mode 100644
index 0000000000..cfc343a748
--- /dev/null
+++ b/.cursor/skills/geo-content-optimizer/references/geo-optimization-techniques.md
@@ -0,0 +1,235 @@
+# GEO Optimization Techniques
+
+Detailed techniques for optimizing content for AI citation across Google AI Overviews, ChatGPT, Perplexity AI, Claude, and Gemini.
+
+## Definition Optimization
+
+AI systems love clear, quotable definitions.
+
+**Before** (Weak for GEO):
+> SEO is really important for businesses and involves various
+> techniques to improve visibility online through search engines.
+
+**After** (Strong for GEO):
+> **Search Engine Optimization (SEO)** is the practice of optimizing
+> websites and content to rank higher in search engine results pages
+> (SERPs), increasing organic traffic and visibility.
+
+**Definition Template**:
+"[Term] is [clear category/classification] that [primary function/purpose],
+[key characteristic or benefit]."
+
+**Checklist for GEO-Optimized Definitions**:
+- [ ] Starts with the term being defined
+- [ ] Provides clear category (what type of thing it is)
+- [ ] Explains primary function or purpose
+- [ ] Uses precise, unambiguous language
+- [ ] Can stand alone as a complete answer
+- [ ] Is 25-50 words for optimal citation length
+
+## Quotable Statement Optimization
+
+AI systems cite specific, standalone statements. Transform vague
+content into quotable facts.
+
+**Weak (Not quotable)**:
+> Email marketing is pretty effective and lots of companies use it.
+
+**Strong (Quotable)**:
+> Email marketing delivers an average ROI of $42 for every $1 spent,
+> making it one of the highest-performing digital marketing channels.
+
+**Types of Quotable Statements**:
+
+1. **Statistics**
+ - Include specific numbers
+ - Cite the source
+ - Add context (timeframe, comparison)
+
+ Example: "According to [Source], [specific statistic] as of [date]."
+
+2. **Facts**
+ - Verifiable information
+ - Unambiguous language
+ - Authoritative source
+
+ Example: "[Subject] was [fact], according to [authoritative source]."
+
+3. **Definitions** (covered above)
+
+4. **Comparisons**
+ - Clear comparison structure
+ - Specific differentiators
+
+ Example: "Unlike [A], [B] [specific difference], which means [implication]."
+
+5. **How-to Steps**
+ - Numbered, clear steps
+ - Action-oriented language
+
+ Example: "To [achieve goal], [step 1], then [step 2], and finally [step 3]."
+
+## Authority Signal Enhancement
+
+**Expert Attribution**
+
+Add expert quotes and credentials:
+
+> "AI will transform how we search for information," says Dr. Jane Smith,
+> AI Research Director at Stanford University.
+
+**Source Citations**
+
+Properly cite sources that AI can verify:
+
+Before:
+> Studies show that most people prefer video content.
+
+After:
+> According to Wyzowl's 2024 Video Marketing Statistics report,
+> 91% of consumers want to see more online video content from brands.
+
+**Authority Elements to Add**:
+- [ ] Author byline with credentials
+- [ ] Expert quotes with attribution
+- [ ] Citations to peer-reviewed research
+- [ ] References to recognized authorities
+- [ ] Original data or research
+- [ ] Case studies with named companies
+- [ ] Industry statistics with sources
+
+## Structure Optimization for GEO
+
+AI systems parse structured content more effectively.
+
+**Q&A Format**
+
+Transform content into question-answer pairs:
+
+```html
+
What is [Topic]?
+
[Direct answer in 40-60 words]
+
+
How does [Topic] work?
+
[Clear explanation with steps if applicable]
+
+
Why is [Topic] important?
+
[Specific reasons with evidence]
+```
+
+**Comparison Tables**
+
+For comparison queries, use clear tables:
+
+| Feature | Option A | Option B |
+|---------|----------|----------|
+| [Feature 1] | [Specific value] | [Specific value] |
+| [Feature 2] | [Specific value] | [Specific value] |
+| **Best for** | [Use case] | [Use case] |
+
+**Numbered Lists**
+
+For process or list queries:
+
+1. **Step 1: [Action]** - [Brief explanation]
+2. **Step 2: [Action]** - [Brief explanation]
+3. **Step 3: [Action]** - [Brief explanation]
+
+**Definition Boxes**
+
+Highlight key definitions:
+
+> **Key Definition**: [Term] refers to [clear definition].
+
+## Factual Density Improvement
+
+AI systems prefer fact-rich content over opinion-heavy content.
+
+**Content Transformation**:
+
+**Low factual density**:
+> Social media marketing is very popular nowadays. Many businesses
+> use it and find it helpful for reaching customers.
+
+**High factual density**:
+> Social media marketing reaches 4.9 billion users globally (Statista, 2024).
+> Businesses using social media marketing report 66% higher lead generation
+> rates compared to non-users (HubSpot State of Marketing Report, 2024).
+> The most effective platforms for B2B marketing are LinkedIn (96% usage),
+> Twitter (82%), and Facebook (80%).
+
+**Factual Enhancement Checklist**:
+- [ ] Add specific statistics with sources
+- [ ] Include exact dates, numbers, percentages
+- [ ] Replace vague claims with verified facts
+- [ ] Add recent data (within last 2 years)
+- [ ] Include multiple data points per section
+- [ ] Cross-reference with authoritative sources
+
+## FAQ Optimization for GEO
+
+FAQ sections are highly effective for GEO because:
+- They match question-based AI queries
+- They provide concise, structured answers
+- FAQ schema helps AI understand Q&A pairs
+
+**FAQ Structure**:
+
+## Frequently Asked Questions
+
+### [Question matching common query]?
+
+[Direct answer: 40-60 words]
+[Supporting detail or example]
+
+### [Question matching common query]?
+
+[Direct answer: 40-60 words]
+[Supporting detail or example]
+
+**FAQ Schema (JSON-LD)**:
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ "mainEntity": [{
+ "@type": "Question",
+ "name": "[Question text]",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "[Answer text]"
+ }
+ }]
+}
+```
+
+## GEO Readiness Checklist
+
+Use this checklist for any content:
+
+**Definitions & Clarity**
+- [ ] Key terms are clearly defined
+- [ ] Definitions can stand alone as answers
+- [ ] Language is precise and unambiguous
+
+**Quotable Content**
+- [ ] Specific statistics included
+- [ ] Facts have source citations
+- [ ] Memorable statements created
+
+**Authority**
+- [ ] Expert quotes or credentials present
+- [ ] Authoritative sources cited
+- [ ] Original data or research included
+
+**Structure**
+- [ ] Q&A format sections included
+- [ ] Clear headings match common queries
+- [ ] Comparison tables where relevant
+- [ ] Numbered lists for processes
+
+**Technical**
+- [ ] FAQ schema markup added
+- [ ] Content freshness indicated
+- [ ] Sources are verifiable
diff --git a/.cursor/skills/geo-content-optimizer/references/quotable-content-examples.md b/.cursor/skills/geo-content-optimizer/references/quotable-content-examples.md
new file mode 100644
index 0000000000..ddc73201f1
--- /dev/null
+++ b/.cursor/skills/geo-content-optimizer/references/quotable-content-examples.md
@@ -0,0 +1,484 @@
+# Quotable Content Examples
+
+Before/after examples of content optimized for AI citation. Each example shows the transformation from generic to quotable.
+
+## Example 1: Definition Block
+
+### Before (Generic, Not Quotable)
+
+> SEO is really important for businesses and involves various techniques to improve visibility online through search engines. It's been around for a while and many businesses use it to get more traffic to their websites.
+
+**Problems**:
+- Vague language ("really important", "various techniques")
+- No clear classification
+- Meandering structure
+- Not standalone
+
+**Citation likelihood**: 1/10
+
+---
+
+### After (Clear, Quotable)
+
+> **Search Engine Optimization (SEO)** is a digital marketing practice that improves website rankings in organic search results through content optimization, technical improvements, and authority building. According to BrightEdge research, organic search drives 53% of all website traffic, making SEO the highest-impact channel for sustainable online visibility.
+
+**Improvements**:
+- Clear term + category classification
+- Specific components listed
+- Authoritative statistic with source
+- Standalone statement
+- Precise language
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 2: Statistical Content
+
+### Before (Weak)
+
+> Email marketing is pretty effective and lots of companies use it. It has a good return on investment compared to other marketing channels.
+
+**Problems**:
+- No specific numbers
+- No source attribution
+- Vague comparisons ("pretty effective", "lots", "good")
+- Not verifiable
+
+**Citation likelihood**: 2/10
+
+---
+
+### After (Strong)
+
+> Email marketing delivers an average ROI of $42 for every $1 spent, according to the Data & Marketing Association's 2024 research. This 4,200% return makes email the highest-performing digital marketing channel, outperforming social media (28% ROI) and paid search (23% ROI) by significant margins.
+
+**Improvements**:
+- Specific statistic with exact numbers
+- Source attribution with year
+- Comparative data
+- Verifiable claim
+- Context provided
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 3: Process/How-To Content
+
+### Before (Unclear)
+
+> To do keyword research, you should think about what your customers might search for and then use some tools to find more keywords. Look at what your competitors are doing too. Then pick the best keywords for your content.
+
+**Problems**:
+- Vague steps ("think about", "some tools")
+- No clear structure
+- Missing specifics
+- No actionable guidance
+
+**Citation likelihood**: 2/10
+
+---
+
+### After (Clear Process)
+
+> To conduct effective keyword research:
+>
+> 1. **Identify seed keywords** - List 5-10 core topics your target audience searches for
+> 2. **Expand with research tools** - Use tools like Google Keyword Planner or Ahrefs to generate 100+ related keywords
+> 3. **Analyze search intent** - Categorize each keyword as informational, commercial, or transactional
+> 4. **Assess competition** - Evaluate ranking difficulty using domain authority and SERP analysis
+> 5. **Prioritize strategically** - Select 10-15 keywords balancing search volume (1,000+ monthly searches) with achievable competition
+>
+> This process typically takes 2-4 hours for a comprehensive initial keyword list.
+
+**Improvements**:
+- Numbered, sequential steps
+- Specific actions in each step
+- Concrete numbers and tools
+- Time estimate included
+- Actionable guidance
+
+**Citation likelihood**: 8/10
+
+---
+
+## Example 4: Comparison Content
+
+### Before (Vague Comparison)
+
+> WordPress and Shopify are both popular website builders. WordPress is more flexible while Shopify is easier to use. The choice depends on what you need.
+
+**Problems**:
+- No specific differentiators
+- Vague claims ("more flexible", "easier")
+- No clear recommendation criteria
+- Not helpful for decision-making
+
+**Citation likelihood**: 2/10
+
+---
+
+### After (Structured Comparison)
+
+> | Factor | WordPress | Shopify |
+> |--------|-----------|---------|
+> | **Best for** | Content-heavy sites, blogs, custom needs | E-commerce, quick setup |
+> | **Setup time** | 4-8 hours | 1-2 hours |
+> | **Monthly cost** | $10-50 (hosting + theme) | $29-299 (subscription) |
+> | **Customization** | Unlimited (50,000+ plugins) | Limited to Shopify apps |
+> | **E-commerce** | Requires WooCommerce plugin | Built-in, optimized |
+> | **SEO control** | Complete control | Good but limited |
+>
+> **Choose WordPress if**: You need maximum flexibility, run a content-first site, or have technical resources.
+>
+> **Choose Shopify if**: E-commerce is your primary goal, you want fast setup, or you lack technical expertise.
+
+**Improvements**:
+- Structured table format
+- Specific, comparable metrics
+- Clear use case recommendations
+- Quantifiable differences
+- Decision framework
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 5: Expert Content
+
+### Before (No Authority)
+
+> Many people think that social media is important for SEO. It can help you get more visibility and traffic.
+
+**Problems**:
+- No expert attribution
+- Vague claims ("many people think")
+- No specific mechanism explained
+- Weak authority signals
+
+**Citation likelihood**: 1/10
+
+---
+
+### After (Expert-Backed)
+
+> While social media doesn't directly impact search rankings, it influences SEO through indirect channels. "Social signals drive discovery, which leads to backlinks and brand searches—both powerful ranking factors," explains Rand Fishkin, founder of SparkToro and former CEO of Moz. Research from Hootsuite found that content promoted on social media earns 2.3x more backlinks than non-promoted content within the first 30 days of publication.
+
+**Improvements**:
+- Expert quote with credentials
+- Specific mechanism explained
+- Research data with source
+- Quantifiable impact (2.3x)
+- Authority signals
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 6: Q&A Content
+
+### Before (Incomplete Answer)
+
+> **How long does SEO take?**
+>
+> It depends on a lot of factors. Sometimes it's fast, sometimes it takes a while. New sites usually take longer than established ones.
+
+**Problems**:
+- No specific timeframe
+- "It depends" without explaining what it depends on
+- Vague language ("fast", "a while", "longer")
+- Not actionable
+
+**Citation likelihood**: 1/10
+
+---
+
+### After (Complete Answer)
+
+> **How long does SEO take to show results?**
+>
+> SEO typically takes 3-6 months to show significant results for new websites. Established sites with existing authority may see improvements in 1-3 months for less competitive keywords. Results depend on four key factors:
+>
+> 1. **Domain authority** - New domains take 6-12 months; established domains see faster results
+> 2. **Competition level** - Low-competition keywords rank in 1-3 months; high-competition may take 12+ months
+> 3. **Implementation quality** - Comprehensive optimization accelerates results
+> 4. **Existing backlink profile** - Sites with 20+ quality backlinks see 40% faster improvements (Ahrefs study)
+>
+> Most businesses should budget 6 months for measurable ROI from SEO efforts.
+
+**Improvements**:
+- Specific timeframes with ranges
+- Factors clearly explained
+- Differentiated by scenario
+- Supporting data with source
+- Actionable planning guidance
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 7: List Content
+
+### Before (Thin List)
+
+> Here are some important on-page SEO factors:
+> - Title tags
+> - Meta descriptions
+> - Headers
+> - Content
+> - Links
+> - Images
+
+**Problems**:
+- No explanations
+- No prioritization
+- Not actionable
+- Missing context
+
+**Citation likelihood**: 2/10
+
+---
+
+### After (Rich List)
+
+> Critical on-page SEO factors ranked by impact:
+>
+> 1. **Title tags** - Most important on-page element; include primary keyword within first 60 characters. Title tags directly influence both rankings and CTR.
+>
+> 2. **Content quality and depth** - Comprehensive content (1,500+ words for competitive keywords) outranks thin content. Focus on topical authority over keyword density.
+>
+> 3. **Header structure (H1-H6)** - Use one H1 with primary keyword, multiple H2s for main sections. Proper hierarchy helps search engines understand content structure.
+>
+> 4. **Internal linking** - Link to 3-5 related pages using descriptive anchor text. Internal links distribute page authority and help search engines discover content.
+>
+> 5. **Image optimization** - Compress images to under 100KB, use descriptive alt text with target keywords. Page speed and accessibility both impact rankings.
+>
+> 6. **URL structure** - Use descriptive, keyword-rich URLs (example.com/keyword-topic) avoiding parameters. Clean URLs improve click-through rates by 25% (Backlinko study).
+>
+> Optimize these six factors before moving to advanced tactics.
+
+**Improvements**:
+- Prioritized ranking
+- Specific guidance for each
+- Actionable recommendations
+- Supporting data
+- Clear next step
+
+**Citation likelihood**: 8/10
+
+---
+
+## Example 8: Trend/Prediction Content
+
+### Before (Speculation)
+
+> AI is going to change SEO a lot in the future. Things will be different and SEO professionals will need to adapt.
+
+**Problems**:
+- Vague predictions ("a lot", "different")
+- No specific changes identified
+- No timeframe
+- No evidence
+
+**Citation likelihood**: 1/10
+
+---
+
+### After (Evidence-Based Trend)
+
+> AI is fundamentally transforming SEO search behavior. According to Gartner's 2024 research, AI-powered search will reduce traditional search engine traffic by 25% by 2026, as users increasingly rely on AI chatbots for information.
+>
+> Three specific shifts SEO professionals must prepare for:
+>
+> 1. **Zero-click content consumption** - Users get answers without visiting websites, requiring optimization for AI citations rather than clicks
+>
+> 2. **Conversation-based queries** - Natural language questions replace keyword-based searches, demanding content that answers specific questions
+>
+> 3. **Source attribution value** - Being cited by AI systems (Generative Engine Optimization/GEO) becomes as important as ranking #1
+>
+> Forrester predicts that by 2025, 30% of organic traffic will come from AI system citations rather than traditional search results, making GEO optimization critical for visibility.
+
+**Improvements**:
+- Specific statistics with sources
+- Clear timeframes
+- Concrete changes identified
+- Multiple research citations
+- Actionable implications
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 9: Technical Explanation
+
+### Before (Complex, Unclear)
+
+> Core Web Vitals are metrics that Google uses to measure user experience. They look at how fast your site loads and how stable it is when people use it. You should try to improve them.
+
+**Problems**:
+- Oversimplified without specifics
+- No concrete metrics
+- Vague improvement guidance
+- Missing thresholds
+
+**Citation likelihood**: 2/10
+
+---
+
+### After (Clear, Specific)
+
+> Core Web Vitals are three specific metrics Google uses to measure page experience:
+>
+> | Metric | Measures | Good Score | Impact |
+> |--------|----------|------------|--------|
+> | **LCP (Largest Contentful Paint)** | Loading performance | <2.5 seconds | How quickly main content appears |
+> | **FID (First Input Delay)** | Interactivity | <100 milliseconds | How quickly page responds to user input |
+> | **CLS (Cumulative Layout Shift)** | Visual stability | <0.1 | How much content shifts during loading |
+>
+> According to Google's 2024 documentation, pages meeting all three thresholds rank higher and receive 24% more clicks than pages failing these metrics. Core Web Vitals became an official ranking factor in June 2021 as part of Google's Page Experience update.
+>
+> Test your Core Web Vitals with ~~page speed tool.
+
+**Improvements**:
+- Three specific metrics defined
+- Clear thresholds provided
+- Table format for scannability
+- Official data with source
+- Testing tool referenced
+
+**Citation likelihood**: 9/10
+
+---
+
+## Example 10: Case Study Content
+
+### Before (Vague Results)
+
+> We improved our client's SEO and they got more traffic. They were really happy with the results and saw an increase in their business.
+
+**Problems**:
+- No specific numbers
+- No timeframe
+- No methodology shared
+- Not reproducible
+
+**Citation likelihood**: 1/10
+
+---
+
+### After (Data-Rich Case Study)
+
+> **Case Study: 312% Organic Traffic Increase in 6 Months**
+>
+> **Client**: B2B SaaS company (project management software)
+>
+> **Challenge**: Only 450 monthly organic visitors, ranking #20+ for target keywords
+>
+> **Strategy implemented**:
+> 1. Created 24 pillar pages targeting high-intent keywords
+> 2. Built internal linking hub connecting 150 existing pages
+> 3. Optimized 80 existing pages for featured snippets
+> 4. Secured 45 high-authority backlinks through expert roundups
+>
+> **Results (6 months)**:
+> - Organic traffic: 450 → 1,854 monthly visitors (+312%)
+> - Keyword rankings: 3 keywords in top 10 → 47 keywords in top 10
+> - Featured snippets: 0 → 12 owned snippets
+> - Lead conversions: 8 → 41 monthly leads (+412%)
+>
+> **Key insight**: 80% of traffic growth came from optimizing existing content rather than creating new pages, demonstrating the power of content refreshing strategies.
+
+**Improvements**:
+- Specific metrics with percentages
+- Clear timeframe
+- Detailed methodology
+- Reproducible tactics
+- Key learning identified
+
+**Citation likelihood**: 9/10
+
+---
+
+## Transformation Checklist
+
+To make any content more quotable:
+
+**Definitions**:
+- [ ] Replace vague language with precise terms
+- [ ] Add clear category classification
+- [ ] Make statement standalone
+- [ ] Include scope and boundaries
+
+**Statistics**:
+- [ ] Add specific numbers
+- [ ] Include source attribution
+- [ ] Specify timeframe/date
+- [ ] Add context for interpretation
+- [ ] Verify accuracy
+
+**Processes**:
+- [ ] Number steps clearly
+- [ ] Make each step actionable
+- [ ] Add time estimates
+- [ ] Include specific tools/methods
+- [ ] Provide success criteria
+
+**Comparisons**:
+- [ ] Use table format
+- [ ] Include specific, comparable metrics
+- [ ] Add "choose if..." recommendations
+- [ ] Quantify differences
+- [ ] Cover key decision factors
+
+**Lists**:
+- [ ] Prioritize items
+- [ ] Add explanations to each
+- [ ] Include specific guidance
+- [ ] Provide examples
+- [ ] Make actionable
+
+**Expert content**:
+- [ ] Add attributed quotes
+- [ ] Include credentials
+- [ ] Reference studies/research
+- [ ] Cite authoritative sources
+- [ ] Quantify claims
+
+---
+
+## Content Optimization Pattern
+
+**Generic → Quotable transformation follows this pattern**:
+
+1. **Add specificity** - Replace "many" with "73%", "fast" with "under 2.5 seconds"
+2. **Include attribution** - Add sources for all claims
+3. **Structure clearly** - Use tables, lists, numbered steps
+4. **Quantify everything** - Numbers > adjectives
+5. **Make standalone** - Each statement should work in isolation
+6. **Add context** - Explain why, not just what
+7. **Use precise language** - Eliminate ambiguity
+8. **Include timeframes** - When was this measured/true?
+9. **Show authority** - Reference experts, studies, data
+10. **Enable verification** - Link to sources, cite methodology
+
+---
+
+## Quick Quotability Test
+
+Ask these questions about your content:
+
+1. **Can AI quote this without additional context?** - If no, add context
+2. **Does it include specific numbers?** - If no, add data
+3. **Is the source clear?** - If no, add attribution
+4. **Is language precise and unambiguous?** - If no, clarify
+5. **Would an expert approve this?** - If no, add authority signals
+6. **Is it scannable?** - If no, add structure
+7. **Is it up-to-date?** - If no, update or add date context
+8. **Can it be verified?** - If no, add sources
+9. **Is it specific to a use case?** - If no, add context
+10. **Does it answer a complete question?** - If no, expand
+
+If you answer "yes" to 8+ questions, content is highly quotable.
+If you answer "yes" to 5-7 questions, content is moderately quotable.
+If you answer "yes" to fewer than 5, content needs significant optimization.
diff --git a/.cursor/skills/improve-codebase-architecture/DEEPENING.md b/.cursor/skills/improve-codebase-architecture/DEEPENING.md
new file mode 100644
index 0000000000..ecaf5d7dcf
--- /dev/null
+++ b/.cursor/skills/improve-codebase-architecture/DEEPENING.md
@@ -0,0 +1,37 @@
+# Deepening
+
+How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
+
+## Dependency categories
+
+When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
+
+### 1. In-process
+
+Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
+
+### 2. Local-substitutable
+
+Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
+
+### 3. Remote but owned (Ports & Adapters)
+
+Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
+
+Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
+
+### 4. True external (Mock)
+
+Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
+
+## Seam discipline
+
+- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
+- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
+
+## Testing strategy: replace, don't layer
+
+- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
+- Write new tests at the deepened module's interface. The **interface is the test surface**.
+- Tests assert on observable outcomes through the interface, not internal state.
+- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
diff --git a/.cursor/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.cursor/skills/improve-codebase-architecture/INTERFACE-DESIGN.md
new file mode 100644
index 0000000000..3197723a0d
--- /dev/null
+++ b/.cursor/skills/improve-codebase-architecture/INTERFACE-DESIGN.md
@@ -0,0 +1,44 @@
+# Interface Design
+
+When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
+
+Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
+
+## Process
+
+### 1. Frame the problem space
+
+Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
+
+- The constraints any new interface would need to satisfy
+- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
+- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
+
+Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
+
+### 2. Spawn sub-agents
+
+Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
+
+Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
+
+- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point."
+- Agent 2: "Maximise flexibility — support many use cases and extension."
+- Agent 3: "Optimise for the most common caller — make the default case trivial."
+- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
+
+Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
+
+Each sub-agent outputs:
+
+1. Interface (types, methods, params — plus invariants, ordering, error modes)
+2. Usage example showing how callers use it
+3. What the implementation hides behind the seam
+4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
+5. Trade-offs — where leverage is high, where it's thin
+
+### 3. Present and compare
+
+Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
+
+After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
diff --git a/.cursor/skills/improve-codebase-architecture/LANGUAGE.md b/.cursor/skills/improve-codebase-architecture/LANGUAGE.md
new file mode 100644
index 0000000000..530c27630a
--- /dev/null
+++ b/.cursor/skills/improve-codebase-architecture/LANGUAGE.md
@@ -0,0 +1,53 @@
+# Language
+
+Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
+
+## Terms
+
+**Module**
+Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
+_Avoid_: unit, component, service.
+
+**Interface**
+Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
+_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
+
+**Implementation**
+What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
+
+**Depth**
+Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
+
+**Seam** _(from Michael Feathers)_
+A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
+_Avoid_: boundary (overloaded with DDD's bounded context).
+
+**Adapter**
+A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
+
+**Leverage**
+What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
+
+**Locality**
+What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
+
+## Principles
+
+- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
+- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
+- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
+- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
+
+## Relationships
+
+- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
+- **Depth** is a property of a **Module**, measured against its **Interface**.
+- A **Seam** is where a **Module**'s **Interface** lives.
+- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
+- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
+
+## Rejected framings
+
+- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
+- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
+- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
diff --git a/.cursor/skills/improve-codebase-architecture/SKILL.md b/.cursor/skills/improve-codebase-architecture/SKILL.md
new file mode 100644
index 0000000000..05984a6096
--- /dev/null
+++ b/.cursor/skills/improve-codebase-architecture/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: improve-codebase-architecture
+description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
+---
+
+# Improve Codebase Architecture
+
+Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
+
+## Glossary
+
+Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
+
+- **Module** — anything with an interface and an implementation (function, class, package, slice).
+- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
+- **Implementation** — the code inside.
+- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
+- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
+- **Adapter** — a concrete thing satisfying an interface at a seam.
+- **Leverage** — what callers get from depth.
+- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
+
+Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
+
+- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
+- **The interface is the test surface.**
+- **One adapter = hypothetical seam. Two adapters = real seam.**
+
+This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
+
+## Process
+
+### 1. Explore
+
+Read the project's domain glossary and any ADRs in the area you're touching first.
+
+Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
+
+- Where does understanding one concept require bouncing between many small modules?
+- Where are modules **shallow** — interface nearly as complex as the implementation?
+- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
+- Where do tightly-coupled modules leak across their seams?
+- Which parts of the codebase are untested, or hard to test through their current interface?
+
+Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
+
+### 2. Present candidates
+
+Present a numbered list of deepening opportunities. For each candidate:
+
+- **Files** — which files/modules are involved
+- **Problem** — why the current architecture is causing friction
+- **Solution** — plain English description of what would change
+- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve
+
+**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
+
+**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
+
+Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
+
+### 3. Grilling loop
+
+Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
+
+Side effects happen inline as decisions crystallize:
+
+- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
+- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
+- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
+- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
diff --git a/.cursor/skills/internal-linking-optimizer/SKILL.md b/.cursor/skills/internal-linking-optimizer/SKILL.md
new file mode 100644
index 0000000000..d83f3eb972
--- /dev/null
+++ b/.cursor/skills/internal-linking-optimizer/SKILL.md
@@ -0,0 +1,351 @@
+---
+name: internal-linking-optimizer
+description: 'Optimize internal links: site architecture, authority distribution, orphan pages, crawl depth analysis. 内链优化/站内架构'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when improving internal link structure, anchor text distribution, orphan pages, or site architecture."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "low"
+ tags:
+ - seo
+ - internal-linking
+ - site-architecture
+ - link-equity
+ - orphan-pages
+ - topical-authority
+ - crawl-depth
+ - 内链优化
+ - 内部リンク
+ - 내부링크
+ - enlaces-internos
+ triggers:
+ # EN-formal
+ - "fix internal links"
+ - "improve site architecture"
+ - "link structure"
+ - "distribute page authority"
+ - "internal linking strategy"
+ - "site navigation"
+ - "link equity"
+ # EN-casual
+ - "orphan pages"
+ - "site architecture is messy"
+ - "pages have no links pointing to them"
+ - "pages have no links"
+ - "site structure is messy"
+ # EN-question
+ - "how to improve internal linking"
+ - "how to fix orphan pages"
+ # ZH-pro
+ - "内链优化"
+ - "站内链接"
+ - "网站架构"
+ - "权重传递"
+ - "锚文本优化"
+ # ZH-casual
+ - "内链怎么做"
+ - "孤立页面"
+ - "网站结构乱"
+ # JA
+ - "内部リンク最適化"
+ - "サイト構造"
+ # KO
+ - "내부 링크 최적화"
+ - "사이트 구조"
+ # ES
+ - "enlaces internos"
+ - "arquitectura del sitio"
+ # PT
+ - "links internos"
+ # Misspellings
+ - "internal linkng"
+---
+
+# Internal Linking Optimizer
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill analyzes your site's internal link structure and provides recommendations to improve SEO through strategic internal linking. It helps distribute authority, establish topical relevance, and improve crawlability.
+
+**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
+
+- Improving site architecture for SEO
+- Distributing authority to important pages
+- Fixing orphan pages with no internal links
+- Creating topic cluster internal link strategies
+- Optimizing anchor text for SEO
+- Recovering pages that have lost rankings
+- Planning internal links for new content
+
+## What This Skill Does
+
+1. **Link Structure Analysis**: Maps current internal linking patterns
+2. **Authority Flow Mapping**: Shows how PageRank flows through site
+3. **Orphan Page Detection**: Finds pages with no internal links
+4. **Anchor Text Optimization**: Improves anchor text diversity
+5. **Topic Cluster Linking**: Creates pillar-cluster link strategies
+6. **Link Opportunity Finding**: Identifies where to add links
+7. **Navigation Optimization**: Improves site-wide link elements
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Analyze Current Structure
+
+```
+Analyze internal linking structure for [domain/sitemap]
+```
+
+```
+Find internal linking opportunities for [URL]
+```
+
+### Create Linking Strategy
+
+```
+Create internal linking plan for topic cluster about [topic]
+```
+
+```
+Suggest internal links for this new article: [content/URL]
+```
+
+### Fix Issues
+
+```
+Find orphan pages on [domain]
+```
+
+```
+Optimize anchor text across the site
+```
+
+## Skill Contract
+
+**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
+
+- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
+- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~web crawler + ~~analytics connected:**
+Claude can automatically perform a full site crawl via ~~web crawler to map the complete link graph, fetch page performance metrics from ~~analytics to identify high-value pages, and analyze link flow throughout the site. This enables data-driven internal linking strategies.
+
+**With manual data only:**
+Ask the user to provide:
+1. Sitemap URL or list of important pages
+2. Key page URLs that need more internal links
+3. Content categories or topic clusters
+4. Any existing link structure documentation
+
+Proceed with the analysis using provided data. Note in the output which findings are from automated crawl vs. manual review.
+
+## Instructions
+
+When a user requests internal linking optimization:
+
+1. **Analyze Current Internal Link Structure**
+
+ ```markdown
+ ## Internal Link Structure Analysis
+
+ ### Overview
+
+ **Domain**: [domain]
+ **Total Pages Analyzed**: [X]
+ **Total Internal Links**: [X]
+ **Average Links per Page**: [X]
+
+ ### Link Distribution
+
+ | Links per Page | Page Count | Percentage |
+ |----------------|------------|------------|
+ | 0 (Orphan) | [X] | [X]% |
+ | 1-5 | [X] | [X]% |
+ | 6-10 | [X] | [X]% |
+ | 11-20 | [X] | [X]% |
+ | 20+ | [X] | [X]% |
+
+ ### Top Linked Pages
+
+ | Page | Internal Links | Authority | Notes |
+ |------|----------------|-----------|-------|
+ | [URL 1] | [X] | High | [notes] |
+ | [URL 2] | [X] | High | [notes] |
+ | [URL 3] | [X] | Medium | [notes] |
+
+ ### Under-Linked Important Pages
+
+ | Page | Current Links | Traffic | Recommended Links |
+ |------|---------------|---------|-------------------|
+ | [URL 1] | [X] | [X]/mo | [X]+ |
+ | [URL 2] | [X] | [X]/mo | [X]+ |
+
+ **Structure Score**: [X]/10
+ ```
+
+2. **Identify Orphan Pages**
+
+ ```markdown
+ ## Orphan Page Analysis
+
+ ### Definition
+ Orphan pages have no internal links pointing to them, making them
+ hard for users and search engines to discover.
+
+ ### Orphan Pages Found: [X]
+
+ | Page | Traffic | Priority | Recommended Action |
+ |------|---------|----------|-------------------|
+ | [URL 1] | [X]/mo | High | Link from [pages] |
+ | [URL 2] | [X]/mo | Medium | Add to navigation |
+ | [URL 3] | 0 | Low | Consider deleting/redirecting |
+
+ ### Fix Strategy
+
+ **High Priority Orphans** (have traffic/rankings):
+ 1. [URL] - Add links from: [relevant pages]
+ 2. [URL] - Add links from: [relevant pages]
+
+ **Medium Priority Orphans** (potentially valuable):
+ 1. [URL] - Add to category/tag page
+ 2. [URL] - Link from related content
+
+ **Low Priority Orphans** (consider removing):
+ 1. [URL] - Redirect to [better page]
+ 2. [URL] - Delete or noindex
+ ```
+
+3. **Analyze Anchor Text Distribution**
+
+ > **CORE-EEAT alignment**: Internal linking quality maps to R08 (Internal Link Graph) in the CORE-EEAT benchmark -- use descriptive anchors, ensure links support topical authority. See [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) for full audit.
+
+ ```markdown
+ ## Anchor Text Analysis
+
+ ### Current Anchor Text Patterns
+
+ **Most Used Anchors**:
+
+ | Anchor Text | Count | Target Pages | Assessment |
+ |-------------|-------|--------------|------------|
+ | "click here" | [X] | [X] pages | ❌ Not descriptive |
+ | "read more" | [X] | [X] pages | ❌ Not descriptive |
+ | "[exact keyword]" | [X] | [page] | ⚠️ May be over-optimized |
+ | "[descriptive phrase]" | [X] | [page] | ✅ Good |
+
+ ### Anchor Text Distribution by Page
+
+ **Page: [Important URL]**
+
+ | Anchor Text | Source Page | Status |
+ |-------------|-------------|--------|
+ | "[anchor 1]" | [source URL] | ✅/⚠️/❌ |
+ | "[anchor 2]" | [source URL] | ✅/⚠️/❌ |
+
+ **Issues Found**:
+ - Over-optimized anchors: [X] instances
+ - Generic anchors: [X] instances
+ - Same anchor to multiple pages: [X] instances
+
+ ### Anchor Text Recommendations
+
+ **For Page: [URL]**
+
+ Current: "[current anchor]" used [X] times
+
+ Recommended variety:
+ - "[variation 1]" - Use from [page type]
+ - "[variation 2]" - Use from [page type]
+ - "[variation 3]" - Use from [page type]
+
+ **Anchor Score**: [X]/10
+ ```
+
+4. **Create Topic Cluster Link Strategy** — Map current pillar/cluster links, recommend link structure, list specific links to add
+
+ > **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the topic cluster link strategy template (Step 4).
+
+5. **Find Contextual Link Opportunities** — Analyze each page for topic-relevant link opportunities, prioritize high-impact additions
+
+ > **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the contextual link opportunities template (Step 5).
+
+6. **Optimize Navigation and Footer Links** — Analyze main/footer/sidebar/breadcrumb navigation, recommend pages to add or remove
+
+ > **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the navigation optimization template (Step 6).
+
+7. **Generate Link Implementation Plan** — Executive summary, current state metrics, phased priority actions (weeks 1-4+), implementation guide, tracking plan
+
+ > **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the full implementation plan template (Step 7).
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Site structure or sitemap provided (URL or file)
+- [ ] Target pages or topic clusters clearly defined
+- [ ] If optimizing specific page, page URL or content provided
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] All link suggestions include source page, target page, and recommended anchor text
+- [ ] Orphan page lists include URLs and recommended actions
+- [ ] Source of each data point clearly stated (~~web crawler data, ~~analytics, user-provided, or manual analysis)
+
+## Example
+
+> **Reference**: See [references/linking-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-example.md) for a full worked example (email marketing best practices internal linking opportunities).
+
+## Tips for Success
+
+1. **Quality over quantity** - Add relevant links, not random ones
+2. **User-first thinking** - Links should help users navigate
+3. **Vary anchor text** - Avoid over-optimization
+4. **Link to important pages** - Distribute authority strategically
+5. **Regular audits** - Internal links need maintenance as content grows
+
+
+### Save Results
+
+After delivering audit or optimization findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/audits/internal-linking-optimizer/YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Reference Materials
+
+- [Link Architecture Patterns](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/link-architecture-patterns.md) — Architecture models (hub-and-spoke, silo, flat, pyramid, mesh), anchor text diversity framework, link equity flow model, and internal link audit checklist
+- [Linking Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) — Detailed output templates for steps 6-7 (navigation optimization, implementation plan)
+- [Linking Example](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-example.md) — Full worked example for internal linking opportunities
+
+## Next Best Skill
+
+- **Primary**: [on-page-seo-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/SKILL.md) — verify that revised internal links support the page-level goals.
diff --git a/.cursor/skills/internal-linking-optimizer/references/link-architecture-patterns.md b/.cursor/skills/internal-linking-optimizer/references/link-architecture-patterns.md
new file mode 100644
index 0000000000..24d2f3de2f
--- /dev/null
+++ b/.cursor/skills/internal-linking-optimizer/references/link-architecture-patterns.md
@@ -0,0 +1,362 @@
+# Link Architecture Patterns
+
+Detailed architecture models with implementation guides, migration strategies, and measurement frameworks for internal linking optimization.
+
+## Architecture Model Deep Dives
+
+### 1. Hub-and-Spoke (Topic Cluster) Model
+
+#### Overview
+
+The hub-and-spoke model organizes content around central "pillar" pages (hubs) that link to and from related "cluster" articles (spokes). This is the most widely recommended architecture for content-driven sites targeting topical authority.
+
+#### Structure Diagram
+
+```
+ ┌──────────────┐
+ │ Homepage │
+ └──────┬───────┘
+ │
+ ┌──────────────┼──────────────┐
+ │ │ │
+ ┌──────▼──────┐ ┌────▼────┐ ┌──────▼──────┐
+ │ Hub A │ │ Hub B │ │ Hub C │
+ │ (Pillar) │ │(Pillar) │ │ (Pillar) │
+ └──┬───┬───┬──┘ └────┬────┘ └──┬───┬───┬──┘
+ │ │ │ │ │ │ │
+ A1 A2 A3 B1 B2 C1 C2 C3
+ └───┼───┘ └───┼───┘
+ cross-links cross-links
+```
+
+#### Implementation Steps
+
+1. **Identify 3-7 core topics** that define your business expertise
+2. **Create pillar pages** (2,000-5,000 words) that broadly cover each core topic
+3. **Map cluster articles** (800-2,000 words) that dive deep into subtopics
+4. **Implement bidirectional links**: every cluster article links to its pillar, every pillar links to all its clusters
+5. **Add cross-links** between related cluster articles within the same hub
+6. **Add bridge links** between hubs where subtopics overlap
+
+#### Link Rules
+
+| Link Type | Direction | Anchor Text Strategy |
+|-----------|-----------|---------------------|
+| Pillar → Cluster | Pillar links to each cluster | Descriptive: "learn about [subtopic]" |
+| Cluster → Pillar | Every cluster links back to pillar | Partial match: "our complete [topic] guide" |
+| Cluster ↔ Cluster | Between related clusters in same hub | Natural: "as we covered in [related article]" |
+| Hub ↔ Hub (bridge) | Between related pillar pages | Branded/natural: "see also our [topic] resource" |
+
+#### When to Use
+- Content marketing sites and blogs
+- SaaS companies building topical authority
+- Publishers covering defined topic areas
+- Any site with 50-500 content pages
+
+#### Measurement
+
+| Metric | Target | Tool |
+|--------|--------|------|
+| Pillar page rankings for head terms | Top 10 | Rank tracker |
+| Cluster article rankings for long-tail | Top 20 | Rank tracker |
+| Internal links per cluster article | 3-5 minimum | Crawl report |
+| Click depth from homepage to cluster | ≤3 clicks | Crawl report |
+| Organic traffic to hub pages | Month-over-month growth | Analytics |
+
+---
+
+### 2. Silo Structure
+
+#### Overview
+
+The silo model creates strict vertical hierarchies where content is organized into isolated "silos" (categories). Links flow vertically within a silo but rarely cross between silos. This concentrates topical relevance within each silo.
+
+#### Structure Diagram
+
+```
+ ┌──────────────┐
+ │ Homepage │
+ └──────┬───────┘
+ │
+ ┌─────────────────┼─────────────────┐
+ │ │ │
+ ┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
+ │ Silo A │ │ Silo B │ │ Silo C │
+ │ Category │ │ Category │ │ Category │
+ └────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ │ │
+ ┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
+ │ Sub-cat │ │ Sub-cat │ │ Sub-cat │
+ └────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ │ │
+ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
+ │ Pages │ │ Pages │ │ Pages │
+ └─────────┘ └─────────┘ └─────────┘
+
+ No horizontal links between silos (strict model)
+```
+
+#### Implementation Steps
+
+1. **Define 5-15 top-level categories** (silos) based on your product/service taxonomy
+2. **Create category landing pages** with overview content and links to subcategories
+3. **Build subcategory pages** linking down to individual product/content pages
+4. **Enforce vertical linking**: pages link up to their parent and down to their children
+5. **Use breadcrumbs** to reinforce the hierarchy visually and structurally
+6. **Limit cross-silo links** to only the most relevant connections (strict model) or allow them strategically (modified model)
+
+#### Link Rules
+
+| Link Type | Direction | Allowed? |
+|-----------|-----------|----------|
+| Parent → Child | Downward within silo | Always |
+| Child → Parent | Upward within silo | Always |
+| Sibling ↔ Sibling | Horizontal within same parent | Yes |
+| Cross-silo | Between different silos | Strict: No. Modified: Sparingly |
+| All pages → Homepage | Upward to root | Yes (via navigation) |
+
+#### When to Use
+- Large e-commerce sites (100+ product categories)
+- Directory sites with clear taxonomy
+- Sites where categories are truly distinct topics
+- Enterprises with separate business lines
+
+#### Limitations
+- Overly strict silos can trap link equity in one branch
+- Cross-topic content becomes difficult to place
+- Users may need to navigate up and over to find related content
+- Modified silo (allowing some cross-links) often works better in practice
+
+---
+
+### 3. Flat Architecture
+
+#### Overview
+
+A flat architecture keeps all pages within 2-3 clicks of the homepage. There is minimal hierarchy; instead, pages are broadly interlinked. This maximizes crawlability and distributes link equity evenly.
+
+#### Structure Diagram
+
+```
+ ┌──────────┐
+ │ Homepage │
+ └────┬─────┘
+ │
+ ┌──────────────┼──────────────┐
+ │ │ │ │ │ │ │
+ P1 P2 P3 P4 P5 P6 P7
+ └────┼────┼────┼────┼────┼────┘
+ └────┴────┴────┘
+ (cross-linked freely)
+```
+
+#### Implementation Steps
+
+1. **Link all key pages from the homepage** (directly or via a comprehensive sitemap page)
+2. **Keep URL structure shallow**: /category/page, not /category/subcategory/year/page
+3. **Cross-link freely** between related pages at the same level
+4. **Use comprehensive navigation** menus, footer links, or HTML sitemaps
+5. **Limit total pages** to keep the architecture manageable
+
+#### When to Use
+- Small sites with fewer than 100 pages
+- Portfolio sites
+- Small business brochure sites
+- Startups with limited content
+
+#### Scaling Limits
+
+| Site Size | Flat Architecture Feasibility |
+|-----------|------------------------------|
+| <50 pages | Ideal |
+| 50-100 pages | Manageable with good navigation |
+| 100-500 pages | Difficult; consider hub-and-spoke |
+| 500+ pages | Not recommended; switch to hierarchical model |
+
+---
+
+### 4. Pyramid Architecture
+
+#### Overview
+
+The pyramid model mirrors traditional website hierarchies: a single homepage at the top, branching into categories, subcategories, and finally individual pages. Authority flows from top to bottom, concentrating at higher levels.
+
+#### Structure Diagram
+
+```
+Level 0: Homepage
+ / \
+Level 1: Category A Category B
+ / \ / \
+Level 2: Sub A1 Sub A2 Sub B1 Sub B2
+ / \ / \ / \ / \
+Level 3: P1 P2 P3 P4 P5 P6 P7 P8
+```
+
+#### Implementation Steps
+
+1. **Design a clear hierarchy** with 3-4 levels maximum
+2. **Homepage links to all top-level categories** prominently
+3. **Category pages link to all subcategories** within them
+4. **Subcategory pages link to all child pages**
+5. **Implement breadcrumbs** to support the hierarchy
+6. **Add "related content" cross-links** at the page level to offset authority concentration
+
+#### Authority Flow Considerations
+
+| Level | Typical Authority | Action to Improve |
+|-------|-------------------|-------------------|
+| Homepage | Highest | Ensure links to priority categories are prominent |
+| Categories | High | Link from blog content, not just navigation |
+| Subcategories | Medium | Add contextual links from other sections |
+| Individual pages | Lowest | Cross-link, feature in "popular posts" widgets |
+
+#### When to Use
+- News and media sites
+- Large blogs (500+ posts)
+- Corporate sites with many divisions
+- Government/educational sites
+
+---
+
+### 5. Mesh/Matrix Architecture
+
+#### Overview
+
+The mesh model allows free-form linking between any related pages, regardless of hierarchy. Every page can link to any other relevant page. This creates a dense web of connections, similar to Wikipedia's link structure.
+
+#### Structure Diagram
+
+```
+ P1 ←──→ P2 ←──→ P3
+ ↕ ╲ ↕ ╱ ↕
+ P4 ←──→ P5 ←──→ P6
+ ↕ ╱ ↕ ╲ ↕
+ P7 ←──→ P8 ←──→ P9
+```
+
+#### Implementation Steps
+
+1. **Set linking rules** to prevent chaos: link only when topically relevant
+2. **Use contextual anchors** that describe the destination page
+3. **Set a link budget** per page (5-15 contextual links per 1,000 words)
+4. **Review link density regularly** to prune irrelevant connections
+5. **Maintain a link map** (spreadsheet or tool) to track the network
+
+#### Governance Rules
+
+| Rule | Purpose |
+|------|---------|
+| Every link must have topical relevance | Prevents link dilution |
+| Maximum 15 contextual links per 1,000 words | Prevents link farms |
+| Review links quarterly | Prunes outdated connections |
+| Use descriptive anchor text only | Maintains semantic value |
+| No reciprocal link trading between unrelated pages | Prevents manipulation patterns |
+
+#### When to Use
+- Knowledge bases and documentation sites
+- Wikis and encyclopedias
+- Research repositories
+- FAQ/help center sites
+
+---
+
+## Migration Between Models
+
+### Common Migration Paths
+
+| From | To | Reason | Difficulty |
+|------|----|--------|-----------|
+| Flat → Hub-and-Spoke | Site grew beyond 100 pages | Medium |
+| Silo → Hub-and-Spoke | Silos too rigid, need cross-topic links | Medium |
+| Pyramid → Hub-and-Spoke | Want to build topical clusters | High |
+| No structure → Any model | Starting from disorganized state | High |
+| Hub-and-Spoke → Hybrid | Need both clusters and strict categories | Medium |
+
+### Migration Steps (General)
+
+1. **Audit current state**: Map all existing internal links using a crawler
+2. **Design target architecture**: Choose model, map pages to their new positions
+3. **Create a link change plan**: Document every link addition, removal, and anchor text change
+4. **Implement in phases**: Start with highest-priority cluster/silo, then expand
+5. **Preserve existing equity**: Do not remove links that pass significant value without replacement
+6. **Monitor impact**: Track rankings and traffic for 4-8 weeks after each phase
+7. **Iterate**: Adjust the plan based on measured results
+
+### Migration Risk Mitigation
+
+| Risk | Mitigation |
+|------|-----------|
+| Temporary ranking drops | Migrate one section at a time, not all at once |
+| Broken internal links | Run crawl after each phase to verify |
+| Lost link equity | Ensure no orphan pages created during migration |
+| Anchor text disruption | Change anchors gradually, not all at once |
+
+---
+
+## Measurement Framework
+
+### Key Metrics by Architecture Model
+
+| Metric | Hub-and-Spoke | Silo | Flat | Pyramid | Mesh |
+|--------|---------------|------|------|---------|------|
+| Avg click depth | ≤3 | ≤4 | ≤2 | ≤4 | ≤3 |
+| Orphan pages | 0 | 0 | 0 | 0 | 0 |
+| Avg internal links per page | 5-10 | 3-7 | 8-15 | 3-5 | 8-15 |
+| Cross-section links | Many | Few | N/A | Some | Many |
+| Authority concentration | Distributed to hubs | Concentrated in silo tops | Even | Top-heavy | Even |
+
+### Monthly Monitoring Checklist
+
+| Check | Tool | Action if Failing |
+|-------|------|-------------------|
+| Orphan pages count | Crawl report | Add internal links immediately |
+| Average click depth | Crawl report | Add shortcuts to deep pages |
+| Crawl depth distribution | Crawl report | Flatten deep branches |
+| Internal link count per page | Crawl report | Add links to under-linked pages |
+| Anchor text diversity | Manual audit | Vary anchors for over-optimized pages |
+| Broken internal links | Crawl report | Fix or remove broken links |
+| New content linked within 48 hours | Editorial process | Add to related pages upon publishing |
+
+### ROI Estimation
+
+| Architecture Change | Typical Impact | Timeline to See Results |
+|--------------------|---------------|----------------------|
+| Fix orphan pages | +15-30% traffic to those pages | 2-4 weeks |
+| Build first topic cluster | +10-25% traffic to cluster pages | 4-8 weeks |
+| Reduce click depth by 1 level | +5-15% crawl efficiency | 2-6 weeks |
+| Anchor text optimization | +5-10% ranking improvement for target terms | 4-12 weeks |
+| Full architecture migration | +20-50% overall organic traffic | 3-6 months |
+
+---
+
+## Hybrid Architecture Strategies
+
+Most real-world sites combine elements from multiple models. Common hybrid patterns:
+
+### Hub-and-Spoke + Silo (Recommended for Medium-Large Sites)
+
+```
+Homepage
+ ├── Category Silo A
+ │ ├── Hub A1 (pillar) ←→ Cluster articles
+ │ └── Hub A2 (pillar) ←→ Cluster articles
+ ├── Category Silo B
+ │ ├── Hub B1 (pillar) ←→ Cluster articles
+ │ └── Hub B2 (pillar) ←→ Cluster articles
+ └── Cross-category bridge links (A1 ↔ B2 where relevant)
+```
+
+- **Silos** provide category organization for navigation and URL structure
+- **Hubs** within each silo build topical authority for specific keyword clusters
+- **Bridge links** connect related content across silos where user intent overlaps
+
+### Implementation Priority Order
+
+1. Fix structural issues first (orphan pages, broken links)
+2. Implement primary architecture model
+3. Add cross-linking strategy
+4. Optimize anchor text
+5. Monitor and iterate
+
+This order ensures each phase builds on a solid foundation rather than optimizing details on a broken structure.
diff --git a/.cursor/skills/internal-linking-optimizer/references/linking-example.md b/.cursor/skills/internal-linking-optimizer/references/linking-example.md
new file mode 100644
index 0000000000..ef8703eea6
--- /dev/null
+++ b/.cursor/skills/internal-linking-optimizer/references/linking-example.md
@@ -0,0 +1,42 @@
+# Internal Linking Optimizer — Worked Example
+
+Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/SKILL.md).
+
+---
+
+## Worked Example
+
+**User**: "Find internal linking opportunities for my blog post about 'email marketing best practices'"
+
+**Output**:
+
+```markdown
+## Internal Linking Opportunities
+
+**Page**: /blog/email-marketing-best-practices/
+**Current Internal Links**: 2
+
+### Recommended Links to Add
+
+| Section | Text to Link | Target Page | Anchor |
+|---------|--------------|-------------|--------|
+| Para 2 | "building your email list" | /blog/grow-email-list/ | "building your email list" |
+| Para 5 | "subject lines" | /blog/email-subject-lines/ | "write compelling subject lines" |
+| Section on segmentation | "audience segments" | /blog/email-segmentation-guide/ | "segment your audience" |
+| CTA section | "marketing automation" | /services/email-automation/ | "email automation services" |
+| Conclusion | "email marketing tools" | /blog/best-email-tools/ | "top email marketing tools" |
+
+### Pages That Should Link TO This Article
+
+| Source Page | Location | Anchor Text |
+|-------------|----------|-------------|
+| /blog/digital-marketing-guide/ | Email section | "email marketing best practices" |
+| /services/marketing-services/ | Related content | "email marketing strategies" |
+| /blog/lead-generation-tips/ | Email mention | "email marketing techniques" |
+
+### Priority Actions
+
+1. Add 5 outbound internal links (listed above)
+2. Request 3 inbound links from related pages
+3. Add to "Marketing" category page
+```
diff --git a/.cursor/skills/internal-linking-optimizer/references/linking-templates.md b/.cursor/skills/internal-linking-optimizer/references/linking-templates.md
new file mode 100644
index 0000000000..ce5e1e4c40
--- /dev/null
+++ b/.cursor/skills/internal-linking-optimizer/references/linking-templates.md
@@ -0,0 +1,219 @@
+# Internal Linking Optimizer — Output Templates
+
+Detailed output templates for internal-linking-optimizer steps 4-7. Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/SKILL.md).
+
+---
+
+## Step 4: Create Topic Cluster Link Strategy
+
+```markdown
+## Topic Cluster Internal Linking
+
+### Cluster: [Main Topic]
+
+**Pillar Page**: [URL]
+**Cluster Articles**: [X]
+
+### Current Link Map
+
+```
+[Pillar Page]
+ ├── [Cluster Article 1] ←→ [linked?]
+ ├── [Cluster Article 2] ←→ [linked?]
+ ├── [Cluster Article 3] ←→ [linked?]
+ └── [Cluster Article 4] ←→ [linked?]
+```
+
+### Recommended Link Structure
+
+```
+[Pillar Page]
+ ├── Links TO all cluster articles ✅
+ │
+ ├── [Cluster Article 1]
+ │ ├── Link TO pillar ✅
+ │ └── Link TO related cluster articles
+ │
+ ├── [Cluster Article 2]
+ │ ├── Link TO pillar ✅
+ │ └── Link TO related cluster articles
+ │
+ └── [etc.]
+```
+
+### Links to Add
+
+| From Page | To Page | Anchor Text | Location |
+|-----------|---------|-------------|----------|
+| [URL 1] | [URL 2] | "[anchor]" | [paragraph/section] |
+| [URL 2] | [URL 3] | "[anchor]" | [paragraph/section] |
+| [Pillar] | [Cluster 1] | "[anchor]" | [section] |
+```
+
+---
+
+## Step 5: Find Contextual Link Opportunities
+
+```markdown
+## Contextual Link Opportunities
+
+### Link Opportunity Analysis
+
+For each page, find relevant pages to link to based on:
+- Topic relevance
+- Keyword overlap
+- User journey logic
+- Authority distribution needs
+
+### Opportunities Found
+
+**Page: [URL 1]**
+**Topic**: [topic]
+**Current internal links**: [X]
+
+| Opportunity | Target Page | Anchor Text | Why Link |
+|-------------|-------------|-------------|----------|
+| Paragraph 2 mentions "[topic]" | [URL] | "[topic phrase]" | Topic match |
+| Section on "[subject]" | [URL] | "[anchor]" | Related guide |
+| CTA at end | [URL] | "[anchor]" | User journey |
+
+**Page: [URL 2]**
+[Continue for each page...]
+
+### Priority Link Additions
+
+**High Impact Links** (add these first):
+
+1. **From**: [Source URL]
+ **To**: [Target URL]
+ **Anchor**: "[anchor text]"
+ **Why**: [reason - e.g., "Target page needs authority boost"]
+ **Where to add**: [specific location in content]
+
+2. **From**: [Source URL]
+ **To**: [Target URL]
+ [etc.]
+```
+
+---
+
+## Step 6: Optimize Navigation and Footer Links
+
+```markdown
+## Site-Wide Link Optimization
+
+### Current Navigation Analysis
+
+**Main Navigation**:
+- Links present: [list]
+- Missing important pages: [list]
+- Too many links: [Yes/No]
+
+**Footer Navigation**:
+- Links present: [list]
+- SEO value: [assessment]
+
+### Navigation Recommendations
+
+| Element | Current | Recommended | Reason |
+|---------|---------|-------------|--------|
+| Main nav | [X] links | [Y] links | [reason] |
+| Footer | [X] links | [Y] links | [reason] |
+| Sidebar | [status] | [recommendation] | [reason] |
+| Breadcrumbs | [status] | [recommendation] | [reason] |
+
+### Pages to Add to Navigation
+
+1. [Page] - Add to [location] because [reason]
+2. [Page] - Add to [location] because [reason]
+
+### Pages to Remove from Navigation
+
+1. [Page] - Move to [footer/remove] because [reason]
+```
+
+---
+
+## Step 7: Generate Link Implementation Plan
+
+```markdown
+# Internal Linking Optimization Plan
+
+**Site**: [domain]
+**Analysis Date**: [date]
+
+## Executive Summary
+
+- Total link opportunities found: [X]
+- Orphan pages to fix: [X]
+- Estimated traffic impact: [+X%]
+- Priority actions: [X]
+
+## Current State
+
+| Metric | Current | Target | Gap |
+|--------|---------|--------|-----|
+| Avg links per page | [X] | [X] | [X] |
+| Orphan pages | [X] | 0 | [X] |
+| Over-optimized anchors | [X]% | <10% | [X]% |
+| Topic cluster coverage | [X]% | 100% | [X]% |
+
+## Priority Actions
+
+### Phase 1: Critical Fixes (Week 1)
+
+**Fix Orphan Pages**:
+- [ ] [URL] - Add links from [X] pages
+- [ ] [URL] - Add links from [X] pages
+
+**High-Value Link Additions**:
+- [ ] Link [Page A] to [Page B] with "[anchor]"
+- [ ] Link [Page A] to [Page C] with "[anchor]"
+
+### Phase 2: Topic Clusters (Week 2-3)
+
+**Cluster 1: [Topic]**
+- [ ] Ensure pillar links to all [X] cluster articles
+- [ ] Add [X] cross-links between cluster articles
+
+**Cluster 2: [Topic]**
+- [ ] [Tasks]
+
+### Phase 3: Optimization (Week 4+)
+
+**Anchor Text Diversity**:
+- [ ] Vary anchors for [Page] - currently [X]% exact match
+- [ ] [Additional tasks]
+
+**Navigation Updates**:
+- [ ] Add [Page] to main navigation
+- [ ] Update footer links
+
+## Implementation Guide
+
+### Adding Internal Links
+
+Best practices:
+1. Add links contextually within content
+2. Use descriptive anchor text (not "click here")
+3. Link to relevant, helpful pages
+4. Aim for 3-10 internal links per 1,000 words
+5. Vary anchor text for the same target
+
+### Anchor Text Guidelines
+
+| Type | Example | Usage |
+|------|---------|-------|
+| Exact match | "keyword research" | 10-20% |
+| Partial match | "tips for keyword research" | 30-40% |
+| Branded | "Brand's guide to..." | 10-20% |
+| Natural | "this article", "learn more" | 20-30% |
+
+## Tracking Success
+
+Monitor these metrics weekly:
+- [ ] Rankings for target keywords
+- [ ] Traffic to previously orphan pages
+- [ ] Crawl stats in ~~search console
+- [ ] Internal link distribution changes
+```
diff --git a/.cursor/skills/keyword-research/SKILL.md b/.cursor/skills/keyword-research/SKILL.md
new file mode 100644
index 0000000000..fdbb208b11
--- /dev/null
+++ b/.cursor/skills/keyword-research/SKILL.md
@@ -0,0 +1,435 @@
+---
+name: keyword-research
+description: 'Find high-value SEO keywords: search volume, difficulty, intent classification, topic clusters. 关键词研究/内容选题'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when starting keyword research for a new page, topic, or campaign. Also when the user asks about search volume, keyword difficulty, topic clusters, long-tail keywords, or what to write about."
+argument-hint: " [market/language]"
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - geo
+ - keywords
+ - keyword-research
+ - search-volume
+ - keyword-difficulty
+ - topic-clusters
+ - long-tail-keywords
+ - search-intent
+ - content-calendar
+ - ahrefs
+ - semrush
+ - google-keyword-planner
+ - 关键词研究
+ - SEO关键词
+ - キーワード調査
+ - 키워드분석
+ - palabras-clave
+ triggers:
+ # EN-formal
+ - "keyword research"
+ - "find keywords"
+ - "keyword analysis"
+ - "keyword discovery"
+ - "search volume analysis"
+ - "keyword difficulty"
+ - "topic research"
+ - "identify ranking opportunities"
+ # EN-casual
+ - "what should I write about"
+ - "what are people searching for"
+ - "what are people googling"
+ - "find me topics to write"
+ - "give me keyword ideas"
+ - "which keywords should I target"
+ - "why is my traffic low"
+ - "I need content ideas"
+ # EN-question
+ - "how do I find good keywords"
+ - "what keywords should I target"
+ - "how competitive is this keyword"
+ # EN-competitor
+ - "Ahrefs keyword explorer alternative"
+ - "Semrush keyword magic tool"
+ - "Google Keyword Planner alternative"
+ - "Ubersuggest alternative"
+ # ZH-pro
+ - "关键词研究"
+ - "关键词分析"
+ - "搜索量查询"
+ - "关键词难度"
+ - "SEO关键词"
+ - "长尾关键词"
+ - "词库整理"
+ - "关键词布局"
+ - "关键词挖掘"
+ # ZH-casual
+ - "写什么内容好"
+ - "找选题"
+ - "帮我挖词"
+ - "不知道写什么"
+ - "查关键词"
+ - "选词"
+ - "帮我找词"
+ # JA
+ - "キーワード調査"
+ - "キーワードリサーチ"
+ - "SEOキーワード分析"
+ - "検索ボリューム"
+ - "ロングテールキーワード"
+ - "検索意図分析"
+ # KO
+ - "키워드 리서치"
+ - "키워드 분석"
+ - "검색량 분석"
+ - "키워드 어떻게 찾아요?"
+ - "검색어 분석"
+ - "경쟁도 낮은 키워드는?"
+ # ES
+ - "investigación de palabras clave"
+ - "análisis de palabras clave"
+ - "volumen de búsqueda"
+ - "posicionamiento web"
+ - "cómo encontrar palabras clave"
+ # PT
+ - "pesquisa de palavras-chave"
+ # Misspellings
+ - "keywrod research"
+ - "keywork research"
+---
+
+# Keyword Research
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+Discovers, analyzes, and prioritizes keywords for SEO and GEO content strategies. Identifies high-value opportunities based on search volume, competition, intent, and business relevance.
+
+**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
+
+- Starting a new content strategy or campaign
+- Expanding into new topics or markets
+- Finding keywords for a specific product or service
+- Identifying long-tail keyword opportunities
+- Understanding search intent for your industry
+- Planning content calendars
+- Researching keywords for GEO optimization
+
+## What This Skill Does
+
+1. **Keyword Discovery**: Generates comprehensive keyword lists from seed terms
+2. **Intent Classification**: Categorizes keywords by user intent (informational, navigational, commercial, transactional)
+3. **Difficulty Assessment**: Evaluates competition level and ranking difficulty
+4. **Opportunity Scoring**: Prioritizes keywords by potential ROI
+5. **Clustering**: Groups related keywords into topic clusters
+6. **GEO Relevance**: Identifies keywords likely to trigger AI responses
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Basic Keyword Research
+
+```
+Research keywords for [topic/product/service]
+```
+
+```
+Find keyword opportunities for a [industry] business targeting [audience]
+```
+
+### With Specific Goals
+
+```
+Find low-competition keywords for [topic] with commercial intent
+```
+
+```
+Identify question-based keywords for [topic] that AI systems might answer
+```
+
+### Competitive Research
+
+```
+What keywords is [competitor URL] ranking for that I should target?
+```
+
+## Skill Contract
+
+**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
+
+- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
+- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
+- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~search console connected:**
+Automatically pull historical search volume data, keyword difficulty scores, SERP analysis, current rankings from ~~search console, and competitor keyword overlap. The skill will fetch seed keyword metrics, related keyword suggestions, and search trend data.
+
+**With manual data only:**
+Ask the user to provide:
+1. Seed keywords or topic description
+2. Target audience and geographic location
+3. Business goals (traffic, leads, sales)
+4. Current domain authority (if known) or site age
+5. Any known keyword performance data or search volume estimates
+
+Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests keyword research:
+
+At the start of each phase, announce: **[Phase X/8: Name]** so the user can track progress.
+
+### Phase 1/8: Scope
+
+ Ask clarifying questions if not provided:
+ - What is your product/service/topic?
+ - Who is your target audience?
+ - What is your business goal? (traffic, leads, sales)
+ - What is your current domain authority? (new site, established, etc.)
+ - Any specific geographic targeting?
+ - Preferred language?
+
+### Phase 2/8: Discover
+
+ Start with:
+ - Core product/service terms
+ - Problem-focused keywords (what issues do you solve?)
+ - Solution-focused keywords (how do you help?)
+ - Audience-specific terms
+ - Industry terminology
+
+### Phase 3/8: Variations
+
+ For each seed keyword, generate variations:
+
+ ```markdown
+ ## Keyword Expansion Patterns
+
+ ### Modifiers
+ - Best [keyword]
+ - Top [keyword]
+ - [keyword] for [audience]
+ - [keyword] near me
+ - [keyword] [year]
+ - How to [keyword]
+ - What is [keyword]
+ - [keyword] vs [alternative]
+ - [keyword] examples
+ - [keyword] tools
+
+ ### Long-tail Variations
+ - [keyword] for beginners
+ - [keyword] for small business
+ - Free [keyword]
+ - [keyword] software/tool/service
+ - [keyword] template
+ - [keyword] checklist
+ - [keyword] guide
+ ```
+
+### Phase 4/8: Classify
+
+ Categorize each keyword:
+
+ | Intent | Signals | Example | Content Type |
+ |--------|---------|---------|--------------|
+ | Informational | what, how, why, guide, learn | "what is SEO" | Blog posts, guides |
+ | Navigational | brand names, specific sites | "google analytics login" | Homepage, product pages |
+ | Commercial | best, review, vs, compare | "best SEO tools [current year]" | Comparison posts, reviews |
+ | Transactional | buy, price, discount, order | "buy SEO software" | Product pages, pricing |
+
+### Phase 5/8: Score
+
+ Score each keyword (1-100 scale):
+
+ ```markdown
+ ### Difficulty Factors
+
+ **High Difficulty (70-100)**
+ - Major brands ranking
+ - High domain authority competitors
+ - Established content (1000+ backlinks)
+ - Paid ads dominating SERP
+
+ **Medium Difficulty (40-69)**
+ - Mix of authority and niche sites
+ - Some opportunities for quality content
+ - Moderate backlink requirements
+
+ **Low Difficulty (1-39)**
+ - Few authoritative competitors
+ - Thin or outdated content ranking
+ - Long-tail variations
+ - New or emerging topics
+ ```
+
+#### Opportunity Score
+
+ Formula: `Opportunity = (Volume × Intent Value) / Difficulty`
+
+ **Intent Value** assigns a numeric weight by search intent:
+ - Informational = 1
+ - Navigational = 1
+ - Commercial = 2
+ - Transactional = 3
+
+ ```markdown
+ ### Opportunity Matrix
+
+ | Scenario | Volume | Difficulty | Intent | Priority |
+ |----------|--------|------------|--------|----------|
+ | Quick Win | Low-Med | Low | High | ⭐⭐⭐⭐⭐ |
+ | Growth | High | Medium | High | ⭐⭐⭐⭐ |
+ | Long-term | High | High | High | ⭐⭐⭐ |
+ | Research | Low | Low | Low | ⭐⭐ |
+ ```
+
+### Phase 6/8: GEO-Check — AI Answer Overlap
+
+ Keywords likely to trigger AI responses:
+
+ ```markdown
+ ### GEO-Relevant Keywords
+
+ **High GEO Potential**
+ - Question formats: "What is...", "How does...", "Why is..."
+ - Definition queries: "[term] meaning", "[term] definition"
+ - Comparison queries: "[A] vs [B]", "difference between..."
+ - List queries: "best [category]", "top [number] [items]"
+ - How-to queries: "how to [action]", "steps to [goal]"
+
+ **AI Answer Indicators**
+ - Query is factual/definitional
+ - Answer can be summarized concisely
+ - Topic is well-documented online
+ - Low commercial intent
+ ```
+
+### Phase 7/8: Cluster
+
+ Group keywords into content clusters:
+
+ ```markdown
+ ## Topic Cluster: [Main Topic]
+
+ **Pillar Content**: [Primary keyword]
+ - Search volume: [X]
+ - Difficulty: [X]
+ - Content type: Comprehensive guide
+
+ **Cluster Content**:
+
+ ### Sub-topic 1: [Secondary keyword]
+ - Volume: [X]
+ - Difficulty: [X]
+ - Links to: Pillar
+ - Content type: [Blog post/Tutorial/etc.]
+
+ ### Sub-topic 2: [Secondary keyword]
+ - Volume: [X]
+ - Difficulty: [X]
+ - Links to: Pillar + Sub-topic 1
+ - Content type: [Blog post/Tutorial/etc.]
+
+ [Continue for all cluster keywords...]
+ ```
+
+### Phase 8/8: Deliver
+
+ Produce a report containing: Executive Summary, Top Keyword Opportunities (Quick Wins, Growth, GEO), Topic Clusters, Content Calendar, and Next Steps.
+
+ **Quality bar** — every recommendation must include at least one specific number. If it reads like the left column, rewrite it before including.
+
+ | ❌ Generic (rewrite before including) | ✅ Actionable |
+ |---|---|
+ | "Target long-tail keywords for better results" | "Target 'project management for nonprofits' (vol: 320, KD: 22) — no DR>40 sites in top 10" |
+ | "This keyword has good potential" | "Opportunity 8.4: vol 4,800, KD 28, transactional intent — gap analysis shows no content updated since 2023 in top 5" |
+ | "Consider creating content around this topic" | "Write '[Tool A] vs [Tool B] for small teams' — 1,200/mo searches, current #1 is a 2022 article with 12 backlinks" |
+ | "Optimize your page for this keyword" | "Add primary keyword to H1 (currently missing), write a 40-word direct answer in paragraph 1, add 3 internal links from your /blog/ cluster" |
+
+ > **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/example-report.md) for the full report template and example.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Seed keywords or topic description clearly provided
+- [ ] Target audience and business goals specified
+- [ ] Geographic and language targeting confirmed
+- [ ] Domain authority or site maturity level established
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] Search volume and difficulty scores included for each keyword
+- [ ] Keywords grouped by intent and mapped to content types
+- [ ] Topic clusters show clear pillar-to-cluster relationships
+- [ ] Source of each data point clearly stated (~~SEO tool data, user-provided, or estimated)
+
+## Example
+
+> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/example-report.md) for a complete example report for "project management software for small businesses".
+
+### Advanced Usage
+
+- **Intent Mapping**: `Map all keywords for [topic] by search intent and funnel stage`
+- **Seasonal Analysis**: `Identify seasonal keyword trends for [industry]`
+- **Competitor Gap**: `What keywords do [competitor 1], [competitor 2] rank for that I'm missing?`
+- **Local Keywords**: `Research local keywords for [business type] in [city/region]`
+
+## Tips for Success
+
+1. **Start with seed keywords** that describe your core offering
+2. **Don't ignore long-tail** - they often have highest conversion rates
+3. **Match content to intent** - informational queries need guides, not sales pages
+4. **Group into clusters** for topical authority
+5. **Prioritize quick wins** to build momentum and credibility
+6. **Include GEO keywords** in your strategy for AI visibility
+7. **Review quarterly** - keyword dynamics change over time
+
+
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/research/keyword-research/YYYY-MM-DD-.md` containing:
+- One-line headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Keyword Intent Taxonomy](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/keyword-intent-taxonomy.md) — Complete intent classification with signal words and content strategies
+- [Topic Cluster Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/topic-cluster-templates.md) — Hub-and-spoke architecture templates for pillar and cluster content
+- [Keyword Prioritization Framework](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/keyword-prioritization-framework.md) — Priority scoring matrix, categories, and seasonal keyword patterns
+- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/example-report.md) — Complete example keyword research report for project management software
+
+## Next Best Skill
+
+- **Primary**: [competitor-analysis](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/SKILL.md) — turn keyword opportunities into a competitive benchmark.
diff --git a/.cursor/skills/keyword-research/references/example-report.md b/.cursor/skills/keyword-research/references/example-report.md
new file mode 100644
index 0000000000..3c0a0f495a
--- /dev/null
+++ b/.cursor/skills/keyword-research/references/example-report.md
@@ -0,0 +1,96 @@
+# Keyword Research — Example Report
+
+This is a complete example output showing the expected format for keyword research deliverables.
+
+## Prompt
+
+**User**: "Research keywords for a project management software company targeting small businesses"
+
+## Example Output
+
+```markdown
+# Keyword Research Report: Project Management Software
+
+**Generated**: [current month and year]
+**Target Audience**: Small business owners and teams
+**Business Goal**: Software signups and trials
+
+## Executive Summary
+
+- Total keywords analyzed: 150+
+- High-priority opportunities: 23
+- Estimated traffic potential: 45,000/month
+- Recommended focus areas:
+ - Task management workflows
+ - Team collaboration
+ - Small business productivity
+
+## Top Keyword Opportunities
+
+### Quick Wins (Priority: Immediate)
+
+| Keyword | Volume | Difficulty | Intent | Score |
+|---------|--------|------------|--------|-------|
+| project management for small teams | 1,200 | 28 | Commercial | 92 |
+| simple task management software | 890 | 25 | Commercial | 89 |
+| best free project management tool | 2,400 | 35 | Commercial | 85 |
+| how to manage remote team projects | 720 | 22 | Informational | 82 |
+| project tracking spreadsheet alternative | 480 | 18 | Commercial | 80 |
+
+### Growth Keywords (Priority: 3-6 months)
+
+| Keyword | Volume | Difficulty | Intent | Score |
+|---------|--------|------------|--------|-------|
+| project management software | 18,000 | 72 | Commercial | 65 |
+| best project management tools [current year] | 8,500 | 65 | Commercial | 62 |
+| project management app | 12,000 | 68 | Commercial | 58 |
+
+### GEO Opportunities (AI-citation potential)
+
+| Keyword | Type | AI Potential | Recommended Format |
+|---------|------|--------------|-------------------|
+| what is project management | Definition | ⭐⭐⭐⭐⭐ | Clear definition + methodology |
+| agile vs waterfall | Comparison | ⭐⭐⭐⭐⭐ | Side-by-side comparison table |
+| project management methodologies | List | ⭐⭐⭐⭐ | Comprehensive list with pros/cons |
+| how to create a project plan | How-to | ⭐⭐⭐⭐ | Step-by-step guide |
+| project management best practices | List | ⭐⭐⭐⭐ | Numbered best practices |
+
+## Topic Clusters
+
+### Cluster 1: Project Management Fundamentals
+
+**Pillar**: "Complete Guide to Project Management" (8,500 volume)
+
+Cluster articles:
+1. What is project management? (2,200 volume)
+2. Project management methodologies explained (1,800 volume)
+3. How to create a project plan (1,400 volume)
+4. Project management best practices (1,200 volume)
+5. Project management roles and responsibilities (890 volume)
+
+### Cluster 2: Team Collaboration
+
+**Pillar**: "Team Collaboration Tools Guide" (4,200 volume)
+
+Cluster articles:
+1. How to improve team communication (1,600 volume)
+2. Remote team management tips (1,400 volume)
+3. Best practices for distributed teams (920 volume)
+4. Team productivity tools comparison (780 volume)
+
+## Content Calendar Recommendations
+
+| Month | Content | Target Keyword | Type |
+|-------|---------|----------------|------|
+| Week 1 | Simple Task Management Guide | simple task management software | Blog + Demo |
+| Week 2 | Project Management for Small Teams | project management for small teams | Pillar Page |
+| Week 3 | Agile vs Waterfall: Complete Comparison | agile vs waterfall | Comparison |
+| Week 4 | Free PM Tools Roundup | best free project management tool | Listicle |
+
+## Next Steps
+
+1. **Immediate**: Create landing pages for top 5 quick-win keywords
+2. **Week 1-2**: Write pillar content for "Project Management Fundamentals"
+3. **Week 3-4**: Build out cluster content with internal linking
+4. **Ongoing**: Track rankings and adjust strategy based on performance
+```
diff --git a/.cursor/skills/keyword-research/references/keyword-intent-taxonomy.md b/.cursor/skills/keyword-research/references/keyword-intent-taxonomy.md
new file mode 100644
index 0000000000..7e5f362506
--- /dev/null
+++ b/.cursor/skills/keyword-research/references/keyword-intent-taxonomy.md
@@ -0,0 +1,324 @@
+# Keyword Intent Taxonomy
+
+A comprehensive classification system for understanding search intent, mapping keywords to content strategies, and aligning with conversion funnels.
+
+## Overview
+
+Search intent (also called user intent or query intent) is the underlying goal a user has when typing a query into a search engine or AI system. Correctly classifying intent determines content format, tone, depth, and conversion strategy.
+
+This taxonomy defines **4 primary intent types**, each with **3-4 sub-categories**, along with signal words, SERP feature correlations, and content format recommendations.
+
+---
+
+## Primary Intent Types
+
+### 1. Informational Intent
+
+The user wants to **learn something**. They are seeking knowledge, not a product.
+
+#### Sub-categories
+
+| Sub-category | Description | Example Queries |
+|-------------|------------|----------------|
+| **Educational** | Broad learning about a topic | "what is SEO", "content marketing explained", "how search engines work" |
+| **Instructional** | Step-by-step guidance on completing a task | "how to set up Google Analytics", "how to write a meta description", "how to do keyword research" |
+| **Exploratory** | Open-ended research to understand options | "types of SEO strategies", "content marketing trends", "link building techniques" |
+| **Troubleshooting** | Diagnosing and fixing a problem | "why is my site not ranking", "Google indexing issues", "404 error fix" |
+
+#### Signal Words and Modifiers
+
+**Strong informational signals:**
+- what, what is, what are, what does
+- how, how to, how does, how do I
+- why, why is, why does, why do
+- when, when to, when should
+- who, who is, who does
+- where, where to, where can I
+- guide, tutorial, learn, explain
+- examples, tips, ideas, ways
+- definition, meaning, difference between
+- history of, basics of, introduction to
+- pros and cons, advantages, disadvantages
+- best practices, checklist, steps
+
+**Weak informational signals (may overlap with commercial):**
+- overview, comparison, review (can be commercial)
+- list of, types of, methods
+
+#### SERP Feature Correlation
+
+| SERP Feature | Frequency for Informational | Notes |
+|-------------|---------------------------|-------|
+| Featured Snippet | Very High | Especially for "what is" and "how to" queries |
+| People Also Ask | Very High | Almost always present for informational queries |
+| Knowledge Panel | High | For entity-related informational queries |
+| AI Overview | Very High | AI systems prioritize informational answers |
+| Video Carousel | Medium-High | Especially for instructional queries |
+| Image Pack | Medium | For visual/diagram-seeking queries |
+| Shopping Results | Very Low | Rarely appears for pure informational |
+
+#### Content Format Recommendations
+
+| Sub-category | Best Formats | Target Length | Key Elements |
+|-------------|-------------|--------------|-------------|
+| Educational | Definitive guides, explainers, glossary entries | 1,500-3,000 words | Clear definitions, examples, visuals |
+| Instructional | Step-by-step tutorials, how-to guides, video walkthroughs | 2,000-4,000 words | Numbered steps, screenshots, code samples |
+| Exploratory | Listicles, roundups, trend reports, comparison tables | 2,000-3,500 words | Categories, pros/cons, use cases |
+| Troubleshooting | FAQ pages, debug guides, knowledge base articles | 800-2,000 words | Problem-solution pairs, error codes, screenshots |
+
+---
+
+### 2. Navigational Intent
+
+The user wants to **find a specific website, page, or resource**. They already know where they want to go.
+
+#### Sub-categories
+
+| Sub-category | Description | Example Queries |
+|-------------|------------|----------------|
+| **Brand Search** | Looking for a specific company or product | "Ahrefs", "HubSpot CRM", "Moz Pro" |
+| **Product/Feature Search** | Looking for a specific feature or tool within a known brand | "Ahrefs keyword explorer", "Google Search Console coverage report" |
+| **Login/Access** | Trying to access an account or dashboard | "Ahrefs login", "Google Analytics sign in", "SEMrush dashboard" |
+| **Support/Docs** | Seeking help documentation for a known product | "Ahrefs API documentation", "Google Search Console help" |
+
+#### Signal Words and Modifiers
+
+**Strong navigational signals:**
+- [brand name] (standalone)
+- [brand name] login, sign in, sign up
+- [brand name] pricing, plans
+- [brand name] support, help, docs
+- [brand name] [feature name]
+- [brand name] download, app
+- official, website, site
+- [brand name] alternatives (borderline commercial)
+
+#### SERP Feature Correlation
+
+| SERP Feature | Frequency for Navigational | Notes |
+|-------------|---------------------------|-------|
+| Sitelinks | Very High | Brand queries almost always show sitelinks |
+| Knowledge Panel | High | For established brands with Google entity |
+| Featured Snippet | Low | Rarely for pure navigational |
+| People Also Ask | Medium | Often brand-related questions |
+| AI Overview | Low | AI usually defers to direct navigation |
+
+#### Content Format Recommendations
+
+| Sub-category | Best Formats | Key Elements |
+|-------------|-------------|-------------|
+| Brand Search | Homepage, about page, landing pages | Clear branding, value prop, navigation |
+| Product/Feature Search | Product pages, feature pages | Feature details, CTAs, screenshots |
+| Login/Access | Login page, getting started guide | Fast load, clear login form, password reset |
+| Support/Docs | Knowledge base, documentation hub | Search functionality, categorized articles |
+
+---
+
+### 3. Commercial Investigation Intent
+
+The user is **researching before making a purchase decision**. They are comparing options, reading reviews, and evaluating alternatives.
+
+#### Sub-categories
+
+| Sub-category | Description | Example Queries |
+|-------------|------------|----------------|
+| **Comparison** | Evaluating two or more specific options | "Ahrefs vs SEMrush", "WordPress vs Squarespace for SEO" |
+| **Review-seeking** | Looking for opinions and evaluations | "Ahrefs review 2026", "best SEO tool reviews", "is Moz worth it" |
+| **Best-of Research** | Seeking curated top options in a category | "best SEO tools", "top keyword research tools", "best free backlink checkers" |
+| **Evaluation** | Assessing fit for a specific need | "SEO tools for small business", "enterprise SEO platform features", "SEO tool for agencies" |
+
+#### Signal Words and Modifiers
+
+**Strong commercial signals:**
+- best, top, leading
+- vs, versus, compared to, comparison
+- review, reviews, rating, ratings
+- alternative, alternatives to
+- like [brand], similar to
+- for [audience], for [use case]
+- pros and cons (when about products)
+- worth it, is [product] good
+- features, capabilities, integrations
+- pricing, cost, how much
+- free vs paid, premium
+
+**Seasonal/temporal commercial signals:**
+- [product] [year] (e.g., "best SEO tools 2026")
+- new, latest, updated
+
+#### SERP Feature Correlation
+
+| SERP Feature | Frequency for Commercial | Notes |
+|-------------|------------------------|-------|
+| People Also Ask | Very High | Comparison and evaluation questions |
+| Featured Snippet | High | Especially for "best" and comparison queries |
+| AI Overview | High | AI often synthesizes comparisons |
+| Shopping Results | Medium | When products have direct purchase path |
+| Video Carousel | Medium | Review and comparison videos |
+| Ads (top) | Very High | Advertisers target commercial intent heavily |
+
+#### Content Format Recommendations
+
+| Sub-category | Best Formats | Target Length | Key Elements |
+|-------------|-------------|--------------|-------------|
+| Comparison | Side-by-side comparison pages, "X vs Y" articles | 2,500-4,000 words | Feature tables, pricing tables, verdict |
+| Review-seeking | In-depth reviews, user testimonials, case studies | 2,000-3,500 words | Honest pros/cons, screenshots, scoring |
+| Best-of Research | Ranked listicles, category roundups | 3,000-5,000 words | Criteria explanation, mini-reviews, comparison table |
+| Evaluation | Buying guides, use-case guides, ROI calculators | 2,000-3,500 words | Decision criteria, audience segmentation, recommendations |
+
+---
+
+### 4. Transactional Intent
+
+The user wants to **complete a specific action**, typically a purchase, signup, or download.
+
+#### Sub-categories
+
+| Sub-category | Description | Example Queries |
+|-------------|------------|----------------|
+| **Purchase** | Ready to buy a product or service | "buy Ahrefs subscription", "SEMrush pricing plans", "order SEO audit" |
+| **Signup/Trial** | Ready to create an account or start a trial | "Ahrefs free trial", "sign up for Moz", "create Google Search Console account" |
+| **Download** | Wants to download software, template, or resource | "download Screaming Frog", "SEO checklist PDF download", "free keyword template" |
+| **Hire/Engage** | Ready to hire a professional or agency | "hire SEO consultant", "SEO agency near me", "freelance SEO expert" |
+
+#### Signal Words and Modifiers
+
+**Strong transactional signals:**
+- buy, purchase, order, subscribe
+- price, pricing, cost, discount, coupon, deal
+- free trial, demo, signup, sign up, register
+- download, install, get
+- hire, book, schedule, request quote
+- near me, in [location]
+- cheap, affordable, budget
+- premium, pro, enterprise
+- [product] discount code, [product] coupon
+
+#### SERP Feature Correlation
+
+| SERP Feature | Frequency for Transactional | Notes |
+|-------------|---------------------------|-------|
+| Shopping Results | Very High | Dominant for product purchase queries |
+| Ads (top + bottom) | Very High | Maximum ad coverage |
+| Local Pack | High | For service-based transactional queries |
+| Sitelinks | Medium | For brand-specific transactional |
+| Featured Snippet | Low | Rarely for pure transactional |
+| AI Overview | Low-Medium | Sometimes for pricing/comparison |
+
+#### Content Format Recommendations
+
+| Sub-category | Best Formats | Key Elements |
+|-------------|-------------|-------------|
+| Purchase | Product pages, pricing pages, checkout | Clear pricing, CTAs, trust signals, reviews |
+| Signup/Trial | Landing pages, signup forms, onboarding | Minimal friction, benefits, social proof |
+| Download | Resource landing pages, gated content | Preview, form, instant delivery |
+| Hire/Engage | Service pages, contact forms, portfolios | Credentials, case studies, clear next step |
+
+---
+
+## Intent Mapping to Conversion Funnel
+
+### Full Funnel Alignment
+
+```
+FUNNEL STAGE INTENT TYPE CONTENT GOAL
+─────────────────────────────────────────────────────────────
+Awareness → Informational Attract & educate
+ (Educational, Exploratory)
+
+Interest → Informational Deepen engagement
+ (Instructional)
+
+Consideration → Commercial Investigation Build preference
+ (Best-of, Comparison)
+
+Evaluation → Commercial Investigation Overcome objections
+ (Review, Evaluation)
+
+Decision → Transactional Convert
+ (Purchase, Signup, Hire)
+
+Retention → Navigational Support & retain
+ (Support/Docs, Login)
+
+Advocacy → Informational Enable sharing
+ (Educational — about you)
+```
+
+### Conversion Potential by Intent
+
+| Intent Type | Avg. Conversion Rate | Typical CTA | Nurture Length |
+|------------|---------------------|-------------|---------------|
+| Informational | 0.5-2% | Newsletter signup, content download | Long (weeks-months) |
+| Navigational | N/A (already engaged) | Login, access feature | N/A |
+| Commercial Investigation | 2-5% | Free trial, demo request, comparison download | Medium (days-weeks) |
+| Transactional | 5-15% | Buy now, subscribe, hire | Short (immediate-days) |
+
+---
+
+## Mixed Intent Queries
+
+Many real-world queries blend multiple intent types. Recognize and address the dominant + secondary intent.
+
+### Common Mixed Intent Patterns
+
+| Query Pattern | Primary Intent | Secondary Intent | Content Strategy |
+|--------------|---------------|-----------------|-----------------|
+| "best [product] for [use case]" | Commercial | Informational | Buying guide with educational context |
+| "how to [task] with [product]" | Instructional | Navigational | Tutorial featuring the product |
+| "[product] review and pricing" | Commercial | Transactional | Review with clear pricing CTAs |
+| "what is [concept] tools" | Informational | Commercial | Explainer that transitions to tool recommendations |
+| "[product] free download" | Transactional | Navigational | Landing page with download + product info |
+
+### Handling Mixed Intent in Content
+
+1. **Lead with the primary intent** -- answer the dominant question first
+2. **Bridge to the secondary intent** -- naturally transition to the secondary need
+3. **Match SERP format** -- if SERPs show mixed results, your content should too
+4. **Segment CTAs** -- provide different CTAs for different intent segments within the page
+
+---
+
+## SEO-Specific Keyword Intent Examples
+
+### SEO Industry Keyword Classification
+
+| Keyword | Intent | Sub-category | Recommended Content |
+|---------|--------|-------------|-------------------|
+| what is SEO | Informational | Educational | Definitive guide with visuals |
+| how to do keyword research | Informational | Instructional | Step-by-step tutorial |
+| best SEO tools 2026 | Commercial | Best-of Research | Ranked listicle with mini-reviews |
+| Ahrefs vs SEMrush | Commercial | Comparison | Side-by-side feature comparison |
+| buy Ahrefs subscription | Transactional | Purchase | Pricing/product page |
+| Google Search Console login | Navigational | Login/Access | Login page or redirect |
+| why is my site not indexing | Informational | Troubleshooting | Debug guide with solutions |
+| SEO agency for ecommerce | Transactional | Hire/Engage | Service page with portfolio |
+| is Moz worth it | Commercial | Review-seeking | Honest review with alternatives |
+| SEO checklist template | Transactional | Download | Gated resource landing page |
+| types of backlinks | Informational | Exploratory | Categorized explainer |
+| SEMrush pricing | Commercial | Evaluation | Pricing breakdown page |
+
+---
+
+## Applying Intent Taxonomy in Keyword Research
+
+### Step-by-Step Classification Process
+
+1. **Read the query literally** -- what is the user asking for?
+2. **Check for signal words** -- match against the signal word tables above
+3. **Verify against SERP** -- what does Google actually show? The SERP is the ground truth
+4. **Identify sub-category** -- narrow from primary intent to sub-category
+5. **Check for mixed intent** -- does the query blend two types?
+6. **Map to content format** -- select format based on intent + sub-category
+7. **Assign funnel stage** -- determine where this fits in the buyer journey
+8. **Set conversion expectation** -- align CTA and goals with intent type
+
+### Common Classification Mistakes
+
+| Mistake | Example | Why It's Wrong | Correct Classification |
+|---------|---------|---------------|----------------------|
+| Treating all "best" queries as informational | "best CRM software" | User is comparing products to buy | Commercial Investigation |
+| Treating all questions as informational | "how much does Ahrefs cost" | User is evaluating a purchase | Commercial / Transactional |
+| Ignoring local intent | "SEO services" | May have strong local intent in many contexts | Transactional (local) |
+| Missing navigational intent | "HubSpot blog" | User wants a specific page | Navigational |
+| Assuming single intent | "SEO tools" | Could be informational OR commercial | Mixed -- check SERP |
diff --git a/.cursor/skills/keyword-research/references/keyword-prioritization-framework.md b/.cursor/skills/keyword-research/references/keyword-prioritization-framework.md
new file mode 100644
index 0000000000..e32b7dda99
--- /dev/null
+++ b/.cursor/skills/keyword-research/references/keyword-prioritization-framework.md
@@ -0,0 +1,41 @@
+# Keyword Prioritization Framework
+
+Systematic scoring methodology for ranking keywords by strategic value.
+
+## Relationship to Opportunity Score
+
+**Which score to use**: Use the Priority Score (below) for initial keyword triage and shortlisting. Use the Opportunity Score (Step 6 in the main workflow) for final content calendar prioritization, where the additional GEO and competitive factors provide more nuanced ranking.
+
+## Priority Scoring Matrix
+
+Score each keyword 1-5 on these factors, then calculate weighted total:
+
+| Factor | Weight | Score 1 (Low) | Score 5 (High) |
+|--------|--------|---------------|----------------|
+| Search Volume | 20% | <100/mo | >10,000/mo |
+| Keyword Difficulty | 25% | KD >80 (hard) | KD <20 (easy) |
+| Business Relevance | 30% | Tangential to offering | Core to offering |
+| Search Intent Match | 15% | Informational only | Transactional/commercial |
+| Trend Direction | 10% | Declining | Growing |
+
+**Priority Score** = Σ(Factor Weight × Score) / 5
+
+## Priority Categories
+
+| Priority | Score Range | Action |
+|----------|------------|--------|
+| P0 — Must Target | 4.0-5.0 | Create content immediately |
+| P1 — High Value | 3.0-3.9 | Queue for next content sprint |
+| P2 — Opportunity | 2.0-2.9 | Plan for future content calendar |
+| P3 — Monitor | 1.0-1.9 | Track but don't prioritize |
+
+## Seasonal Keyword Patterns
+
+### Seasonal Analysis Framework
+
+| Season Trigger | Example Keywords | Planning Lead Time | Content Strategy |
+|---------------|-----------------|-------------------|-----------------|
+| Calendar events | "Black Friday SEO", "New Year marketing plan" | 3-4 months ahead | Publish 6-8 weeks before peak |
+| Industry events | "[Conference] takeaways", "Google algorithm update" | 1-2 months / reactive | Pre-plan templates, react quickly |
+| Budget cycles | "marketing budget template Q1", "SEO ROI report" | 2-3 months ahead | Target planning season (Oct-Dec) |
+| Seasonal demand | "summer marketing ideas", "holiday email campaigns" | 2-3 months ahead | Refresh annually with new data |
diff --git a/.cursor/skills/keyword-research/references/topic-cluster-templates.md b/.cursor/skills/keyword-research/references/topic-cluster-templates.md
new file mode 100644
index 0000000000..da6ec594d2
--- /dev/null
+++ b/.cursor/skills/keyword-research/references/topic-cluster-templates.md
@@ -0,0 +1,286 @@
+# Topic Cluster Templates
+
+Planning worksheets, architecture patterns, and measurement frameworks for building topic clusters that establish topical authority and drive organic traffic.
+
+## Overview
+
+A topic cluster is a group of interlinked content pieces organized around a central pillar page. The pillar covers a broad topic comprehensively, while cluster pages dive deep into specific subtopics. Internal links bind the cluster together, signaling topical authority to search engines and AI systems.
+
+---
+
+## Topic Cluster Planning Worksheet
+
+### Step 1: Define the Pillar Topic
+
+Use this template to evaluate whether a topic deserves a full cluster:
+
+| Field | Your Input |
+|-------|-----------|
+| **Pillar Topic** | [Broad topic name] |
+| **Pillar Keyword** | [Head keyword, typically 1-3 words] |
+| **Monthly Search Volume** | [Volume] |
+| **Keyword Difficulty** | [KD score] |
+| **Business Relevance** | [1-5 scale: how core is this to your product/service?] |
+| **Current Ranking** | [Your current position, or "Not ranking"] |
+| **Competitor Coverage** | [How many competitors have pillar content on this?] |
+| **Estimated Cluster Size** | [How many subtopics can you identify?] |
+| **Content Assets Available** | [Existing content you can repurpose or link] |
+
+**Pillar viability checklist:**
+- [ ] At least 8-12 subtopics can be identified
+- [ ] Combined cluster keyword volume exceeds 5,000/month
+- [ ] Topic is directly relevant to your product or service
+- [ ] You can provide unique expertise or data on this topic
+- [ ] Competitors have not yet built a dominant cluster
+
+### Step 2: Map Cluster Subtopics
+
+| # | Subtopic | Target Keyword | Volume | KD | Intent | Content Format | Status |
+|---|---------|---------------|--------|-----|--------|---------------|--------|
+| 1 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 2 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 3 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 4 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 5 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 6 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 7 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+| 8 | [Subtopic name] | [Long-tail keyword] | [Vol] | [KD] | [I/N/C/T] | [Guide/Tutorial/List/etc.] | [Idea/Draft/Published] |
+
+### Step 3: Define Internal Linking Map
+
+| Source Page | Links To | Anchor Text Strategy |
+|------------|---------|---------------------|
+| Pillar | Cluster 1, 2, 3... (all) | Descriptive, keyword-relevant anchors |
+| Cluster 1 | Pillar + Cluster 2, 3 | Natural contextual links |
+| Cluster 2 | Pillar + Cluster 1, 4 | Natural contextual links |
+| Cluster 3 | Pillar + Cluster 1 | Natural contextual links |
+
+**Linking rules:**
+- Every cluster page MUST link to the pillar page
+- The pillar page MUST link to every cluster page
+- Cluster pages SHOULD link to 2-3 related cluster pages where contextually relevant
+- Use descriptive anchor text (not "click here" or bare URLs)
+- Link placement should be within body content, not just in a footer list
+
+---
+
+## Hub-and-Spoke Architecture Patterns
+
+### Pattern 1: Classic Hub-Spoke (Best for Educational Topics)
+
+```
+ ┌──────────────────┐
+ ┌────│ What is [Topic]? │
+ │ └──────────────────┘
+ │ ┌──────────────────┐
+ ├────│ [Topic] Benefits │
+ │ └──────────────────┘
+┌───────────────┐ │ ┌──────────────────┐
+│ PILLAR: │───┼────│ [Topic] Examples │
+│ Complete │ │ └──────────────────┘
+│ Guide to │ │ ┌──────────────────┐
+│ [Topic] │ ├────│ [Topic] Tools │
+└───────────────┘ │ └──────────────────┘
+ │ ┌──────────────────┐
+ ├────│ [Topic] Mistakes │
+ │ └──────────────────┘
+ │ ┌──────────────────┐
+ └────│ [Topic] Checklist │
+ └──────────────────┘
+```
+
+**Best for:** Broad educational topics where subtopics don't overlap much.
+**Example:** "Content Marketing" pillar with spokes for strategy, types, examples, tools, metrics, mistakes.
+
+### Pattern 2: Layered Cluster (Best for Technical Topics)
+
+```
+ ┌─────────────────────────────┐
+ ┌────│ BEGINNER CLUSTER │
+ │ │ - Getting Started │
+ │ │ - Basic Concepts │
+ │ │ - First Steps Tutorial │
+ │ └─────────────────────────────┘
+┌──────────┐ │ ┌─────────────────────────────┐
+│ PILLAR: │───┼────│ INTERMEDIATE CLUSTER │
+│ Complete │ │ │ - Advanced Techniques │
+│ Guide │ │ │ - Common Patterns │
+└──────────┘ │ │ - Optimization Strategies │
+ │ └─────────────────────────────┘
+ │ ┌─────────────────────────────┐
+ └────│ ADVANCED CLUSTER │
+ │ - Expert Strategies │
+ │ - Edge Cases │
+ │ - Integration Guides │
+ └─────────────────────────────┘
+```
+
+**Best for:** Topics with clear skill progressions (programming, technical SEO, data analysis).
+**Example:** "Technical SEO" pillar with beginner (crawling basics), intermediate (JavaScript rendering), advanced (log file analysis) layers.
+
+### Pattern 3: Use-Case Cluster (Best for Product/Service Topics)
+
+```
+ ┌──────────────────────┐
+ ┌────│ [Topic] for SMBs │
+ │ └──────────────────────┘
+ │ ┌──────────────────────┐
+┌──────────┐ ├────│ [Topic] for Enterprise│
+│ PILLAR: │───┤ └──────────────────────┘
+│ [Topic] │ │ ┌──────────────────────┐
+│ Guide │ ├────│ [Topic] for Agencies │
+└──────────┘ │ └──────────────────────┘
+ │ ┌──────────────────────┐
+ ├────│ [Topic] for Ecommerce │
+ │ └──────────────────────┘
+ │ ┌──────────────────────┐
+ └────│ [Topic] for SaaS │
+ └──────────────────────┘
+```
+
+**Best for:** Products/services with distinct audience segments.
+**Example:** "SEO Strategy" pillar with spokes for different business types (ecommerce SEO, SaaS SEO, local SEO, B2B SEO).
+
+### Pattern 4: Process Cluster (Best for How-To Topics)
+
+```
+┌──────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
+│ PILLAR: │────→│ Step 1: │────→│ Step 2: │────→│ Step 3: │────→│ Step 4: │
+│ How to │ │ Research │ │ Plan │ │ Execute │ │ Measure │
+│ [Process]│ └─────────┘ └─────────┘ └─────────┘ └─────────┘
+└──────────┘ │ │ │ │
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
+ │Sub-guide │ │Template │ │Tutorial │ │Dashboard │
+ │ 1a │ │ 2a │ │ 3a │ │ Guide │
+ └─────────┘ └─────────┘ └─────────┘ └─────────┘
+```
+
+**Best for:** Multi-step processes where each step is complex enough for its own article.
+**Example:** "Link Building" pillar with sequential steps (prospecting, outreach, content creation, tracking).
+
+---
+
+## Internal Linking Patterns Within Clusters
+
+### Linking Density Guidelines
+
+| Cluster Size | Min Links Per Cluster Page | Max Links Per Cluster Page | Pillar Link Density |
+|-------------|--------------------------|--------------------------|-------------------|
+| 5-8 pages | 2-3 internal links | 5-6 internal links | Link to every cluster page |
+| 9-15 pages | 3-4 internal links | 6-8 internal links | Link to every cluster page |
+| 16+ pages | 4-5 internal links | 8-10 internal links | Link to top cluster pages, categorize rest |
+
+### Anchor Text Strategy
+
+| Link Type | Anchor Text Approach | Example |
+|-----------|---------------------|---------|
+| Cluster → Pillar | Broad keyword or branded | "our complete guide to keyword research" |
+| Pillar → Cluster | Specific keyword for that cluster page | "learn about long-tail keyword strategies" |
+| Cluster → Cluster | Contextual, conversational | "this connects to how you assess keyword difficulty" |
+
+### Linking Anti-Patterns to Avoid
+
+| Anti-Pattern | Why It's Bad | Fix |
+|-------------|-------------|-----|
+| Footer-only links to cluster pages | Low link equity, poor UX | Move links into body content |
+| Generic anchors ("click here", "read more") | No keyword signal | Use descriptive, keyword-relevant anchors |
+| Orphan cluster pages (no inbound links) | Search engines can't discover/value them | Add contextual links from pillar and related clusters |
+| Over-linking (20+ internal links per page) | Dilutes link equity, feels spammy | Keep to 5-10 relevant internal links |
+| Linking only to pillar (ignoring sibling clusters) | Misses cross-cluster relevance | Link to 2-3 related sibling pages |
+
+---
+
+## Content Calendar Integration
+
+### Cluster Build Sequence
+
+The order you publish cluster content matters. Follow this sequence for maximum impact:
+
+| Phase | What to Publish | Why This Order |
+|-------|----------------|---------------|
+| **Phase 1: Pillar** | Publish the pillar page first | Establishes the hub; cluster pages need something to link to |
+| **Phase 2: Quick-win clusters** | Publish 3-4 lowest-difficulty cluster pages | Build early traffic and indexing momentum |
+| **Phase 3: High-value clusters** | Publish highest-volume cluster pages | Leverage pillar authority for competitive terms |
+| **Phase 4: Long-tail clusters** | Publish remaining niche subtopics | Fill coverage gaps, capture long-tail traffic |
+| **Phase 5: Update cycle** | Refresh pillar with new links; update outdated clusters | Maintain freshness signals |
+
+### Sample Content Calendar for One Cluster
+
+| Week | Content Piece | Type | Target Keyword | Word Count | Dependencies |
+|------|--------------|------|---------------|-----------|-------------|
+| 1 | Complete Guide to [Pillar Topic] | Pillar | [Head keyword] | 3,500-5,000 | None |
+| 2 | What is [Subtopic A]? | Cluster | [Long-tail A] | 1,500-2,000 | Pillar published |
+| 3 | How to [Subtopic B] | Cluster | [Long-tail B] | 2,000-2,500 | Pillar published |
+| 4 | [Subtopic C] vs [Subtopic D] | Cluster | [Long-tail C] | 2,000-2,500 | Pillar published |
+| 5 | Best [Subtopic E] Tools | Cluster | [Long-tail E] | 2,500-3,000 | Pillar published |
+| 6 | [Subtopic F] for Beginners | Cluster | [Long-tail F] | 1,500-2,000 | Pillar published |
+| 7 | [Subtopic G] Checklist | Cluster | [Long-tail G] | 1,000-1,500 | Pillar published |
+| 8 | Update Pillar + add all internal links | Update | -- | -- | All clusters published |
+
+### Publishing Cadence Recommendations
+
+| Team Size | Cluster Build Time | Recommended Cadence |
+|-----------|-------------------|-------------------|
+| Solo content creator | 6-8 weeks per cluster | 1 cluster per quarter |
+| Small team (2-3 writers) | 3-4 weeks per cluster | 1 cluster per month |
+| Content team (4-6 writers) | 2-3 weeks per cluster | 2 clusters per month |
+| Large team (7+ writers) | 1-2 weeks per cluster | 1 cluster per week |
+
+---
+
+## Cluster Performance Measurement Framework
+
+### Key Metrics by Level
+
+#### Cluster-Level Metrics
+
+| Metric | What It Measures | Target | How to Track |
+|--------|-----------------|--------|-------------|
+| Total cluster traffic | Aggregate organic visits to all pages in cluster | Growing month-over-month | Analytics: filter by URL folder/tag |
+| Keyword coverage | Number of keywords cluster ranks for | 50+ keywords per mature cluster | SEO tool: filter by cluster URLs |
+| Average position | Mean ranking across all cluster keywords | Improving trend toward top 10 | SEO tool: average position report |
+| Internal link equity | PageRank flow within cluster | Pillar has highest internal links | Site audit tool: internal link report |
+| Cluster completeness | Percentage of planned subtopics published | 100% within planned timeframe | Content calendar tracking |
+
+#### Page-Level Metrics (Per Cluster Page)
+
+| Metric | Pillar Target | Cluster Page Target |
+|--------|-------------|-------------------|
+| Organic traffic | Highest in cluster | Proportional to keyword volume |
+| Keywords ranking | 50-200+ | 10-50 |
+| Backlinks | Attracts most links | Some organic links |
+| Avg. time on page | 4-8 minutes | 2-5 minutes |
+| Bounce rate | <60% | <70% |
+| Internal CTR | High clicks to cluster pages | Clicks to pillar + sibling pages |
+
+### Performance Review Cadence
+
+| Timeframe | What to Review | Action If Underperforming |
+|-----------|---------------|--------------------------|
+| 2 weeks post-publish | Indexing status, initial impressions | Fix indexing issues, check for crawl errors |
+| 1 month post-publish | Early ranking signals, traffic | Optimize titles/metas, add internal links |
+| 3 months post-publish | Ranking positions, traffic trends | Content refresh, add missing subtopics, build links |
+| 6 months post-publish | Full performance assessment | Major content update or strategic pivot |
+| Quarterly (ongoing) | Cluster-level aggregate trends | Identify declining pages, plan refreshes |
+
+### Cluster Health Scorecard
+
+Rate each cluster quarterly on these dimensions:
+
+| Dimension | Score 1 (Poor) | Score 3 (Average) | Score 5 (Excellent) |
+|-----------|---------------|-------------------|-------------------|
+| Traffic growth | Declining | Flat | Growing 10%+ MoM |
+| Keyword coverage | <20 keywords | 20-50 keywords | 50+ keywords |
+| Top 10 rankings | 0 keywords in top 10 | 1-5 in top 10 | 5+ in top 10 |
+| Content freshness | Not updated in 12+ months | Updated within 6 months | Updated within 3 months |
+| Internal linking | Missing links, orphan pages | Basic linking in place | Full cross-linking with relevant anchors |
+| Completeness | <50% of subtopics covered | 50-80% covered | 80-100% covered |
+
+**Cluster Health Score** = Average of all dimension scores
+
+| Score Range | Health Status | Action |
+|------------|--------------|--------|
+| 4.0-5.0 | Healthy | Maintain cadence, expand to adjacent clusters |
+| 3.0-3.9 | Needs attention | Refresh outdated content, fill subtopic gaps |
+| 2.0-2.9 | At risk | Major content update, link building campaign |
+| 1.0-1.9 | Failing | Strategic review -- consider merging, rewriting, or retiring |
diff --git a/.cursor/skills/memory-management/SKILL.md b/.cursor/skills/memory-management/SKILL.md
new file mode 100644
index 0000000000..c9ec3af3b5
--- /dev/null
+++ b/.cursor/skills/memory-management/SKILL.md
@@ -0,0 +1,342 @@
+---
+name: memory-management
+description: 'Persist SEO/GEO campaign context across Claude sessions with automatic hot-list, active work, and archive tiers. 项目记忆/跨会话'
+version: "7.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when reviewing, archiving, or cleaning up campaign memory. Also when the user asks to check saved findings, manage hot cache, or archive old data."
+argument-hint: "[review|archive|cleanup]"
+metadata:
+ author: aaron-he-zhu
+ version: "7.0.0"
+ geo-relevance: "low"
+ tags:
+ - seo
+ - geo
+ - project-memory
+ - context-management
+ - campaign-tracking
+ - session-context
+ - hot-cache
+ - 项目记忆
+ - プロジェクト記憶
+ - 프로젝트메모리
+ - memoria-proyecto
+ triggers:
+ # EN-formal
+ - "remember project context"
+ - "save SEO data"
+ - "track campaign progress"
+ - "store keyword data"
+ - "manage project memory"
+ - "project context"
+ - "refresh wiki index"
+ - "build wiki index"
+ - "wiki lint"
+ # EN-casual
+ - "remember this for next time"
+ - "save my keyword data"
+ - "keep track of this campaign"
+ - "what did we decide last time"
+ - "what do we know so far"
+ - "project status"
+ # EN-question
+ - "how to save project progress"
+ # ZH-pro
+ - "项目记忆管理"
+ - "SEO数据保存"
+ - "跨会话记忆"
+ - "刷新wiki索引"
+ - "项目状况"
+ # ZH-casual
+ - "保存进度"
+ - "上次说了什么"
+ - "记住这个"
+ # JA
+ - "プロジェクト記憶"
+ - "SEOデータ保存"
+ # KO
+ - "프로젝트 메모리"
+ - "데이터 저장"
+ # ES
+ - "memoria del proyecto"
+ - "guardar progreso"
+ # PT
+ - "memória do projeto"
+---
+
+# Memory Management
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+This skill implements a three-tier memory system (HOT/WARM/COLD) for SEO and GEO projects. HOT memory (80 lines max) loads automatically every session via the SessionStart hook. WARM memory loads on demand per skill. COLD memory is archived data queried only when explicitly requested. The skill manages the full lifecycle: capture, promote, demote, and archive.
+
+**System role**: Campaign Memory Loop. It defines how project context is captured, promoted, archived, and handed off across sessions. It is the sole executor of WARM-to-COLD archival and the aggregator for cross-skill project status queries.
+
+## When This Must Trigger
+
+Use this whenever project state should survive the current session — even if the user doesn't use memory terminology:
+
+- User says "remember this", "save this", "keep track of this"
+- User asks "what did we decide", "what do we know", "project status"
+- Setting up memory structure for a new SEO project
+- After completing audits, ranking checks, or performance reports (Stop hook reminds automatically)
+- When project context needs updating (new keywords, competitors, priorities)
+- When you need to look up historical data or project-specific terminology
+- After 30+ days of work to clean up and archive stale data
+- When open-loops.md has items older than 7 days (SessionStart hook reminds automatically)
+
+## What This Skill Does
+
+1. **HOT Cache Management**: Maintains `memory/hot-cache.md` (80 lines max) — loaded automatically every session by SessionStart hook
+2. **WARM Storage**: Organizes dated findings in `memory/` subdirectories — loaded on demand by relevant skills
+3. **COLD Archive**: Moves stale data (90+ days unreferenced) to `memory/archive/` with date prefix
+4. **Promotion**: Elevates frequently-referenced findings from WARM to HOT (3+ refs in 7 days, or 2+ skill refs)
+5. **Demotion**: Moves unreferenced HOT items to WARM (30 days), WARM to COLD (90 days)
+6. **Cross-Skill Aggregation**: When user asks "what do we know", aggregates from all `memory/` subdirectories
+7. **Open Loop Tracking**: Maintains `memory/open-loops.md`, reminds user of stale items via SessionStart hook
+8. **Wiki Index Maintenance**: Compiles `memory/wiki/index.md` — a structured, auto-refreshed index of all WARM files with precise fields (score, 健康度, status, next_action, mtime) and best-effort summaries. Supports project isolation via `memory/wiki//index.md`. Auto-refreshed on PostToolUse; user confirmation not required (index is a fully rebuildable derived artifact). Delete `memory/wiki/` at any time to revert to pre-wiki behavior.
+9. **Wiki Compiled Pages** (Phase 2): Generates interlinked entity, keyword, and topic pages from WARM files with source hash tracking, contradiction detection, and confidence-labeled reconciliation. Requires user confirmation before writing.
+10. **Wiki Lint** (Phase 2): Detects contradictions, orphan pages, stale claims, missing pages, and source hash mismatches across wiki and WARM files via `/seo:wiki-lint`.
+11. **WARM Retirement Preview** (Phase 3): `wiki-lint --retire-preview` lists WARM files fully covered by wiki compiled pages as retirement candidates. Actual archival to COLD requires explicit user confirmation.
+
+## Quick Start
+
+Start with one of these prompts. Finish with a hot-cache update plan and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Initialize Wiki Index
+
+```
+Refresh wiki index
+```
+
+```
+Build wiki index for [project name]
+```
+
+Generates `memory/wiki/index.md` from existing WARM files. Required once to enable wiki features; subsequent refreshes happen automatically.
+
+### Initialize Memory Structure
+
+```
+Set up SEO memory for [project name]
+```
+
+```
+Initialize memory structure for a new [industry] website optimization project
+```
+
+### Update After Analysis
+
+```
+Update memory after ranking check for [keyword group]
+```
+
+```
+Refresh hot cache with latest competitor analysis findings
+```
+
+### Query Stored Context
+
+```
+What are our hero keywords?
+```
+
+```
+Show me the last ranking update date for [keyword category]
+```
+
+```
+Look up our primary competitors and their domain authority
+```
+
+### Promotion and Demotion
+
+```
+Promote [keyword] to hot cache
+```
+
+```
+Archive stale data that hasn't been referenced in 30+ days
+```
+
+### Glossary Management
+
+```
+Add [term] to project glossary: [definition]
+```
+
+```
+What does [internal jargon] mean in this project?
+```
+
+## Skill Contract
+
+**Expected output**: a memory update plan, hot-cache changes, and a short handoff summary.
+
+- **Reads**: current campaign facts, new findings from other skills, approved decisions, and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+- **Writes**: updates to `memory/hot-cache.md`, `memory/open-loops.md`, `memory/decisions.md`, and related `memory/` folders. Manages WARM-to-COLD archival in `memory/archive/`. Compiles `memory/wiki/index.md` (auto-refreshed) and wiki compiled pages (user-confirmed).
+- **Promotes**: durable strategy, blockers, terminology, entity candidates, and major deltas. Applies temperature lifecycle rules: promote to HOT on high reference frequency, demote on staleness.
+- **Next handoff**: use the `Next Best Skill` below when the project memory baseline is ready for active work.
+
+### Temperature Lifecycle Rules
+
+> See [references/promotion-demotion-rules.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/promotion-demotion-rules.md) for the full promotion/demotion table and action procedures.
+
+### Hook Integration
+
+This skill's behavior is reinforced by the library's prompt-based hooks:
+- **SessionStart**: loads `memory/hot-cache.md`, reminds of stale open loops; loads `memory/wiki//index.md` (or global `index.md`) if it exists; provides light-user guidance based on Quick Status when `next_action` items are available
+- **PostToolUse**: after any WARM file write, silently refreshes `memory/wiki/index.md` (Phase 1); prompts to update compiled pages (Phase 2)
+- **Stop**: prompts to save session findings, auto-saves veto issues to hot-cache; appends changelog entry to index.md bottom
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~analytics + ~~search console connected:**
+Automatically populate memory from historical data: keyword rankings over time, competitor domain authority changes, traffic metrics, conversion data, backlink profile evolution. The skill will fetch current rankings, alert on significant changes, and update both hot cache and cold storage.
+
+**With manual data only:**
+Ask the user to provide:
+1. Current target keywords with priority levels
+2. Primary competitors (3-5 domains)
+3. Key performance metrics and last update date
+4. Active campaigns and their status
+5. Any project-specific terminology or abbreviations
+
+Proceed with memory structure creation using provided data. Note in CLAUDE.md which data requires manual updates vs. automated refresh.
+
+## Instructions
+
+When a user requests SEO memory management:
+
+### 1. Initialize Memory Structure
+
+For new projects, create the directory structure defined in the [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md). Key directories: `memory/` (decisions, open-loops, glossary, entities, research, content, audits, monitoring) plus `memory/wiki/` (auto-managed compiled index with optional per-project subdirectories).
+
+> **Templates**: [hot-cache-template.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/hot-cache-template.md) · [glossary-template.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/glossary-template.md) · [Wiki spec](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/proposal-wiki-layer-v3.md)
+
+### 2. Context Lookup Flow
+
+When a user references something unclear, follow this lookup sequence:
+
+**Step 1: Check CLAUDE.md (Hot Cache)**
+- Is it in active keywords?
+- Is it in primary competitors?
+- Is it in current priorities or campaigns?
+
+**Step 2: Check Wiki Index** (`memory/wiki/index.md` or project-level) — locate relevant WARM files
+
+**Step 3: Check memory/glossary.md**
+- Is it defined as project terminology?
+- Is it a custom segment or shorthand?
+
+**Step 4: Check Cold Storage**
+- Search memory/research/keywords/ for historical keyword context
+- Search memory/research/competitors/ for past analyses
+- Search memory/monitoring/reports/ for archived mentions
+
+**Step 5: Ask User**
+- If not found in any layer, ask for clarification
+- Log the new term in glossary if it's project-specific
+
+Example lookup:
+
+```markdown
+User: "Update rankings for our hero KWs"
+
+Step 1: Check CLAUDE.md → Found "Hero Keywords (Priority 1)" section
+Step 2: Extract keyword list from hot cache
+Step 3: Execute ranking check
+Step 4: Update both CLAUDE.md and memory/monitoring/rank-history/YYYY-MM-DD-ranks.csv
+```
+
+### 3. Promotion & Demotion Logic
+
+> **Reference**: See [references/promotion-demotion-rules.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/promotion-demotion-rules.md) for detailed promotion/demotion triggers (keywords, competitors, metrics, campaigns) and the action procedures for each.
+
+### 4. Update Triggers, Archive Management & Cross-Skill Integration
+
+> **Reference**: See [references/update-triggers-integration.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/update-triggers-integration.md) for the complete update procedures after ranking checks, competitor analyses, audits, and reports; monthly/quarterly archive routines; and integration points with all 8 connected skills (keyword-research, rank-tracker, competitor-analysis, content-gap-analysis, seo-content-writer, content-quality-auditor, domain-authority-auditor).
+
+### 5. Memory Hygiene Checks
+
+When invoked for review or cleanup:
+
+1. **Line count check**: Count lines in `memory/hot-cache.md`. If >80, list oldest entries for archival.
+2. **Byte check**: If hot-cache exceeds 25KB, warn and recommend trimming long entries.
+3. **Staleness scan**: List memory files older than 30 days that have not been referenced. Recommend archival for files >90 days.
+4. **Frontmatter audit**: Check that all memory files (except hot-cache.md) have `name`, `description`, and `type` in their frontmatter. Report any missing fields.
+
+### 6. Save Results
+
+After delivering any memory update or aggregation to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Validation Checkpoints
+
+### Structure Validation
+- [ ] memory/hot-cache.md exists and is under 80 lines
+- [ ] memory/ directory structure matches the shared state model
+- [ ] glossary.md exists and is populated with project basics
+- [ ] All historical data files include timestamps in filename or metadata
+
+### Content Validation
+- [ ] CLAUDE.md "Last Updated" date is current
+- [ ] Every keyword in hot cache has current rank, target rank, and status
+- [ ] Every competitor has domain authority and position assessment
+- [ ] Every active campaign has status percentage and expected completion date
+- [ ] Key Metrics Snapshot shows "Previous" values for comparison
+
+### Lookup Validation
+- [ ] Test lookup flow: reference a term → verify it finds it in correct layer
+- [ ] Test promotion: manually promote item → verify it appears in CLAUDE.md
+- [ ] Test demotion: manually archive item → verify removed from CLAUDE.md
+- [ ] Glossary contains all custom segments and shorthand used in CLAUDE.md
+
+### Update Validation
+- [ ] After ranking check, `memory/monitoring/rank-history/` has a dated snapshot or export
+- [ ] After competitor analysis, `memory/research/competitors/` has a dated file
+- [ ] After audit, top action items appear in CLAUDE.md priorities
+- [ ] After monthly report, metrics snapshot reflects new data
+
+## Examples
+
+> **Reference**: See [references/examples.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/examples.md) for three complete examples: (1) updating hero keyword rankings with memory refresh, (2) glossary lookup flow, and (3) initializing memory for a new e-commerce project.
+
+## Advanced Features
+
+- **Smart Context Loading**: `Load full context for [campaign name]` — retrieves hot cache + all cold storage files for a campaign
+- **Memory Health Check**: `Run memory health check` — finds orphaned files, missing timestamps, stale items, broken references
+- **Bulk Promotion/Demotion**: `Promote all keywords ranking in top 10 to hot cache` / `Demote all completed campaigns from Q3`
+- **Memory Snapshot**: `Create memory snapshot for [date/milestone]` — point-in-time copy for major milestones
+- **Cross-Project Memory**: `Compare memory with [other project]` — keyword overlaps, competitor intersections across projects
+- **Wiki Lint**: `/seo:wiki-lint [--fix] [--project name] [--retire-preview]` — contradictions, orphans, stale claims, hash mismatches. See [commands/wiki-lint.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/commands/wiki-lint.md)
+
+## Practical Limitations
+
+- **Concurrent access**: Use timestamped filenames to avoid overwrites from parallel sessions.
+- **Cold storage retrieval**: WARM/COLD files only load on demand. Hot cache is the primary cross-session mechanism.
+- **Data freshness**: Stale data (>90 days) should be flagged for refresh. Wiki index `mtime` field helps detect staleness.
+- **Wiki compilation**: Index is best-effort for summaries; precise fields (score, status, mtime) are deterministic. Delete `memory/wiki/` anytime to revert.
+
+## Reference Materials
+
+- [CORE-EEAT Content Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md) — Content quality scoring stored in memory
+- [CITE Domain Rating](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md) — Domain authority scoring stored in memory
+
+## Next Best Skill
+
+- **Primary**: [keyword-research](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/SKILL.md) — seed or refresh campaign strategy with current demand signals.
diff --git a/.cursor/skills/memory-management/references/examples.md b/.cursor/skills/memory-management/references/examples.md
new file mode 100644
index 0000000000..c114b5ea53
--- /dev/null
+++ b/.cursor/skills/memory-management/references/examples.md
@@ -0,0 +1,138 @@
+# Memory Management Examples
+
+Detailed examples showing memory management in action.
+
+---
+
+## Example 1: Update Rankings for Hero Keywords
+
+**User**: "Update rankings for our hero keywords"
+
+**Assistant**:
+
+```markdown
+I'll update rankings for your hero keywords. Let me check the hot cache for the list.
+
+[Reads CLAUDE.md, finds Hero Keywords section:]
+- keyword research tools (currently #15, target #5)
+- best seo software (currently #23, target #10)
+- free backlink checker (currently #8, target #3)
+- content optimization guide (currently #12, target #5)
+- technical seo audit (currently #18, target #8)
+
+[If ~~SEO tool connected: runs automated ranking check]
+[If manual: asks user for current rankings]
+
+---
+
+## Ranking Update - December 15, 2024
+
+| Keyword | Previous | Current | Change | Target | Status |
+|---------|----------|---------|--------|--------|--------|
+| keyword research tools | #15 | #12 | up 3 | #5 | Improving |
+| best seo software | #23 | #24 | down 1 | #10 | Stable |
+| free backlink checker | #8 | #6 | up 2 | #3 | Near target |
+| content optimization guide | #12 | #11 | up 1 | #5 | Improving |
+| technical seo audit | #18 | #16 | up 2 | #8 | Improving |
+
+### Notable Changes
+- **free backlink checker** moved to #6 (only 3 positions from target!)
+- **keyword research tools** gained 3 positions (strong movement)
+- All other keywords showing stable or positive movement
+
+### Updated Files
+1. CLAUDE.md -> Hero Keywords table updated with current ranks
+2. memory/monitoring/rank-history/2024-12-15-ranks.csv -> New row added:
+ `2024-12-15,keyword research tools,12,best seo software,24,free backlink checker,6,...`
+
+### Recommendations
+1. Push additional content/backlinks to "free backlink checker" pages to hit #3 target
+2. Investigate why "keyword research tools" suddenly gained 3 positions (replicate success)
+3. Monitor "best seo software" for any negative trends
+
+Hot cache refreshed. Next ranking check recommended: December 22, 2024.
+```
+
+---
+
+## Example 2: Glossary Lookup
+
+**User**: "What does 'quick wins' mean in our project?"
+
+**Assistant**:
+
+```markdown
+Let me look that up in the project memory.
+
+[Step 1: Check CLAUDE.md -> not found]
+[Step 2: Check memory/glossary.md -> found!]
+
+From your project glossary:
+
+**Quick Wins**
+- **Definition**: Low-difficulty, high-value target keywords
+- **Keyword Count**: 25 keywords
+- **Context**: This segment includes keywords where you can achieve page 1 rankings within 30-60 days due to low competition, despite having decent search volume
+
+According to your memory files, your current quick wins are stored in:
+`memory/research/keywords/long-tail-keywords.md` (tagged with "quick-win" label)
+
+Would you like me to show you the current list of quick win keywords?
+```
+
+---
+
+## Example 3: Initialize Memory for New Project
+
+**User**: "Set up memory for a new e-commerce site selling outdoor gear"
+
+**Assistant**:
+
+```markdown
+I'll set up the two-layer memory system for your outdoor gear e-commerce site.
+
+## Creating Directory Structure
+
+[Creates: memory/glossary.md, memory/research/, memory/content/, memory/monitoring/, etc.]
+
+## CLAUDE.md (Hot Cache) - Initial Setup
+
+I'll need some information to populate the hot cache:
+
+1. **Target Keywords**: What are your top 10 hero keywords?
+ (e.g., "hiking backpacks", "camping tents", "outdoor clothing")
+
+2. **Primary Competitors**: Who are your top 3-5 competitors?
+ (e.g., rei.com, backcountry.com, moosejaw.com)
+
+3. **Current Metrics** (if available):
+ - Domain authority?
+ - Current organic traffic?
+ - Any existing rankings?
+
+4. **Active Initiatives**:
+ - Are you launching a new category?
+ - Running a content campaign?
+ - Focusing on any specific product line?
+
+5. **Project Terminology**:
+ - Any internal product names or abbreviations?
+ - How do you segment your keyword strategy?
+
+Please provide what you have, and I'll create your initial CLAUDE.md and glossary. We can start with basics and enrich as we gather more data.
+
+---
+
+Once you provide this information, I'll generate:
+- CLAUDE.md with your initial hot cache
+- memory/glossary.md with project terminology
+- memory/research/keywords/ structure with placeholder files
+- memory/research/competitors/ with initial competitor list
+- memory/content/calendar/active-calendar.md for Q1 2025
+
+After setup, you can use commands like:
+- "Update rankings for hero keywords"
+- "Add new competitor [domain]"
+- "Promote [keyword] to hot cache"
+- "Archive campaigns from Q3 2024"
+```
diff --git a/.cursor/skills/memory-management/references/glossary-template.md b/.cursor/skills/memory-management/references/glossary-template.md
new file mode 100644
index 0000000000..2e3edd309d
--- /dev/null
+++ b/.cursor/skills/memory-management/references/glossary-template.md
@@ -0,0 +1,64 @@
+# Glossary Template
+
+Use this template to create the project glossary at memory/glossary.md.
+
+### 3. Glossary Structure (memory/glossary.md)
+
+```markdown
+# [Project Name] - SEO Glossary
+
+**Purpose**: Project-specific terminology, brand terms, internal jargon, and abbreviations.
+
+**Last Updated**: [Date]
+
+## Brand Terms
+
+| Term | Full Name | Context |
+|------|-----------|---------|
+| [abbreviation] | [full name] | [when/how it's used] |
+
+## Product Names
+
+| Internal Name | Public Name | Category |
+|---------------|-------------|----------|
+| [code name] | [market name] | [product type] |
+
+## Custom Segments
+
+| Segment Name | Definition | Keyword Count |
+|--------------|------------|---------------|
+| Hero KWs | Top 10 revenue-driving keywords | 10 |
+| Quick Wins | Low-difficulty, high-value targets | 25 |
+| Brand Defense | Brand + modifier combinations | 15 |
+
+## Competitor Shorthand
+
+| Shorthand | Full Domain | Notes |
+|-----------|-------------|-------|
+| Comp A | competitor-a.com | Main competitor, [industry] |
+| Comp B | competitor-b.com | Niche competitor, [specialty] |
+
+## Project Acronyms
+
+| Acronym | Full Term | Definition |
+|---------|-----------|------------|
+| [ABC] | [Full term] | [What it means in this project] |
+
+## Internal Jargon
+
+| Phrase | Meaning | Example Usage |
+|--------|---------|---------------|
+| [phrase] | [definition] | "Check rankings for [phrase]" |
+
+## Historical Context
+
+- **Project started**: [Date]
+- **Major algorithm updates affecting us**: [List with dates]
+- **Site migrations/redesigns**: [List with dates]
+- **Key milestones**: [List with dates]
+
+---
+
+_This glossary helps decode project shorthand and maintain consistency across reports and communications._
+```
+
diff --git a/.cursor/skills/memory-management/references/hot-cache-template.md b/.cursor/skills/memory-management/references/hot-cache-template.md
new file mode 100644
index 0000000000..a57a46bd55
--- /dev/null
+++ b/.cursor/skills/memory-management/references/hot-cache-template.md
@@ -0,0 +1,96 @@
+# Hot Cache (CLAUDE.md) Template
+
+Use this template when initializing memory for a new SEO project.
+
+### 2. Hot Cache (CLAUDE.md) Structure
+
+Create or update CLAUDE.md with this template:
+
+```markdown
+# [Project Name] - SEO Memory (Hot Cache)
+
+**Last Updated**: [Date]
+**Project Status**: [Active/Maintenance/Growth]
+
+## Active Target Keywords (Top 10-20)
+
+### Hero Keywords (Priority 1)
+| Keyword | Current Rank | Target Rank | Volume | Status |
+|---------|--------------|-------------|--------|--------|
+| [keyword 1] | #[X] | #[Y] | [vol] | [tracking/optimizing/achieved] |
+| [keyword 2] | #[X] | #[Y] | [vol] | [tracking/optimizing/achieved] |
+
+### Secondary Keywords (Priority 2)
+| Keyword | Current Rank | Target Rank | Volume | Status |
+|---------|--------------|-------------|--------|--------|
+| [keyword 1] | #[X] | #[Y] | [vol] | [tracking/optimizing/achieved] |
+
+_Full keyword research: memory/research/keywords/_
+
+## Primary Competitors (Top 3-5)
+
+1. **[Competitor 1]** (DA: [X])
+ - Main strengths: [brief]
+ - Our position vs. them: [ahead/behind/competitive]
+
+2. **[Competitor 2]** (DA: [X])
+ - Main strengths: [brief]
+ - Our position vs. them: [ahead/behind/competitive]
+
+_Detailed analyses: memory/research/competitors/_
+
+## Current Optimization Priorities
+
+1. **[Priority 1]** - [Brief description]
+ - Status: [not started/in progress/completed]
+ - Expected impact: [high/medium/low]
+ - Deadline: [date]
+
+2. **[Priority 2]** - [Brief description]
+ - Status: [not started/in progress/completed]
+ - Expected impact: [high/medium/low]
+ - Deadline: [date]
+
+## Key Metrics Snapshot
+
+**Last Metrics Update**: [Date]
+
+| Metric | Current | Previous | Change | Target |
+|--------|---------|----------|--------|--------|
+| Organic Traffic | [X] | [X] | [+/-X%] | [target] |
+| Avg. Position | [X] | [X] | [+/-X] | [target] |
+| Total Keywords Ranking | [X] | [X] | [+/-X] | [target] |
+| Page 1 Rankings | [X] | [X] | [+/-X] | [target] |
+| Domain Authority | [X] | [X] | [+/-X] | [target] |
+| CITE Score | [X] | [X] | [+/-X] | [target] |
+| Last Content Audit Score | [score]/100 | ([rating]) | — [date] | [page audited] |
+| Total Backlinks | [X] | [X] | [+/-X%] | [target] |
+
+_Historical data: memory/monitoring/reports/ and memory/monitoring/rank-history/_
+
+## Active Campaigns
+
+### [Campaign Name 1]
+- **Duration**: [Start date] - [End date]
+- **Goal**: [Specific goal]
+- **Status**: [planning/active/completed]
+- **Progress**: [X]%
+- **Key activities**: [brief list]
+
+### [Campaign Name 2]
+- **Duration**: [Start date] - [End date]
+- **Goal**: [Specific goal]
+- **Status**: [planning/active/completed]
+- **Progress**: [X]%
+- **Key activities**: [brief list]
+
+_Campaign plans and archives: memory/content/calendar/_
+
+## Quick Reference Notes
+
+- [Important note 1]
+- [Important note 2]
+- [Important note 3]
+
+_For project terminology, see: memory/glossary.md_
+```
diff --git a/.cursor/skills/memory-management/references/promotion-demotion-rules.md b/.cursor/skills/memory-management/references/promotion-demotion-rules.md
new file mode 100644
index 0000000000..01332cb1b7
--- /dev/null
+++ b/.cursor/skills/memory-management/references/promotion-demotion-rules.md
@@ -0,0 +1,58 @@
+# Promotion and Demotion Rules
+
+Detailed triggers and actions for moving items between the hot cache (CLAUDE.md) and cold storage (memory/ subdirectories).
+
+## Promotion Logic
+
+**Promote to Hot Cache when:**
+
+### Keyword promotion triggers:
+- Keyword referenced in 3+ conversations within 7 days
+- Keyword showing significant movement (5+ position change)
+- Keyword targeted in new active campaign
+- Keyword suddenly driving traffic spike
+
+### Competitor promotion triggers:
+- Competitor mentioned in 3+ recent analyses
+- Competitor showing aggressive SEO activity
+- Competitor launching competing content
+
+### Metric promotion triggers:
+- Metric requested frequently for reporting
+- Metric showing anomalous behavior
+- Metric tied to active priority/campaign
+
+**Promotion action:**
+```markdown
+1. Add item to relevant section in CLAUDE.md
+2. Add note: "Promoted [date] - [reason]"
+3. Keep detailed data in cold storage
+4. Set reminder to review in 30 days
+```
+
+## Demotion Logic
+
+**Demote to Cold Storage when:**
+
+### Keyword demotion triggers:
+- Not referenced in 30+ days
+- Target rank achieved and stable for 60+ days
+- Keyword no longer in active strategy
+- Keyword replaced by higher-priority target
+
+### Competitor demotion triggers:
+- No significant activity in 60+ days
+- Fell out of top 5 competitors by relevance
+- No longer targeting same keywords
+
+### Campaign demotion triggers:
+- Campaign completed 30+ days ago
+- Campaign cancelled or postponed indefinitely
+
+**Demotion action:**
+```markdown
+1. Remove from CLAUDE.md
+2. Archive full data in memory/[category]/archive/
+3. Add line to CLAUDE.md: "Last reviewed [category]: [date]"
+4. Keep 1-line summary if historically significant
+```
diff --git a/.cursor/skills/memory-management/references/update-triggers-integration.md b/.cursor/skills/memory-management/references/update-triggers-integration.md
new file mode 100644
index 0000000000..00faa54c43
--- /dev/null
+++ b/.cursor/skills/memory-management/references/update-triggers-integration.md
@@ -0,0 +1,102 @@
+# Update Triggers & Cross-Skill Integration
+
+Systematic memory update procedures triggered by specific events, plus integration points with other SEO skills.
+
+## Update Triggers
+
+After specific events, update memory systematically:
+
+### After Ranking Check
+```markdown
+1. Update CLAUDE.md -> Hero Keywords table (current ranks)
+2. Save a dated snapshot to memory/monitoring/rank-history/YYYY-MM-DD-ranks.csv
+3. Note any keywords with significant movement
+4. Update "Last Metrics Update" date in CLAUDE.md
+5. If hero keyword moves +/-5 positions, create alert note
+```
+
+### After Competitor Analysis
+```markdown
+1. Update CLAUDE.md -> Primary Competitors section (DA, position vs. them)
+2. Save full report to memory/research/competitors/YYYY-MM-DD-analysis.md
+3. Update competitor overview notes in memory/research/competitors/
+4. Note new competitor strategies in hot cache
+```
+
+### After Audit (Technical/Content/Backlink)
+```markdown
+1. Save full report to memory/audits/[type]/YYYY-MM-DD-[audit-name].md
+2. Extract top 3-5 action items -> CLAUDE.md Current Optimization Priorities
+3. Update Key Metrics Snapshot if audit includes metrics
+4. Create campaign entry if audit spawns new initiative
+```
+
+### After Monthly/Quarterly Report
+```markdown
+1. Save report to memory/monitoring/reports/[period]/YYYY-MM-report.md
+2. Update all metrics in CLAUDE.md Key Metrics Snapshot
+3. Review hot cache -> demote stale items
+4. Update campaign statuses
+5. Archive completed campaigns
+```
+
+## Archive Management
+
+### Monthly Archive Routine
+```markdown
+1. Review CLAUDE.md for items not referenced in 30 days
+2. Move stale items to appropriate cold storage
+3. Create monthly snapshot: memory/monitoring/snapshots/YYYY-MM-CLAUDE.md
+4. Compress old rank-history exports (keep recent snapshots easiest to access)
+5. Update glossary with new terms from the month
+```
+
+### Quarterly Archive Routine
+```markdown
+1. Review entire cold storage structure
+2. Compress files older than 6 months
+3. Create quarterly summary report
+4. Update project timeline in glossary
+5. Audit all active campaigns -> archive completed ones
+```
+
+## Cross-Skill Memory Integration
+
+This skill coordinates with other SEO skills:
+
+### When keyword-research runs:
+- Add discovered keywords to memory/research/keywords/
+- Promote top opportunities to CLAUDE.md if high-value
+- Update glossary if new terminology emerges
+
+### When rank-tracker runs:
+- Update memory/monitoring/rank-history/
+- Refresh CLAUDE.md Hero Keywords table
+- Flag significant movements for hot cache notes
+
+### When competitor-analysis runs:
+- Update competitor files in memory/research/competitors/
+- Refresh CLAUDE.md Primary Competitors section
+- Add new competitors if they outrank current top 5
+
+### When content-gap-analysis runs:
+- Store full findings in memory/research/content-gaps/
+- Promote gap opportunities to CLAUDE.md priorities
+- Update memory/content/calendar/ with recommended topics
+
+### When seo-content-writer produces content:
+- Log to memory/content/published/YYYY-MM-DD-[slug].md
+- Track target keyword and publish date
+- Set reminder to check performance in 30 days
+
+### When content-quality-auditor runs:
+- Save full report to `memory/audits/content/YYYY-MM-DD-core-eeat-[page-slug].md`
+- Update CLAUDE.md Key Metrics with latest score
+- If score < 60 (Poor/Low), flag in Active Campaigns section
+- Track dimension scores for trend analysis
+
+### When domain-authority-auditor runs:
+- Save full report to memory/audits/domain/YYYY-MM-DD-cite-audit.md
+- Update CITE Score in CLAUDE.md Key Metrics Snapshot
+- Note veto item status and dimension scores
+- Compare against previous CITE audit if available
diff --git a/.cursor/skills/meta-tags-optimizer/SKILL.md b/.cursor/skills/meta-tags-optimizer/SKILL.md
new file mode 100644
index 0000000000..1e3a81c9f9
--- /dev/null
+++ b/.cursor/skills/meta-tags-optimizer/SKILL.md
@@ -0,0 +1,417 @@
+---
+name: meta-tags-optimizer
+description: 'Optimize title tags, meta descriptions, Open Graph, Twitter cards for maximum CTR with A/B variations. 标题优化/元描述/CTR'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when optimizing title tags, meta descriptions, Open Graph tags, or Twitter Cards for a page."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "low"
+ tags:
+ - seo
+ - meta-tags
+ - title-tag
+ - meta-description
+ - open-graph
+ - twitter-card
+ - ctr-optimization
+ - social-sharing
+ - 标题优化
+ - 元描述
+ - メタタグ
+ - 메타태그
+ - meta-tags-seo
+ triggers:
+ # EN-formal
+ - "optimize title tag"
+ - "write meta description"
+ - "improve CTR"
+ - "Open Graph tags"
+ - "social media preview"
+ - "title optimization"
+ - "meta tags"
+ - "Twitter cards"
+ # EN-casual
+ - "my title tag needs work"
+ - "low click-through rate"
+ - "fix my meta tags"
+ - "OG tags not showing"
+ - "social preview looks wrong"
+ - "my click rate is low"
+ # EN-question
+ - "how to write a good title tag"
+ - "how to improve click-through rate"
+ - "what is a good meta description"
+ # EN-competitor
+ - "Yoast SEO title tool"
+ - "RankMath title optimizer"
+ # ZH-pro
+ - "标题标签优化"
+ - "元描述优化"
+ - "OG标签"
+ - "点击率提升"
+ - "社交预览"
+ - "TDK优化"
+ # ZH-casual
+ - "标题不好"
+ - "点击率太低"
+ - "社交分享预览不对"
+ - "标题怎么写"
+ - "TDK怎么写"
+ # JA
+ - "メタタグ最適化"
+ - "タイトルタグ"
+ - "CTR改善"
+ # KO
+ - "메타 태그 최적화"
+ - "제목 태그"
+ - "클릭률 개선"
+ # ES
+ - "optimizar meta tags"
+ - "mejorar CTR"
+ - "etiquetas Open Graph"
+ # PT
+ - "otimizar meta tags"
+ # Misspellings
+ - "meta discription"
+ - "tittle tag"
+---
+
+# Meta Tags Optimizer
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill creates compelling, optimized meta tags that improve click-through rates from search results and enhance social media sharing. It covers title tags, meta descriptions, and social meta tags.
+
+**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
+
+- Creating meta tags for new pages
+- Optimizing existing meta tags for better CTR
+- Preparing pages for social media sharing
+- Fixing duplicate or missing meta tags
+- A/B testing title and description variations
+- Optimizing for specific SERP features
+- Creating meta tags for different page types
+
+## What This Skill Does
+
+1. **Title Tag Creation**: Writes compelling, keyword-optimized titles
+2. **Meta Description Writing**: Creates click-worthy descriptions
+3. **Open Graph Optimization**: Prepares pages for social sharing
+4. **Twitter Card Setup**: Optimizes Twitter-specific meta tags
+5. **CTR Analysis**: Suggests improvements for better click rates
+6. **Character Counting**: Ensures proper length for SERP display
+7. **A/B Test Suggestions**: Provides variations for testing
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Create Meta Tags
+
+```
+Create meta tags for a page about [topic] targeting [keyword]
+```
+
+```
+Write title and meta description for this content: [content/URL]
+```
+
+### Optimize Existing Tags
+
+```
+Improve these meta tags for better CTR: [current tags]
+```
+
+### Social Media Tags
+
+```
+Create Open Graph and Twitter card tags for [page/URL]
+```
+
+## Skill Contract
+
+**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
+
+- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
+- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
+- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~search console + ~~SEO tool connected:**
+Automatically pull current meta tags, CTR data by query, competitor title/description patterns, SERP preview data, and impression/click metrics to identify optimization opportunities.
+
+**With manual data only:**
+Ask the user to provide:
+1. Current title and meta description (if optimizing existing)
+2. Target primary keyword and 2-3 secondary keywords
+3. Page URL and main content/value proposition
+4. Competitor URLs or examples of well-performing titles in the SERP
+
+Proceed with the full workflow using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests meta tag optimization:
+
+1. **Gather Page Information**
+
+ ```markdown
+ ### Page Analysis
+
+ **Page URL**: [URL]
+ **Page Type**: [blog/product/landing/service/homepage]
+ **Primary Keyword**: [keyword]
+ **Secondary Keywords**: [keywords]
+ **Target Audience**: [audience]
+ **Primary CTA**: [action you want users to take]
+ **Unique Value Prop**: [what makes this page special]
+ ```
+
+2. **Create Optimized Title Tag**
+
+ ```markdown
+ ### Title Tag Optimization
+
+ **Requirements**:
+ - Length: 50-60 characters (displays fully in SERP)
+ - Include primary keyword (preferably near front)
+ - Make it compelling and click-worthy
+ - Match search intent
+ - Include brand name if appropriate
+
+ **Title Tag Formula Options**:
+
+ 1. **Keyword | Benefit | Brand**
+ "[Primary Keyword]: [Benefit] | [Brand Name]"
+
+ 2. **Number + Keyword + Promise**
+ "[Number] [Keyword] That [Promise/Result]"
+
+ 3. **How-to Format**
+ "How to [Keyword]: [Benefit/Result]"
+
+ 4. **Question Format**
+ "What is [Keyword]? [Brief Answer/Hook]"
+
+ 5. **Year + Keyword**
+ "[Keyword] in [Year]: [Hook/Update]"
+
+ **Generated Title Options**:
+
+ | Option | Title | Length | Power Words | Keyword Position |
+ |--------|-------|--------|-------------|------------------|
+ | 1 | [Title] | [X] chars | [words] | [Front/Middle] |
+ | 2 | [Title] | [X] chars | [words] | [Front/Middle] |
+ | 3 | [Title] | [X] chars | [words] | [Front/Middle] |
+
+ **Recommended**: Option [X]
+ **Reasoning**: [Why this option is best]
+
+ **Title Tag Code**:
+ ```html
+ [Selected Title]
+ ```
+ ```
+
+3. **Write Meta Description**
+
+ ```markdown
+ ### Meta Description Optimization
+
+ **Requirements**:
+ - Length: 150-160 characters (displays fully in SERP)
+ - Include primary keyword naturally
+ - Include clear call-to-action
+ - Match page content accurately
+ - Create urgency or curiosity
+ - Avoid duplicate descriptions
+
+ **Meta Description Formula**:
+
+ [What the page offers] + [Benefit to user] + [Call-to-action]
+
+ **Power Elements to Include**:
+ - Numbers and statistics
+ - Current year
+ - Emotional triggers
+ - Action verbs
+ - Unique value proposition
+
+ **Generated Description Options**:
+
+ | Option | Description | Length | CTA | Emotional Trigger |
+ |--------|-------------|--------|-----|-------------------|
+ | 1 | [Description] | [X] chars | [CTA] | [Trigger] |
+ | 2 | [Description] | [X] chars | [CTA] | [Trigger] |
+ | 3 | [Description] | [X] chars | [CTA] | [Trigger] |
+
+ **Recommended**: Option [X]
+ **Reasoning**: [Why this option is best]
+
+ **Meta Description Code**:
+ ```html
+
+ ```
+ ```
+
+4. **Create Open Graph, Twitter Card, and Additional Meta Tags**
+
+ Generate OG tags (og:type, og:url, og:title, og:description, og:image), Twitter Card tags, canonical URL, robots, viewport, author, and article-specific tags. Then combine into a complete meta tag block.
+
+ > **Reference**: See [references/meta-tag-code-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/meta-tag-code-templates.md) for OG type selection guide, Twitter card type selection, all HTML code templates, and the complete meta tag block template.
+
+5. **CORE-EEAT Alignment Check**
+
+ Verify meta tags align with content quality standards. Reference: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
+
+ ```markdown
+ ### CORE-EEAT Meta Tag Alignment
+
+ | Check | Status | Notes |
+ |-------|--------|-------|
+ | **C01 Intent Alignment**: Title promise matches actual content delivery | ✅/⚠️/❌ | [Does the title accurately represent what the page delivers?] |
+ | **C02 Direct Answer**: Meta description reflects the core answer available in first 150 words | ✅/⚠️/❌ | [Does the description preview the direct answer?] |
+
+ **If C01 fails**: Title is misleading — rewrite to match actual content.
+ **If C02 fails**: Content may need restructuring to front-load the answer, or description should better reflect available content.
+ ```
+
+9. **Provide CTR Optimization Tips**
+
+ ```markdown
+ ## CTR Optimization Analysis
+
+ ### Power Words Used
+ - [Word 1] - Creates [emotion/action]
+ - [Word 2] - Creates [emotion/action]
+
+ ### CTR Boosting Elements
+
+ | Element | Present | Impact |
+ |---------|---------|--------|
+ | Numbers | Yes/No | +20-30% CTR |
+ | Current Year | Yes/No | +15-20% CTR |
+ | Power Words | Yes/No | +10-15% CTR |
+ | Question | Yes/No | +10-15% CTR |
+ | Brackets | Yes/No | +10% CTR |
+
+ ### A/B Test Suggestions
+
+ Test these variations:
+
+ **Version A** (Current):
+ - Title: [Title]
+ - Description: [Description]
+
+ **Version B** (Test):
+ - Title: [Alternative title]
+ - Description: [Alternative description]
+ - Hypothesis: [Why this might perform better]
+ ```
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Primary keyword confirmed and matches page content
+- [ ] Page type identified (blog/product/landing/service/homepage)
+- [ ] Target audience and search intent clearly defined
+- [ ] Unique value proposition articulated
+
+### Output Validation
+- [ ] Title length 50-60 characters (displays fully in SERP)
+- [ ] Meta description length 150-160 characters
+- [ ] Primary keyword appears in both title and description
+- [ ] Open Graph image specified (1200x630px recommended)
+- [ ] All HTML syntax valid (no unclosed quotes or tags)
+- [ ] Source of each data point clearly stated (~~search console CTR data, ~~SEO tool competitor data, user-provided, or estimated)
+
+## Example
+
+**User**: "Create meta tags for a blog post about 'how to start a podcast in [current year]'"
+
+**Output**:
+
+```markdown
+## Meta Tags: How to Start a Podcast ([current year])
+
+### Title Tag
+```html
+How to Start a Podcast in [current year]: Complete Beginner's Guide
+```
+**Length**: ~55 characters ✅
+**Keyword**: "how to start a podcast" at front ✅
+**Power Words**: "Complete", "Beginner's" ✅
+
+### Meta Description
+```html
+
+```
+**Length**: ~163 characters ✅
+**Keyword**: Included naturally ✅
+**CTA**: "Start podcasting today!" ✅
+
+_Complete meta tag block (with OG, Twitter, Article tags) generated using template from [references/meta-tag-code-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/meta-tag-code-templates.md)._
+
+### A/B Test Variations
+
+**Title Variation B**:
+"Start a Podcast in [current year]: Step-by-Step Guide (+ Free Checklist)"
+
+**Title Variation C**:
+"How to Start a Podcast: [current year] Guide [Equipment + Software + Tips]"
+
+**Description Variation B**:
+"Want to start a podcast in [current year]? This guide covers everything: equipment ($100 budget option), best hosting platforms, recording tips, and how to get your first 1,000 listeners."
+```
+
+## Tips for Success
+
+1. **Front-load keywords** - Put important terms at the beginning
+2. **Match intent** - Description should preview what page delivers
+3. **Be specific** - Vague descriptions get ignored
+4. **Test variations** - Small changes can significantly impact CTR
+5. **Update regularly** - Add current year, refresh messaging
+6. **Check competitors** - See what's working in your SERP
+
+
+### Save Results
+
+After delivering content or optimization output to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/content/YYYY-MM-DD-.md` containing:
+- One-line description of what was created
+- Target keyword and content type
+- Open loops or items needing review
+- Source data references
+
+**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Meta Tag Formulas](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/meta-tag-formulas.md) — Proven title and description formulas
+- [CTR and Social Reference](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/ctr-and-social-reference.md) — Page-type templates, CTR data, OG best practices
+
+## Next Best Skill
+
+- **Primary**: [schema-markup-generator](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/SKILL.md) — complete the SERP packaging with structured data.
diff --git a/.cursor/skills/meta-tags-optimizer/references/ctr-and-social-reference.md b/.cursor/skills/meta-tags-optimizer/references/ctr-and-social-reference.md
new file mode 100644
index 0000000000..8fc5205c80
--- /dev/null
+++ b/.cursor/skills/meta-tags-optimizer/references/ctr-and-social-reference.md
@@ -0,0 +1,149 @@
+# CTR Optimization, Social Tags, and Page-Type Templates
+
+Reference tables for click-through rate optimization, Open Graph best practices, meta description copywriting frameworks, and page-type meta tag templates.
+
+---
+
+## Page-Type Meta Tag Templates
+
+### Homepage
+
+```html
+[Brand Name] - [Primary Value Proposition]
+
+```
+
+### Product Page
+
+```html
+[Product Name] - [Key Benefit] | [Brand]
+
+```
+
+### Blog Post
+
+```html
+[How to/What is/Number] [Keyword] [Benefit/Year]
+
+```
+
+### Service Page
+
+```html
+[Service] in [Location] - [Brand] | [Differentiator]
+
+```
+
+---
+
+## Title Tag Formula Patterns
+
+### Proven Title Formulas
+
+| Formula | Template | Example | Best For |
+|---------|----------|---------|---------|
+| How-To | How to [Achieve Result] ([Year]) | How to Improve SEO Rankings (2026) | Informational guides |
+| Number List | [Number] [Adjective] [Topic] for [Audience] | 15 Proven SEO Strategies for E-commerce | Listicles |
+| Question | [Question]? Here's [Answer Preview] | What Is Technical SEO? Complete Guide | Definition content |
+| Comparison | [Option A] vs [Option B]: [Differentiator] | Ahrefs vs SEMrush: Which SEO Tool Wins? | Comparison pages |
+| Year-Based | Best [Topic] in [Year] ([Tested/Ranked]) | Best SEO Tools in 2026 (Expert Tested) | Evergreen + fresh |
+| Benefit-Driven | [Benefit]: [How to Achieve It] | Rank #1 on Google: 10-Step SEO Checklist | High-CTR pages |
+| Parenthetical | [Topic] ([Modifier]) | Link Building Guide (With Templates) | Adding value signal |
+| Negative | [Number] [Topic] Mistakes [Consequence] | 7 SEO Mistakes That Kill Your Rankings | Problem-awareness content |
+
+### Title Tag Power Words
+
+| Category | Power Words | Use Sparingly |
+|----------|-----------|---------------|
+| Urgency | Now, Today, Quick, Fast, Instant, 2026 | Don't overuse; pair with substance |
+| Value | Free, Proven, Complete, Essential, Ultimate | Avoid hyperbole without backing |
+| Specificity | [Exact number], Step-by-Step, Checklist, Template | More specific = more clicks |
+| Curiosity | Secret, Little-Known, Surprising, Actually | Must deliver on the promise |
+| Authority | Expert, Research-Backed, Data-Driven, Tested | Only use when genuinely expert content |
+| Emotional | Best, Worst, Mistakes, Warning, Powerful | Balance emotion with credibility |
+
+### Title Tag Length Optimization
+
+| Length Range | SERP Behavior | Recommendation |
+|-------------|--------------|----------------|
+| <30 characters | May appear thin | Expand with modifier or brand |
+| 30-50 characters | Fully displayed on all devices | Ideal for mobile-first |
+| 50-60 characters | Full display on desktop, may truncate mobile | Sweet spot for desktop |
+| 60-65 characters | Truncated on some devices | Front-load keywords |
+| >65 characters | Truncated everywhere | Avoid — key info gets cut |
+
+---
+
+## Meta Description Copywriting Frameworks
+
+### AIDA Framework
+
+| Element | What It Does | Example |
+|---------|-------------|---------|
+| **A**ttention | Hook with a bold claim or question | "Want to double your organic traffic?" |
+| **I**nterest | Build relevance | "This guide covers 15 proven strategies..." |
+| **D**esire | Show benefit | "...used by top-ranking sites to increase traffic by 200%." |
+| **A**ction | CTA | "Read the full guide now." |
+
+**Full Example**: "Want to double your organic traffic? This guide covers 15 proven SEO strategies used by top-ranking sites to increase traffic by 200%. Read the full guide now." (158 chars)
+
+### PAS Framework
+
+| Element | What It Does | Example |
+|---------|-------------|---------|
+| **P**roblem | Identify pain point | "Struggling to rank on Google?" |
+| **A**gitate | Amplify the problem | "Most SEO guides are outdated and miss key ranking factors." |
+| **S**olution | Offer the solution | "Our 2026 guide covers what actually works. Read now." |
+
+### Benefit-Proof-CTA Framework
+
+| Element | Example |
+|---------|---------|
+| Benefit | "Learn the exact SEO process that grew our traffic 5x" |
+| Proof | "— with real data from 200+ audits." |
+| CTA | "Get the free checklist." |
+
+---
+
+## CTR Optimization Data
+
+### Factors That Increase Organic CTR
+
+| Factor | CTR Impact | Implementation |
+|--------|-----------|----------------|
+| Number in title | +20-30% | "7 Ways", "15 Tips", "2026" |
+| Question in title | +14% | Start with How, What, Why |
+| Emotional words | +7% | "Proven", "Essential", "Mistake" |
+| Brackets/parentheses | +38% | "[Guide]", "(With Examples)" |
+| Current year | +10-15% | "Best SEO Tools 2026" |
+| Power words | +12% | See Power Words table above |
+| Match search intent | +15-25% | Align title with SERP expectations |
+| Rich results (schema) | +30% | FAQ, HowTo, Review stars |
+
+---
+
+## Open Graph Best Practices by Platform
+
+### Platform-Specific OG Optimization
+
+| Platform | Image Size | Title Length | Description Length | Special Tags |
+|----------|-----------|-------------|-------------------|-------------|
+| Facebook | 1200x630px | 40-60 chars | 125-155 chars | og:type, og:locale |
+| Twitter/X | 1200x600px | 70 chars max | 200 chars | twitter:card, twitter:site |
+| LinkedIn | 1200x627px | 70 chars | 150 chars | Standard OG tags |
+| Pinterest | 1000x1500px (2:3) | 100 chars | 500 chars | og:type=article preferred |
+| Slack | 500x500px min | Full title | First ~300 chars | Standard OG tags |
+
+### OG Tag Checklist
+
+| Tag | Required? | Notes |
+|-----|----------|-------|
+| og:title | Yes | Can differ from HTML title; optimize for social sharing |
+| og:description | Yes | Social-optimized; can differ from meta description |
+| og:image | Yes | Must be absolute URL; minimum 200x200px, ideal 1200x630px |
+| og:url | Yes | Canonical URL |
+| og:type | Yes | "website" for homepage, "article" for blog posts |
+| og:site_name | Recommended | Your brand name |
+| og:locale | Recommended | e.g., "en_US" |
+| twitter:card | Recommended | "summary_large_image" for articles |
+| twitter:site | Recommended | @yourbrand |
diff --git a/.cursor/skills/meta-tags-optimizer/references/meta-tag-code-templates.md b/.cursor/skills/meta-tags-optimizer/references/meta-tag-code-templates.md
new file mode 100644
index 0000000000..88d5b62867
--- /dev/null
+++ b/.cursor/skills/meta-tags-optimizer/references/meta-tag-code-templates.md
@@ -0,0 +1,153 @@
+# Meta Tag Code Templates
+
+HTML code templates for Open Graph tags, Twitter cards, additional meta tags, and complete meta tag blocks.
+
+---
+
+## Open Graph Tags (Facebook, LinkedIn, etc.)
+
+**Required OG Tags**:
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+**OG Type Selection Guide**:
+
+| Page Type | og:type |
+|-----------|---------|
+| Blog post | article |
+| Homepage | website |
+| Product | product |
+| Video | video.other |
+| Profile | profile |
+
+**OG Title Considerations**:
+- Can be different from title tag
+- Optimize for social sharing context
+- More conversational tone acceptable
+- Up to 60 characters ideal
+
+**OG Description Considerations**:
+- Can be longer than meta description (up to 200 chars)
+- Focus on shareability
+- What would make someone click when shared?
+
+**OG Image Requirements**:
+- Recommended size: 1200x630 pixels
+- Minimum size: 600x315 pixels
+- Format: JPG or PNG
+- Keep text to less than 20% of image
+- Include branding subtly
+
+---
+
+## Twitter Card Tags
+
+**Card Type Selection**:
+
+| Card Type | Best For | Image Size |
+|-----------|----------|------------|
+| summary | Articles, blogs | 144x144 min |
+| summary_large_image | Visual content | 300x157 min |
+| player | Video/audio | 640x360 min |
+| app | Mobile apps | 800x418 |
+
+**Twitter Card Code**:
+
+```html
+
+
+
+
+
+
+
+
+```
+
+**Twitter-Specific Considerations**:
+- Shorter titles work better (under 70 chars)
+- Include @mentions if relevant
+- Hashtag-relevant terms can help discovery
+- Test with Twitter Card Validator
+
+---
+
+## Additional Recommended Meta Tags
+
+**Canonical URL** (Prevent duplicates):
+```html
+
+```
+
+**Robots Tag** (Indexing control):
+```html
+
+```
+
+**Viewport** (Mobile optimization):
+```html
+
+```
+
+**Author** (For articles):
+```html
+
+```
+
+**Language**:
+```html
+
+```
+
+**Article-Specific** (For blog posts):
+```html
+
+
+
+
+
+```
+
+---
+
+## Complete Meta Tag Block Template
+
+Copy and paste this complete meta tag block:
+
+```html
+
+[Optimized Title]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
diff --git a/.cursor/skills/meta-tags-optimizer/references/meta-tag-formulas.md b/.cursor/skills/meta-tags-optimizer/references/meta-tag-formulas.md
new file mode 100644
index 0000000000..dd1399f2be
--- /dev/null
+++ b/.cursor/skills/meta-tags-optimizer/references/meta-tag-formulas.md
@@ -0,0 +1,355 @@
+# Meta Tag Formulas Reference
+
+Extended title tag formulas, meta description templates, A/B testing methodology, seasonal patterns, CTR benchmarks, and multi-language optimization guidance.
+
+---
+
+## Title Tag Formulas by Content Type
+
+### Informational Content
+
+| # | Formula | Template | Example |
+|---|---------|----------|---------|
+| 1 | How-To + Year | How to [Action] in [Year] | How to Build Backlinks in 2026 |
+| 2 | How-To + Result | How to [Action] ([Result]) | How to Write Meta Tags (Rank Higher) |
+| 3 | Definitive Guide | The Definitive Guide to [Topic] | The Definitive Guide to Technical SEO |
+| 4 | Complete Guide | [Topic]: The Complete Guide ([Year]) | Schema Markup: The Complete Guide (2026) |
+| 5 | Beginner's Guide | [Topic] for Beginners: [Promise] | SEO for Beginners: Rank in 30 Days |
+| 6 | What Is | What Is [Topic]? [Short Clarifier] | What Is E-E-A-T? Google's Quality Standard |
+| 7 | Everything You Need | Everything You Need to Know About [Topic] | Everything You Need to Know About Core Web Vitals |
+| 8 | Step-by-Step | [Topic]: A Step-by-Step Guide | Link Building: A Step-by-Step Guide |
+
+### Listicle Content
+
+| # | Formula | Template | Example |
+|---|---------|----------|---------|
+| 9 | Numbered Tips | [N] [Topic] Tips That [Result] | 12 On-Page SEO Tips That Boost Rankings |
+| 10 | Best Of | [N] Best [Items] in [Year] ([Qualifier]) | 9 Best SEO Tools in 2026 (Expert Tested) |
+| 11 | Ways To | [N] Ways to [Achieve Goal] [Timeframe] | 7 Ways to Increase Organic Traffic This Month |
+| 12 | Strategies | [N] [Adjective] [Topic] Strategies for [Audience] | 15 Proven Content Strategies for SaaS |
+| 13 | Examples | [N] [Topic] Examples [Value Add] | 20 Meta Description Examples (+ Templates) |
+| 14 | Mistakes | [N] [Topic] Mistakes [Negative Consequence] | 10 Title Tag Mistakes That Hurt Your CTR |
+| 15 | Secrets | [N] [Topic] Secrets [Experts/Pros] Use | 5 SEO Secrets Top Agencies Use |
+| 16 | Reasons | [N] Reasons [Why/To] [Statement] | 8 Reasons Your Site Isn't Ranking |
+
+### Comparison Content
+
+| # | Formula | Template | Example |
+|---|---------|----------|---------|
+| 17 | A vs B | [A] vs [B]: [Differentiator] | Ahrefs vs SEMrush: Which Tool Is Better? |
+| 18 | A vs B + Year | [A] vs [B] ([Year]): [Angle] | WordPress vs Webflow (2026): Honest Comparison |
+| 19 | A vs B + Winner | [A] vs [B]: Which [Category] Wins? | Yoast vs Rank Math: Which SEO Plugin Wins? |
+| 20 | Alternatives | [N] Best [Product] Alternatives in [Year] | 7 Best Ahrefs Alternatives in 2026 |
+
+### Commercial / Transactional Content
+
+| # | Formula | Template | Example |
+|---|---------|----------|---------|
+| 21 | Product + Benefit | [Product] - [Primary Benefit] \| [Brand] | SEO Audit Tool - Find Issues in Minutes \| SiteChecker |
+| 22 | Pricing | [Product] Pricing: [Value Statement] | Semrush Pricing: Plans from $119/mo (2026) |
+| 23 | Review | [Product] Review ([Year]): [Verdict] | Surfer SEO Review (2026): Worth the Price? |
+| 24 | Free Tool | Free [Tool Type]: [What It Does] | Free Meta Tag Generator: Preview Your SERP Listing |
+
+### Local SEO Content
+
+| # | Formula | Template | Example |
+|---|---------|----------|---------|
+| 25 | Service + Location | [Service] in [City] - [Brand] \| [Differentiator] | Plumber in Austin - FastFix \| Same-Day Service |
+| 26 | Best + Location | Best [Service] in [City] ([Year]) | Best Coffee Shops in Portland (2026) |
+| 27 | Near Me Style | [Service] Near [Location]: [Value] | SEO Agency Near Chicago: Free Site Audit |
+| 28 | Location Guide | [Topic] in [Location]: [Qualifier] Guide | Restaurants in Brooklyn: The Local's Guide |
+
+### Problem-Awareness Content
+
+| # | Formula | Template | Example |
+|---|---------|----------|---------|
+| 29 | Warning | Warning: [Problem] [Consequence] | Warning: These SEO Tactics Will Get You Penalized |
+| 30 | Why + Problem | Why [Problem Exists] (And How to Fix It) | Why Your Meta Descriptions Are Ignored (And How to Fix It) |
+| 31 | Stop Doing | Stop [Bad Practice]: [Better Alternative] | Stop Keyword Stuffing: What to Do Instead |
+| 32 | Truth About | The Truth About [Topic] [Revelation] | The Truth About SEO in 2026: What Changed |
+
+---
+
+## Meta Description Templates
+
+### By Content Type
+
+#### Blog Posts / Articles
+
+| # | Template | Character Count |
+|---|----------|-----------------|
+| 1 | Learn [topic] with our [qualifier] guide. Covers [point 1], [point 2], and [point 3]. [CTA]. | ~140-155 |
+| 2 | [Question]? This [year] guide explains [what], [why], and [how]. Get actionable tips now. | ~130-150 |
+| 3 | Discover [N] [adjective] [topic] strategies that [result]. Backed by [proof element]. Read the full guide. | ~145-160 |
+| 4 | [Bold claim about topic]. We tested [N] [methods/tools] and found what works. See the results. | ~130-150 |
+
+#### Product / Service Pages
+
+| # | Template | Character Count |
+|---|----------|-----------------|
+| 5 | [Product] helps you [benefit]. [Feature 1], [Feature 2], [Feature 3]. [Price/offer]. [CTA]. | ~140-155 |
+| 6 | Looking for [solution]? [Product] [key differentiator]. Trusted by [social proof]. [CTA]. | ~130-150 |
+| 7 | [Product] - [primary benefit] in [timeframe]. [Star rating] from [N]+ reviews. [CTA]. | ~130-145 |
+
+#### E-commerce / Category Pages
+
+| # | Template | Character Count |
+|---|----------|-----------------|
+| 8 | Shop [category] from [Brand]. [N]+ [items] starting at $[price]. Free shipping on orders over $[amount]. | ~145-160 |
+| 9 | Browse our [adjective] collection of [products]. [Unique selling point]. [Guarantee/offer]. Order today. | ~140-155 |
+
+#### Comparison Pages
+
+| # | Template | Character Count |
+|---|----------|-----------------|
+| 10 | [A] vs [B]: which is better for [use case]? We compared [criteria]. See the winner + detailed breakdown. | ~145-160 |
+| 11 | Comparing [A] and [B] on [criteria 1], [criteria 2], and price. Our [year] verdict inside. | ~130-145 |
+
+#### Local Business Pages
+
+| # | Template | Character Count |
+|---|----------|-----------------|
+| 12 | [Service] in [City] by [Brand]. [Experience] years of experience. [N]-star rated. [CTA] — call [phone] today. | ~150-160 |
+| 13 | Need [service] in [location]? [Brand] offers [differentiator]. [Guarantee]. Book your [offer] now. | ~140-155 |
+
+---
+
+## A/B Testing Methodology for Titles and Descriptions
+
+### Pre-Test Preparation
+
+1. **Baseline metrics** — Record current CTR, average position, impressions, and clicks for the target page (minimum 30 days of data from ~~search console).
+2. **Hypothesis** — State what you expect to change and why: "Adding a number to the title will increase CTR by X% because listicle titles outperform generic ones at this SERP position."
+3. **Single variable** — Change only ONE element per test (title OR description, not both).
+4. **Minimum sample** — Wait until the page has accumulated at least 1,000 impressions in the test period before drawing conclusions.
+
+### Test Execution Steps
+
+| Step | Action | Duration |
+|------|--------|----------|
+| 1 | Record baseline CTR for 30 days | 30 days |
+| 2 | Implement title/description change | Day 0 |
+| 3 | Allow Google to re-crawl and update SERP | 3-7 days |
+| 4 | Monitor new CTR (exclude first 7 days) | 30+ days |
+| 5 | Compare new CTR vs. baseline at same avg. position | — |
+| 6 | Decide: keep, revert, or iterate | — |
+
+### What to Test (Priority Order)
+
+| Priority | Element | Test Variable | Expected Impact |
+|----------|---------|--------------|-----------------|
+| 1 | Title tag | Add/remove number | +15-25% CTR |
+| 2 | Title tag | Add/remove year | +10-15% CTR |
+| 3 | Title tag | Add brackets/parentheses | +10-38% CTR |
+| 4 | Title tag | Change power word | +5-12% CTR |
+| 5 | Title tag | Reorder keyword placement | +5-10% CTR |
+| 6 | Meta description | Add CTA | +5-10% CTR |
+| 7 | Meta description | Add numbers/statistics | +5-15% CTR |
+| 8 | Meta description | Change emotional tone | +3-8% CTR |
+
+### Interpreting Results
+
+| Scenario | Interpretation | Action |
+|----------|---------------|--------|
+| CTR up, position stable | Title/description change is working | Keep the change |
+| CTR up, position up | Improved engagement signals helping rankings | Keep and document |
+| CTR down, position stable | Change was negative | Revert immediately |
+| CTR flat, position stable | Change had no impact | Try a bolder variation |
+| CTR up, position down | Higher CTR but lower impressions | Evaluate net click change |
+
+### Statistical Significance Checklist
+
+- [ ] Minimum 1,000 impressions in test period
+- [ ] Test ran for at least 30 days (after re-crawl)
+- [ ] Average position did not shift more than 2 positions
+- [ ] No major algorithm updates during test period
+- [ ] No seasonal bias (compare similar months year-over-year)
+- [ ] CTR change exceeds 10% relative difference
+
+---
+
+## Seasonal Title Patterns
+
+### Calendar-Driven Updates
+
+| Season/Event | Title Modifier | Example |
+|-------------|---------------|---------|
+| New Year (Jan) | [Year] Edition, Updated for [Year] | SEO Checklist: 2026 Edition |
+| Q1 Planning | [Year] Strategy, Q1 [Year] | Content Strategy for Q1 2026 |
+| Spring | Spring [Year], Fresh | Spring 2026 SEO Audit Checklist |
+| Back to School (Aug-Sep) | Fall [Year], New Season | Fall 2026 Marketing Trends |
+| Black Friday (Nov) | Black Friday [Year], Holiday | Black Friday SEO Prep Guide (2026) |
+| End of Year (Dec) | [Year] Wrap-Up, [Next Year] Preview | 2026 SEO Wrap-Up + 2027 Predictions |
+| Any month | [Month] [Year] | Best SEO Tools (February 2026) |
+
+### Evergreen + Fresh Hybrid Strategy
+
+Keep the core title evergreen but append a freshness signal:
+
+| Base Title (Evergreen) | Freshness Signal | Combined |
+|-----------------------|------------------|----------|
+| How to Start a Blog | (Updated Feb 2026) | How to Start a Blog (Updated Feb 2026) |
+| Best CRM Software | [2026 Rankings] | Best CRM Software [2026 Rankings] |
+| SEO Beginner's Guide | - Latest Edition | SEO Beginner's Guide - Latest Edition |
+
+**Update cadence**: Refresh the year/date modifier every 3-6 months for pages targeting "best", "top", or year-based queries.
+
+---
+
+## CTR Benchmarks by Industry
+
+### Average Organic CTR by SERP Position (All Industries)
+
+| Position | Desktop CTR | Mobile CTR | Combined Avg |
+|----------|------------|------------|-------------|
+| 1 | 31.7% | 26.9% | 29.3% |
+| 2 | 17.5% | 15.1% | 16.3% |
+| 3 | 11.3% | 10.2% | 10.8% |
+| 4 | 7.9% | 7.1% | 7.5% |
+| 5 | 5.8% | 5.2% | 5.5% |
+| 6 | 4.4% | 3.9% | 4.2% |
+| 7 | 3.5% | 3.1% | 3.3% |
+| 8 | 2.9% | 2.5% | 2.7% |
+| 9 | 2.5% | 2.1% | 2.3% |
+| 10 | 2.2% | 1.8% | 2.0% |
+
+### CTR Variation by Industry Vertical
+
+| Industry | Avg CTR (Pos 1) | Avg CTR (Pos 1-3) | Notes |
+|----------|----------------|-------------------|-------|
+| Technology / SaaS | 25-30% | 50-55% | High competition; rich results common |
+| E-commerce | 20-25% | 40-45% | Shopping results reduce organic CTR |
+| Healthcare | 28-35% | 55-60% | Users seek authoritative sources |
+| Finance | 22-28% | 45-50% | Featured snippets capture clicks |
+| Travel | 18-24% | 38-44% | Maps and hotel packs reduce CTR |
+| Legal | 30-38% | 58-65% | High intent; fewer SERP features |
+| Education | 26-32% | 52-58% | Informational queries; high trust needed |
+| Real Estate | 20-26% | 42-48% | Local packs reduce organic CTR |
+| Food / Recipes | 15-22% | 35-42% | Recipe carousels dominate top positions |
+| B2B Services | 28-34% | 55-62% | Less SERP competition; niche queries |
+
+### When Your CTR Is Below Benchmark
+
+| CTR vs. Benchmark | Likely Cause | Recommended Action |
+|-------------------|-------------|-------------------|
+| 50%+ below | Title does not match intent | Rewrite title to align with query intent |
+| 25-50% below | Weak title or strong SERP features | Add power words, numbers, or brackets |
+| 10-25% below | Competitors have better titles | Analyze top 3 competitor titles and differentiate |
+| At benchmark | Performing as expected | Test incremental improvements |
+| Above benchmark | Strong title; optimize further | Protect current title; test description instead |
+
+---
+
+## Multi-Language Title Optimization
+
+### General Principles
+
+1. **Do not translate literally** -- Adapt the title formula to the target language's natural phrasing and cultural norms.
+2. **Character limits vary** -- CJK characters are wider; effective SERP display length is shorter by character count.
+3. **Keyword research per locale** -- Search volume and intent differ across languages. Always do locale-specific keyword research.
+4. **Brand name placement** -- Some markets prefer brand-first; others prefer keyword-first.
+
+### Language-Specific Considerations
+
+| Language | Max Chars (SERP) | Key Differences | Example Adaptation |
+|----------|-----------------|-----------------|-------------------|
+| English | 55-60 | Standard baseline | How to Learn SEO (2026 Guide) |
+| Spanish | 55-60 | Longer phrases; use abbreviations | Como Aprender SEO: Guia Completa 2026 |
+| French | 55-60 | Accented characters count same | Guide SEO Complet : Comment se Classer en 2026 |
+| German | 50-55 | Compound words are long | SEO-Leitfaden 2026: Schritt-fur-Schritt |
+| Japanese | 30-35 | CJK characters take ~2x width | SEO対策ガイド2026【初心者向け】 |
+| Chinese (Simplified) | 28-32 | CJK characters; brackets common | SEO优化指南(2026完整版) |
+| Korean | 30-35 | CJK width; particles add length | SEO 최적화 가이드 2026 [초보자용] |
+| Arabic | 50-55 | RTL display; test SERP rendering | دليل تحسين محركات البحث 2026 |
+| Portuguese (BR) | 55-60 | Similar to Spanish | Guia de SEO 2026: Como Ranquear no Google |
+| Hindi | 45-50 | Devanagari takes more width | SEO कैसे करें: 2026 गाइड |
+
+### Multi-Language Meta Description Tips
+
+| Principle | Detail |
+|-----------|--------|
+| Localize the CTA | "Learn more" may not translate well; use culturally appropriate CTAs |
+| Adjust character targets | For CJK, aim for 70-80 characters; for Latin-script languages, 150-160 |
+| Use local proof points | Reference local statistics, regulations, or market leaders |
+| Test SERP rendering | Use Google's SERP preview in the target locale to check truncation |
+| Separate hreflang tags | Ensure each language version has the correct hreflang annotation |
+
+---
+
+## Title Tag Modifier Library
+
+### Modifiers by Purpose
+
+| Purpose | Modifiers |
+|---------|----------|
+| Freshness | 2026, Updated, Latest, New, This Month, This Week |
+| Comprehensiveness | Complete, Ultimate, Definitive, A-to-Z, Full, In-Depth |
+| Ease | Easy, Simple, Quick, Beginner, No-Code, DIY |
+| Speed | Fast, Instant, [N]-Minute, Today, Quick-Start |
+| Authority | Expert, Pro, Advanced, Research-Backed, Data-Driven |
+| Value | Free, Cheap, Budget, Affordable, Open-Source |
+| Format | Guide, Checklist, Template, Cheat Sheet, Playbook, Toolkit |
+| Exclusivity | Insider, Members-Only, Exclusive, First Look, Early Access |
+| Social Proof | Trusted, [N]+ Users, Award-Winning, #1 Rated |
+| Scarcity | Limited, Last Chance, Ending Soon, Only [N] Left |
+
+### Modifier Placement Rules
+
+| Position | When to Use | Example |
+|----------|------------|---------|
+| Front | When modifier IS the keyword ("Free", "Best") | Free SEO Audit Tool for Small Business |
+| Middle | When clarifying the topic | SEO Tools (Free) for Small Business |
+| End (parenthetical) | When adding value signal | SEO Tools for Small Business (Free) |
+| End (pipe/dash) | When adding brand | SEO Tools for Small Business \| Brand |
+
+---
+
+## Common Title Tag Mistakes
+
+| Mistake | Why It Hurts | Fix |
+|---------|-------------|-----|
+| Keyword stuffing | Google may rewrite your title | Use primary keyword once; vary phrasing |
+| Too generic ("Home", "Welcome") | Zero CTR differentiation | Write a benefit-driven title |
+| All caps | Looks spammy; may be rewritten | Use title case or sentence case |
+| Missing primary keyword | Misses ranking signal | Include target keyword naturally |
+| Duplicate titles across pages | Cannibalization; Google may pick wrong page | Unique title per page |
+| Brand name first (non-branded query) | Wastes prime keyword space | Put brand at end after pipe |
+| No emotional hook | Blends in with competitors | Add one power word or number |
+| Exceeding 60 characters | Key info truncated in SERP | Front-load; keep under 60 chars |
+| Misleading clickbait | High bounce rate hurts rankings | Title must match content delivery |
+| Ignoring SERP context | Title doesn't stand out among competitors | Check live SERP before finalizing |
+
+---
+
+## Quick-Reference: Title Formula Selection Flowchart
+
+```
+START: What type of content is this?
+|
++-- Informational (how-to, guide, tutorial)
+| +-- Is it step-by-step? --> Use Formula #2 or #8
+| +-- Is it a definition? --> Use Formula #6
+| +-- Is it comprehensive? --> Use Formula #3 or #4
+| +-- Is it for beginners? --> Use Formula #5
+|
++-- Listicle (tips, tools, examples)
+| +-- Is it "best of"? --> Use Formula #10
+| +-- Is it mistakes/warnings? --> Use Formula #14 or #29
+| +-- Is it tips/strategies? --> Use Formula #9 or #12
+| +-- Is it examples? --> Use Formula #13
+|
++-- Comparison
+| +-- Two products/tools? --> Use Formula #17 or #18
+| +-- Multiple alternatives? --> Use Formula #20
+|
++-- Commercial / Product
+| +-- Product page? --> Use Formula #21
+| +-- Pricing page? --> Use Formula #22
+| +-- Review? --> Use Formula #23
+| +-- Free tool? --> Use Formula #24
+|
++-- Local
+| +-- Service page? --> Use Formula #25
+| +-- Best-of local? --> Use Formula #26
+| +-- Near me? --> Use Formula #27
+```
diff --git a/.cursor/skills/on-page-seo-auditor/SKILL.md b/.cursor/skills/on-page-seo-auditor/SKILL.md
new file mode 100644
index 0000000000..170bee1145
--- /dev/null
+++ b/.cursor/skills/on-page-seo-auditor/SKILL.md
@@ -0,0 +1,364 @@
+---
+name: on-page-seo-auditor
+description: 'Audit on-page SEO: titles, headers, images, links with scored report and fix priorities. 页面SEO审计/排名诊断'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when auditing a page's on-page SEO health, checking heading structure, keyword placement, image optimization, or content quality signals."
+argument-hint: " [keyword]"
+allowed-tools: WebFetch
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - on-page-audit
+ - page-optimization
+ - seo-score
+ - content-audit
+ - h1-optimization
+ - meta-audit
+ - seo-checklist
+ - yoast-alternative
+ - screaming-frog-alternative
+ - 页面SEO
+ - 网页优化
+ - ページSEO
+ - 페이지감사
+ - auditoria-seo
+ triggers:
+ # EN-formal
+ - "audit page SEO"
+ - "on-page SEO check"
+ - "SEO score"
+ - "page optimization"
+ - "on-page audit"
+ - "SEO page analysis"
+ - "content audit"
+ # EN-casual
+ - "what SEO issues does this page have"
+ - "check my page"
+ - "score my page"
+ - "why isn't this page ranking"
+ - "what's wrong with this page's SEO"
+ - "is my page optimized"
+ - "my rankings tanked"
+ - "why did my rankings drop"
+ # EN-question
+ - "why is my page not ranking"
+ - "how do I improve my page SEO"
+ - "what SEO problems does this page have"
+ # EN-competitor
+ - "Screaming Frog alternative"
+ - "Yoast SEO alternative"
+ # ZH-pro
+ - "页面SEO审计"
+ - "网页优化检查"
+ - "SEO评分"
+ - "页面诊断"
+ - "页面优化分析"
+ # ZH-casual
+ - "页面有什么问题"
+ - "为什么排不上去"
+ - "检查一下我的页面"
+ - "SEO打分"
+ - "排名上不去怎么办"
+ - "网页收录问题"
+ # JA
+ - "ページSEO監査"
+ - "オンページSEO"
+ - "ページ最適化"
+ - "SEOスコア"
+ # KO
+ - "페이지 SEO 감사"
+ - "온페이지 SEO"
+ - "SEO 점수"
+ - "이 페이지 뭐가 문제야?"
+ - "왜 순위가 안 올라가?"
+ - "SEO 점수 확인해줘"
+ # ES
+ - "auditoría SEO on-page"
+ - "análisis de página SEO"
+ - "puntuación SEO"
+ # PT
+ - "auditoria SEO on-page"
+ # Misspellings
+ - "on page SEO aduit"
+ - "SEO scroe"
+---
+
+# On-Page SEO Auditor
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill performs detailed on-page SEO audits to identify issues and optimization opportunities. It analyzes all on-page elements that affect search rankings and provides actionable recommendations.
+
+**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
+
+- Auditing pages before or after publishing
+- Identifying why a page isn't ranking well
+- Optimizing existing content for better performance
+- Creating pre-publish SEO checklists
+- Comparing your on-page SEO to competitors
+- Systematic site-wide SEO improvements
+- Training team members on SEO best practices
+
+## What This Skill Does
+
+1. **Title Tag Analysis**: Evaluates title optimization and CTR potential
+2. **Meta Description Review**: Checks description quality and length
+3. **Header Structure Audit**: Analyzes H1-H6 hierarchy
+4. **Content Quality Assessment**: Reviews content depth and optimization
+5. **Keyword Usage Analysis**: Checks keyword placement and density
+6. **Internal Link Review**: Evaluates internal linking structure
+7. **Image Optimization Check**: Audits alt text and file optimization
+8. **Technical On-Page Review**: Checks URL, canonical, and mobile factors
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Audit a Single Page
+
+```
+Audit the on-page SEO of [URL]
+```
+
+```
+Check SEO issues on this page targeting [keyword]: [URL/content]
+```
+
+### Compare Against Competitors
+
+```
+Compare on-page SEO of [your URL] vs [competitor URL] for [keyword]
+```
+
+### Audit Content Before Publishing
+
+```
+Pre-publish SEO audit for this content targeting [keyword]: [content]
+```
+
+## Skill Contract
+
+**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
+
+- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
+- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~web crawler connected:**
+Claude can automatically pull page HTML via ~~web crawler, fetch keyword search volume and difficulty from ~~SEO tool, retrieve click-through rate data from ~~search console, and download competitor pages for comparison. This enables fully automated audits with live data.
+
+**With manual data only:**
+Ask the user to provide:
+1. Page URL or complete HTML content
+2. Target primary and secondary keywords
+3. Competitor page URLs for comparison (optional)
+
+Proceed with the full audit using provided data. Note in the output which findings are from automated crawl vs. manual review.
+
+## Instructions
+
+When a user requests an on-page SEO audit:
+
+1. **Gather Page Information**
+
+ ```markdown
+ ### Audit Setup
+
+ **Page URL**: [URL]
+ **Target Keyword**: [primary keyword]
+ **Secondary Keywords**: [additional keywords]
+ **Page Type**: [blog/product/landing/service]
+ **Business Goal**: [traffic/conversions/authority]
+ ```
+
+2. **Audit Title Tag**
+
+ ```markdown
+ ## Title Tag Analysis
+
+ **Current Title**: [title]
+ **Character Count**: [X] characters
+
+ | Criterion | Status | Notes |
+ |-----------|--------|-------|
+ | Length (50-60 chars) | ✅/⚠️/❌ | [notes] |
+ | Keyword included | ✅/⚠️/❌ | Position: [front/middle/end] |
+ | Keyword at front | ✅/⚠️/❌ | [notes] |
+ | Unique across site | ✅/⚠️/❌ | [notes] |
+ | Compelling/clickable | ✅/⚠️/❌ | [notes] |
+ | Matches intent | ✅/⚠️/❌ | [notes] |
+
+ **Title Score**: [X]/10
+
+ **Issues Found**:
+ - [Issue 1]
+ - [Issue 2]
+
+ **Recommended Title**:
+ "[Optimized title suggestion]"
+
+ **Why**: [Explanation of improvements]
+ ```
+
+3. **Audit Meta Description**
+
+ ```markdown
+ ## Meta Description Analysis
+
+ **Current Description**: [description]
+ **Character Count**: [X] characters
+
+ | Criterion | Status | Notes |
+ |-----------|--------|-------|
+ | Length (150-160 chars) | ✅/⚠️/❌ | [notes] |
+ | Keyword included | ✅/⚠️/❌ | [notes] |
+ | Call-to-action present | ✅/⚠️/❌ | [notes] |
+ | Unique across site | ✅/⚠️/❌ | [notes] |
+ | Accurately describes page | ✅/⚠️/❌ | [notes] |
+ | Compelling copy | ✅/⚠️/❌ | [notes] |
+
+ **Description Score**: [X]/10
+
+ **Issues Found**:
+ - [Issue 1]
+
+ **Recommended Description**:
+ "[Optimized description suggestion]" ([X] chars)
+ ```
+
+4. **Audit Header Structure**
+
+ ```markdown
+ ## Header Structure Analysis
+
+ ### Current Header Hierarchy
+
+ ```
+ H1: [H1 text]
+ H2: [H2 text]
+ H3: [H3 text]
+ H3: [H3 text]
+ H2: [H2 text]
+ H3: [H3 text]
+ H2: [H2 text]
+ ```
+
+ | Criterion | Status | Notes |
+ |-----------|--------|-------|
+ | Single H1 | ✅/⚠️/❌ | Found: [X] H1s |
+ | H1 includes keyword | ✅/⚠️/❌ | [notes] |
+ | Logical hierarchy | ✅/⚠️/❌ | [notes] |
+ | H2s include keywords | ✅/⚠️/❌ | [X]/[Y] contain keywords |
+ | No skipped levels | ✅/⚠️/❌ | [notes] |
+ | Descriptive headers | ✅/⚠️/❌ | [notes] |
+
+ **Header Score**: [X]/10
+
+ **Issues Found**:
+ - [Issue 1]
+ - [Issue 2]
+
+ **Recommended Changes**:
+ - H1: [suggestion]
+ - H2s: [suggestions]
+ ```
+
+5. **Audit Content Quality** — Word count, reading level, comprehensiveness, formatting, E-E-A-T signals, content elements checklist, gap identification
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the content quality template (Step 5).
+
+6. **Audit Keyword Usage** — Primary/secondary keyword placement across all page elements, LSI/related terms, density analysis
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the keyword optimization template (Step 6).
+
+7. **Audit Internal Links** — Link count, anchor text relevance, broken links, recommended additions
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the internal linking template (Step 7).
+
+8. **Audit Images** — Alt text, file names, sizes, formats, lazy loading
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the image optimization template (Step 8).
+
+9. **Audit Technical On-Page Elements** — URL, canonical, mobile, speed, HTTPS, schema
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the technical on-page template (Step 9).
+
+10. **CORE-EEAT Content Quality Quick Scan** — 17 on-page-relevant items from the 80-item CORE-EEAT benchmark
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the CORE-EEAT quick scan template (Step 10). Full benchmark: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md).
+
+11. **Generate Audit Summary** — Overall score with visual breakdown, priority issues (critical/important/minor), quick wins, detailed recommendations, competitor comparison, action checklist, expected results
+
+ > **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the full audit summary template (Step 11).
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Target keyword(s) clearly specified by user
+- [ ] Page content accessible (either via URL or provided HTML)
+- [ ] If competitor comparison requested, competitor URL provided
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] Scores based on measurable criteria, not subjective opinion
+- [ ] All suggested changes include specific locations (title tag, H2 #3, paragraph 5, etc.)
+- [ ] Source of each data point clearly stated (~~SEO tool data, user-provided, ~~web crawler, or manual review)
+
+## Example
+
+> **Reference**: See [references/audit-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-example.md) for a full worked example (noise-cancelling headphones audit) and page-type checklists (blog post, product page, landing page).
+
+## Tips for Success
+
+1. **Prioritize issues by impact** - Fix critical issues first
+2. **Compare to competitors** - See what's working for top rankings
+3. **Balance optimization and readability** - Don't over-optimize
+4. **Audit regularly** - Content degrades over time
+5. **Test changes** - Track ranking changes after updates
+
+> **Scoring details**: For the complete weight distribution, scoring scale, issue resolution playbook, and industry benchmarks, see [references/scoring-rubric.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/scoring-rubric.md).
+
+
+### Save Results
+
+After delivering audit or optimization findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/audits/on-page-seo-auditor/YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Reference Materials
+
+- [Scoring Rubric](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/scoring-rubric.md) — Detailed scoring criteria, weight distribution, and grade boundaries for on-page audits
+- [Audit Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) — Detailed output templates for steps 5-11 (content quality, keywords, links, images, technical, CORE-EEAT scan, audit summary)
+- [Audit Example & Checklists](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-example.md) — Full worked example and page-type checklists (blog, product, landing page)
+
+## Next Best Skill
+
+- **Primary**: [content-refresher](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/SKILL.md) — turn page-level findings into concrete edits.
diff --git a/.cursor/skills/on-page-seo-auditor/references/audit-example.md b/.cursor/skills/on-page-seo-auditor/references/audit-example.md
new file mode 100644
index 0000000000..60b2b59882
--- /dev/null
+++ b/.cursor/skills/on-page-seo-auditor/references/audit-example.md
@@ -0,0 +1,109 @@
+# On-Page SEO Auditor — Worked Example & Page Type Checklists
+
+Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/SKILL.md).
+
+---
+
+## Worked Example
+
+**User**: "Audit the on-page SEO of https://example.com/best-noise-cancelling-headphones targeting 'best noise cancelling headphones'"
+
+**Output**:
+
+```markdown
+# On-Page SEO Audit Report
+
+**Page**: https://example.com/best-noise-cancelling-headphones
+**Target Keyword**: best noise cancelling headphones
+**Secondary Keywords**: wireless noise cancelling headphones, ANC headphones, noise cancelling headphones review
+**Page Type**: commercial (reviews/roundup)
+**Audit Date**: 2025-01-15
+
+## Summary
+
+| Audit Area | Score | Key Finding |
+|------------|-------|-------------|
+| Title Tag | 8/10 | Good keyword placement; slightly long at 63 chars |
+| Meta Description | 6/10 | Missing CTA; keyword present but generic copy |
+| Header Structure | 9/10 | Clean hierarchy; H2s cover all major products |
+| Content Quality | 7/10 | 2,400 words is solid; lacks original test data |
+| Keyword Optimization | 8/10 | Strong placement; density at 1.2% is healthy |
+| Internal Links | 5/10 | Only 2 internal links; missing links to brand pages |
+| Images | 6/10 | 3/8 images missing alt text; no WebP format |
+| Technical Elements | 7/10 | Missing Product schema; good URL and mobile |
+
+## Overall Score: 71/100
+
+Calculation: (8x0.15 + 6x0.05 + 9x0.10 + 7x0.25 + 8x0.15 + 5x0.10 + 6x0.10 + 7x0.10) x 10 = 71
+
+Score Breakdown:
+████████░░ Title Tag: 8/10 (15%)
+██████░░░░ Meta Description: 6/10 ( 5%)
+█████████░ Headers: 9/10 (10%)
+███████░░░ Content: 7/10 (25%)
+████████░░ Keywords: 8/10 (15%)
+█████░░░░░ Internal Links: 5/10 (10%)
+██████░░░░ Images: 6/10 (10%)
+███████░░░ Technical: 7/10 (10%)
+
+## Priority Issues
+
+### Critical
+1. **Internal linking severely underdeveloped** — Only 2 internal links found. Add links to individual headphone review pages (/sony-wh1000xm5-review, /bose-qc-ultra-review) and the headphones category page. Target 5-8 contextual internal links.
+2. **3 product images missing alt text** — Images for Sony WH-1000XM5, Bose QC Ultra, and Apple AirPods Max have empty alt attributes. Each missing alt tag is a lost ranking signal in Google Images.
+
+### Important
+1. **Meta description lacks call-to-action** — Current description states facts but does not compel clicks. Add "Compare prices and features" or "See our top picks" to drive CTR.
+
+## Quick Wins
+
+1. **Add alt text to 3 images** (5 min) — Use descriptive text like "Sony WH-1000XM5 noise cancelling headphones on desk" instead of empty attributes.
+2. **Rewrite meta description with CTA** (5 min) — Change to: "Compare the 10 best noise cancelling headphones for 2025. Expert-tested picks from Sony, Bose, and Apple with pros, cons, and pricing. See our top picks."
+3. **Add 4+ internal links** (10 min) — Link product names to their individual review pages and add a "See all headphones" link to the category hub.
+```
+
+---
+
+## Audit Checklists by Page Type
+
+### Blog Post Checklist
+
+```markdown
+- [ ] Title includes keyword and is compelling
+- [ ] Meta description has keyword and CTA
+- [ ] Single H1 with keyword
+- [ ] H2s cover main topics
+- [ ] Keyword in first 100 words
+- [ ] 1,500+ words for competitive topics
+- [ ] 3+ internal links with varied anchors
+- [ ] Images with descriptive alt text
+- [ ] FAQ section with schema
+- [ ] Author bio with credentials
+```
+
+### Product Page Checklist
+
+```markdown
+- [ ] Product name in title
+- [ ] Price and availability in description
+- [ ] H1 is product name
+- [ ] Product features in H2s
+- [ ] Multiple product images with alt text
+- [ ] Customer reviews visible
+- [ ] Product schema implemented
+- [ ] Related products linked
+- [ ] Clear CTA button
+```
+
+### Landing Page Checklist
+
+```markdown
+- [ ] Keyword-optimized title
+- [ ] Benefit-focused meta description
+- [ ] Clear H1 value proposition
+- [ ] Supporting H2 sections
+- [ ] Trust signals (testimonials, logos)
+- [ ] Single clear CTA
+- [ ] Fast page load speed
+- [ ] Mobile-optimized layout
+```
diff --git a/.cursor/skills/on-page-seo-auditor/references/audit-templates.md b/.cursor/skills/on-page-seo-auditor/references/audit-templates.md
new file mode 100644
index 0000000000..660944145d
--- /dev/null
+++ b/.cursor/skills/on-page-seo-auditor/references/audit-templates.md
@@ -0,0 +1,315 @@
+# On-Page SEO Auditor — Output Templates
+
+Detailed output templates for on-page-seo-auditor steps 5-11. Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/SKILL.md).
+
+---
+
+## Step 5: Audit Content Quality
+
+```markdown
+## Content Quality Analysis
+
+**Word Count**: [X] words
+**Reading Level**: [Grade level]
+**Estimated Read Time**: [X] minutes
+
+| Criterion | Status | Notes |
+|-----------|--------|-------|
+| Sufficient length | ✅/⚠️/❌ | [comparison to ranking content] |
+| Comprehensive coverage | ✅/⚠️/❌ | [notes] |
+| Unique value/insights | ✅/⚠️/❌ | [notes] |
+| Up-to-date information | ✅/⚠️/❌ | [notes] |
+| Proper formatting | ✅/⚠️/❌ | [notes] |
+| Readability | ✅/⚠️/❌ | [notes] |
+| E-E-A-T signals | ✅/⚠️/❌ | [notes] |
+
+**Content Elements Present**:
+- [ ] Introduction with keyword
+- [ ] Clear sections/structure
+- [ ] Bullet points/lists
+- [ ] Tables where appropriate
+- [ ] Images/visuals
+- [ ] Examples/case studies
+- [ ] Statistics with sources
+- [ ] Expert quotes
+- [ ] FAQ section
+- [ ] Conclusion with CTA
+
+**Content Score**: [X]/10
+
+**Gaps Identified**:
+- [Missing topic/section 1]
+- [Missing topic/section 2]
+
+**Recommendations**:
+1. [Specific improvement]
+2. [Specific improvement]
+```
+
+---
+
+## Step 6: Audit Keyword Usage
+
+```markdown
+## Keyword Optimization Analysis
+
+**Primary Keyword**: "[keyword]"
+**Keyword Density**: [X]%
+
+### Keyword Placement
+
+| Location | Present | Notes |
+|----------|---------|-------|
+| Title tag | ✅/❌ | Position: [X] |
+| Meta description | ✅/❌ | [notes] |
+| H1 | ✅/❌ | [notes] |
+| First 100 words | ✅/❌ | Word position: [X] |
+| H2 headings | ✅/❌ | In [X]/[Y] H2s |
+| Body content | ✅/❌ | [X] occurrences |
+| URL slug | ✅/❌ | [notes] |
+| Image alt text | ✅/❌ | In [X]/[Y] images |
+| Conclusion | ✅/❌ | [notes] |
+
+### Secondary Keywords
+
+| Keyword | Occurrences | Status |
+|---------|-------------|--------|
+| [keyword 1] | [X] | ✅/⚠️/❌ |
+| [keyword 2] | [X] | ✅/⚠️/❌ |
+
+### LSI/Related Terms
+
+**Present**: [list of related terms found]
+**Missing**: [important related terms not found]
+
+**Keyword Score**: [X]/10
+
+**Issues**:
+- [Issue 1]
+
+**Recommendations**:
+- [Suggestion 1]
+```
+
+---
+
+## Step 7: Audit Internal Links
+
+```markdown
+## Internal Linking Analysis
+
+**Total Internal Links**: [X]
+**Unique Internal Links**: [X]
+
+| Criterion | Status | Notes |
+|-----------|--------|-------|
+| Number of internal links | ✅/⚠️/❌ | [X] (recommend 3-5+) |
+| Relevant anchor text | ✅/⚠️/❌ | [notes] |
+| Links to related content | ✅/⚠️/❌ | [notes] |
+| Links to important pages | ✅/⚠️/❌ | [notes] |
+| No broken links | ✅/⚠️/❌ | [X] broken found |
+| Natural placement | ✅/⚠️/❌ | [notes] |
+
+**Current Internal Links**:
+1. "[Anchor text]" → [URL]
+2. "[Anchor text]" → [URL]
+3. "[Anchor text]" → [URL]
+
+**Internal Linking Score**: [X]/10
+
+**Recommended Additional Links**:
+1. Add link to "[Related page]" with anchor "[suggested anchor]"
+2. Add link to "[Related page]" with anchor "[suggested anchor]"
+
+**Anchor Text Improvements**:
+- Change "[current anchor]" to "[improved anchor]"
+```
+
+---
+
+## Step 8: Audit Images
+
+```markdown
+## Image Optimization Analysis
+
+**Total Images**: [X]
+
+### Image Audit Table
+
+| Image | Alt Text | File Name | Size | Status |
+|-------|----------|-----------|------|--------|
+| [img1] | [alt or "missing"] | [filename] | [KB] | ✅/⚠️/❌ |
+| [img2] | [alt or "missing"] | [filename] | [KB] | ✅/⚠️/❌ |
+
+| Criterion | Status | Notes |
+|-----------|--------|-------|
+| All images have alt text | ✅/⚠️/❌ | [X]/[Y] have alt |
+| Alt text includes keywords | ✅/⚠️/❌ | [notes] |
+| Descriptive file names | ✅/⚠️/❌ | [notes] |
+| Appropriate file sizes | ✅/⚠️/❌ | [notes] |
+| Modern formats (WebP) | ✅/⚠️/❌ | [notes] |
+| Lazy loading enabled | ✅/⚠️/❌ | [notes] |
+
+**Image Score**: [X]/10
+
+**Recommendations**:
+1. Add alt text to image [X]: "[suggested alt text]"
+2. Compress image [Y]: Currently [X]KB, should be under [Y]KB
+3. Rename [filename] to [better-filename]
+```
+
+---
+
+## Step 9: Audit Technical On-Page Elements
+
+```markdown
+## Technical On-Page Analysis
+
+| Element | Current Value | Status | Recommendation |
+|---------|---------------|--------|----------------|
+| URL | [URL] | ✅/⚠️/❌ | [notes] |
+| URL length | [X] chars | ✅/⚠️/❌ | [notes] |
+| URL keywords | [present/absent] | ✅/⚠️/❌ | [notes] |
+| Canonical tag | [URL or "missing"] | ✅/⚠️/❌ | [notes] |
+| Mobile-friendly | [yes/no] | ✅/⚠️/❌ | [notes] |
+| Page speed | [X]s | ✅/⚠️/❌ | [notes] |
+| HTTPS | [yes/no] | ✅/⚠️/❌ | [notes] |
+| Schema markup | [types or "none"] | ✅/⚠️/❌ | [notes] |
+
+**Technical Score**: [X]/10
+```
+
+---
+
+## Step 10: CORE-EEAT Content Quality Quick Scan
+
+Run a quick scan of on-page-relevant CORE-EEAT items. Reference: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
+
+```markdown
+## CORE-EEAT Quick Scan
+
+Content-relevant items from the 80-item benchmark:
+
+| ID | Check Item | Status | Notes |
+|----|-----------|--------|-------|
+| C01 | Intent Alignment | ✅/⚠️/❌ | Title promise = content delivery |
+| C02 | Direct Answer | ✅/⚠️/❌ | Core answer in first 150 words |
+| C09 | FAQ Coverage | ✅/⚠️/❌ | Structured FAQ present |
+| C10 | Semantic Closure | ✅/⚠️/❌ | Conclusion answers opening |
+| O01 | Heading Hierarchy | ✅/⚠️/❌ | H1→H2→H3, no skipping |
+| O02 | Summary Box | ✅/⚠️/❌ | TL;DR or Key Takeaways |
+| O03 | Data Tables | ✅/⚠️/❌ | Comparisons in tables |
+| O05 | Schema Markup | ✅/⚠️/❌ | Appropriate JSON-LD |
+| O06 | Section Chunking | ✅/⚠️/❌ | Single topic per section |
+| R01 | Data Precision | ✅/⚠️/❌ | ≥5 precise numbers |
+| R02 | Citation Density | ✅/⚠️/❌ | ≥1 per 500 words |
+| R06 | Timestamp | ✅/⚠️/❌ | Updated <1 year |
+| R08 | Internal Link Graph | ✅/⚠️/❌ | Descriptive anchors |
+| R10 | Content Consistency | ✅/⚠️/❌ | No contradictions |
+| Exp01 | First-Person Narrative | ✅/⚠️/❌ | "I tested" or "We found" |
+| Ept01 | Author Identity | ✅/⚠️/❌ | Byline + bio present |
+| T04 | Disclosure Statements | ✅/⚠️/❌ | Affiliate links disclosed |
+
+**CORE-EEAT Quick Score**: [X]/17 items passing
+
+> For a complete 80-item audit with weighted scoring, use `content-quality-auditor`.
+```
+
+---
+
+## Step 11: Generate Audit Summary
+
+```markdown
+# On-Page SEO Audit Report
+
+**Page**: [URL]
+**Target Keyword**: [keyword]
+**Audit Date**: [date]
+
+## Overall Score: [X]/100
+
+```
+Score Breakdown:
+████████░░ Title Tag: 8/10
+██████░░░░ Meta Description: 6/10
+█████████░ Headers: 9/10
+███████░░░ Content: 7/10
+██████░░░░ Keywords: 6/10
+█████░░░░░ Internal Links: 5/10
+████░░░░░░ Images: 4/10
+████████░░ Technical: 8/10
+```
+
+## Priority Issues
+
+### 🔴 Critical (Fix Immediately)
+1. [Critical issue 1]
+2. [Critical issue 2]
+
+### 🟡 Important (Fix Soon)
+1. [Important issue 1]
+2. [Important issue 2]
+
+### 🟢 Minor (Nice to Have)
+1. [Minor issue 1]
+2. [Minor issue 2]
+
+## Quick Wins
+
+These changes will have immediate impact:
+
+1. **[Change 1]**: [Why and how]
+2. **[Change 2]**: [Why and how]
+3. **[Change 3]**: [Why and how]
+
+## Detailed Recommendations
+
+### Title Tag
+- **Current**: [current title]
+- **Recommended**: [new title]
+- **Impact**: [expected improvement]
+
+### Meta Description
+- **Current**: [current description]
+- **Recommended**: [new description]
+- **Impact**: [expected improvement]
+
+### Content Improvements
+1. [Specific content change with location]
+2. [Specific content change with location]
+
+### Internal Linking
+1. Add link: "[anchor]" → [destination]
+2. Add link: "[anchor]" → [destination]
+
+### Image Optimization
+1. [Image 1]: [change needed]
+2. [Image 2]: [change needed]
+
+## Competitor Comparison
+
+| Element | Your Page | Top Competitor | Gap |
+|---------|-----------|----------------|-----|
+| Word count | [X] | [Y] | [+/-Z] |
+| Internal links | [X] | [Y] | [+/-Z] |
+| Images | [X] | [Y] | [+/-Z] |
+| H2 headings | [X] | [Y] | [+/-Z] |
+
+## Action Checklist
+
+- [ ] Update title tag
+- [ ] Rewrite meta description
+- [ ] Add keyword to H1
+- [ ] Add [X] more internal links
+- [ ] Add alt text to [X] images
+- [ ] Add [X] more content sections
+- [ ] Implement FAQ schema
+- [ ] [Additional action items]
+
+## Expected Results
+
+After implementing these changes:
+- Estimated ranking improvement: [X] positions
+- Estimated CTR improvement: [X]%
+- Estimated traffic increase: [X]%
+```
diff --git a/.cursor/skills/on-page-seo-auditor/references/scoring-rubric.md b/.cursor/skills/on-page-seo-auditor/references/scoring-rubric.md
new file mode 100644
index 0000000000..037f2a2106
--- /dev/null
+++ b/.cursor/skills/on-page-seo-auditor/references/scoring-rubric.md
@@ -0,0 +1,496 @@
+# On-Page SEO Scoring Rubric
+
+Detailed scoring criteria for each of the 8 audit sections. Use this rubric to ensure consistent, objective scoring across audits.
+
+## How to Use This Rubric
+
+1. Score each section independently using the criteria below
+2. Apply the section weight to calculate the weighted score
+3. Sum all weighted scores for the overall page score (out of 100)
+4. Use calibration examples to verify your scoring is consistent
+
+## Section 1: Title Tag (Weight: 15%, Max: 15 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Keyword presence | 3 | Primary keyword appears in title |
+| Keyword position | 2 | Primary keyword in first half of title |
+| Length optimization | 2 | Between 50-60 characters |
+| Uniqueness | 2 | Title is unique across the site |
+| Compelling copy | 2 | Includes benefit, modifier, or hook |
+| Intent match | 2 | Title matches search intent accurately |
+| Brand inclusion | 1 | Brand name present (at end) |
+| No truncation risk | 1 | Displays fully in SERP without cutoff |
+
+### Calibration Examples
+
+**Score 15/15 (Excellent)**:
+- Title: "Keyword Research: 7 Proven Methods to Find Low-Competition Keywords | Brand"
+- 58 characters, keyword at front, benefit-driven, unique, brand at end
+
+**Score 11/15 (Good)**:
+- Title: "The Complete Guide to Keyword Research for Beginners"
+- 52 characters, keyword present but not at front, no brand, still compelling
+
+**Score 7/15 (Needs Work)**:
+- Title: "Keyword Research"
+- Too short (16 chars), no benefit, no brand, not compelling, generic
+
+**Score 3/15 (Poor)**:
+- Title: "Blog Post #47 - Untitled"
+- No keyword, no benefit, not descriptive, not unique
+
+**Score 0/15 (Missing)**:
+- No title tag present, or title tag is empty
+
+## Section 2: Meta Description (Weight: 5%, Max: 5 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Keyword inclusion | 1 | Primary keyword appears naturally |
+| Length optimization | 1 | Between 150-160 characters |
+| Call-to-action | 1 | Contains explicit or implicit CTA |
+| Unique description | 1 | Not duplicated from other pages |
+| Accurate summary | 1 | Accurately describes page content |
+
+### Calibration Examples
+
+**Score 5/5 (Excellent)**:
+- Description: "Learn 7 proven keyword research methods that top SEOs use to find low-competition opportunities. Includes free templates, real examples, and step-by-step walkthroughs. Get started today."
+- 155 chars, keyword present, CTA ("Get started"), accurate, unique
+
+**Score 3/5 (Needs Work)**:
+- Description: "This page talks about keyword research and some tips you might find useful for SEO."
+- 83 chars (too short), weak copy, no CTA, vague
+
+**Score 0/5 (Missing)**:
+- No meta description set; search engine generates snippet automatically
+
+## Section 3: Header Structure (Weight: 10%, Max: 10 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Single H1 present | 2 | Exactly one H1 on the page |
+| H1 contains keyword | 2 | Primary keyword in H1 text |
+| Logical hierarchy | 2 | No skipped levels (H1→H2→H3, not H1→H3) |
+| H2s cover key subtopics | 2 | H2s address main topic facets |
+| Descriptive headers | 1 | Headers describe section content clearly |
+| Keyword variations in H2s | 1 | Secondary keywords or LSI terms in subheadings |
+
+### Calibration Examples
+
+**Score 10/10 (Excellent)**:
+```
+H1: How to Do Keyword Research: A Step-by-Step Guide
+ H2: What Is Keyword Research and Why Does It Matter?
+ H2: 7 Keyword Research Methods That Work
+ H3: Method 1: Seed Keyword Brainstorming
+ H3: Method 2: Competitor Keyword Analysis
+ H3: Method 3: Google Autocomplete Mining
+ H2: Free Keyword Research Tools Compared
+ H2: How to Prioritize Keywords by Difficulty
+ H2: Keyword Research FAQ
+```
+- Single H1 with keyword, logical hierarchy, descriptive H2s with variations
+
+**Score 6/10 (Needs Work)**:
+```
+H1: Keyword Research
+H2: Introduction
+H2: Methods
+H2: Tools
+H2: Conclusion
+```
+- Single H1 but generic, H2s are vague, no keyword variations
+
+**Score 2/10 (Poor)**:
+```
+H1: Welcome to Our Blog
+H1: Keyword Research Guide
+H3: Some Tips
+H2: More Information
+```
+- Multiple H1s, skipped levels, generic, no keyword strategy
+
+## Section 4: Content Quality (Weight: 25%, Max: 25 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Sufficient length | 4 | Meets minimum for query type (see benchmarks) |
+| Comprehensive coverage | 4 | Covers all major subtopics that top-ranking pages cover |
+| Unique value | 4 | Original insights, data, or perspective not found elsewhere |
+| Up-to-date information | 3 | Statistics, dates, and references are current |
+| Proper formatting | 3 | Uses lists, tables, bold, images to improve readability |
+| Readability | 3 | Appropriate reading level for target audience |
+| E-E-A-T signals | 4 | Author byline, credentials, first-person experience, cited sources |
+
+### Content Length Benchmarks (for "Sufficient length" criterion)
+
+| Query Type | Minimum for 4/4 | Minimum for 3/4 | Minimum for 2/4 | Below 1/4 |
+|-----------|-----------------|-----------------|-----------------|-----------|
+| Informational | 1,500+ words | 1,000-1,499 | 500-999 | <500 |
+| Commercial | 1,200+ words | 800-1,199 | 400-799 | <400 |
+| Transactional | 500+ words | 350-499 | 200-349 | <200 |
+| Local | 400+ words | 250-399 | 150-249 | <150 |
+
+### Calibration Examples
+
+**Score 25/25 (Excellent)**:
+- 2,400-word guide on keyword research
+- Covers all subtopics competitors cover plus unique methods
+- Contains original data from author's experiments
+- All statistics from the last 12 months
+- Well-formatted with tables, lists, images, code examples
+- Written at appropriate level for target audience
+- Author byline with SEO credentials, first-person "I tested" language
+
+**Score 16/25 (Good)**:
+- 1,600-word guide, covers main subtopics
+- Some unique insights but mostly curated information
+- Most statistics current, a few outdated
+- Good formatting but could use more visual elements
+- Author byline present but limited credentials
+
+**Score 8/25 (Needs Work)**:
+- 700-word article on an informational query (too thin)
+- Missing several subtopics competitors cover
+- No original data or insights
+- Several outdated statistics
+- Wall of text with minimal formatting
+- No author byline or E-E-A-T signals
+
+## Section 5: Keyword Optimization (Weight: 15%, Max: 15 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Keyword in title | 2 | Primary keyword in title tag |
+| Keyword in H1 | 2 | Primary keyword in H1 |
+| Keyword in first 100 words | 2 | Primary keyword appears early in content |
+| Keyword density (0.5-2.5%) | 2 | Natural density, not stuffed, not too sparse |
+| Secondary keywords present | 2 | 2-3 secondary/related keywords used |
+| LSI/semantic terms | 2 | Related terms and synonyms present |
+| Keyword in URL | 1 | Primary keyword in URL slug |
+| Keyword in image alt text | 1 | At least one image alt contains keyword naturally |
+| Keyword in meta description | 1 | Primary keyword in meta description |
+
+### Keyword Density Guidelines
+
+| Density | Score Impact | Assessment |
+|---------|-------------|-----------|
+| 0.5-1.0% | Full points | Natural, well-integrated |
+| 1.0-2.0% | Full points | Acceptable, slightly keyword-focused |
+| 2.0-2.5% | -1 point | Borderline, review for naturalness |
+| 2.5-3.0% | -2 points | Over-optimized, needs reduction |
+| >3.0% | 0 points for density | Keyword stuffing, immediate fix needed |
+| <0.5% | -1 point | Under-optimized, add more natural usage |
+
+### Calibration Examples
+
+**Score 15/15 (Excellent)**:
+- "keyword research" in title (position 1), H1, first sentence, URL slug, 1 image alt
+- Density: 1.2% across 2,000 words (24 occurrences, well-distributed)
+- Secondary keywords: "keyword tool", "search volume", "keyword difficulty" all present
+- LSI terms: "SEO", "search terms", "Google", "organic traffic", "SERP" present
+
+**Score 9/15 (Needs Work)**:
+- Keyword in title and H1, but not in first 100 words
+- Density: 0.3% (too sparse for a 2,000-word article)
+- Missing secondary keywords
+- No LSI/semantic terms beyond the primary keyword
+
+## Section 6: Internal/External Links (Weight: 10%, Max: 10 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Internal link count | 2 | 3-5+ contextual internal links (per 1,000 words) |
+| Internal link relevance | 2 | Links point to topically related pages |
+| Descriptive anchor text | 2 | Anchors describe destination, not "click here" |
+| External link quality | 2 | Links to authoritative, relevant external sources |
+| No broken links | 1 | All links return 200 status |
+| Link placement | 1 | Links placed naturally within content flow |
+
+### Internal Link Count Guidelines
+
+| Content Length | Minimum Links | Ideal Range | Too Many |
+|---------------|--------------|-------------|---------|
+| <500 words | 2 | 2-4 | >8 |
+| 500-1,000 words | 3 | 3-6 | >12 |
+| 1,000-2,000 words | 4 | 5-10 | >20 |
+| 2,000+ words | 5 | 8-15 | >25 |
+
+### Calibration Examples
+
+**Score 10/10 (Excellent)**:
+- 8 internal links in a 2,000-word article
+- All link to relevant related content
+- Anchor text like "learn about keyword difficulty scoring" (descriptive)
+- 3 external links to authoritative sources (Google documentation, industry studies)
+- Zero broken links
+
+**Score 5/10 (Needs Work)**:
+- 2 internal links in a 2,000-word article (too few)
+- One link uses "click here" anchor text
+- No external links to sources
+- One broken link found
+
+## Section 7: Image Optimization (Weight: 10%, Max: 10 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| All images have alt text | 3 | Every image has descriptive alt attribute |
+| Alt text includes keywords | 2 | At least 1 image alt naturally includes primary keyword |
+| Descriptive file names | 1 | Files named descriptively (keyword-research-tool.webp, not IMG_4532.jpg) |
+| Optimized file sizes | 2 | Images compressed appropriately (<200KB for photos, <50KB for graphics) |
+| Modern formats | 1 | Uses WebP or AVIF where supported |
+| Lazy loading | 1 | Below-fold images use loading="lazy" |
+
+### Image Size Guidelines
+
+| Image Type | Target Size | Format | Notes |
+|-----------|------------|--------|-------|
+| Hero/banner | <200KB | WebP | Resize to actual display dimensions |
+| Content photos | <150KB | WebP | Compress at 80% quality |
+| Screenshots | <100KB | WebP/PNG | Use PNG only if text clarity critical |
+| Icons/graphics | <30KB | SVG/WebP | SVG preferred for vector graphics |
+| Thumbnails | <50KB | WebP | Generate at actual thumbnail dimensions |
+
+### Calibration Examples
+
+**Score 10/10 (Excellent)**:
+- 5 images, all with descriptive alt text
+- Alt text: "screenshot of Ahrefs keyword research tool showing difficulty score"
+- Files named: keyword-research-tool-ahrefs.webp
+- All images <150KB, WebP format, lazy loading on below-fold images
+
+**Score 4/10 (Needs Work)**:
+- 3 images, 1 missing alt text
+- Alt text on others: "image1", "screenshot" (not descriptive)
+- Files named: IMG_3421.jpg, photo.png
+- One image is 1.2MB uncompressed JPEG
+
+## Section 8: Page-Level Technical (Weight: 10%, Max: 10 points)
+
+### Scoring Criteria
+
+| Criterion | Points | Requirement |
+|-----------|--------|-------------|
+| Clean URL structure | 2 | Short, descriptive, keyword-containing URL |
+| Correct canonical tag | 2 | Self-referencing canonical or appropriate cross-domain canonical |
+| Mobile-friendly | 2 | Passes mobile-friendly test, responsive layout |
+| Page speed (LCP) | 2 | LCP ≤2.5s on mobile |
+| HTTPS | 1 | Page served over HTTPS with valid certificate |
+| Schema markup | 1 | Appropriate schema type implemented (Article, FAQ, HowTo, etc.) |
+
+### URL Quality Guidelines
+
+| Aspect | Good | Bad |
+|--------|------|-----|
+| Length | /blog/keyword-research-guide | /blog/2024/01/15/the-complete-ultimate-guide-to-keyword-research-for-beginners-and-experts |
+| Keywords | /services/seo-audit | /services/page-id-4532 |
+| Characters | lowercase, hyphens | UPPERCASE, underscores, special chars |
+| Parameters | /products/shoes (clean) | /products?id=432&color=red&size=9 |
+
+### Calibration Examples
+
+**Score 10/10 (Excellent)**:
+- URL: /blog/keyword-research-guide (clean, keyword-present)
+- Self-referencing canonical tag present
+- Fully responsive, passes mobile-friendly test
+- LCP: 1.8s on mobile
+- HTTPS with valid certificate
+- Article + FAQ schema implemented
+
+**Score 5/10 (Needs Work)**:
+- URL: /blog/?p=4532 (not descriptive)
+- No canonical tag
+- Mobile-friendly but some tap targets too small
+- LCP: 3.2s (needs improvement)
+- HTTPS present
+- No schema markup
+
+## Overall Score Calculation
+
+### Formula
+
+```
+Overall Score = Sum of (Section Score / Section Max * Section Weight * 100)
+```
+
+### Example Calculation
+
+| Section | Raw Score | Max | Weight | Weighted Score |
+|---------|-----------|-----|--------|---------------|
+| Title Tag | 12 | 15 | 15% | 12.0 |
+| Meta Description | 4 | 5 | 5% | 4.0 |
+| Header Structure | 8 | 10 | 10% | 8.0 |
+| Content Quality | 20 | 25 | 25% | 20.0 |
+| Keyword Optimization | 11 | 15 | 15% | 11.0 |
+| Internal/External Links | 7 | 10 | 10% | 7.0 |
+| Image Optimization | 6 | 10 | 10% | 6.0 |
+| Page-Level Technical | 8 | 10 | 10% | 8.0 |
+| **Total** | | | **100%** | **76.0/100** |
+
+### Overall Score Interpretation
+
+| Score Range | Grade | Assessment |
+|------------|-------|-----------|
+| 90-100 | A+ | Exceptional — minor tweaks only |
+| 80-89 | A | Strong — a few optimization opportunities |
+| 70-79 | B | Good — several areas need attention |
+| 60-69 | C | Average — significant improvements needed |
+| 50-59 | D | Below average — major issues present |
+| <50 | F | Poor — comprehensive overhaul required |
+
+## Calibration Guidance
+
+### Avoiding Common Scoring Errors
+
+1. **Halo effect**: A great title does not mean great content. Score each section independently.
+2. **Recency bias**: Do not inflate scores for recently published content. Freshness is one factor among many.
+3. **Tool dependency**: If you cannot verify a metric (e.g., page speed without tool access), note it as "unverified" rather than guessing.
+4. **Competitor anchoring**: Score against the rubric criteria, not relative to competitors. Competitor comparison is a separate analysis step.
+5. **Intent mismatch**: A page with perfect technical SEO but wrong intent alignment should still score low on Content Quality.
+
+### When to Adjust Weights
+
+The default weights suit most content pages. Consider adjusting for:
+
+| Page Type | Increase Weight | Decrease Weight | Reason |
+|-----------|----------------|----------------|--------|
+| E-commerce product | Image Optimization, Technical | Content Quality | Products need visuals and speed more than long-form |
+| Long-form guide | Content Quality, Keywords | Image Optimization | Content depth matters most |
+| Landing page | Technical, Title | Content Quality | Speed and CTR drive conversions |
+| Local service page | Technical, Links | Keywords | NAP consistency and local signals matter more |
+
+Always document weight adjustments and the reasoning in the audit report.
+
+---
+
+## Scoring Rubric
+
+### Section Weight Distribution
+
+| Audit Section | Weight | Max Score | Rationale |
+|--------------|--------|-----------|-----------|
+| Title Tag | 15% | 15 | Strongest single ranking signal |
+| Meta Description | 5% | 5 | CTR impact, not direct ranking factor |
+| Header Structure | 10% | 10 | Content organization, semantic signals |
+| Content Quality | 25% | 25 | Strongest holistic ranking factor |
+| Keyword Optimization | 15% | 15 | Relevance signals |
+| Internal/External Links | 10% | 10 | Authority flow, context signals |
+| Image Optimization | 10% | 10 | Accessibility + image search opportunity |
+| Page-Level Technical | 10% | 10 | Core Web Vitals, mobile, security |
+
+### Scoring Scale per Factor
+
+| Score | Meaning | Action Required |
+|-------|---------|-----------------|
+| 10/10 | Excellent — follows all best practices | None |
+| 7-9/10 | Good — minor improvements possible | Optional optimization |
+| 4-6/10 | Needs work — notable issues | Fix within this week |
+| 1-3/10 | Poor — significant problems | Fix immediately (Critical) |
+| 0/10 | Missing or broken | Fix immediately (Blocking) |
+
+### Scoring Conversion Formula
+
+Each section is scored out of 10, then converted to the 100-point overall score using section weights:
+
+```
+Overall Score = Sum of (section_score x section_weight) x 10
+```
+
+Where section weights are: Title 0.15, Meta 0.05, Headers 0.10, Content 0.25, Keywords 0.15, Links 0.10, Images 0.10, Technical 0.10.
+
+**Worked example:**
+
+| Section | Score /10 | Weight | Weighted |
+|---------|-----------|--------|----------|
+| Title Tag | 8 | 0.15 | 1.20 |
+| Meta Description | 6 | 0.05 | 0.30 |
+| Header Structure | 9 | 0.10 | 0.90 |
+| Content Quality | 7 | 0.25 | 1.75 |
+| Keyword Optimization | 8 | 0.15 | 1.20 |
+| Internal/External Links | 5 | 0.10 | 0.50 |
+| Image Optimization | 6 | 0.10 | 0.60 |
+| Page-Level Technical | 7 | 0.10 | 0.70 |
+| **Total** | | **1.00** | **7.15** |
+
+**Overall Score** = 7.15 x 10 = **71 / 100**
+
+## Common Issue Resolution Playbook
+
+### Title Tag Issues
+
+| Issue | Impact | Quick Fix Template |
+|-------|--------|-------------------|
+| Missing title | Critical | Add: "[Primary Keyword]: [Benefit] | [Brand]" |
+| Too long (>60 chars) | Medium | Shorten: move brand to end, remove filler words |
+| Too short (<30 chars) | Medium | Expand: add modifier, benefit, or year |
+| Missing keyword | High | Rewrite to include primary keyword in first half |
+| Duplicate title | High | Make each page title unique; add page-specific modifier |
+
+### Meta Description Issues
+
+| Issue | Impact | Quick Fix Template |
+|-------|--------|-------------------|
+| Missing description | Medium | Write: "[What this page covers]. [Key benefit]. [CTA]." (150-160 chars) |
+| Too long (>160 chars) | Low | Trim from end; ensure core message fits in 150 chars |
+| Missing keyword | Low | Naturally incorporate primary keyword |
+| No CTA | Low | Add: "Learn more", "Discover", "Find out", "Get started" |
+| Duplicated across pages | Medium | Write unique description for each page |
+
+### Header Issues
+
+| Issue | Impact | Quick Fix |
+|-------|--------|-----------|
+| Missing H1 | Critical | Add one H1 per page containing primary keyword |
+| Multiple H1s | High | Keep one H1, convert others to H2 |
+| Skipped heading levels | Medium | Use sequential hierarchy: H1→H2→H3 |
+| Headers not descriptive | Medium | Rewrite to include keyword variations |
+| No H2s (single long block) | Medium | Break content into sections with descriptive H2s every 200-300 words |
+
+### Content Issues
+
+| Issue | Impact | Quick Fix |
+|-------|--------|-----------|
+| Thin content (<300 words) | Critical | Expand with subtopics, FAQ, examples |
+| Keyword stuffing (>3%) | High | Reduce usage, use synonyms and related terms |
+| No structured data | Medium | Add relevant schema (FAQ, HowTo, Article) |
+| Missing internal links | Medium | Add 3-5 contextual internal links |
+| No images | Low | Add 2-3 relevant images with alt text |
+
+## Industry Benchmark Data
+
+### Content Length Benchmarks by Query Type
+
+| Query Type | Top 10 Average Word Count | Recommended Minimum |
+|-----------|--------------------------|-------------------|
+| Informational (guides) | 2,200 words | 1,500 words |
+| Commercial (reviews) | 1,800 words | 1,200 words |
+| Transactional (product) | 800 words | 500 words |
+| Local (service pages) | 600 words | 400 words |
+| Definition queries | 1,200 words | 800 words |
+
+### Page Speed Benchmarks
+
+| Metric | Good | Needs Improvement | Poor |
+|--------|------|-------------------|------|
+| LCP | ≤2.5s | 2.5-4.0s | >4.0s |
+| FID/INP | ≤100ms/200ms | 100-300ms | >300ms |
+| CLS | ≤0.1 | 0.1-0.25 | >0.25 |
+| TTFB | ≤800ms | 800-1800ms | >1800ms |
+
diff --git a/.cursor/skills/performance-reporter/SKILL.md b/.cursor/skills/performance-reporter/SKILL.md
new file mode 100644
index 0000000000..27ae07d35e
--- /dev/null
+++ b/.cursor/skills/performance-reporter/SKILL.md
@@ -0,0 +1,263 @@
+---
+name: performance-reporter
+description: 'Generate SEO/GEO dashboards: rankings, traffic, backlinks, AI visibility for stakeholders. SEO报告/绩效仪表盘'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when generating SEO performance reports, traffic summaries, ranking reports, or stakeholder-facing dashboards."
+argument-hint: " [date range]"
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - geo
+ - seo-reporting
+ - performance-report
+ - kpi-dashboard
+ - traffic-report
+ - monthly-report
+ - stakeholder-report
+ - SEO报告
+ - SEOレポート
+ - SEO리포트
+ - informe-seo
+ triggers:
+ # EN-formal
+ - "generate SEO report"
+ - "performance report"
+ - "traffic report"
+ - "SEO dashboard"
+ - "SEO analytics"
+ - "monthly report"
+ # EN-casual
+ - "report to stakeholders"
+ - "monthly SEO report"
+ - "show me my SEO results"
+ - "present SEO results to my boss"
+ - "report to my boss"
+ - "monthly SEO summary"
+ # EN-question
+ - "how are my SEO metrics"
+ - "how is my SEO performing"
+ # ZH-pro
+ - "SEO报告"
+ - "绩效仪表盘"
+ - "流量报告"
+ - "数据看板"
+ # ZH-casual
+ - "出SEO报告"
+ - "汇报给老板"
+ - "看看数据"
+ - "月报"
+ - "出月报"
+ - "周报"
+ # JA
+ - "SEOレポート"
+ - "パフォーマンスレポート"
+ # KO
+ - "SEO 리포트"
+ - "성과 보고서"
+ # ES
+ - "informe SEO"
+ - "reporte de rendimiento"
+ # PT
+ - "relatório SEO"
+ # Misspellings
+ - "SEO repoort"
+---
+
+# Performance Reporter
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill creates comprehensive SEO and GEO performance reports that combine multiple metrics into actionable insights. It produces executive summaries, detailed analyses, and visual data presentations for stakeholder communication.
+
+**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
+
+- Monthly/quarterly SEO reporting
+- Executive stakeholder updates
+- Client reporting for agencies
+- Tracking campaign performance
+- Combining multiple SEO metrics
+- Creating GEO visibility reports
+- Documenting ROI from SEO efforts
+
+## What This Skill Does
+
+1. **Data Aggregation**: Combines multiple SEO data sources
+2. **Trend Analysis**: Identifies patterns across metrics
+3. **Executive Summaries**: Creates high-level overviews
+4. **Visual Reports**: Presents data in clear formats
+5. **Benchmark Comparison**: Tracks against goals and competitors
+6. **Content Quality Tracking**: Integrates CORE-EEAT scores across audited pages
+7. **ROI Calculation**: Measures SEO investment returns
+8. **Recommendations**: Suggests actions based on data
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Generate Performance Report
+
+```
+Create an SEO performance report for [domain] for [time period]
+```
+
+### Executive Summary
+
+```
+Generate an executive summary of SEO performance for [month/quarter]
+```
+
+### Specific Report Types
+
+```
+Create a GEO visibility report for [domain]
+```
+
+```
+Generate a content performance report
+```
+
+## Skill Contract
+
+**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
+
+- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
+- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when a change needs action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~analytics + ~~search console + ~~SEO tool + ~~AI monitor connected:**
+Automatically aggregate traffic metrics from ~~analytics, search performance data from ~~search console, ranking and backlink data from ~~SEO tool, and GEO visibility metrics from ~~AI monitor. Creates comprehensive multi-source reports with historical trends.
+
+**With manual data only:**
+Ask the user to provide:
+1. Analytics screenshots or traffic data export (sessions, users, conversions)
+2. Search Console data (impressions, clicks, average position)
+3. Keyword ranking data for the reporting period
+4. Backlink metrics (referring domains, new/lost links)
+5. Key performance indicators and goals for comparison
+6. AI citation data if tracking GEO metrics
+
+Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests a performance report:
+
+1. **Define Report Parameters** -- Domain, report period, comparison period, report type (Monthly/Quarterly/Annual), audience (Executive/Technical/Client), focus areas.
+
+2. **Create Executive Summary** -- Overall performance rating, key wins/watch areas/action required, metrics at a glance table (traffic, rankings, conversions, DA, AI citations), SEO ROI calculation.
+
+3. **Report Organic Traffic Performance** -- Traffic overview (sessions, users, pageviews, bounce rate), traffic trend visualization, traffic by source/device, top performing pages.
+
+4. **Report Keyword Rankings** -- Rankings overview by position range, distribution change visualization, top improvements and declines, SERP feature performance.
+
+5. **Report GEO/AI Performance** -- AI citation overview, citations by topic, GEO wins, optimization opportunities.
+
+6. **Report Domain Authority (CITE Score)** -- If a CITE audit has been run, include CITE dimension scores (C/I/T/E) with period-over-period trends and veto status. If no audit exists, note as "Not yet evaluated."
+
+7. **Content Quality (CORE-EEAT Score)** -- If content-quality-auditor has been run, include average scores across all 8 CORE-EEAT dimensions with trends. If no audit exists, note as "Not yet evaluated."
+
+8. **Report Backlink Performance** -- Link profile summary, weekly link acquisition, notable new links, competitive position.
+
+9. **Report Content Performance** -- Publishing summary, top performing content, content needing attention, content ROI.
+
+10. **Generate Recommendations** -- Immediate/short-term/long-term actions with priority, expected impact, and owner. Goals for next period.
+
+11. **Compile Full Report** -- Combine all sections with table of contents, appendix (data sources, methodology, glossary).
+
+ > **Reference**: See [references/report-output-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/report-output-templates.md) for complete output templates for all 11 report sections.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Reporting period clearly defined with comparison period
+- [ ] All required data sources available or alternatives noted
+- [ ] Target audience identified (executive/technical/client)
+- [ ] Performance goals and KPIs established for benchmarking
+
+### Output Validation
+- [ ] Every metric cites its data source and collection date
+- [ ] Trends include period-over-period comparisons
+- [ ] Recommendations are specific, prioritized, and actionable
+- [ ] Source of each data point clearly stated (~~analytics data, ~~search console data, ~~SEO tool data, user-provided, or estimated)
+
+## Example
+
+**User**: "Create a monthly SEO report for cloudhosting.com for January 2025"
+
+**Output** (abbreviated -- full report uses templates from all 11 steps):
+
+```markdown
+# CloudHosting SEO & GEO Performance Report — January 2025
+
+## Executive Summary — Overall Performance: Good
+
+| Metric | Jan 2025 | Dec 2024 | Change | Target | Status |
+|--------|----------|----------|--------|--------|--------|
+| Organic Traffic | 52,100 | 45,200 | +15.3% | 50,000 | On track |
+| Keywords Top 10 | 87 | 79 | +8 | 90 | Watch |
+| Organic Conversions | 684 | 612 | +11.8% | 700 | Watch |
+| Domain Rating | 54 | 53 | +1 | 55 | Watch |
+| AI Citations | 18 | 12 | +50.0% | 20 | Watch |
+
+**SEO ROI**: $8,200 invested / $41,040 organic revenue = 400%
+
+**Immediate**: Fix 37 crawl errors on /pricing/ pages
+**This Month**: Optimize mobile LCP; publish 3 AI Overview comparison pages
+**This Quarter**: Build Wikidata entry for CloudHost Inc.
+```
+
+## Tips for Success
+
+1. **Lead with insights** - Start with what matters, not raw data
+2. **Visualize data** - Charts and graphs improve comprehension
+3. **Compare periods** - Context makes data meaningful
+4. **Include actions** - Every report should drive decisions
+5. **Customize for audience** - Executives need different info than technical teams
+6. **Track GEO metrics** - AI visibility is increasingly important
+
+
+### Save Results
+
+After delivering monitoring data or reports to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-.md` containing:
+- One-line headline finding or status change
+- Top 3-5 actionable items
+- Open loops or anomalies requiring follow-up
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Report Output Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/report-output-templates.md) — Complete output templates for all 11 report sections
+- [KPI Definitions](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/kpi-definitions.md) — SEO/GEO metric definitions with benchmarks, good ranges, warning thresholds, trend analysis, and attribution guidance
+- [Report Templates by Audience](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/report-templates.md) — Copy-ready templates for executive, marketing, technical, and client audiences
+
+## Next Best Skill
+
+- **Primary**: [alert-manager](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/SKILL.md) — turn reporting insights into ongoing monitoring rules.
diff --git a/.cursor/skills/performance-reporter/references/kpi-definitions.md b/.cursor/skills/performance-reporter/references/kpi-definitions.md
new file mode 100644
index 0000000000..74632b0361
--- /dev/null
+++ b/.cursor/skills/performance-reporter/references/kpi-definitions.md
@@ -0,0 +1,524 @@
+# SEO/GEO KPI Definitions
+
+Complete glossary of SEO and GEO key performance indicators with calculation formulas, data sources, benchmark ranges by industry, and interpretation guidance.
+
+---
+
+## 1. Organic Search KPIs
+
+### Organic Sessions
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Number of visits to your site originating from organic (unpaid) search engine results |
+| **Formula** | Count of sessions where medium = "organic" |
+| **Data Source** | ~~analytics (Google Analytics, Adobe Analytics, or equivalent) |
+| **Good Range** | Growing month-over-month; 3-10% MoM growth is healthy |
+| **Warning** | Decline >10% MoM without known seasonal cause |
+| **Segmentation** | Always separate brand vs. non-brand organic sessions |
+
+**Interpretation:**
+- Growing organic sessions with stable conversion rate = SEO strategy is working.
+- Growing sessions but declining conversions = traffic quality issue; check keyword targeting.
+- Flat sessions despite new content = content not ranking or cannibalizing existing pages.
+
+---
+
+### Organic Click-Through Rate (CTR)
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of search impressions that result in a click to your site |
+| **Formula** | (Organic Clicks / Organic Impressions) x 100 |
+| **Data Source** | ~~search console |
+| **Good Range** | >3% overall; varies significantly by position and query type |
+| **Warning** | <1.5% overall or declining trend |
+| **Segmentation** | By query type (brand vs. non-brand), by page, by position range |
+
+**Benchmarks by Position:**
+
+| Position | Expected CTR Range |
+|----------|-------------------|
+| #1 | 25-35% |
+| #2 | 12-18% |
+| #3 | 8-13% |
+| #4-5 | 4-8% |
+| #6-10 | 2-5% |
+| #11-20 | 0.5-2% |
+
+**Interpretation:**
+- High impressions but low CTR = title tags and meta descriptions need optimization.
+- CTR declining for stable positions = SERP features (AI Overview, PAA) stealing clicks.
+- CTR higher than position benchmarks = strong brand recognition or compelling snippets.
+
+---
+
+### Average Position
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Mean ranking position across all tracked keywords or queries |
+| **Formula** | Sum of all positions / count of keywords |
+| **Data Source** | ~~search console (query-level), ~~SEO tool (keyword-level) |
+| **Good Range** | <20 for tracked keywords; improving trend |
+| **Warning** | >30 or rising (worsening) trend |
+| **Segmentation** | By keyword group, by page, by intent type |
+
+**Interpretation:**
+- Average position is a directional indicator, not an absolute measure. A few very low-ranking keywords can drag the average down significantly.
+- Always pair with keyword distribution (how many keywords in top 10, top 20, etc.) for a complete picture.
+
+---
+
+### Keyword Visibility Score
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Weighted score combining keyword positions and search volumes into a single index |
+| **Formula** | Sum of (estimated CTR at position x monthly search volume) for each keyword |
+| **Data Source** | ~~SEO tool |
+| **Good Range** | Growing over time; absolute value depends on niche |
+| **Warning** | Declining trend for 3+ consecutive weeks |
+| **Segmentation** | By topic cluster, by competitor |
+
+**Interpretation:**
+- Visibility score accounts for both ranking position and keyword importance (volume).
+- A single high-volume keyword moving from #1 to #5 can cause a larger visibility drop than 20 low-volume keywords dropping off page 1.
+
+---
+
+### Pages Indexed
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Number of your pages included in Google's search index |
+| **Formula** | Count of valid indexed pages in Index Coverage report |
+| **Data Source** | ~~search console (Index Coverage / Pages report) |
+| **Good Range** | Indexed count close to total intended indexable pages; growing with new content |
+| **Warning** | Indexed count dropping without intentional removal; large gap between submitted and indexed |
+| **Segmentation** | By sitemap, by content type, by subdirectory |
+
+**Interpretation:**
+- Indexed < submitted = quality or technical issues preventing indexing.
+- Sudden drop in indexed pages = possible noindex tag, robots.txt change, or manual action.
+- Indexed > intended = duplicate content, parameter URLs, or faceted navigation issues.
+
+---
+
+### Organic Conversion Rate
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of organic sessions that complete a defined conversion goal |
+| **Formula** | (Organic Conversions / Organic Sessions) x 100 |
+| **Data Source** | ~~analytics |
+| **Good Range** | >2% for lead generation; >1% for e-commerce (varies by industry) |
+| **Warning** | <0.5% or declining while traffic grows |
+| **Segmentation** | By landing page, by keyword intent, by device |
+
+**Industry Benchmarks:**
+
+| Industry | Typical Organic CVR |
+|----------|-------------------|
+| SaaS / Software | 2-5% |
+| E-commerce | 1-3% |
+| Finance | 3-6% |
+| Healthcare | 2-4% |
+| B2B Services | 2-5% |
+| Media / Publishing | 0.5-2% (ad-supported) |
+| Education | 2-5% |
+
+---
+
+### Non-Brand Organic Traffic Share
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of organic traffic coming from non-branded search queries |
+| **Formula** | (Organic sessions - brand query sessions) / Organic sessions x 100 |
+| **Data Source** | ~~search console + ~~analytics |
+| **Good Range** | >50% of total organic; growing |
+| **Warning** | <30% (over-reliance on brand awareness, not SEO) |
+| **Segmentation** | Trend over time |
+
+**Interpretation:**
+- High non-brand share = SEO is driving new audience discovery.
+- Low non-brand share = organic traffic is mostly people who already know your brand; SEO is underperforming for acquisition.
+
+---
+
+## 2. GEO / AI Visibility KPIs
+
+### AI Citation Rate
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of monitored queries where your content is cited in AI-generated answers |
+| **Formula** | (Queries where you are cited / Total monitored queries with AI answers) x 100 |
+| **Data Source** | ~~AI monitor |
+| **Good Range** | >20% of monitored queries |
+| **Warning** | <5% or declining trend |
+| **Segmentation** | By topic cluster, by content type |
+
+---
+
+### AI Citation Position
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Your average position among cited sources in AI-generated responses |
+| **Formula** | Sum of citation positions / count of citations |
+| **Data Source** | ~~AI monitor |
+| **Good Range** | Top 3 sources on average |
+| **Warning** | Not cited or consistently cited in position 5+ |
+| **Segmentation** | By query, by topic |
+
+---
+
+### AI Answer Coverage
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of your target topics that appear in AI-generated answers |
+| **Formula** | (Topics with AI answers / Total target topics) x 100 |
+| **Data Source** | ~~AI monitor |
+| **Good Range** | Growing over time as AI answers expand |
+| **Warning** | Declining coverage may indicate content quality issues |
+| **Segmentation** | By topic cluster |
+
+---
+
+### Brand Mention in AI Responses
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Number of times your brand is mentioned in AI-generated responses across monitored queries |
+| **Formula** | Count of AI responses containing your brand name |
+| **Data Source** | ~~AI monitor |
+| **Good Range** | Growing; present in responses for your key topics |
+| **Warning** | Zero mentions for topics where you are an authority |
+| **Segmentation** | By query category |
+
+---
+
+## 3. Domain Authority KPIs
+
+### Domain Rating / Domain Authority
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Proprietary metric estimating the overall strength of a domain's backlink profile (0-100 scale) |
+| **Formula** | Varies by tool (logarithmic scale based on backlink quantity and quality) |
+| **Data Source** | ~~SEO tool (Ahrefs DR, Moz DA, or equivalent) |
+| **Good Range** | Growing; competitive with top-ranking sites in your niche |
+| **Warning** | Declining or significantly below competitors |
+| **Segmentation** | Compare against competitors |
+
+**Benchmarks by Site Stage:**
+
+| Site Stage | Typical DR/DA |
+|-----------|--------------|
+| Brand new (0-6 months) | 0-15 |
+| Early growth (6-18 months) | 15-30 |
+| Established (18-36 months) | 25-50 |
+| Mature (3+ years) | 40-70+ |
+| Industry leader | 70-90+ |
+
+---
+
+### Referring Domains
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Count of unique domains that link to your site |
+| **Formula** | Count of distinct root domains with at least one dofollow or nofollow link |
+| **Data Source** | ~~link database |
+| **Good Range** | Growing MoM; higher than primary competitors |
+| **Warning** | Net loss of referring domains for 2+ consecutive months |
+| **Segmentation** | By authority tier (DR 0-20, 20-40, 40-60, 60+) |
+
+---
+
+### Backlink Growth Rate
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Net new backlinks acquired per month |
+| **Formula** | New backlinks gained - backlinks lost in the period |
+| **Data Source** | ~~link database |
+| **Good Range** | Positive and steady; proportional to content output |
+| **Warning** | Negative for 2+ months; sudden spikes (may indicate spam) |
+| **Segmentation** | By link quality tier |
+
+---
+
+### Toxic Link Ratio
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of your backlinks classified as toxic or spammy |
+| **Formula** | (Toxic backlinks / Total backlinks) x 100 |
+| **Data Source** | ~~link database (toxic score/spam score) |
+| **Good Range** | <5% |
+| **Warning** | 5-10% (monitor and clean up) |
+| **Critical** | >10% (immediate disavow action needed) |
+| **Segmentation** | By toxic type (PBN, spam, irrelevant) |
+
+---
+
+## 4. Technical SEO KPIs
+
+### Core Web Vitals
+
+| Metric | Definition | Good | Needs Improvement | Poor |
+|--------|-----------|------|-------------------|------|
+| **LCP** (Largest Contentful Paint) | Time to render largest content element | <=2.5s | 2.5-4.0s | >4.0s |
+| **CLS** (Cumulative Layout Shift) | Visual stability during page load | <=0.1 | 0.1-0.25 | >0.25 |
+| **INP** (Interaction to Next Paint) | Responsiveness to user interactions | <=200ms | 200-500ms | >500ms |
+
+**Data Source:** ~~search console (Core Web Vitals report), Chrome UX Report, PageSpeed Insights
+
+---
+
+### Crawl Budget Utilization
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | How efficiently search engine crawlers are spending their crawl budget on your site |
+| **Formula** | (Useful pages crawled / Total pages crawled) x 100 |
+| **Data Source** | ~~search console (Crawl Stats), server logs |
+| **Good Range** | >80% of crawled pages are indexable, valuable pages |
+| **Warning** | High crawl of non-indexable or low-value pages |
+| **Segmentation** | By content type, by HTTP status code |
+
+---
+
+### Index Coverage Rate
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of submitted pages that are successfully indexed |
+| **Formula** | (Indexed pages / Submitted pages) x 100 |
+| **Data Source** | ~~search console |
+| **Good Range** | >90% for sites with curated sitemaps |
+| **Warning** | <80% or declining |
+| **Segmentation** | By sitemap, by exclusion reason |
+
+---
+
+## 5. Content Performance KPIs
+
+### Content Efficiency Score
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Ratio of content investment to organic traffic generated |
+| **Formula** | Organic sessions per content piece / cost per content piece |
+| **Data Source** | ~~analytics + internal cost tracking |
+| **Good Range** | Improving over time; varies by content type |
+| **Warning** | Declining efficiency despite continued investment |
+| **Segmentation** | By content type, by topic, by author |
+
+---
+
+### Content Decay Rate
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of existing content losing organic traffic over a defined period |
+| **Formula** | (Pages with >20% traffic decline over 6 months / Total pages with traffic) x 100 |
+| **Data Source** | ~~analytics |
+| **Good Range** | <20% of pages decaying per 6-month period |
+| **Warning** | >30% of pages decaying |
+| **Segmentation** | By content age, by topic, by content type |
+
+---
+
+### Organic Revenue Per Session
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Average revenue generated per organic search session |
+| **Formula** | Total organic revenue / Total organic sessions |
+| **Data Source** | ~~analytics (e-commerce tracking or goal values) |
+| **Good Range** | Stable or growing; varies hugely by industry |
+| **Warning** | Declining while traffic grows (traffic quality deteriorating) |
+| **Segmentation** | By landing page, by keyword intent, by device |
+
+---
+
+## 6. Competitive KPIs
+
+### Share of Voice (SOV)
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Your visibility as a percentage of total visibility across tracked keywords |
+| **Formula** | (Your visibility score / Sum of all tracked competitors' visibility scores) x 100 |
+| **Data Source** | ~~SEO tool |
+| **Good Range** | Growing; leading in your core topic areas |
+| **Warning** | Declining for 3+ consecutive months |
+| **Segmentation** | By topic cluster, by competitor |
+
+---
+
+### Competitive Keyword Overlap
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Percentage of your tracked keywords where a specific competitor also ranks in the top 20 |
+| **Formula** | (Keywords where both rank in top 20 / Your total tracked keywords) x 100 |
+| **Data Source** | ~~SEO tool |
+| **Good Range** | Context-dependent; high overlap for direct competitors is expected |
+| **Warning** | New competitor appearing with high overlap indicates emerging threat |
+| **Segmentation** | By competitor, by keyword group |
+
+---
+
+## 7. ROI and Business Impact KPIs
+
+### SEO ROI
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Return on investment from SEO activities |
+| **Formula** | ((Organic Revenue - SEO Investment) / SEO Investment) x 100 |
+| **Data Source** | ~~analytics + internal cost tracking |
+| **Good Range** | >200% annually (SEO compounds over time) |
+| **Warning** | <100% after 12+ months of investment |
+| **Segmentation** | By content type, by campaign |
+
+**Note:** SEO ROI should be measured over 12+ month horizons. Short-term ROI calculations are misleading because SEO benefits compound over time.
+
+---
+
+### Organic Traffic Value
+
+| Attribute | Detail |
+|-----------|--------|
+| **Definition** | Estimated cost to acquire equivalent traffic through paid search |
+| **Formula** | Sum of (monthly organic clicks per keyword x CPC for that keyword) |
+| **Data Source** | ~~SEO tool (traffic value calculation) |
+| **Good Range** | Growing; significantly higher than SEO investment |
+| **Warning** | Declining traffic value despite stable traffic (keywords losing CPC value) |
+| **Segmentation** | By keyword group, by page |
+
+**Interpretation:**
+- Organic traffic value represents how much you would need to spend on PPC to get the same traffic.
+- Useful for communicating SEO value to stakeholders who understand paid media budgets.
+- A site with $50K/month organic traffic value that spends $10K/month on SEO is getting a 5:1 return.
+
+---
+
+## SEO/GEO Metric Definitions and Benchmarks
+
+### Organic Search Metrics
+
+| Metric | Definition | Good Range | Warning | Source |
+|--------|-----------|-----------|---------|--------|
+| Organic sessions | Visits from organic search | Growing MoM | >10% decline | ~~analytics |
+| Keyword visibility | % of target keywords in top 100 | >60% | <40% | ~~SEO tool |
+| Average position | Mean position across tracked keywords | <20 | >30 | ~~search console |
+| Organic CTR | Clicks / impressions from search | >3% | <1.5% | ~~search console |
+| Pages indexed | Pages in Google index | Growing | Dropping | ~~search console |
+| Organic conversion rate | Conversions / organic sessions | >2% | <0.5% | ~~analytics |
+| Non-brand organic traffic | Organic traffic minus brand searches | >50% of total organic | <30% | ~~analytics |
+
+### GEO/AI Visibility Metrics
+
+| Metric | Definition | Good Range | Warning | Source |
+|--------|-----------|-----------|---------|--------|
+| AI citation rate | % of monitored queries citing your content | >20% | <5% | ~~AI monitor |
+| AI citation position | Average position in AI response citations | Top 3 sources | Not cited | ~~AI monitor |
+| AI answer coverage | % of your topics appearing in AI answers | Growing | Declining | ~~AI monitor |
+| Brand mention in AI | Times your brand is mentioned in AI responses | Growing | Zero | ~~AI monitor |
+
+### Domain Authority Metrics
+
+| Metric | Definition | Good Range | Warning | Source |
+|--------|-----------|-----------|---------|--------|
+| Domain Rating/Authority | Overall domain strength | Growing | Declining | ~~SEO tool |
+| Referring domains | Unique domains linking to you | Growing MoM | Loss >10% MoM | ~~link database |
+| Backlink growth rate | Net new backlinks per month | Positive | Negative trend | ~~link database |
+| Toxic link ratio | Toxic links / total links | <5% | >10% | ~~link database |
+
+## Reporting Templates by Audience
+
+### Executive Report (C-Suite / Leadership)
+
+**Focus:** Business outcomes, ROI, competitive position
+**Length:** 1 page + appendix
+**Frequency:** Monthly or Quarterly
+
+| Section | Content |
+|---------|---------|
+| Traffic & Revenue | Organic traffic trend + attributed revenue |
+| Competitive Position | Visibility share vs. top 3 competitors |
+| AI Visibility | AI citation trend and coverage |
+| Key Wins | Top 3 achievements with business impact |
+| Risks | Top 3 concerns with proposed mitigation |
+| Investment Ask | Resources needed for next period |
+
+### Marketing Team Report
+
+**Focus:** Channel performance, content effectiveness, technical health
+**Length:** 2-3 pages
+**Frequency:** Monthly
+
+| Section | Content |
+|---------|---------|
+| Keyword Performance | Rankings gained/lost, new keywords discovered |
+| Content Performance | Top pages by traffic, engagement, conversions |
+| Technical Health | Crawl errors, speed scores, indexation |
+| Backlink Profile | New links, lost links, quality assessment |
+| GEO Performance | AI citation changes, new citations |
+| Action Items | P0-P3 prioritized task list |
+
+### Technical SEO Report
+
+**Focus:** Crawlability, indexation, speed, errors
+**Length:** Detailed
+**Frequency:** Weekly or Bi-weekly
+
+| Section | Content |
+|---------|---------|
+| Crawl Stats | Pages crawled, errors, crawl budget usage |
+| Index Coverage | Indexed/excluded/errored pages |
+| Core Web Vitals | LCP, CLS, INP trends |
+| Error Log | New 4xx/5xx errors with resolution status |
+| Schema Validation | New warnings, rich result eligibility |
+| Technical Debt | Outstanding issues by priority |
+
+## Trend Analysis Framework
+
+### Period-Over-Period Analysis
+
+| Comparison | Best For | Limitation |
+|-----------|---------|-----------|
+| Week over week (WoW) | Detecting sudden changes | Noisy, affected by day-of-week patterns |
+| Month over month (MoM) | Identifying trends | Seasonal bias |
+| Year over year (YoY) | Accounting for seasonality | Does not reflect recent trajectory |
+| Rolling 30-day average | Smoothing noise | Lags behind real changes |
+
+### Trend Interpretation Guidelines
+
+| Pattern | Likely Cause | Recommended Action |
+|---------|-------------|-------------------|
+| Steady growth | Strategy is working | Continue, optimize high performers |
+| Sudden spike then drop | Viral content or algorithm volatility | Investigate cause, build on if repeatable |
+| Gradual decline | Content decay, competition, technical debt | Comprehensive audit needed |
+| Flat line | Plateau — existing strategy maxed out | New content areas, new link strategies |
+| Seasonal pattern | Industry/demand cycles | Plan content calendar around peaks |
+
+## SEO Attribution Guidance
+
+### Attribution Challenges in SEO
+
+| Challenge | Impact | Mitigation |
+|----------|--------|-----------|
+| Long conversion paths | SEO rarely gets last-touch credit | Use assisted conversions report |
+| Brand vs. non-brand | Brand searches inflate organic metrics | Always separate brand/non-brand |
+| Cross-device journeys | Mobile search to desktop conversion | Enable cross-device tracking |
+| SEO + paid overlap | Cannibalization or lift? | Test turning off paid for branded terms |
+| Content assists sales | Hard to attribute | Track content touches in CRM |
+
diff --git a/.cursor/skills/performance-reporter/references/report-output-templates.md b/.cursor/skills/performance-reporter/references/report-output-templates.md
new file mode 100644
index 0000000000..fa0f0fdfaf
--- /dev/null
+++ b/.cursor/skills/performance-reporter/references/report-output-templates.md
@@ -0,0 +1,417 @@
+# Performance Report Output Templates
+
+Detailed output templates for each step of the performance reporting workflow. Use these templates when generating reports for stakeholders.
+
+---
+
+## 1. Report Configuration Template
+
+```markdown
+## Report Configuration
+
+**Domain**: [domain]
+**Report Period**: [start date] to [end date]
+**Comparison Period**: [previous period for comparison]
+**Report Type**: [Monthly/Quarterly/Annual/Custom]
+**Audience**: [Executive/Technical/Client]
+**Focus Areas**: [Rankings/Traffic/Content/Backlinks/GEO]
+```
+
+---
+
+## 2. Executive Summary Template
+
+```markdown
+# SEO Performance Report
+
+**Domain**: [domain]
+**Period**: [date range]
+**Prepared**: [date]
+
+---
+
+## Executive Summary
+
+### Overall Performance: [Excellent/Good/Needs Attention/Critical]
+
+**Key Highlights**:
+
+Wins:
+- [Win 1 - e.g., "Organic traffic increased 25%"]
+- [Win 2 - e.g., "3 new #1 rankings achieved"]
+- [Win 3 - e.g., "Conversion rate improved 15%"]
+
+Watch Areas:
+- [Area 1 - e.g., "Mobile rankings declining slightly"]
+- [Area 2 - e.g., "Competitor gaining ground on key terms"]
+
+Action Required:
+- [Issue 1 - e.g., "Technical SEO audit needed"]
+
+### Key Metrics at a Glance
+
+| Metric | This Period | Last Period | Change | Target | Status |
+|--------|-------------|-------------|--------|--------|--------|
+| Organic Traffic | [X] | [Y] | [+/-Z%] | [T] | [status] |
+| Keyword Rankings (Top 10) | [X] | [Y] | [+/-Z] | [T] | [status] |
+| Organic Conversions | [X] | [Y] | [+/-Z%] | [T] | [status] |
+| Domain Authority | [X] | [Y] | [+/-Z] | [T] | [status] |
+| AI Citations | [X] | [Y] | [+/-Z%] | [T] | [status] |
+
+### SEO ROI
+
+**Investment**: $[X] (content, tools, effort)
+**Organic Revenue**: $[Y]
+**ROI**: [Z]%
+```
+
+---
+
+## 3. Organic Traffic Analysis Template
+
+```markdown
+## Organic Traffic Analysis
+
+### Traffic Overview
+
+| Metric | This Period | vs Last Period | vs Last Year |
+|--------|-------------|----------------|--------------|
+| Sessions | [X] | [+/-Y%] | [+/-Z%] |
+| Users | [X] | [+/-Y%] | [+/-Z%] |
+| Pageviews | [X] | [+/-Y%] | [+/-Z%] |
+| Avg. Session Duration | [X] | [+/-Y%] | [+/-Z%] |
+| Bounce Rate | [X]% | [+/-Y%] | [+/-Z%] |
+| Pages per Session | [X] | [+/-Y] | [+/-Z] |
+
+### Traffic Trend
+
+```
+[Month 1] ████████████████████ [X]
+[Month 2] █████████████████████ [Y]
+[Month 3] ███████████████████████ [Z]
+[Current] ████████████████████████ [W]
+```
+
+### Traffic by Source
+
+| Channel | Sessions | % of Total | Change |
+|---------|----------|------------|--------|
+| Organic Search | [X] | [Y]% | [+/-Z%] |
+| Direct | [X] | [Y]% | [+/-Z%] |
+| Referral | [X] | [Y]% | [+/-Z%] |
+| Social | [X] | [Y]% | [+/-Z%] |
+
+### Top Performing Pages
+
+| Page | Sessions | Change | Conversions |
+|------|----------|--------|-------------|
+| [Page 1] | [X] | [+/-Y%] | [Z] |
+| [Page 2] | [X] | [+/-Y%] | [Z] |
+| [Page 3] | [X] | [+/-Y%] | [Z] |
+
+### Traffic by Device
+
+| Device | Sessions | Change | Conv. Rate |
+|--------|----------|--------|------------|
+| Desktop | [X] ([Y]%) | [+/-Z%] | [%] |
+| Mobile | [X] ([Y]%) | [+/-Z%] | [%] |
+| Tablet | [X] ([Y]%) | [+/-Z%] | [%] |
+```
+
+---
+
+## 4. Keyword Ranking Performance Template
+
+```markdown
+## Keyword Ranking Performance
+
+### Rankings Overview
+
+| Position Range | Keywords | Change | Traffic Impact |
+|----------------|----------|--------|----------------|
+| Position 1 | [X] | [+/-Y] | [Z] sessions |
+| Position 2-3 | [X] | [+/-Y] | [Z] sessions |
+| Position 4-10 | [X] | [+/-Y] | [Z] sessions |
+| Position 11-20 | [X] | [+/-Y] | [Z] sessions |
+| Position 21-50 | [X] | [+/-Y] | [Z] sessions |
+
+### Top Ranking Improvements
+
+| Keyword | Previous | Current | Change | Traffic |
+|---------|----------|---------|--------|---------|
+| [kw 1] | [X] | [Y] | +[Z] | [sessions] |
+
+### Rankings That Declined
+
+| Keyword | Previous | Current | Change | Impact | Action |
+|---------|----------|---------|--------|--------|--------|
+| [kw 1] | [X] | [Y] | -[Z] | -[sessions] | [action] |
+
+### SERP Feature Performance
+
+| Feature | Won | Lost | Opportunities |
+|---------|-----|------|---------------|
+| Featured Snippets | [X] | [Y] | [Z] |
+| People Also Ask | [X] | [Y] | [Z] |
+| Local Pack | [X] | [Y] | [Z] |
+```
+
+---
+
+## 5. GEO/AI Visibility Template
+
+```markdown
+## GEO (AI Visibility) Performance
+
+### AI Citation Overview
+
+| Metric | This Period | Last Period | Change |
+|--------|-------------|-------------|--------|
+| Keywords with AI Overview | [X]/[Y] | [X]/[Y] | [+/-Z] |
+| Your AI Citations | [X] | [Y] | [+/-Z%] |
+| Citation Rate | [X]% | [Y]% | [+/-Z%] |
+| Avg Citation Position | [X] | [Y] | [+/-Z] |
+
+### AI Citation by Topic
+
+| Topic Cluster | Opportunities | Citations | Rate |
+|---------------|---------------|-----------|------|
+| [Topic 1] | [X] | [Y] | [Z]% |
+
+### GEO Wins This Period
+
+| Query | Citation Status | Source Page | Impact |
+|-------|-----------------|-------------|--------|
+| [query 1] | New citation | [page] | High visibility |
+
+### GEO Optimization Opportunities
+
+| Query | AI Overview | You Cited? | Gap | Action |
+|-------|-------------|------------|-----|--------|
+| [query] | Yes | No | [gap] | [action] |
+```
+
+---
+
+## 6. Domain Authority (CITE Score) Template
+
+```markdown
+## Domain Authority (CITE Score)
+
+### CITE Score Summary
+
+| Metric | This Period | Last Period | Change |
+|--------|-------------|-------------|--------|
+| CITE Score | [X]/100 | [Y]/100 | [+/-Z] |
+| C -- Citation | [X]/100 | [Y]/100 | [+/-Z] |
+| I -- Identity | [X]/100 | [Y]/100 | [+/-Z] |
+| T -- Trust | [X]/100 | [Y]/100 | [+/-Z] |
+| E -- Eminence | [X]/100 | [Y]/100 | [+/-Z] |
+
+**Veto Status**: No triggers / [item] triggered
+
+### Key Changes
+
+- [Notable improvement or concern 1]
+- [Notable improvement or concern 2]
+
+_For full 40-item evaluation, run `/seo:audit-domain`_
+```
+
+**Note**: If no previous CITE audit exists, note this section as "Not yet evaluated -- run domain-authority-auditor for baseline" and skip.
+
+---
+
+## 7. Content Quality (CORE-EEAT Score) Template
+
+```markdown
+## Content Quality (CORE-EEAT Score)
+
+### Content Quality Summary
+
+| Metric | Value |
+|--------|-------|
+| Pages Audited | [count] |
+| Average CORE-EEAT Score | [score]/100 ([rating]) |
+| Average GEO Score (CORE) | [score]/100 |
+| Average SEO Score (EEAT) | [score]/100 |
+| Veto Items Triggered | [count] ([item IDs]) |
+
+### Dimension Averages Across Audited Pages
+
+| Dimension | Average Score | Trend |
+|-----------|--------------|-------|
+| C -- Contextual Clarity | [score] | [up/down/stable] |
+| O -- Organization | [score] | [up/down/stable] |
+| R -- Referenceability | [score] | [up/down/stable] |
+| E -- Exclusivity | [score] | [up/down/stable] |
+| Exp -- Experience | [score] | [up/down/stable] |
+| Ept -- Expertise | [score] | [up/down/stable] |
+| A -- Authority | [score] | [up/down/stable] |
+| T -- Trust | [score] | [up/down/stable] |
+
+### Key Content Quality Changes
+
+- [Notable score changes since last report]
+- [Pages with significant quality improvements/declines]
+
+_For full 80-item evaluation, run `/seo:audit-page` on individual pages._
+```
+
+**Note**: If no content quality audit exists, note this section as "Content quality not yet evaluated -- run `/seo:audit-page` on key landing pages to establish baseline" and skip.
+
+---
+
+## 8. Backlink Performance Template
+
+```markdown
+## Backlink Performance
+
+### Link Profile Summary
+
+| Metric | This Period | Last Period | Change |
+|--------|-------------|-------------|--------|
+| Total Backlinks | [X] | [Y] | [+/-Z] |
+| Referring Domains | [X] | [Y] | [+/-Z] |
+| Domain Authority | [X] | [Y] | [+/-Z] |
+| Avg. Link DA | [X] | [Y] | [+/-Z] |
+
+### Link Acquisition
+
+| Period | New Links | Lost Links | Net |
+|--------|-----------|------------|-----|
+| Week 1 | [X] | [Y] | [+/-Z] |
+| Week 2 | [X] | [Y] | [+/-Z] |
+| Week 3 | [X] | [Y] | [+/-Z] |
+| Week 4 | [X] | [Y] | [+/-Z] |
+| **Total** | **[X]** | **[Y]** | **[+/-Z]** |
+
+### Notable New Links
+
+| Source | DA | Type | Value |
+|--------|-----|------|-------|
+| [domain 1] | [DA] | [type] | High |
+
+### Competitive Position
+
+Your referring domains rank #[X] of [Y] competitors.
+```
+
+---
+
+## 9. Content Performance Template
+
+```markdown
+## Content Performance
+
+### Content Publishing Summary
+
+| Metric | This Period | Last Period | Target |
+|--------|-------------|-------------|--------|
+| New articles published | [X] | [Y] | [Z] |
+| Content updates | [X] | [Y] | [Z] |
+| Total word count | [X] | [Y] | - |
+
+### Top Performing Content
+
+| Content | Traffic | Rankings | Conversions | Status |
+|---------|---------|----------|-------------|--------|
+| [Title 1] | [X] | [Y] keywords | [Z] | Top performer |
+| [Title 2] | [X] | [Y] keywords | [Z] | Growing |
+
+### Content Needing Attention
+
+| Content | Issue | Traffic Change | Action |
+|---------|-------|----------------|--------|
+| [Title] | [issue] | -[X]% | [action] |
+
+### Content ROI
+
+| Content Piece | Investment | Traffic Value | ROI |
+|---------------|------------|---------------|-----|
+| [Title 1] | $[X] | $[Y] | [Z]% |
+```
+
+---
+
+## 10. Recommendations Template
+
+```markdown
+## Recommendations & Next Steps
+
+### Immediate Actions (This Week)
+
+| Priority | Action | Expected Impact | Owner |
+|----------|--------|-----------------|-------|
+| High | [Action 1] | [Impact] | [Owner] |
+
+### Short-term (This Month)
+
+| Priority | Action | Expected Impact | Owner |
+|----------|--------|-----------------|-------|
+| Medium | [Action 1] | [Impact] | [Owner] |
+
+### Long-term (This Quarter)
+
+| Priority | Action | Expected Impact | Owner |
+|----------|--------|-----------------|-------|
+| Planned | [Action 1] | [Impact] | [Owner] |
+
+### Goals for Next Period
+
+| Metric | Current | Target | Action to Achieve |
+|--------|---------|--------|-------------------|
+| Organic Traffic | [X] | [Y] | [action] |
+| Keywords Top 10 | [X] | [Y] | [action] |
+| AI Citations | [X] | [Y] | [action] |
+| Referring Domains | [X] | [Y] | [action] |
+```
+
+---
+
+## 11. Full Report Compilation Template
+
+```markdown
+# [Company] SEO & GEO Performance Report
+
+## [Month/Quarter] [Year]
+
+---
+
+### Table of Contents
+
+1. Executive Summary
+2. Organic Traffic Performance
+3. Keyword Rankings
+4. GEO/AI Visibility
+5. Domain Authority (CITE Score)
+6. Content Quality (CORE-EEAT Score)
+7. Backlink Analysis
+8. Content Performance
+9. Technical Health
+10. Competitive Landscape
+11. Recommendations
+12. Appendix
+
+---
+
+[Include all sections from above]
+
+---
+
+## Appendix
+
+### Data Sources
+- ~~analytics (traffic and conversion data)
+- ~~search console (search performance)
+- ~~SEO tool (rankings and backlinks)
+- ~~AI monitor (GEO metrics)
+
+### Methodology
+[Explain how metrics were calculated]
+
+### Glossary
+- **GEO**: Generative Engine Optimization
+- **DA**: Domain Authority
+- [Additional terms]
+```
diff --git a/.cursor/skills/performance-reporter/references/report-templates.md b/.cursor/skills/performance-reporter/references/report-templates.md
new file mode 100644
index 0000000000..2312194dbe
--- /dev/null
+++ b/.cursor/skills/performance-reporter/references/report-templates.md
@@ -0,0 +1,383 @@
+# SEO/GEO Report Templates
+
+Copy-ready report templates for executive, marketing, and technical audiences. Each template includes example data to illustrate proper formatting and data presentation.
+
+---
+
+## 1. Executive Report Template
+
+Use this template for C-suite, VP-level, or board reporting. Focuses on business impact, competitive position, and strategic recommendations. Keep to 1 page plus optional appendix.
+
+---
+
+```markdown
+# SEO & GEO Performance Summary
+
+**Period:** January 2025
+**Prepared for:** Leadership Team
+**Prepared by:** [Name], SEO Lead
+
+---
+
+## Performance at a Glance
+
+| KPI | Jan 2025 | Dec 2024 | MoM Change | YoY Change | Target | Status |
+|-----|----------|----------|------------|------------|--------|--------|
+| Organic Revenue | $142,000 | $128,000 | +10.9% | +34% | $130,000 | On track |
+| Organic Sessions | 285,000 | 261,000 | +9.2% | +28% | 270,000 | On track |
+| Keywords in Top 10 | 187 | 172 | +15 | +62 | 180 | On track |
+| AI Citations | 34 | 28 | +21.4% | N/A | 30 | On track |
+| Domain Rating | 52 | 51 | +1 | +8 | 55 | In progress |
+| Organic CVR | 2.8% | 2.6% | +0.2pp | +0.5pp | 2.5% | On track |
+
+## Competitive Position
+
+**Share of Voice Ranking:** #2 of 5 tracked competitors (up from #3 in Q3)
+
+| Rank | Competitor | Visibility Share |
+|------|-----------|-----------------|
+| 1 | CompetitorA.com | 28% |
+| **2** | **YourSite.com** | **24%** |
+| 3 | CompetitorB.com | 21% |
+| 4 | CompetitorC.com | 16% |
+| 5 | CompetitorD.com | 11% |
+
+## Key Wins
+
+1. **Achieved #1 ranking for "project management software"** — estimated $18K/month traffic value
+2. **AI citation rate increased to 34%** — up from 22% in Q3, now cited in AI responses for core product queries
+3. **Published research report generated 45 backlinks** — from sites including TechCrunch (DR 94) and Forbes (DR 95)
+
+## Risks & Mitigation
+
+1. **Google core update expected in February** — monitoring closely; content quality scores above benchmark
+2. **CompetitorA increased content production 2x** — accelerating our editorial calendar in response
+3. **Mobile CWV scores degraded** — engineering team addressing LCP issue, fix scheduled for Feb 15
+
+## Investment & ROI
+
+| Item | Jan Spend | Organic Revenue | Cumulative ROI (12mo) |
+|------|----------|----------------|----------------------|
+| SEO team | $12,000 | $142,000 | 418% |
+| Content production | $8,000 | (included above) | (included above) |
+| Tools & data | $2,500 | (included above) | (included above) |
+| **Total** | **$22,500** | **$142,000** | **418%** |
+
+## Ask for Next Period
+
+- Approve $5,000 budget increase for digital PR campaign targeting Q2 product launch
+- Engineering allocation: 20 hours for Core Web Vitals remediation
+```
+
+---
+
+## 2. Marketing Team Report Template
+
+Use this template for marketing managers, content teams, and channel leads. Provides detailed performance data with actionable insights.
+
+---
+
+```markdown
+# Monthly SEO & GEO Performance Report
+
+**Domain:** yoursite.com
+**Period:** January 1-31, 2025
+**Comparison:** vs. December 2024 and January 2024
+**Prepared:** February 3, 2025
+
+---
+
+## 1. Traffic Performance
+
+### Overview
+
+| Metric | Jan 2025 | Dec 2024 | MoM | Jan 2024 | YoY |
+|--------|----------|----------|-----|----------|-----|
+| Organic Sessions | 285,000 | 261,000 | +9.2% | 223,000 | +27.8% |
+| Organic Users | 198,000 | 182,000 | +8.8% | 158,000 | +25.3% |
+| Organic Pageviews | 412,000 | 378,000 | +9.0% | 318,000 | +29.6% |
+| Bounce Rate | 42% | 44% | -2pp | 48% | -6pp |
+| Avg Session Duration | 3:12 | 3:05 | +3.8% | 2:48 | +14.3% |
+| Pages/Session | 2.4 | 2.3 | +4.3% | 2.1 | +14.3% |
+
+### Traffic by Device
+
+| Device | Sessions | % of Total | MoM Change | CVR |
+|--------|----------|------------|------------|-----|
+| Desktop | 142,500 (50%) | 50% | +7.1% | 3.2% |
+| Mobile | 131,100 (46%) | 46% | +11.5% | 2.1% |
+| Tablet | 11,400 (4%) | 4% | +5.6% | 2.8% |
+
+### Non-Brand vs. Brand Split
+
+| Segment | Sessions | % of Organic | MoM Change |
+|---------|----------|-------------|------------|
+| Non-brand | 184,000 | 64.6% | +12.1% |
+| Brand | 101,000 | 35.4% | +4.1% |
+
+**Insight:** Non-brand growth outpacing brand growth indicates SEO is driving new audience discovery effectively.
+
+## 2. Keyword Rankings
+
+### Position Distribution
+
+| Range | Jan 2025 | Dec 2024 | Change |
+|-------|----------|----------|--------|
+| Position #1 | 23 | 19 | +4 |
+| Position #2-3 | 41 | 38 | +3 |
+| Position #4-10 | 123 | 115 | +8 |
+| Position #11-20 | 89 | 94 | -5 |
+| Position #21-50 | 156 | 162 | -6 |
+| Not ranking | 68 | 72 | -4 |
+| **Total tracked** | **500** | **500** | — |
+
+### Top 5 Ranking Improvements
+
+| Keyword | Volume | Old Rank | New Rank | Change | Est. Traffic |
+|---------|--------|----------|----------|--------|-------------|
+| project management software | 18,100 | 4 | 1 | +3 | +4,200/mo |
+| best CRM for small business | 8,200 | 12 | 6 | +6 | +380/mo |
+| team collaboration tools | 6,500 | 8 | 3 | +5 | +420/mo |
+| agile methodology guide | 4,400 | 22 | 9 | +13 | +180/mo |
+| remote work software | 5,100 | 15 | 7 | +8 | +210/mo |
+
+### Top 5 Ranking Declines
+
+| Keyword | Volume | Old Rank | New Rank | Change | Action |
+|---------|--------|----------|----------|--------|--------|
+| kanban board software | 3,600 | 5 | 11 | -6 | Content refresh scheduled Feb 10 |
+| project planning template | 2,900 | 7 | 14 | -7 | Competitor published superior template |
+| time tracking tools | 4,100 | 9 | 15 | -6 | New SERP features displacing organic |
+| workflow automation | 3,200 | 6 | 9 | -3 | Monitoring, within normal fluctuation |
+| sprint planning guide | 1,800 | 3 | 8 | -5 | Content update needed with 2025 examples |
+
+## 3. Content Performance
+
+### Top Performing Content (by Organic Traffic)
+
+| Page | Sessions | MoM | Rankings | Conversions |
+|------|----------|-----|----------|-------------|
+| /blog/project-management-guide | 18,400 | +22% | 12 KWs top 10 | 184 |
+| /product/features | 15,200 | +8% | 8 KWs top 10 | 456 |
+| /blog/crm-comparison | 12,100 | +15% | 9 KWs top 10 | 121 |
+| /resources/templates | 9,800 | +31% | 6 KWs top 10 | 98 |
+| /blog/remote-work-tips | 8,600 | +5% | 5 KWs top 10 | 43 |
+
+### Content Needing Refresh (Decaying Traffic)
+
+| Page | Sessions | MoM | Age | Action |
+|------|----------|-----|-----|--------|
+| /blog/2023-trends | 2,100 | -35% | 14mo | Rewrite as 2025 trends |
+| /blog/old-tool-comparison | 1,400 | -28% | 18mo | Update with current pricing/features |
+| /guides/beginner-pm | 3,200 | -18% | 10mo | Add interactive elements, update screenshots |
+
+## 4. GEO / AI Visibility
+
+| Metric | Jan 2025 | Dec 2024 | Change |
+|--------|----------|----------|--------|
+| Queries with AI Overview | 78/500 | 72/500 | +6 |
+| Your citations in AI | 34 | 28 | +6 |
+| Citation rate | 43.6% | 38.9% | +4.7pp |
+| Avg citation position | 2.1 | 2.4 | +0.3 (better) |
+
+### New AI Citations Won
+
+- "best project management software" — cited as #1 source
+- "how to create a project plan" — cited as #2 source
+- "agile vs waterfall" — cited as #3 source
+
+## 5. Backlink Performance
+
+| Metric | Jan 2025 | Dec 2024 | Change |
+|--------|----------|----------|--------|
+| Total referring domains | 1,847 | 1,812 | +35 |
+| New links acquired | 62 | 48 | +14 |
+| Links lost | 27 | 31 | -4 (fewer lost) |
+| Average new link DR | 41 | 38 | +3 |
+
+### Notable New Links
+
+| Source | DR | Type |
+|--------|-----|------|
+| TechCrunch | 94 | Press mention (research report) |
+| Forbes | 95 | Expert quote in roundup |
+| HubSpot Blog | 93 | Resource citation |
+
+## 6. Action Items
+
+| Priority | Action | Owner | Deadline |
+|----------|--------|-------|----------|
+| P0 | Fix mobile LCP issue (currently 3.8s) | Engineering | Feb 15 |
+| P1 | Refresh kanban board software article | Content | Feb 10 |
+| P1 | Publish 2025 trends article (replace 2023 version) | Content | Feb 14 |
+| P2 | Build links to CRM comparison page (target: 5 new) | SEO | Feb 28 |
+| P2 | Optimize 3 pages for AI citation (add structured data) | SEO | Feb 21 |
+| P3 | Research new keyword opportunities in "AI tools" space | SEO | Feb 28 |
+```
+
+---
+
+## 3. Technical SEO Report Template
+
+Use this template for engineering teams, dev leads, and technical stakeholders. Focuses on crawlability, indexation, performance, and error resolution.
+
+---
+
+```markdown
+# Technical SEO Health Report
+
+**Domain:** yoursite.com
+**Period:** January 2025
+**Prepared:** February 3, 2025
+
+---
+
+## 1. Crawl Health
+
+### Crawl Stats Summary
+
+| Metric | Jan 2025 | Dec 2024 | Change | Status |
+|--------|----------|----------|--------|--------|
+| Total pages crawled | 12,400 | 11,800 | +5.1% | Normal |
+| Avg crawl rate (pages/day) | 400 | 381 | +5.0% | Normal |
+| Avg response time | 320ms | 290ms | +10.3% | Monitor |
+| Crawl errors | 18 | 12 | +6 | Investigate |
+
+### Crawl Budget Efficiency
+
+| Page Type | Pages Crawled | % of Crawl | Should Crawl? |
+|-----------|-------------|-----------|---------------|
+| Blog posts | 4,200 | 33.9% | Yes |
+| Product pages | 3,100 | 25.0% | Yes |
+| Category pages | 2,400 | 19.4% | Yes |
+| Parameter URLs | 1,800 | 14.5% | No — block in robots.txt |
+| Paginated archives | 900 | 7.3% | Partially — consolidate |
+
+**Action:** Block parameter URLs via robots.txt to reclaim 14.5% of crawl budget.
+
+## 2. Index Coverage
+
+### Index Status
+
+| Status | Count | Change | Action |
+|--------|-------|--------|--------|
+| Valid (indexed) | 2,847 | +23 | None — healthy growth |
+| Valid with warnings | 42 | +3 | Review warnings |
+| Excluded (intentional) | 1,204 | +8 | Verify intentional |
+| Excluded (error) | 67 | +12 | Fix errors |
+
+### Exclusion Reasons (Top 5)
+
+| Reason | Count | Change | Priority |
+|--------|-------|--------|----------|
+| Crawled, not indexed | 34 | +8 | P1 — improve content quality |
+| Duplicate without canonical | 15 | +2 | P2 — add canonical tags |
+| Blocked by robots.txt | 12 | 0 | Verify intentional |
+| Soft 404 | 6 | +2 | P1 — fix or redirect |
+| Server error (5xx) | 0 | 0 | Clean |
+
+## 3. Core Web Vitals
+
+### Field Data (CrUX — 28-day rolling)
+
+| Metric | Mobile | Desktop | Status |
+|--------|--------|---------|--------|
+| LCP | 3.8s (Poor) | 2.1s (Good) | Mobile needs fix |
+| CLS | 0.08 (Good) | 0.05 (Good) | Passing |
+| INP | 180ms (Good) | 120ms (Good) | Passing |
+
+### LCP Diagnosis (Mobile)
+
+| Page Group | Mobile LCP | Issue | Fix |
+|-----------|-----------|-------|-----|
+| /blog/* | 4.2s | Hero images not optimized | Convert to WebP, add lazy loading |
+| /product/* | 3.5s | Third-party script blocking render | Defer non-critical scripts |
+| /resources/* | 3.1s | Large CSS bundle | Code-split CSS |
+
+**Estimated fix timeline:** 2 weeks (engineering sprint Feb 10-21)
+
+## 4. Error Log
+
+### New Errors This Period
+
+| Error Type | Count | Pages Affected | Priority |
+|-----------|-------|---------------|----------|
+| 404 Not Found | 8 | /blog/deleted-post-1, /old/page-2, +6 | P1 |
+| 500 Server Error | 3 | /api/webhook (intermittent) | P0 |
+| Redirect chain (3+ hops) | 5 | /old-url -> /new-url -> /final-url | P2 |
+| Mixed content warning | 2 | /resources/old-guide, /tools/calc | P2 |
+
+### Error Resolution Status
+
+| Error | Opened | Status | Owner |
+|-------|--------|--------|-------|
+| 404 on /blog/deleted-post-1 | Jan 5 | Redirect created Jan 8 | SEO |
+| 500 on /api/webhook | Jan 12 | Investigating | Backend |
+| Redirect chain on /old-url | Jan 15 | Pending | SEO |
+
+## 5. Schema & Rich Results
+
+### Schema Validation
+
+| Schema Type | Pages | Valid | Warnings | Errors |
+|------------|-------|-------|----------|--------|
+| Article | 342 | 338 | 4 | 0 |
+| Product | 89 | 87 | 0 | 2 |
+| FAQ | 56 | 56 | 0 | 0 |
+| HowTo | 23 | 23 | 0 | 0 |
+| BreadcrumbList | 2,847 | 2,847 | 0 | 0 |
+
+**Action:** Fix 2 Product schema errors (missing "price" property on clearance items).
+
+## 6. Technical Debt Tracker
+
+| Item | Priority | Effort | Impact | Status |
+|------|----------|--------|--------|--------|
+| Block parameter URLs in robots.txt | P1 | Low | Crawl budget +14% | Scheduled Feb 5 |
+| Fix mobile LCP (image optimization) | P0 | Medium | CWV pass rate | Sprint Feb 10-21 |
+| Consolidate duplicate pages (42 pages) | P2 | Medium | Index quality | Backlog |
+| Implement hreflang for EN-GB pages | P3 | High | International SEO | Q2 planned |
+| Migrate HTTP resources to HTTPS | P2 | Low | Security + trust | Scheduled Feb 12 |
+
+## 7. Recommendations for Engineering
+
+1. **Immediate:** Resolve intermittent 500 errors on /api/webhook endpoint
+2. **This sprint:** Optimize hero images on blog templates (convert to WebP, implement responsive sizes)
+3. **Next sprint:** Defer third-party scripts on product pages (move to async loading)
+4. **Backlog:** Implement edge caching for static blog content to improve TTFB
+```
+
+---
+
+## 4. Report Assembly Guidelines
+
+### How to Combine Templates
+
+| Audience | Include Sections | Exclude |
+|----------|-----------------|---------|
+| CEO / Board | Executive template only | All technical detail |
+| VP Marketing | Executive + Marketing sections 1-4, 6 | Crawl stats, schema details |
+| Marketing Manager | Full Marketing template | Deep technical debugging |
+| SEO Manager | Marketing + Technical templates | Executive ROI framing |
+| Engineering Lead | Technical template only | Revenue, keyword details |
+| Client (agency) | Executive + Marketing sections 1-3, 6 | Internal cost data, technical debt |
+
+### Data Freshness Requirements
+
+| Report Type | Maximum Data Age | Reason |
+|------------|-----------------|--------|
+| Executive monthly | 3 days after period end | Allow for data processing lag |
+| Marketing weekly | 1 day after period end | Timely action items |
+| Technical | Real-time where possible | Issues need immediate attention |
+| Quarterly review | 5 days after quarter end | Allow for data reconciliation |
+
+### Report Delivery Checklist
+
+- [ ] All data sources verified and dated
+- [ ] Period-over-period comparisons included
+- [ ] Competitor context provided where relevant
+- [ ] Action items are specific, assigned, and time-bound
+- [ ] Report tailored to audience (no jargon for executives, full detail for technical)
+- [ ] Visualizations are clear and labeled
+- [ ] Sources cited for all external benchmarks
+- [ ] Draft reviewed for accuracy before distribution
diff --git a/.cursor/skills/playwright-testing/SKILL.md b/.cursor/skills/playwright-testing/SKILL.md
new file mode 100644
index 0000000000..6512f12dc6
--- /dev/null
+++ b/.cursor/skills/playwright-testing/SKILL.md
@@ -0,0 +1,295 @@
+---
+name: playwright-testing
+description: Use when writing Playwright tests, fixing flaky tests, debugging failures, implementing Page Object Model, configuring CI/CD, optimizing performance, mocking APIs, handling authentication or OAuth, testing accessibility (axe-core), file uploads/downloads, date/time mocking, WebSockets, geolocation, permissions, multi-tab/popup flows, mobile/responsive layouts, touch gestures, GraphQL, error handling, offline mode, multi-user collaboration, third-party services (payments, email verification), console error monitoring, global setup/teardown, test annotations (skip, fixme, slow), test tags (@smoke, @fast, @critical, filtering with --grep), project dependencies, security testing (XSS, CSRF, auth), performance budgets (Web Vitals, Lighthouse), iframes, component testing, canvas/WebGL, service workers/PWA, test coverage, i18n/localization, Electron apps, or browser extension testing. Covers E2E, component, API, visual, accessibility, security, Electron, and extension testing.
+---
+
+# Playwright Testing
+
+This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites.
+
+## Activity-Based Reference Guide
+
+Consult these references based on what you're doing:
+
+### Writing New Tests
+
+**When to use**: Creating new test files, writing test cases, implementing test scenarios
+
+| Activity | Reference Files |
+| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Writing E2E tests** | [test-suite-structure.md](core/test-suite-structure.md), [locators.md](core/locators.md), [assertions-waiting.md](core/assertions-waiting.md) |
+| **Writing component tests** | [component-testing.md](testing-patterns/component-testing.md), [test-suite-structure.md](core/test-suite-structure.md) |
+| **Writing API tests** | [api-testing.md](testing-patterns/api-testing.md), [test-suite-structure.md](core/test-suite-structure.md) |
+| **Writing GraphQL tests** | [graphql-testing.md](testing-patterns/graphql-testing.md), [api-testing.md](testing-patterns/api-testing.md) |
+| **Writing visual regression tests** | [visual-regression.md](testing-patterns/visual-regression.md), [canvas-webgl.md](testing-patterns/canvas-webgl.md) |
+| **Structuring test code with POM** | [page-object-model.md](core/page-object-model.md), [test-suite-structure.md](core/test-suite-structure.md) |
+| **Setting up test data/fixtures** | [fixtures-hooks.md](core/fixtures-hooks.md), [test-data.md](core/test-data.md) |
+| **Handling authentication** | [authentication.md](advanced/authentication.md), [authentication-flows.md](advanced/authentication-flows.md) |
+| **Testing date/time features** | [clock-mocking.md](advanced/clock-mocking.md) |
+| **Testing file upload/download** | [file-operations.md](testing-patterns/file-operations.md), [file-upload-download.md](testing-patterns/file-upload-download.md) |
+| **Testing forms/validation** | [forms-validation.md](testing-patterns/forms-validation.md) |
+| **Testing drag and drop** | [drag-drop.md](testing-patterns/drag-drop.md) |
+| **Testing accessibility** | [accessibility.md](testing-patterns/accessibility.md) |
+| **Testing security (XSS, CSRF)** | [security-testing.md](testing-patterns/security-testing.md) |
+| **Using test annotations** | [annotations.md](core/annotations.md) |
+| **Using test tags** | [test-tags.md](core/test-tags.md) |
+| **Testing iframes** | [iframes.md](browser-apis/iframes.md) |
+| **Testing canvas/WebGL** | [canvas-webgl.md](testing-patterns/canvas-webgl.md) |
+| **Internationalization (i18n)** | [i18n.md](testing-patterns/i18n.md) |
+| **Testing Electron apps** | [electron.md](testing-patterns/electron.md) |
+| **Testing browser extensions** | [browser-extensions.md](testing-patterns/browser-extensions.md) |
+
+### Mobile & Responsive Testing
+
+**When to use**: Testing mobile devices, touch interactions, responsive layouts
+
+| Activity | Reference Files |
+| ------------------------------- | -------------------------------------------------------------------------------- |
+| **Device emulation** | [mobile-testing.md](advanced/mobile-testing.md) |
+| **Touch gestures (swipe, tap)** | [mobile-testing.md](advanced/mobile-testing.md) |
+| **Viewport/breakpoint testing** | [mobile-testing.md](advanced/mobile-testing.md) |
+| **Mobile-specific UI** | [mobile-testing.md](advanced/mobile-testing.md), [locators.md](core/locators.md) |
+
+### Real-Time & Browser APIs
+
+**When to use**: Testing WebSockets, geolocation, permissions, multi-tab flows
+
+| Activity | Reference Files |
+| ------------------------------- | ---------------------------------------------------------------------------------------- |
+| **WebSocket/real-time testing** | [websockets.md](browser-apis/websockets.md) |
+| **Geolocation mocking** | [browser-apis.md](browser-apis/browser-apis.md) |
+| **Permission handling** | [browser-apis.md](browser-apis/browser-apis.md) |
+| **Clipboard testing** | [browser-apis.md](browser-apis/browser-apis.md) |
+| **Camera/microphone mocking** | [browser-apis.md](browser-apis/browser-apis.md) |
+| **Multi-tab/popup flows** | [multi-context.md](advanced/multi-context.md) |
+| **OAuth popup handling** | [third-party.md](advanced/third-party.md), [multi-context.md](advanced/multi-context.md) |
+
+### Debugging & Troubleshooting
+
+**When to use**: Test failures, element not found, timeouts, unexpected behavior
+
+| Activity | Reference Files |
+| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Debugging test failures** | [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md) |
+| **Fixing flaky tests** | [flaky-tests.md](debugging/flaky-tests.md), [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md) |
+| **Debugging flaky parallel runs** | [flaky-tests.md](debugging/flaky-tests.md), [performance.md](infrastructure-ci-cd/performance.md), [fixtures-hooks.md](core/fixtures-hooks.md) |
+| **Ensuring test isolation / avoiding state leak** | [flaky-tests.md](debugging/flaky-tests.md), [fixtures-hooks.md](core/fixtures-hooks.md), [performance.md](infrastructure-ci-cd/performance.md) |
+| **Fixing selector issues** | [locators.md](core/locators.md), [debugging.md](debugging/debugging.md) |
+| **Investigating timeout issues** | [assertions-waiting.md](core/assertions-waiting.md), [debugging.md](debugging/debugging.md) |
+| **Using trace viewer** | [debugging.md](debugging/debugging.md) |
+| **Debugging race conditions** | [flaky-tests.md](debugging/flaky-tests.md), [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md) |
+| **Debugging console/JS errors** | [console-errors.md](debugging/console-errors.md), [debugging.md](debugging/debugging.md) |
+
+### Error & Edge Case Testing
+
+**When to use**: Testing error states, offline mode, network failures, validation
+
+| Activity | Reference Files |
+| ------------------------------ | ----------------------------------------------------------------------------------------------------- |
+| **Error boundary testing** | [error-testing.md](debugging/error-testing.md) |
+| **Network failure simulation** | [error-testing.md](debugging/error-testing.md), [network-advanced.md](advanced/network-advanced.md) |
+| **Offline mode testing** | [error-testing.md](debugging/error-testing.md), [service-workers.md](browser-apis/service-workers.md) |
+| **Service worker testing** | [service-workers.md](browser-apis/service-workers.md) |
+| **Loading state testing** | [error-testing.md](debugging/error-testing.md) |
+| **Form validation testing** | [error-testing.md](debugging/error-testing.md) |
+
+### Multi-User & Collaboration Testing
+
+**When to use**: Testing features involving multiple users, roles, or real-time collaboration
+
+| Activity | Reference Files |
+| ------------------------------ | ------------------------------------------------------------------------------------ |
+| **Multiple users in one test** | [multi-user.md](advanced/multi-user.md) |
+| **Real-time collaboration** | [multi-user.md](advanced/multi-user.md), [websockets.md](browser-apis/websockets.md) |
+| **Role-based access testing** | [multi-user.md](advanced/multi-user.md) |
+| **Concurrent action testing** | [multi-user.md](advanced/multi-user.md) |
+
+### Architecture Decisions
+
+**When to use**: Choosing test patterns, deciding between approaches, planning test architecture
+
+| Activity | Reference Files |
+| ---------------------------- | --------------------------------------------------------- |
+| **POM vs fixtures decision** | [pom-vs-fixtures.md](architecture/pom-vs-fixtures.md) |
+| **Test type selection** | [test-architecture.md](architecture/test-architecture.md) |
+| **Mock vs real services** | [when-to-mock.md](architecture/when-to-mock.md) |
+| **Test suite structure** | [test-suite-structure.md](core/test-suite-structure.md) |
+
+### Framework-Specific Testing
+
+**When to use**: Testing React or Next.js applications
+
+| Activity | Reference Files |
+| ------------------------- | ----------------------------------- |
+| **Testing React apps** | [react.md](frameworks/react.md) |
+| **Testing Next.js apps** | [nextjs.md](frameworks/nextjs.md) |
+
+### Refactoring & Maintenance
+
+**When to use**: Improving existing tests, code review, reducing duplication
+
+| Activity | Reference Files |
+| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
+| **Refactoring to Page Object Model** | [page-object-model.md](core/page-object-model.md), [test-suite-structure.md](core/test-suite-structure.md) |
+| **Improving test organization** | [test-suite-structure.md](core/test-suite-structure.md), [page-object-model.md](core/page-object-model.md) |
+| **Extracting common setup/teardown** | [fixtures-hooks.md](core/fixtures-hooks.md) |
+| **Replacing brittle selectors** | [locators.md](core/locators.md) |
+| **Removing explicit waits** | [assertions-waiting.md](core/assertions-waiting.md) |
+| **Creating test data factories** | [test-data.md](core/test-data.md) |
+| **Configuration setup** | [configuration.md](core/configuration.md) |
+
+### Infrastructure & Configuration
+
+**When to use**: Setting up projects, configuring CI/CD, optimizing performance
+
+| Activity | Reference Files |
+| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| **Configuring Playwright project** | [configuration.md](core/configuration.md), [projects-dependencies.md](core/projects-dependencies.md) |
+| **Setting up CI/CD pipelines** | [ci-cd.md](infrastructure-ci-cd/ci-cd.md), [github-actions.md](infrastructure-ci-cd/github-actions.md) |
+| **GitHub Actions setup** | [github-actions.md](infrastructure-ci-cd/github-actions.md) |
+| **GitLab CI setup** | [gitlab.md](infrastructure-ci-cd/gitlab.md) |
+| **Other CI providers** | [other-providers.md](infrastructure-ci-cd/other-providers.md) |
+| **Docker/container setup** | [docker.md](infrastructure-ci-cd/docker.md) |
+| **Global setup & teardown** | [global-setup.md](core/global-setup.md) |
+| **Project dependencies** | [projects-dependencies.md](core/projects-dependencies.md) |
+| **Optimizing test performance** | [performance.md](infrastructure-ci-cd/performance.md), [test-suite-structure.md](core/test-suite-structure.md) |
+| **Configuring parallel execution** | [parallel-sharding.md](infrastructure-ci-cd/parallel-sharding.md), [performance.md](infrastructure-ci-cd/performance.md) |
+| **Isolating test data between workers** | [fixtures-hooks.md](core/fixtures-hooks.md), [performance.md](infrastructure-ci-cd/performance.md) |
+| **Test coverage** | [test-coverage.md](infrastructure-ci-cd/test-coverage.md) |
+| **Test reporting/artifacts** | [reporting.md](infrastructure-ci-cd/reporting.md) |
+
+### Advanced Patterns
+
+**When to use**: Complex scenarios, API mocking, network interception
+
+| Activity | Reference Files |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
+| **Mocking API responses** | [test-suite-structure.md](core/test-suite-structure.md), [network-advanced.md](advanced/network-advanced.md) |
+| **Network interception** | [network-advanced.md](advanced/network-advanced.md), [assertions-waiting.md](core/assertions-waiting.md) |
+| **GraphQL mocking** | [network-advanced.md](advanced/network-advanced.md) |
+| **HAR recording/playback** | [network-advanced.md](advanced/network-advanced.md) |
+| **Custom fixtures** | [fixtures-hooks.md](core/fixtures-hooks.md) |
+| **Advanced waiting strategies** | [assertions-waiting.md](core/assertions-waiting.md) |
+| **OAuth/SSO mocking** | [third-party.md](advanced/third-party.md), [multi-context.md](advanced/multi-context.md) |
+| **Payment gateway mocking** | [third-party.md](advanced/third-party.md) |
+| **Email/SMS verification mocking** | [third-party.md](advanced/third-party.md) |
+| **Failing on console errors** | [console-errors.md](debugging/console-errors.md) |
+| **Security testing (XSS, CSRF)** | [security-testing.md](testing-patterns/security-testing.md) |
+| **Performance budgets & Web Vitals** | [performance-testing.md](testing-patterns/performance-testing.md) |
+| **Lighthouse integration** | [performance-testing.md](testing-patterns/performance-testing.md) |
+| **Test annotations (skip, fixme)** | [annotations.md](core/annotations.md) |
+| **Test tags (@smoke, @fast)** | [test-tags.md](core/test-tags.md) |
+| **Test steps for reporting** | [annotations.md](core/annotations.md) |
+
+## Quick Decision Tree
+
+```
+What are you doing?
+│
+├─ Writing a new test?
+│ ├─ E2E test → core/test-suite-structure.md, core/locators.md, core/assertions-waiting.md
+│ ├─ Component test → testing-patterns/component-testing.md
+│ ├─ API test → testing-patterns/api-testing.md, core/test-suite-structure.md
+│ ├─ GraphQL test → testing-patterns/graphql-testing.md
+│ ├─ Visual regression → testing-patterns/visual-regression.md
+│ ├─ Visual/canvas test → testing-patterns/canvas-webgl.md, core/test-suite-structure.md
+│ ├─ Accessibility test → testing-patterns/accessibility.md
+│ ├─ Mobile/responsive test → advanced/mobile-testing.md
+│ ├─ i18n/locale test → testing-patterns/i18n.md
+│ ├─ Electron app test → testing-patterns/electron.md
+│ ├─ Browser extension test → testing-patterns/browser-extensions.md
+│ ├─ Multi-user test → advanced/multi-user.md
+│ ├─ Form validation test → testing-patterns/forms-validation.md
+│ └─ Drag and drop test → testing-patterns/drag-drop.md
+│
+├─ Testing specific features?
+│ ├─ File upload/download → testing-patterns/file-operations.md, testing-patterns/file-upload-download.md
+│ ├─ Date/time dependent → advanced/clock-mocking.md
+│ ├─ WebSocket/real-time → browser-apis/websockets.md
+│ ├─ Geolocation/permissions → browser-apis/browser-apis.md
+│ ├─ OAuth/SSO mocking → advanced/third-party.md, advanced/multi-context.md
+│ ├─ Payments/email/SMS → advanced/third-party.md
+│ ├─ iFrames → browser-apis/iframes.md
+│ ├─ Canvas/WebGL/charts → testing-patterns/canvas-webgl.md
+│ ├─ Service workers/PWA → browser-apis/service-workers.md
+│ ├─ i18n/localization → testing-patterns/i18n.md
+│ ├─ Security (XSS, CSRF) → testing-patterns/security-testing.md
+│ └─ Performance/Web Vitals → testing-patterns/performance-testing.md
+│
+├─ Architecture decisions?
+│ ├─ POM vs fixtures → architecture/pom-vs-fixtures.md
+│ ├─ Test type selection → architecture/test-architecture.md
+│ ├─ Mock vs real services → architecture/when-to-mock.md
+│ └─ Test suite structure → core/test-suite-structure.md
+│
+├─ Framework-specific testing?
+│ ├─ React app → frameworks/react.md
+│ └─ Next.js app → frameworks/nextjs.md
+│
+├─ Authentication testing?
+│ ├─ Basic auth patterns → advanced/authentication.md
+│ └─ Complex flows (MFA, reset) → advanced/authentication-flows.md
+│
+├─ Test is failing/flaky?
+│ ├─ Flaky test investigation → debugging/flaky-tests.md
+│ ├─ Element not found → core/locators.md, debugging/debugging.md
+│ ├─ Timeout issues → core/assertions-waiting.md, debugging/debugging.md
+│ ├─ Race conditions → debugging/flaky-tests.md, debugging/debugging.md
+│ ├─ Flaky only with multiple workers → debugging/flaky-tests.md, infrastructure-ci-cd/performance.md
+│ ├─ State leak / isolation → debugging/flaky-tests.md, core/fixtures-hooks.md
+│ ├─ Console/JS errors → debugging/console-errors.md, debugging/debugging.md
+│ └─ General debugging → debugging/debugging.md
+│
+├─ Testing error scenarios?
+│ ├─ Network failures → debugging/error-testing.md, advanced/network-advanced.md
+│ ├─ Offline (unexpected) → debugging/error-testing.md
+│ ├─ Offline-first/PWA → browser-apis/service-workers.md
+│ ├─ Error boundaries → debugging/error-testing.md
+│ └─ Form validation → testing-patterns/forms-validation.md, debugging/error-testing.md
+│
+├─ Refactoring existing code?
+│ ├─ Implementing POM → core/page-object-model.md
+│ ├─ Improving selectors → core/locators.md
+│ ├─ Extracting fixtures → core/fixtures-hooks.md
+│ ├─ Creating data factories → core/test-data.md
+│ └─ Configuration setup → core/configuration.md
+│
+├─ Setting up infrastructure?
+│ ├─ CI/CD → infrastructure-ci-cd/ci-cd.md
+│ ├─ GitHub Actions → infrastructure-ci-cd/github-actions.md
+│ ├─ GitLab CI → infrastructure-ci-cd/gitlab.md
+│ ├─ Other CI providers → infrastructure-ci-cd/other-providers.md
+│ ├─ Docker/containers → infrastructure-ci-cd/docker.md
+│ ├─ Sharding/parallel → infrastructure-ci-cd/parallel-sharding.md
+│ ├─ Reporting/artifacts → infrastructure-ci-cd/reporting.md
+│ ├─ Global setup/teardown → core/global-setup.md
+│ ├─ Project dependencies → core/projects-dependencies.md
+│ ├─ Test performance → infrastructure-ci-cd/performance.md
+│ ├─ Test coverage → infrastructure-ci-cd/test-coverage.md
+│ └─ Project config → core/configuration.md, core/projects-dependencies.md
+│
+├─ Organizing tests?
+│ ├─ Skip/fixme/slow tests → core/annotations.md
+│ ├─ Test tags (@smoke, @fast) → core/test-tags.md
+│ ├─ Filtering tests (--grep) → core/test-tags.md
+│ ├─ Test steps → core/annotations.md
+│ └─ Conditional execution → core/annotations.md
+│
+└─ Running subset of tests?
+ ├─ By tag (@smoke, @critical) → core/test-tags.md
+ ├─ Exclude slow/flaky tests → core/test-tags.md
+ ├─ PR vs nightly tests → core/test-tags.md, infrastructure-ci-cd/ci-cd.md
+ └─ Project-specific filtering → core/test-tags.md, core/configuration.md
+```
+
+## Test Validation Loop
+
+After writing or modifying tests:
+
+1. **Run tests**: `npx playwright test --reporter=list`
+2. **If tests fail**:
+ - Review error output and trace (`npx playwright show-trace`)
+ - Fix locators, waits, or assertions
+ - Re-run tests
+3. **Only proceed when all tests pass**
+4. **Run multiple times** for critical tests: `npx playwright test --repeat-each=5`
diff --git a/.cursor/skills/playwright-testing/advanced/authentication-flows.md b/.cursor/skills/playwright-testing/advanced/authentication-flows.md
new file mode 100644
index 0000000000..24ad08c177
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/authentication-flows.md
@@ -0,0 +1,360 @@
+# Complex Authentication Flow Patterns
+
+## Table of Contents
+
+1. [Email Verification Flows](#email-verification-flows)
+2. [Password Reset](#password-reset)
+3. [Session Timeout](#session-timeout)
+4. [Remember Me Persistence](#remember-me-persistence)
+5. [Logout Patterns](#logout-patterns)
+6. [Tips](#tips)
+7. [Related](#related)
+
+> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).
+
+---
+
+## Email Verification Flows
+
+### Capturing Verification Tokens
+
+Intercept API responses to capture verification tokens for testing:
+
+```typescript
+test('completes registration with email verification', async ({ page }) => {
+ let capturedToken = '';
+
+ await page.route('**/api/auth/register', async (route) => {
+ const response = await route.fetch();
+ const body = await response.json();
+ capturedToken = body.verificationToken;
+ await route.fulfill({ response });
+ });
+
+ await page.goto('/register');
+ await page.getByLabel('Name').fill('New User');
+ await page.getByLabel('Email').fill('newuser@test.com');
+ await page.getByLabel('Password', { exact: true }).fill('SecurePass!');
+ await page.getByLabel('Confirm password').fill('SecurePass!');
+ await page.getByRole('button', { name: 'Create account' }).click();
+
+ await expect(page.getByText('Check your inbox')).toBeVisible();
+
+ expect(capturedToken).toBeTruthy();
+ await page.goto(`/verify?token=${capturedToken}`);
+
+ await expect(page.getByText('Email confirmed')).toBeVisible();
+});
+```
+
+### Fully Mocked Verification
+
+```typescript
+test('verifies email with mocked endpoints', async ({ page }) => {
+ const mockToken = 'test-verification-abc123';
+
+ await page.route('**/api/auth/register', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }),
+ });
+ });
+
+ await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ verified: true }),
+ });
+ });
+
+ await page.goto('/register');
+ await page.getByLabel('Email').fill('test@example.com');
+ await page.getByLabel('Password', { exact: true }).fill('Password123!');
+ await page.getByRole('button', { name: 'Sign up' }).click();
+
+ await expect(page.getByText('Check your inbox')).toBeVisible();
+
+ await page.goto(`/verify?token=${mockToken}`);
+ await expect(page.getByText('Email confirmed')).toBeVisible();
+});
+```
+
+---
+
+## Password Reset
+
+### Complete Reset Flow
+
+```typescript
+test('resets password through email link', async ({ page }) => {
+ let resetToken = '';
+
+ await page.route('**/api/auth/forgot-password', async (route) => {
+ const response = await route.fetch();
+ const body = await response.json();
+ resetToken = body.resetToken;
+ await route.fulfill({ response });
+ });
+
+ await page.goto('/forgot-password');
+ await page.getByLabel('Email').fill('user@test.com');
+ await page.getByRole('button', { name: 'Send link' }).click();
+
+ await expect(page.getByText('Reset email sent')).toBeVisible();
+
+ expect(resetToken).toBeTruthy();
+ await page.goto(`/reset-password?token=${resetToken}`);
+
+ await page.getByLabel('New password', { exact: true }).fill('NewPassword456!');
+ await page.getByLabel('Confirm password').fill('NewPassword456!');
+ await page.getByRole('button', { name: 'Update password' }).click();
+
+ await expect(page.getByText('Password updated')).toBeVisible();
+});
+```
+
+### Expired Token Handling
+
+```typescript
+test('shows error for expired reset token', async ({ page }) => {
+ await page.goto('/reset-password?token=expired-token');
+
+ await page.getByLabel('New password', { exact: true }).fill('NewPass!');
+ await page.getByLabel('Confirm password').fill('NewPass!');
+ await page.getByRole('button', { name: 'Update password' }).click();
+
+ await expect(page.getByRole('alert')).toContainText(/expired|invalid/i);
+});
+```
+
+### Password Strength Validation
+
+```typescript
+test('enforces password requirements on reset', async ({ page }) => {
+ await page.goto('/reset-password?token=valid-token');
+
+ await page.getByLabel('New password', { exact: true }).fill('weak');
+ await page.getByLabel('Confirm password').fill('weak');
+ await page.getByRole('button', { name: 'Update password' }).click();
+
+ await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
+});
+```
+
+---
+
+## Session Timeout
+
+### Detecting Expired Sessions
+
+```typescript
+test('redirects to signin after session expires', async ({ page, context }) => {
+ await page.goto('/signin');
+ await page.getByLabel('Email').fill('user@test.com');
+ await page.getByLabel('Password').fill('Password!');
+ await page.getByRole('button', { name: 'Sign in' }).click();
+ await expect(page).toHaveURL('/home');
+
+ const cookies = await context.cookies();
+ const sessionCookie = cookies.find((c) => c.name.includes('session'));
+
+ if (sessionCookie) {
+ await context.clearCookies({ name: sessionCookie.name });
+ }
+
+ await page.goto('/profile');
+ await expect(page).toHaveURL(/\/signin/);
+ await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible();
+});
+```
+
+### Session Extension Warning
+
+```typescript
+test('shows warning before session expires', async ({ page }) => {
+ await page.route('**/api/auth/session', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ valid: true, expiresIn: 60 }),
+ });
+ });
+
+ await page.goto('/home');
+
+ await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
+ await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible();
+});
+```
+
+### Session Extension Action
+
+```typescript
+test('extends session when user clicks extend', async ({ page }) => {
+ let sessionExtended = false;
+
+ await page.route('**/api/auth/session', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ valid: true, expiresIn: 60 }),
+ });
+ });
+
+ await page.route('**/api/auth/refresh', async (route) => {
+ sessionExtended = true;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ valid: true, expiresIn: 3600 }),
+ });
+ });
+
+ await page.goto('/home');
+
+ await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({
+ timeout: 10000,
+ });
+ await page.getByRole('button', { name: /extend|stay signed in/i }).click();
+
+ expect(sessionExtended).toBe(true);
+ await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
+});
+```
+
+---
+
+## Remember Me Persistence
+
+### Persistent Session
+
+```typescript
+test('persists session with remember me enabled', async ({ browser }) => {
+ const ctx1 = await browser.newContext();
+ const page1 = await ctx1.newPage();
+
+ await page1.goto('/signin');
+ await page1.getByLabel('Email').fill('user@test.com');
+ await page1.getByLabel('Password').fill('Password!');
+ await page1.getByLabel('Keep me signed in').check();
+ await page1.getByRole('button', { name: 'Sign in' }).click();
+
+ await expect(page1).toHaveURL('/home');
+
+ const state = await ctx1.storageState();
+ await ctx1.close();
+
+ const ctx2 = await browser.newContext({ storageState: state });
+ const page2 = await ctx2.newPage();
+
+ await page2.goto('/home');
+ await expect(page2).toHaveURL('/home');
+ await expect(page2.getByText('Welcome')).toBeVisible();
+
+ await ctx2.close();
+});
+```
+
+### Session-Only Login
+
+```typescript
+test('session-only login does not persist across browser restarts', async ({ browser }) => {
+ const ctx1 = await browser.newContext();
+ const page1 = await ctx1.newPage();
+
+ await page1.goto('/signin');
+ await page1.getByLabel('Email').fill('user@test.com');
+ await page1.getByLabel('Password').fill('Password!');
+ // Leave "Remember me" unchecked
+ await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked();
+ await page1.getByRole('button', { name: 'Sign in' }).click();
+
+ await expect(page1).toHaveURL('/home');
+
+ // Only keep persistent cookies (filter out session cookies)
+ const cookies = await ctx1.cookies();
+ await ctx1.close();
+
+ const persistentCookies = cookies.filter((c) => c.expires > 0);
+ const ctx2 = await browser.newContext();
+ await ctx2.addCookies(persistentCookies);
+ const page2 = await ctx2.newPage();
+
+ await page2.goto('/home');
+
+ // Should redirect to login since session was not persisted
+ await expect(page2).toHaveURL(/\/signin/);
+
+ await ctx2.close();
+});
+```
+
+---
+
+## Logout Patterns
+
+### Standard Logout with Session Cleanup
+
+```typescript
+test.use({ storageState: '.auth/user.json' });
+
+test('logs out and clears session', async ({ page, context }) => {
+ await page.goto('/home');
+
+ await page.getByRole('button', { name: /account|menu/i }).click();
+ await page.getByRole('menuitem', { name: 'Sign out' }).click();
+
+ await expect(page).toHaveURL('/signin');
+
+ const cookies = await context.cookies();
+ const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token'));
+ expect(sessionCookies).toHaveLength(0);
+
+ await page.goto('/home');
+ await expect(page).toHaveURL(/\/signin/);
+});
+```
+
+### Logout from All Devices
+
+```typescript
+test('logs out from all devices', async ({ page }) => {
+ let logoutAllCalled = false;
+
+ await page.route('**/api/auth/logout-all', async (route) => {
+ logoutAllCalled = true;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Logged out everywhere' }),
+ });
+ });
+
+ await page.goto('/settings/security');
+
+ await page.getByRole('button', { name: 'Sign out everywhere' }).click();
+ await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
+
+ expect(logoutAllCalled).toBe(true);
+ await expect(page).toHaveURL(/\/signin/);
+});
+```
+
+---
+
+## Tips
+
+1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests
+2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens
+3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared
+4. **Test the full flow end-to-end** — Password reset should verify login with new password works
+
+---
+
+## Related
+
+- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login
+- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures
+- [third-party.md](./third-party.md) — Mocking external auth providers
diff --git a/.cursor/skills/playwright-testing/advanced/authentication.md b/.cursor/skills/playwright-testing/advanced/authentication.md
new file mode 100644
index 0000000000..02c2dd7b27
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/authentication.md
@@ -0,0 +1,871 @@
+# Authentication Testing
+
+## Table of Contents
+
+1. [Quick Reference](#quick-reference)
+2. [Patterns](#patterns)
+3. [Decision Guide](#decision-guide)
+4. [Anti-Patterns](#anti-patterns)
+5. [Troubleshooting](#troubleshooting)
+6. [Related](#related)
+
+> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.
+
+## Quick Reference
+
+```typescript
+// Storage state reuse — the #1 pattern for fast auth
+await page.goto("/login");
+await page.getByLabel("Username").fill("testuser@example.com");
+await page.getByLabel("Password").fill("secretPass123");
+await page.getByRole("button", { name: "Log in" }).click();
+await page.context().storageState({ path: ".auth/session.json" });
+
+// Reuse in config — every test starts authenticated
+{
+ use: {
+ storageState: ".auth/session.json"
+ }
+}
+
+// API login — skip the UI entirely
+const context = await browser.newContext();
+const response = await context.request.post("/api/auth/login", {
+ data: { email: "testuser@example.com", password: "secretPass123" },
+});
+await context.storageState({ path: ".auth/session.json" });
+```
+
+## Patterns
+
+### Storage State Reuse
+
+**Use when**: You need authenticated tests and want to avoid logging in before every test.
+**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.
+
+`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
+
+```typescript
+// scripts/generate-auth.ts — run once to generate the state file
+import { chromium } from "@playwright/test";
+
+async function generateAuthState() {
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ await page.goto("http://localhost:4000/login");
+ await page.getByLabel("Username").fill("testuser@example.com");
+ await page.getByLabel("Password").fill("secretPass123");
+ await page.getByRole("button", { name: "Log in" }).click();
+ await page.waitForURL("/home");
+
+ await context.storageState({ path: ".auth/session.json" });
+ await browser.close();
+}
+
+generateAuthState();
+```
+
+```typescript
+// playwright.config.ts — load saved state for all tests
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ use: {
+ baseURL: "http://localhost:4000",
+ storageState: ".auth/session.json",
+ },
+});
+```
+
+```typescript
+// tests/home.spec.ts — test starts already logged in
+import { test, expect } from "@playwright/test";
+
+test("authenticated user sees home page", async ({ page }) => {
+ await page.goto("/home");
+ await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
+});
+```
+
+### Global Setup Authentication
+
+**Use when**: You want to authenticate once before the entire test suite runs.
+**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.
+
+```typescript
+// global-setup.ts
+import { chromium, type FullConfig } from "@playwright/test";
+
+async function globalSetup(config: FullConfig) {
+ const { baseURL } = config.projects[0].use;
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ await page.goto(`${baseURL}/login`);
+ await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
+ await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
+ await page.getByRole("button", { name: "Log in" }).click();
+ await page.waitForURL("**/home");
+
+ await context.storageState({ path: ".auth/session.json" });
+ await browser.close();
+}
+
+export default globalSetup;
+```
+
+```typescript
+// playwright.config.ts
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ globalSetup: require.resolve("./global-setup"),
+ use: {
+ baseURL: "http://localhost:4000",
+ storageState: ".auth/session.json",
+ },
+});
+```
+
+Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.
+
+### Per-Worker Authentication
+
+**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.
+**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.
+
+> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.
+
+```typescript
+// fixtures/auth.ts
+import { test as base, type BrowserContext } from "@playwright/test";
+
+type AuthFixtures = {
+ authenticatedContext: BrowserContext;
+};
+
+export const test = base.extend<{}, AuthFixtures>({
+ authenticatedContext: [
+ async ({ browser }, use) => {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ await page.goto("/login");
+ await page
+ .getByLabel("Username")
+ .fill(`worker-${test.info().parallelIndex}@example.com`);
+ await page.getByLabel("Password").fill("secretPass123");
+ await page.getByRole("button", { name: "Log in" }).click();
+ await page.waitForURL("/home");
+ await page.close();
+
+ await use(context);
+ await context.close();
+ },
+ { scope: "worker" },
+ ],
+});
+
+export { expect } from "@playwright/test";
+```
+
+```typescript
+// tests/settings.spec.ts
+import { test, expect } from "../fixtures/auth";
+
+test("update display name", async ({ authenticatedContext }) => {
+ const page = await authenticatedContext.newPage();
+ await page.goto("/settings/profile");
+ await page.getByLabel("Display name").fill("Updated Name");
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByText("Profile saved")).toBeVisible();
+});
+```
+
+### Multiple Roles
+
+**Use when**: Your app has role-based access control and you need to test different permission levels.
+**Avoid when**: Your app has a single user role.
+
+```typescript
+// global-setup.ts — authenticate all roles
+import { chromium, type FullConfig } from "@playwright/test";
+
+const accounts = [
+ {
+ role: "admin",
+ email: "admin@example.com",
+ password: process.env.ADMIN_PASSWORD!,
+ },
+ {
+ role: "member",
+ email: "member@example.com",
+ password: process.env.MEMBER_PASSWORD!,
+ },
+ {
+ role: "guest",
+ email: "guest@example.com",
+ password: process.env.GUEST_PASSWORD!,
+ },
+];
+
+async function globalSetup(config: FullConfig) {
+ const { baseURL } = config.projects[0].use;
+
+ for (const { role, email, password } of accounts) {
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ await page.goto(`${baseURL}/login`);
+ await page.getByLabel("Username").fill(email);
+ await page.getByLabel("Password").fill(password);
+ await page.getByRole("button", { name: "Log in" }).click();
+ await page.waitForURL("**/home");
+
+ await context.storageState({ path: `.auth/${role}.json` });
+ await browser.close();
+ }
+}
+
+export default globalSetup;
+```
+
+```typescript
+// playwright.config.ts — one project per role
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ globalSetup: require.resolve("./global-setup"),
+ projects: [
+ {
+ name: "admin",
+ use: { storageState: ".auth/admin.json" },
+ testMatch: "**/*.admin.spec.ts",
+ },
+ {
+ name: "member",
+ use: { storageState: ".auth/member.json" },
+ testMatch: "**/*.member.spec.ts",
+ },
+ {
+ name: "guest",
+ use: { storageState: ".auth/guest.json" },
+ testMatch: "**/*.guest.spec.ts",
+ },
+ {
+ name: "anonymous",
+ use: { storageState: { cookies: [], origins: [] } },
+ testMatch: "**/*.anon.spec.ts",
+ },
+ ],
+});
+```
+
+```typescript
+// tests/admin-panel.admin.spec.ts
+import { test, expect } from "@playwright/test";
+
+test("admin can access user management", async ({ page }) => {
+ await page.goto("/admin/users");
+ await expect(
+ page.getByRole("heading", { name: "User Management" })
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled();
+});
+```
+
+```typescript
+// tests/admin-panel.guest.spec.ts
+import { test, expect } from "@playwright/test";
+
+test("guest cannot access admin panel", async ({ page }) => {
+ await page.goto("/admin/users");
+ await expect(page.getByText("Access denied")).toBeVisible();
+});
+```
+
+**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.
+
+```typescript
+// fixtures/auth.ts — role-based fixture
+import { test as base, type Page } from "@playwright/test";
+import fs from "fs";
+
+type RoleFixtures = {
+ loginAs: (role: "admin" | "member" | "guest") => Promise;
+};
+
+export const test = base.extend({
+ loginAs: async ({ browser }, use) => {
+ const pages: Page[] = [];
+
+ await use(async (role) => {
+ const statePath = `.auth/${role}.json`;
+ if (!fs.existsSync(statePath)) {
+ throw new Error(
+ `Auth state for role "${role}" not found at ${statePath}`
+ );
+ }
+ const context = await browser.newContext({ storageState: statePath });
+ const page = await context.newPage();
+ pages.push(page);
+ return page;
+ });
+
+ for (const page of pages) {
+ await page.context().close();
+ }
+ },
+});
+
+export { expect } from "@playwright/test";
+```
+
+```typescript
+// tests/role-comparison.spec.ts
+import { test, expect } from "../fixtures/auth";
+
+test("admin sees remove button, guest does not", async ({ loginAs }) => {
+ const adminPage = await loginAs("admin");
+ await adminPage.goto("/admin/users");
+ await expect(
+ adminPage.getByRole("button", { name: "Remove user" })
+ ).toBeVisible();
+
+ const guestPage = await loginAs("guest");
+ await guestPage.goto("/admin/users");
+ await expect(guestPage.getByText("Access denied")).toBeVisible();
+});
+```
+
+### OAuth/SSO Mocking
+
+**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.
+**Avoid when**: You have a dedicated test tenant on the OAuth provider.
+
+A typical OAuth flow works like this:
+
+1. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...`
+2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)
+3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page
+
+In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.
+
+For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.
+
+```typescript
+// tests/oauth-login.spec.ts — mock the callback route
+import { test, expect } from "@playwright/test";
+
+test("login via mocked OAuth flow", async ({ page }) => {
+ await page.route("https://accounts.provider.com/**", async (route) => {
+ const callbackUrl = new URL("http://localhost:4000/auth/callback");
+ callbackUrl.searchParams.set("code", "mock-auth-code-xyz");
+ callbackUrl.searchParams.set("state", "expected-state-value");
+ await route.fulfill({
+ status: 302,
+ headers: { location: callbackUrl.toString() },
+ });
+ });
+
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Sign in with Provider" }).click();
+
+ await page.waitForURL("/home");
+ await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
+});
+```
+
+```typescript
+// tests/oauth-login.spec.ts — API-based session injection
+import { test, expect } from "@playwright/test";
+
+test("bypass OAuth entirely via API session injection", async ({
+ page,
+}) => {
+ // Call a test-only endpoint that creates a session without OAuth
+ const response = await page.request.post("/api/test/create-session", {
+ data: {
+ email: "oauth-user@example.com",
+ provider: "provider",
+ role: "member",
+ },
+ });
+ expect(response.ok()).toBeTruthy();
+
+ await page.context().storageState({ path: ".auth/oauth-user.json" });
+ await page.goto("/home");
+ await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
+});
+```
+
+**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.
+
+### MFA Handling
+
+**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).
+**Avoid when**: MFA is optional and you can disable it for test accounts.
+
+**Strategy 1**: Generate real TOTP codes from a shared secret.
+
+```typescript
+// helpers/totp.ts
+import * as OTPAuth from "otpauth";
+
+export function generateTOTP(secret: string): string {
+ const totp = new OTPAuth.TOTP({
+ secret: OTPAuth.Secret.fromBase32(secret),
+ digits: 6,
+ period: 30,
+ algorithm: "SHA1",
+ });
+ return totp.generate();
+}
+```
+
+```typescript
+// tests/mfa-login.spec.ts
+import { test, expect } from "@playwright/test";
+import { generateTOTP } from "../helpers/totp";
+
+test("login with TOTP two-factor auth", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByLabel("Username").fill("mfa-user@example.com");
+ await page.getByLabel("Password").fill("secretPass123");
+ await page.getByRole("button", { name: "Log in" }).click();
+
+ await expect(page.getByText("Enter your authentication code")).toBeVisible();
+
+ const code = generateTOTP(process.env.MFA_TOTP_SECRET!);
+ await page.getByLabel("Authentication code").fill(code);
+ await page.getByRole("button", { name: "Verify" }).click();
+
+ await page.waitForURL("/home");
+ await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
+});
+```
+
+**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.
+
+**Strategy 3**: Disable MFA for test accounts at the infrastructure level.
+
+### Session Refresh
+
+**Use when**: Your tokens expire during long test runs.
+**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.
+
+```typescript
+// fixtures/auth-with-refresh.ts
+import { test as base, type BrowserContext } from "@playwright/test";
+import fs from "fs";
+
+type AuthFixtures = {
+ authenticatedPage: import("@playwright/test").Page;
+};
+
+export const test = base.extend({
+ authenticatedPage: async ({ browser }, use) => {
+ const statePath = ".auth/session.json";
+
+ let context: BrowserContext;
+ if (fs.existsSync(statePath)) {
+ context = await browser.newContext({ storageState: statePath });
+ const page = await context.newPage();
+
+ const response = await page.request.get("/api/auth/me");
+ if (response.ok()) {
+ await use(page);
+ await context.close();
+ return;
+ }
+ await context.close();
+ }
+
+ context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto("/login");
+ await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
+ await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
+ await page.getByRole("button", { name: "Log in" }).click();
+ await page.waitForURL("/home");
+
+ await context.storageState({ path: statePath });
+
+ await use(page);
+ await context.close();
+ },
+});
+
+export { expect } from "@playwright/test";
+```
+
+### Login Page Object
+
+**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.
+**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.
+
+```typescript
+// page-objects/LoginPage.ts
+import { type Page, type Locator, expect } from "@playwright/test";
+
+export class LoginPage {
+ readonly page: Page;
+ readonly usernameInput: Locator;
+ readonly passwordInput: Locator;
+ readonly loginButton: Locator;
+ readonly errorMessage: Locator;
+ readonly forgotPasswordLink: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.usernameInput = page.getByLabel("Username");
+ this.passwordInput = page.getByLabel("Password");
+ this.loginButton = page.getByRole("button", { name: "Log in" });
+ this.errorMessage = page.getByRole("alert");
+ this.forgotPasswordLink = page.getByRole("link", {
+ name: "Forgot password",
+ });
+ }
+
+ async goto() {
+ await this.page.goto("/login");
+ await expect(this.loginButton).toBeVisible();
+ }
+
+ async login(username: string, password: string) {
+ await this.usernameInput.fill(username);
+ await this.passwordInput.fill(password);
+ await this.loginButton.click();
+ }
+
+ async loginAndWaitForHome(username: string, password: string) {
+ await this.login(username, password);
+ await this.page.waitForURL("/home");
+ }
+
+ async expectError(message: string | RegExp) {
+ await expect(this.errorMessage).toContainText(message);
+ }
+
+ async expectFieldError(field: "username" | "password", message: string) {
+ const input =
+ field === "username" ? this.usernameInput : this.passwordInput;
+ await expect(input).toHaveAttribute("aria-invalid", "true");
+ const errorId = await input.getAttribute("aria-describedby");
+ if (errorId) {
+ await expect(this.page.locator(`#${errorId}`)).toContainText(message);
+ }
+ }
+}
+```
+
+```typescript
+// tests/login.spec.ts
+import { test, expect } from "@playwright/test";
+import { LoginPage } from "../page-objects/LoginPage";
+
+test.use({ storageState: { cookies: [], origins: [] } });
+
+test.describe("login page", () => {
+ let loginPage: LoginPage;
+
+ test.beforeEach(async ({ page }) => {
+ loginPage = new LoginPage(page);
+ await loginPage.goto();
+ });
+
+ test("successful login redirects to home", async ({ page }) => {
+ await loginPage.loginAndWaitForHome(
+ "testuser@example.com",
+ "secretPass123"
+ );
+ await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
+ });
+
+ test("wrong password shows error", async () => {
+ await loginPage.login("testuser@example.com", "wrong-password");
+ await loginPage.expectError("Invalid username or password");
+ });
+
+ test("empty fields show validation errors", async () => {
+ await loginPage.loginButton.click();
+ await loginPage.expectFieldError("username", "Username is required");
+ });
+
+ test("forgot password link navigates correctly", async ({ page }) => {
+ await loginPage.forgotPasswordLink.click();
+ await page.waitForURL("/forgot-password");
+ await expect(
+ page.getByRole("heading", { name: "Reset password" })
+ ).toBeVisible();
+ });
+});
+```
+
+### API-Based Login
+
+**Use when**: You want the fastest possible authentication without any browser interaction.
+**Avoid when**: You are specifically testing the login UI.
+
+API login is typically 5-10x faster than UI login.
+
+```typescript
+// global-setup.ts — API-based login (fastest)
+import { request, type FullConfig } from "@playwright/test";
+
+async function globalSetup(config: FullConfig) {
+ const { baseURL } = config.projects[0].use;
+
+ const requestContext = await request.newContext({ baseURL });
+
+ const response = await requestContext.post("/api/auth/login", {
+ data: {
+ email: process.env.TEST_USER_EMAIL!,
+ password: process.env.TEST_USER_PASSWORD!,
+ },
+ });
+
+ if (!response.ok()) {
+ throw new Error(
+ `API login failed: ${response.status()} ${await response.text()}`
+ );
+ }
+
+ await requestContext.storageState({ path: ".auth/session.json" });
+ await requestContext.dispose();
+}
+
+export default globalSetup;
+```
+
+```typescript
+// fixtures/api-auth.ts — fixture version for per-test authentication
+import { test as base } from "@playwright/test";
+
+export const test = base.extend({
+ authenticatedPage: async ({ browser, playwright }, use) => {
+ const apiContext = await playwright.request.newContext({
+ baseURL: "http://localhost:4000",
+ });
+
+ await apiContext.post("/api/auth/login", {
+ data: {
+ email: "testuser@example.com",
+ password: "secretPass123",
+ },
+ });
+
+ const state = await apiContext.storageState();
+ const context = await browser.newContext({ storageState: state });
+ const page = await context.newPage();
+
+ await use(page);
+
+ await context.close();
+ await apiContext.dispose();
+ },
+});
+
+export { expect } from "@playwright/test";
+```
+
+### Unauthenticated Tests
+
+**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.
+**Avoid when**: The test requires a logged-in user.
+
+When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.
+
+```typescript
+// tests/public-pages.spec.ts
+import { test, expect } from "@playwright/test";
+
+test.use({ storageState: { cookies: [], origins: [] } });
+
+test.describe("unauthenticated access", () => {
+ test("homepage is accessible without login", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();
+ });
+
+ test("protected route redirects to login", async ({ page }) => {
+ await page.goto("/home");
+ await page.waitForURL("**/login**");
+ expect(page.url()).toContain("redirect=%2Fhome");
+ });
+
+ test("expired session shows re-login prompt", async ({ page, context }) => {
+ await page.goto("/home");
+ await context.clearCookies();
+
+ await page.goto("/settings");
+ await page.waitForURL("**/login**");
+ await expect(page.getByText("Your session has expired")).toBeVisible();
+ });
+
+ test("signup flow creates account", async ({ page }) => {
+ await page.goto("/signup");
+ await page.getByLabel("Name").fill("New User");
+ await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`);
+ await page.getByLabel("Password", { exact: true }).fill("secretPass123");
+ await page.getByLabel("Confirm password").fill("secretPass123");
+ await page.getByRole("button", { name: "Create account" }).click();
+
+ await page.waitForURL("/onboarding");
+ await expect(page.getByText("Welcome, New User")).toBeVisible();
+ });
+});
+```
+
+## Decision Guide
+
+| Scenario | Approach | Speed | Isolation | When to Choose |
+| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |
+| Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project |
+| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data |
+| Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/member/guest roles |
+| Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` |
+| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI |
+| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass |
+| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use |
+| Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles |
+| API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth |
+
+### UI Login vs API Login vs Storage State
+
+```text
+Need to test the login page itself?
+├── Yes → UI login with LoginPage POM, no storageState
+└── No → Do you have a login API endpoint?
+ ├── Yes → API login in global setup, save storageState (fastest)
+ └── No → UI login in global setup, save storageState
+ └── Tokens expire quickly?
+ ├── Yes → Add session refresh fixture
+ └── No → Standard storageState reuse is fine
+```
+
+## Anti-Patterns
+
+| Don't Do This | Problem | Do This Instead |
+| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |
+| Log in via UI before every test | Adds 2-5 seconds per test | Use `storageState` to skip login entirely |
+| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with `{ scope: 'worker' }` |
+| Hardcode credentials in test files | Security risk | Use environment variables and `.env` files |
+| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture |
+| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection |
+| Use `page.waitForTimeout(2000)` after login | Arbitrary delay | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |
+| Store `.auth/*.json` files in git | Tokens in version control | Add `.auth/` to `.gitignore` |
+| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role |
+| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated | Pass `storageState` when creating the context |
+| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test |
+
+## Troubleshooting
+
+### Global setup fails with "Target page, context or browser has been closed"
+
+**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.
+
+**Fix**:
+
+- Add `await page.waitForURL()` after the login action
+- Check that `baseURL` in your config matches the actual server URL and protocol
+- Add error handling to global setup:
+
+```typescript
+const response = await page.waitForResponse("**/api/auth/**");
+if (!response.ok()) {
+ throw new Error(
+ `Login failed in global setup: ${response.status()} ${await response.text()}`
+ );
+}
+```
+
+### Tests fail with 401 Unauthorized after running for a while
+
+**Cause**: The session token saved in `storageState` has expired.
+
+**Fix**:
+
+- Use the session refresh fixture pattern
+- Increase token expiry in test environment configuration
+- Switch to API-based login in a worker-scoped fixture
+
+### `storageState` file is empty or contains no cookies
+
+**Cause**: `storageState()` was called before the login response set cookies.
+
+**Fix**:
+
+- Wait for the post-login page to load: `await page.waitForURL('/home')`
+- Verify cookies exist before saving:
+
+```typescript
+const cookies = await context.cookies();
+if (cookies.length === 0) {
+ throw new Error("No cookies found after login");
+}
+await context.storageState({ path: ".auth/session.json" });
+```
+
+### Different browsers get different cookies
+
+**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.
+
+**Fix**:
+
+- Generate separate auth state files per browser project
+- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:
+
+```typescript
+projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },
+ },
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },
+ },
+],
+```
+
+### Parallel tests interfere with each other's sessions
+
+**Cause**: Multiple workers share the same test account and one worker's actions affect others.
+
+**Fix**:
+
+- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`
+- Use the per-worker authentication fixture pattern
+- Make tests idempotent
+
+### OAuth mock does not work — still redirects to real provider
+
+**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.
+
+**Fix**:
+
+- Register route handlers before any navigation: call `page.route()` before `page.goto()`
+- Log the actual redirect URL to verify the pattern:
+
+```typescript
+page.on("request", (req) => {
+ if (req.url().includes("oauth") || req.url().includes("accounts.provider")) {
+ console.log("OAuth request:", req.url());
+ }
+});
+```
+
+## Related
+
+- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown
+- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration
+- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies
+- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking
+- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login
+- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness
diff --git a/.cursor/skills/playwright-testing/advanced/clock-mocking.md b/.cursor/skills/playwright-testing/advanced/clock-mocking.md
new file mode 100644
index 0000000000..073d087055
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/clock-mocking.md
@@ -0,0 +1,364 @@
+# Date, Time & Clock Mocking
+
+## Table of Contents
+
+1. [Clock API Basics](#clock-api-basics)
+2. [Fixed Time Testing](#fixed-time-testing)
+3. [Time Advancement](#time-advancement)
+4. [Timezone Testing](#timezone-testing)
+5. [Timer Mocking](#timer-mocking)
+
+## Clock API Basics
+
+### Install Clock
+
+```typescript
+test("mock current time", async ({ page }) => {
+ // Install clock before navigating
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+
+ await page.goto("/dashboard");
+
+ // Page sees January 15, 2025 as current date
+ await expect(page.getByText("January 15, 2025")).toBeVisible();
+});
+```
+
+### Clock with Fixture
+
+```typescript
+// fixtures/clock.fixture.ts
+import { test as base } from "@playwright/test";
+
+type ClockFixtures = {
+ mockTime: (date: Date | string) => Promise;
+};
+
+export const test = base.extend({
+ mockTime: async ({ page }, use) => {
+ await use(async (date) => {
+ const time = typeof date === "string" ? new Date(date) : date;
+ await page.clock.install({ time });
+ });
+ },
+});
+
+// Usage
+test("subscription expiry", async ({ page, mockTime }) => {
+ await mockTime("2025-12-31T23:59:00");
+ await page.goto("/subscription");
+
+ await expect(page.getByText("Expires today")).toBeVisible();
+});
+```
+
+## Fixed Time Testing
+
+### Test Date-Dependent Features
+
+```typescript
+test("show holiday banner in December", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-12-20T10:00:00") });
+
+ await page.goto("/");
+
+ await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible();
+});
+
+test("no holiday banner in January", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
+
+ await page.goto("/");
+
+ await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden();
+});
+```
+
+### Test Relative Time Display
+
+```typescript
+test("shows relative time correctly", async ({ page }) => {
+ // Fix time to control "posted 2 hours ago" text
+ await page.clock.install({ time: new Date("2025-06-15T14:00:00") });
+
+ // Mock API to return post with known timestamp
+ await page.route("**/api/posts/1", (route) =>
+ route.fulfill({
+ json: {
+ id: 1,
+ title: "Test Post",
+ createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time
+ },
+ }),
+ );
+
+ await page.goto("/posts/1");
+
+ await expect(page.getByText("2 hours ago")).toBeVisible();
+});
+```
+
+### Test Date Boundaries
+
+```typescript
+test.describe("end of month billing", () => {
+ test("shows billing on last day of month", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-31T10:00:00") });
+ await page.goto("/billing");
+
+ await expect(page.getByText("Payment due today")).toBeVisible();
+ });
+
+ test("shows days remaining mid-month", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
+ await page.goto("/billing");
+
+ await expect(page.getByText("16 days until payment")).toBeVisible();
+ });
+});
+```
+
+## Time Advancement
+
+### Advance Time Manually
+
+```typescript
+test("session timeout warning", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+ await page.goto("/dashboard");
+
+ // Advance 25 minutes (session timeout at 30 min)
+ await page.clock.fastForward("25:00");
+
+ await expect(page.getByText("Session expires in 5 minutes")).toBeVisible();
+
+ // Advance 5 more minutes
+ await page.clock.fastForward("05:00");
+
+ await expect(page.getByText("Session expired")).toBeVisible();
+});
+```
+
+### Pause and Resume Time
+
+```typescript
+test("countdown timer", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+ await page.goto("/sale");
+
+ // Initial state
+ await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible();
+
+ // Advance 1 hour
+ await page.clock.fastForward("01:00:00");
+
+ await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible();
+
+ // Advance past end
+ await page.clock.fastForward("01:00:01");
+
+ await expect(page.getByText("Sale ended")).toBeVisible();
+});
+```
+
+### Run Pending Timers
+
+```typescript
+test("debounced search", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+ await page.goto("/search");
+
+ await page.getByLabel("Search").fill("playwright");
+
+ // Search is debounced by 300ms, won't fire yet
+ await expect(page.getByTestId("search-results")).toBeHidden();
+
+ // Fast forward past debounce
+ await page.clock.fastForward(300);
+
+ // Now search should execute
+ await expect(page.getByTestId("search-results")).toBeVisible();
+});
+```
+
+## Timezone Testing
+
+### Test Different Timezones
+
+```typescript
+test.describe("timezone display", () => {
+ test("shows correct time in PST", async ({ browser }) => {
+ const context = await browser.newContext({
+ timezoneId: "America/Los_Angeles",
+ });
+ const page = await context.newPage();
+
+ await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
+
+ await page.goto("/schedule");
+
+ // Should show 9 AM PST
+ await expect(page.getByText("9:00 AM")).toBeVisible();
+
+ await context.close();
+ });
+
+ test("shows correct time in JST", async ({ browser }) => {
+ const context = await browser.newContext({
+ timezoneId: "Asia/Tokyo",
+ });
+ const page = await context.newPage();
+
+ await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
+
+ await page.goto("/schedule");
+
+ // Should show 2 AM next day JST
+ await expect(page.getByText("2:00 AM")).toBeVisible();
+
+ await context.close();
+ });
+});
+```
+
+### Timezone Fixture
+
+```typescript
+// fixtures/timezone.fixture.ts
+import { test as base } from "@playwright/test";
+
+type TimezoneFixtures = {
+ pageInTimezone: (timezone: string) => Promise;
+};
+
+export const test = base.extend({
+ pageInTimezone: async ({ browser }, use) => {
+ const pages: Page[] = [];
+
+ await use(async (timezone) => {
+ const context = await browser.newContext({ timezoneId: timezone });
+ const page = await context.newPage();
+ pages.push(page);
+ return page;
+ });
+
+ // Cleanup
+ for (const page of pages) {
+ await page.context().close();
+ }
+ },
+});
+```
+
+## Timer Mocking
+
+### Mock setInterval
+
+```typescript
+test("auto-refresh data", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+
+ let apiCalls = 0;
+ await page.route("**/api/data", (route) => {
+ apiCalls++;
+ route.fulfill({ json: { value: apiCalls } });
+ });
+
+ await page.goto("/live-data"); // Sets up 30s refresh interval
+
+ expect(apiCalls).toBe(1); // Initial load
+
+ // Advance 30 seconds
+ await page.clock.fastForward("00:30");
+ expect(apiCalls).toBe(2); // First refresh
+
+ // Advance another 30 seconds
+ await page.clock.fastForward("00:30");
+ expect(apiCalls).toBe(3); // Second refresh
+});
+```
+
+### Mock setTimeout Chains
+
+```typescript
+test("notification queue", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+ await page.goto("/notifications");
+
+ // Trigger 3 notifications that show sequentially
+ await page.getByRole("button", { name: "Show All" }).click();
+
+ // First notification appears immediately
+ await expect(page.getByText("Notification 1")).toBeVisible();
+
+ // Second appears after 2 seconds
+ await page.clock.fastForward("00:02");
+ await expect(page.getByText("Notification 2")).toBeVisible();
+
+ // Third appears after 2 more seconds
+ await page.clock.fastForward("00:02");
+ await expect(page.getByText("Notification 3")).toBeVisible();
+});
+```
+
+### Test Animation Frames
+
+```typescript
+test("animation completes", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+ await page.goto("/animation-demo");
+
+ await page.getByRole("button", { name: "Animate" }).click();
+
+ // Animation runs for 500ms
+ const element = page.getByTestId("animated-box");
+ await expect(element).toHaveCSS("opacity", "0");
+
+ // Fast forward through animation
+ await page.clock.fastForward(500);
+
+ await expect(element).toHaveCSS("opacity", "1");
+});
+```
+
+## Best Practices
+
+### Always Install Clock Before Navigation
+
+```typescript
+// Good
+test("date test", async ({ page }) => {
+ await page.clock.install({ time: new Date("2025-01-15") });
+ await page.goto("/"); // Page loads with mocked time
+});
+
+// Bad - time already captured by page
+test("date test", async ({ page }) => {
+ await page.goto("/");
+ await page.clock.install({ time: new Date("2025-01-15") }); // Too late!
+});
+```
+
+### Use ISO Strings for Clarity
+
+```typescript
+// Good - explicit timezone
+await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") });
+
+// Ambiguous - uses local timezone
+await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ---------------------------------------- | ------------------------------- | -------------------------------------- |
+| Installing clock after navigation | Page already captured real time | Install clock before `goto()` |
+| Hardcoded relative dates | Tests break over time | Use fixed dates with clock mock |
+| Not accounting for timezone | Tests fail in different regions | Use explicit UTC times or set timezone |
+| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers | Use `fastForward` instead |
+
+## Related References
+
+- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions
+- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures
diff --git a/.cursor/skills/playwright-testing/advanced/mobile-testing.md b/.cursor/skills/playwright-testing/advanced/mobile-testing.md
new file mode 100644
index 0000000000..e928bdee50
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/mobile-testing.md
@@ -0,0 +1,409 @@
+# Mobile & Responsive Testing
+
+## Table of Contents
+
+1. [Device Emulation](#device-emulation)
+2. [Touch Gestures](#touch-gestures)
+3. [Viewport Testing](#viewport-testing)
+4. [Mobile-Specific UI](#mobile-specific-ui)
+5. [Responsive Breakpoints](#responsive-breakpoints)
+
+## Device Emulation
+
+### Use Built-in Devices
+
+```typescript
+import { test, devices } from "@playwright/test";
+
+// Configure in playwright.config.ts
+export default defineConfig({
+ projects: [
+ { name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
+ { name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
+ { name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
+ { name: "Tablet", use: { ...devices["iPad Pro 11"] } },
+ ],
+});
+```
+
+### Custom Device Configuration
+
+```typescript
+test.use({
+ viewport: { width: 390, height: 844 },
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ userAgent:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
+});
+
+test("custom mobile device", async ({ page }) => {
+ await page.goto("/");
+ // Test runs with custom device settings
+});
+```
+
+### Test Across Multiple Devices
+
+```typescript
+const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];
+
+for (const deviceName of mobileDevices) {
+ test(`checkout on ${deviceName}`, async ({ browser }) => {
+ const device = devices[deviceName];
+ const context = await browser.newContext({ ...device });
+ const page = await context.newPage();
+
+ await page.goto("/checkout");
+ await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();
+
+ await context.close();
+ });
+}
+```
+
+## Touch Gestures
+
+### Tap
+
+```typescript
+test.use({ hasTouch: true });
+
+test("tap to interact", async ({ page }) => {
+ await page.goto("/gallery");
+
+ // Tap is like click but for touch devices
+ await page.getByRole("img", { name: "Photo 1" }).tap();
+
+ await expect(page.getByRole("dialog")).toBeVisible();
+});
+```
+
+### Swipe
+
+```typescript
+test("swipe carousel", async ({ page }) => {
+ await page.goto("/carousel");
+
+ const carousel = page.getByTestId("carousel");
+ const box = await carousel.boundingBox();
+
+ if (box) {
+ // Swipe left
+ await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);
+ await page.mouse.move(box.x + 50, box.y + box.height / 2);
+
+ // Or use drag
+ await carousel.dragTo(carousel, {
+ sourcePosition: { x: box.width - 50, y: box.height / 2 },
+ targetPosition: { x: 50, y: box.height / 2 },
+ });
+ }
+
+ await expect(page.getByText("Slide 2")).toBeVisible();
+});
+```
+
+### Swipe Fixture
+
+```typescript
+// fixtures/touch.fixture.ts
+import { test as base, Page } from "@playwright/test";
+
+type TouchFixtures = {
+ swipe: (
+ element: Locator,
+ direction: "left" | "right" | "up" | "down",
+ ) => Promise;
+};
+
+export const test = base.extend({
+ swipe: async ({ page }, use) => {
+ await use(async (element, direction) => {
+ const box = await element.boundingBox();
+ if (!box) throw new Error("Element not visible");
+
+ const centerX = box.x + box.width / 2;
+ const centerY = box.y + box.height / 2;
+ const distance = 100;
+
+ const moves = {
+ left: {
+ startX: centerX + distance,
+ endX: centerX - distance,
+ y: centerY,
+ },
+ right: {
+ startX: centerX - distance,
+ endX: centerX + distance,
+ y: centerY,
+ },
+ up: {
+ startX: centerX,
+ endX: centerX,
+ startY: centerY + distance,
+ endY: centerY - distance,
+ },
+ down: {
+ startX: centerX,
+ endX: centerX,
+ startY: centerY - distance,
+ endY: centerY + distance,
+ },
+ };
+
+ const move = moves[direction];
+ await page.touchscreen.tap(move.startX, move.startY ?? move.y);
+ await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 });
+ await page.mouse.up();
+ });
+ },
+});
+
+// Usage
+test("swipe to delete", async ({ page, swipe }) => {
+ await page.goto("/inbox");
+
+ const message = page.getByTestId("message-1");
+ await swipe(message, "left");
+
+ await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();
+});
+```
+
+### Long Press
+
+```typescript
+test("long press for context menu", async ({ page }) => {
+ await page.goto("/files");
+
+ const file = page.getByText("document.pdf");
+ const box = await file.boundingBox();
+
+ if (box) {
+ // Touch down
+ await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
+
+ // Hold for 500ms
+ await page.waitForTimeout(500);
+
+ // Context menu should appear
+ await expect(page.getByRole("menu")).toBeVisible();
+ }
+});
+```
+
+### Pinch Zoom
+
+```typescript
+test("pinch to zoom image", async ({ page }) => {
+ await page.goto("/map");
+
+ // Pinch zoom requires two touch points
+ // Playwright doesn't have native pinch support, so we simulate via evaluate
+ await page.evaluate(() => {
+ const element = document.querySelector("#map");
+ if (element) {
+ // Simulate wheel event as fallback for zoom
+ element.dispatchEvent(
+ new WheelEvent("wheel", {
+ deltaY: -100, // Negative = zoom in
+ ctrlKey: true, // Ctrl+wheel = pinch on many apps
+ }),
+ );
+ }
+ });
+
+ // Or trigger the app's zoom function directly
+ await page.evaluate(() => {
+ (window as any).mapInstance?.setZoom(15);
+ });
+});
+```
+
+## Viewport Testing
+
+### Test Different Sizes
+
+```typescript
+const viewports = [
+ { name: "mobile", width: 375, height: 667 },
+ { name: "tablet", width: 768, height: 1024 },
+ { name: "desktop", width: 1920, height: 1080 },
+];
+
+for (const { name, width, height } of viewports) {
+ test(`navigation on ${name}`, async ({ page }) => {
+ await page.setViewportSize({ width, height });
+ await page.goto("/");
+
+ if (width < 768) {
+ // Mobile: should have hamburger menu
+ await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
+ } else {
+ // Desktop: should have visible nav links
+ await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
+ }
+ });
+}
+```
+
+### Dynamic Viewport Changes
+
+```typescript
+test("responsive layout change", async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await page.goto("/dashboard");
+
+ // Desktop: sidebar visible
+ await expect(page.getByRole("complementary")).toBeVisible();
+
+ // Resize to mobile
+ await page.setViewportSize({ width: 375, height: 667 });
+
+ // Mobile: sidebar hidden, hamburger visible
+ await expect(page.getByRole("complementary")).toBeHidden();
+ await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
+});
+```
+
+## Mobile-Specific UI
+
+### Hamburger Menu
+
+```typescript
+test("mobile navigation", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.goto("/");
+
+ // Open hamburger menu
+ await page.getByRole("button", { name: "Menu" }).click();
+
+ // Navigation drawer should appear
+ const nav = page.getByRole("navigation");
+ await expect(nav).toBeVisible();
+
+ // Navigate via mobile menu
+ await nav.getByRole("link", { name: "Products" }).click();
+
+ await expect(page).toHaveURL("/products");
+ // Menu should close after navigation
+ await expect(nav).toBeHidden();
+});
+```
+
+### Bottom Sheet
+
+```typescript
+test("bottom sheet interaction", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.goto("/product/123");
+
+ await page.getByRole("button", { name: "Add to Cart" }).click();
+
+ // Bottom sheet appears
+ const sheet = page.getByRole("dialog");
+ await expect(sheet).toBeVisible();
+
+ // Select options
+ await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");
+ await sheet.getByRole("button", { name: "Confirm" }).click();
+
+ await expect(page.getByText("Added to cart")).toBeVisible();
+});
+```
+
+### Pull to Refresh
+
+```typescript
+test("pull to refresh", async ({ page }) => {
+ await page.goto("/feed");
+
+ const feed = page.getByTestId("feed");
+ const initialFirstItem = await feed.locator("> *").first().textContent();
+
+ // Simulate pull down
+ const box = await feed.boundingBox();
+ if (box) {
+ await page.touchscreen.tap(box.x + box.width / 2, box.y + 50);
+ await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 });
+ await page.mouse.up();
+ }
+
+ // Wait for refresh
+ await expect(page.getByTestId("loading")).toBeVisible();
+ await expect(page.getByTestId("loading")).toBeHidden();
+
+ // Content should be updated (in a real app)
+});
+```
+
+## Responsive Breakpoints
+
+### Test All Breakpoints
+
+```typescript
+const breakpoints = {
+ xs: 320,
+ sm: 640,
+ md: 768,
+ lg: 1024,
+ xl: 1280,
+ "2xl": 1536,
+};
+
+test.describe("responsive header", () => {
+ for (const [name, width] of Object.entries(breakpoints)) {
+ test(`header at ${name} (${width}px)`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+
+ if (width < 768) {
+ await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
+ await expect(page.getByTestId("desktop-nav")).toBeHidden();
+ } else {
+ await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
+ await expect(page.getByTestId("desktop-nav")).toBeVisible();
+ }
+ });
+ }
+});
+```
+
+### Visual Regression at Breakpoints
+
+```typescript
+test.describe("visual regression", () => {
+ const sizes = [
+ { width: 375, height: 667, name: "mobile" },
+ { width: 768, height: 1024, name: "tablet" },
+ { width: 1440, height: 900, name: "desktop" },
+ ];
+
+ for (const { width, height, name } of sizes) {
+ test(`homepage at ${name}`, async ({ page }) => {
+ await page.setViewportSize({ width, height });
+ await page.goto("/");
+
+ await expect(page).toHaveScreenshot(`homepage-${name}.png`);
+ });
+ }
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| --------------------------- | ------------------------- | -------------------------------- |
+| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |
+| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |
+| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` |
+| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |
+
+## Related References
+
+- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing
+- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors
+- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)
+- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements
diff --git a/.cursor/skills/playwright-testing/advanced/multi-context.md b/.cursor/skills/playwright-testing/advanced/multi-context.md
new file mode 100644
index 0000000000..ed1cf8a07c
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/multi-context.md
@@ -0,0 +1,288 @@
+# Multi-Tab, Window & Popup Testing
+
+This file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).
+
+## Table of Contents
+
+1. [Popup Handling](#popup-handling)
+2. [New Tab Navigation](#new-tab-navigation)
+3. [OAuth Flows](#oauth-flows)
+4. [Multiple Windows](#multiple-windows)
+5. [Tab Coordination](#tab-coordination)
+
+## Popup Handling
+
+### Basic Popup
+
+```typescript
+test("handle popup window", async ({ page }) => {
+ await page.goto("/");
+
+ // Start waiting for popup before triggering it
+ const popupPromise = page.waitForEvent("popup");
+ await page.getByRole("button", { name: "Open Support Chat" }).click();
+ const popup = await popupPromise;
+
+ // Wait for popup to load
+ await popup.waitForLoadState();
+
+ // Interact with popup
+ await popup.getByLabel("Message").fill("Need help");
+ await popup.getByRole("button", { name: "Send" }).click();
+
+ await expect(popup.getByText("Message sent")).toBeVisible();
+
+ // Close popup
+ await popup.close();
+});
+```
+
+### Popup with Authentication
+
+```typescript
+test("popup login flow", async ({ page }) => {
+ await page.goto("/dashboard");
+
+ const popupPromise = page.waitForEvent("popup");
+ await page.getByRole("button", { name: "Connect Account" }).click();
+ const popup = await popupPromise;
+
+ await popup.waitForLoadState();
+
+ // Complete login in popup
+ await popup.getByLabel("Email").fill("user@example.com");
+ await popup.getByLabel("Password").fill("password123");
+ await popup.getByRole("button", { name: "Log In" }).click();
+
+ // Popup should close automatically after auth
+ await popup.waitForEvent("close");
+
+ // Main page should reflect connected state
+ await expect(page.getByText("Account connected")).toBeVisible();
+});
+```
+
+### Handle Blocked Popups
+
+```typescript
+test("handle popup blocker", async ({ page }) => {
+ await page.goto("/share");
+
+ // Listen for console messages about blocked popup
+ page.on("console", (msg) => {
+ if (msg.text().includes("popup blocked")) {
+ console.log("Popup was blocked");
+ }
+ });
+
+ const popupPromise = page.waitForEvent("popup").catch(() => null);
+ await page.getByRole("button", { name: "Share to Twitter" }).click();
+ const popup = await popupPromise;
+
+ if (!popup) {
+ // Popup blocked - app should show fallback
+ await expect(page.getByText("Copy share link instead")).toBeVisible();
+ }
+});
+```
+
+## New Tab Navigation
+
+### Link Opens in New Tab
+
+```typescript
+test("external link opens in new tab", async ({ page, context }) => {
+ await page.goto("/resources");
+
+ // Wait for new page in context
+ const pagePromise = context.waitForEvent("page");
+ await page.getByRole("link", { name: "Documentation" }).click();
+ const newPage = await pagePromise;
+
+ await newPage.waitForLoadState();
+
+ expect(newPage.url()).toContain("docs.example.com");
+ await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible();
+
+ // Original page still there
+ expect(page.url()).toContain("/resources");
+
+ await newPage.close();
+});
+```
+
+### Intercept New Tab
+
+```typescript
+test("prevent new tab for testing", async ({ page }) => {
+ await page.goto("/links");
+
+ // Remove target="_blank" to keep navigation in same tab
+ await page.evaluate(() => {
+ document.querySelectorAll('a[target="_blank"]').forEach((a) => {
+ a.removeAttribute("target");
+ });
+ });
+
+ // Now link opens in same tab
+ await page.getByRole("link", { name: "External Site" }).click();
+
+ // Can test the destination page
+ await expect(page).toHaveURL(/external-site\.com/);
+});
+```
+
+## OAuth Flows
+
+### Google OAuth Popup
+
+```typescript
+test("Google OAuth login", async ({ page }) => {
+ await page.goto("/login");
+
+ const popupPromise = page.waitForEvent("popup");
+ await page.getByRole("button", { name: "Sign in with Google" }).click();
+ const popup = await popupPromise;
+
+ await popup.waitForLoadState();
+
+ // Handle Google's OAuth flow
+ await popup.getByLabel("Email or phone").fill("test@gmail.com");
+ await popup.getByRole("button", { name: "Next" }).click();
+
+ await popup.getByLabel("Enter your password").fill("password");
+ await popup.getByRole("button", { name: "Next" }).click();
+
+ // Wait for redirect back and popup close
+ await popup.waitForEvent("close");
+
+ // Verify logged in on main page
+ await expect(page.getByText("Welcome, Test User")).toBeVisible();
+});
+```
+
+### Mock OAuth (Recommended)
+
+```typescript
+test("mock OAuth flow", async ({ page, context }) => {
+ // Mock the OAuth callback instead of real flow
+ await page.route("**/auth/callback**", async (route) => {
+ // Simulate successful OAuth
+ const url = new URL(route.request().url());
+ url.searchParams.set("code", "mock-auth-code");
+ await route.fulfill({
+ status: 302,
+ headers: { Location: "/dashboard" },
+ });
+ });
+
+ // Mock token exchange
+ await page.route("**/api/auth/token", (route) =>
+ route.fulfill({
+ json: {
+ access_token: "mock-token",
+ user: { name: "Test User", email: "test@example.com" },
+ },
+ }),
+ );
+
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Sign in with Google" }).click();
+
+ // Should redirect to dashboard without actual OAuth
+ await expect(page).toHaveURL("/dashboard");
+ await expect(page.getByText("Welcome, Test User")).toBeVisible();
+});
+```
+
+### OAuth Fixture
+
+> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.
+
+## Multiple Windows
+
+### Test Across Multiple Windows
+
+```typescript
+test("sync between windows", async ({ context }) => {
+ // Open two pages
+ const page1 = await context.newPage();
+ const page2 = await context.newPage();
+
+ await page1.goto("/dashboard");
+ await page2.goto("/dashboard");
+
+ // Make change in first window
+ await page1.getByRole("button", { name: "Add Item" }).click();
+ await page1.getByLabel("Name").fill("New Item");
+ await page1.getByRole("button", { name: "Save" }).click();
+
+ // Should sync to second window (if app supports real-time sync)
+ await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 });
+});
+```
+
+### Different Users in Different Windows
+
+> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.
+
+## Tab Coordination
+
+### Switch Between Tabs
+
+```typescript
+test("manage multiple tabs", async ({ context }) => {
+ const page1 = await context.newPage();
+ await page1.goto("/editor");
+
+ const page2 = await context.newPage();
+ await page2.goto("/preview");
+
+ // Edit in first tab
+ await page1.bringToFront();
+ await page1.getByLabel("Content").fill("Hello World");
+
+ // Check preview in second tab
+ await page2.bringToFront();
+ await page2.reload(); // If preview needs refresh
+ await expect(page2.getByText("Hello World")).toBeVisible();
+});
+```
+
+### Close All Tabs Except One
+
+```typescript
+test("cleanup tabs after test", async ({ context }) => {
+ const mainPage = await context.newPage();
+ await mainPage.goto("/");
+
+ // Open several popups during test
+ for (let i = 0; i < 3; i++) {
+ const popup = await context.newPage();
+ await popup.goto(`/popup/${i}`);
+ }
+
+ // Close all except main page
+ for (const page of context.pages()) {
+ if (page !== mainPage) {
+ await page.close();
+ }
+ }
+
+ expect(context.pages()).toHaveLength(1);
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ----------------------- | ------------------------------ | ------------------------------------------ |
+| Not waiting for popup | Race condition | Use `waitForEvent("popup")` before trigger |
+| Testing real OAuth | Slow, flaky, needs credentials | Mock OAuth endpoints |
+| Assuming popup opens | May be blocked | Handle both open and blocked cases |
+| Not closing extra pages | Resource leak | Close pages in cleanup |
+
+## Related References
+
+- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
+- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth
diff --git a/.cursor/skills/playwright-testing/advanced/multi-user.md b/.cursor/skills/playwright-testing/advanced/multi-user.md
new file mode 100644
index 0000000000..301e55c4fe
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/multi-user.md
@@ -0,0 +1,393 @@
+# Multi-User & Collaboration Testing
+
+## Table of Contents
+
+1. [Multiple Browser Contexts](#multiple-browser-contexts)
+2. [Real-Time Collaboration](#real-time-collaboration)
+3. [Role-Based Testing](#role-based-testing)
+4. [Concurrent Actions](#concurrent-actions)
+5. [Chat & Messaging](#chat--messaging)
+
+## Multiple Browser Contexts
+
+### Two Users in Same Test
+
+```typescript
+test("two users see each other's changes", async ({ browser }) => {
+ // Create two isolated contexts (like two browsers)
+ const userAContext = await browser.newContext();
+ const userBContext = await browser.newContext();
+
+ const userAPage = await userAContext.newPage();
+ const userBPage = await userBContext.newPage();
+
+ // Both users go to the same document
+ await userAPage.goto("/doc/shared-123");
+ await userBPage.goto("/doc/shared-123");
+
+ // User A types
+ await userAPage.getByLabel("Content").fill("Hello from User A");
+
+ // User B should see the change
+ await expect(userBPage.getByText("Hello from User A")).toBeVisible();
+
+ // Cleanup
+ await userAContext.close();
+ await userBContext.close();
+});
+```
+
+### Multiple Users with Auth States
+
+```typescript
+test("admin and user interaction", async ({ browser }) => {
+ // Load different auth states
+ const adminContext = await browser.newContext({
+ storageState: ".auth/admin.json",
+ });
+ const userContext = await browser.newContext({
+ storageState: ".auth/user.json",
+ });
+
+ const adminPage = await adminContext.newPage();
+ const userPage = await userContext.newPage();
+
+ // User submits request
+ await userPage.goto("/support");
+ await userPage.getByLabel("Message").fill("Need help!");
+ await userPage.getByRole("button", { name: "Submit" }).click();
+
+ // Admin sees and responds
+ await adminPage.goto("/admin/tickets");
+ await expect(adminPage.getByText("Need help!")).toBeVisible();
+ await adminPage.getByRole("button", { name: "Reply" }).click();
+ await adminPage.getByLabel("Response").fill("How can I help?");
+ await adminPage.getByRole("button", { name: "Send" }).click();
+
+ // User sees response
+ await expect(userPage.getByText("How can I help?")).toBeVisible();
+
+ await adminContext.close();
+ await userContext.close();
+});
+```
+
+### Multi-User Fixture
+
+```typescript
+// fixtures/multi-user.fixture.ts
+import { test as base, Browser, BrowserContext, Page } from "@playwright/test";
+
+type UserSession = {
+ context: BrowserContext;
+ page: Page;
+};
+
+type MultiUserFixtures = {
+ createUser: (authState?: string) => Promise;
+};
+
+export const test = base.extend({
+ createUser: async ({ browser }, use) => {
+ const sessions: UserSession[] = [];
+
+ await use(async (authState) => {
+ const context = await browser.newContext({
+ storageState: authState,
+ });
+ const page = await context.newPage();
+ sessions.push({ context, page });
+ return { context, page };
+ });
+
+ // Cleanup all sessions
+ for (const session of sessions) {
+ await session.context.close();
+ }
+ },
+});
+
+// Usage
+test("3 users collaborate", async ({ createUser }) => {
+ const alice = await createUser(".auth/alice.json");
+ const bob = await createUser(".auth/bob.json");
+ const charlie = await createUser(".auth/charlie.json");
+
+ // All navigate to same room
+ await alice.page.goto("/room/123");
+ await bob.page.goto("/room/123");
+ await charlie.page.goto("/room/123");
+
+ // Test interactions...
+});
+```
+
+## Real-Time Collaboration
+
+### Collaborative Document
+
+```typescript
+test("real-time collaborative editing", async ({ browser }) => {
+ const user1 = await browser.newContext();
+ const user2 = await browser.newContext();
+
+ const page1 = await user1.newPage();
+ const page2 = await user2.newPage();
+
+ await page1.goto("/docs/shared");
+ await page2.goto("/docs/shared");
+
+ // User 1 types at the beginning
+ const editor1 = page1.getByRole("textbox");
+ await editor1.click();
+ await editor1.press("Home");
+ await editor1.type("User 1: ");
+
+ // User 2 types at the end
+ const editor2 = page2.getByRole("textbox");
+ await editor2.click();
+ await editor2.press("End");
+ await editor2.type(" - User 2");
+
+ // Both should see combined result
+ await expect(page1.getByRole("textbox")).toContainText("User 1:");
+ await expect(page1.getByRole("textbox")).toContainText("- User 2");
+ await expect(page2.getByRole("textbox")).toContainText("User 1:");
+ await expect(page2.getByRole("textbox")).toContainText("- User 2");
+
+ await user1.close();
+ await user2.close();
+});
+```
+
+### Cursor Presence
+
+```typescript
+test("shows other user cursors", async ({ browser }) => {
+ const ctx1 = await browser.newContext();
+ const ctx2 = await browser.newContext();
+
+ const page1 = await ctx1.newPage();
+ const page2 = await ctx2.newPage();
+
+ // Mock to identify users
+ await page1.route("**/api/me", (route) =>
+ route.fulfill({ json: { id: "user-1", name: "Alice" } }),
+ );
+ await page2.route("**/api/me", (route) =>
+ route.fulfill({ json: { id: "user-2", name: "Bob" } }),
+ );
+
+ await page1.goto("/whiteboard/123");
+ await page2.goto("/whiteboard/123");
+
+ // Move cursor on page1
+ await page1.mouse.move(200, 200);
+
+ // Page2 should see Alice's cursor
+ await expect(page2.getByTestId("cursor-user-1")).toBeVisible();
+ await expect(page2.getByText("Alice")).toBeVisible();
+
+ await ctx1.close();
+ await ctx2.close();
+});
+```
+
+## Role-Based Testing
+
+### Test RBAC
+
+```typescript
+const roles = [
+ { role: "admin", canDelete: true, canEdit: true, canView: true },
+ { role: "editor", canDelete: false, canEdit: true, canView: true },
+ { role: "viewer", canDelete: false, canEdit: false, canView: true },
+];
+
+for (const { role, canDelete, canEdit, canView } of roles) {
+ test(`${role} permissions`, async ({ browser }) => {
+ const context = await browser.newContext({
+ storageState: `.auth/${role}.json`,
+ });
+ const page = await context.newPage();
+
+ await page.goto("/document/123");
+
+ // Check view permission
+ if (canView) {
+ await expect(page.getByTestId("content")).toBeVisible();
+ } else {
+ await expect(page.getByText("Access denied")).toBeVisible();
+ }
+
+ // Check edit permission
+ const editButton = page.getByRole("button", { name: "Edit" });
+ if (canEdit) {
+ await expect(editButton).toBeEnabled();
+ } else {
+ await expect(editButton).toBeDisabled();
+ }
+
+ // Check delete permission
+ const deleteButton = page.getByRole("button", { name: "Delete" });
+ if (canDelete) {
+ await expect(deleteButton).toBeVisible();
+ } else {
+ await expect(deleteButton).toBeHidden();
+ }
+
+ await context.close();
+ });
+}
+```
+
+### Permission Escalation Test
+
+```typescript
+test("cannot access admin routes as user", async ({ browser }) => {
+ const userContext = await browser.newContext({
+ storageState: ".auth/user.json",
+ });
+ const page = await userContext.newPage();
+
+ // Try to access admin page directly
+ await page.goto("/admin/users");
+
+ // Should redirect or show error
+ await expect(page).not.toHaveURL("/admin/users");
+ await expect(page.getByText("Access denied")).toBeVisible();
+
+ await userContext.close();
+});
+```
+
+## Concurrent Actions
+
+### Race Condition Testing
+
+```typescript
+test("handles concurrent edits", async ({ browser }) => {
+ const ctx1 = await browser.newContext();
+ const ctx2 = await browser.newContext();
+
+ const page1 = await ctx1.newPage();
+ const page2 = await ctx2.newPage();
+
+ await page1.goto("/item/123");
+ await page2.goto("/item/123");
+
+ // Both click edit at the same time
+ await Promise.all([
+ page1.getByRole("button", { name: "Edit" }).click(),
+ page2.getByRole("button", { name: "Edit" }).click(),
+ ]);
+
+ // Both try to save different values
+ await page1.getByLabel("Name").fill("Value from User 1");
+ await page2.getByLabel("Name").fill("Value from User 2");
+
+ await Promise.all([
+ page1.getByRole("button", { name: "Save" }).click(),
+ page2.getByRole("button", { name: "Save" }).click(),
+ ]);
+
+ // One should succeed, one should get conflict error
+ const page1HasConflict = await page1.getByText("Conflict").isVisible();
+ const page2HasConflict = await page2.getByText("Conflict").isVisible();
+
+ // Exactly one should have conflict
+ expect(page1HasConflict || page2HasConflict).toBe(true);
+ expect(page1HasConflict && page2HasConflict).toBe(false);
+
+ await ctx1.close();
+ await ctx2.close();
+});
+```
+
+### Optimistic Locking Test
+
+```typescript
+test("optimistic locking prevents overwrites", async ({ browser }) => {
+ const ctx1 = await browser.newContext();
+ const ctx2 = await browser.newContext();
+
+ const page1 = await ctx1.newPage();
+ const page2 = await ctx2.newPage();
+
+ // Both load the same version
+ await page1.goto("/record/123");
+ await page2.goto("/record/123");
+
+ // User 1 edits and saves first
+ await page1.getByRole("button", { name: "Edit" }).click();
+ await page1.getByLabel("Value").fill("Updated by User 1");
+ await page1.getByRole("button", { name: "Save" }).click();
+ await expect(page1.getByText("Saved")).toBeVisible();
+
+ // User 2 tries to save with stale version
+ await page2.getByRole("button", { name: "Edit" }).click();
+ await page2.getByLabel("Value").fill("Updated by User 2");
+ await page2.getByRole("button", { name: "Save" }).click();
+
+ // Should fail with version conflict
+ await expect(page2.getByText("Someone else modified this")).toBeVisible();
+ await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible();
+
+ await ctx1.close();
+ await ctx2.close();
+});
+```
+
+## Chat & Messaging
+
+### Real-Time Chat
+
+```typescript
+test("chat messages sync between users", async ({ browser }) => {
+ const aliceCtx = await browser.newContext();
+ const bobCtx = await browser.newContext();
+
+ const alicePage = await aliceCtx.newPage();
+ const bobPage = await bobCtx.newPage();
+
+ // Setup user identities
+ await alicePage.route("**/api/me", (r) =>
+ r.fulfill({ json: { name: "Alice" } }),
+ );
+ await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } }));
+
+ await alicePage.goto("/chat/room-1");
+ await bobPage.goto("/chat/room-1");
+
+ // Alice sends message
+ await alicePage.getByLabel("Message").fill("Hi Bob!");
+ await alicePage.getByRole("button", { name: "Send" }).click();
+
+ // Bob sees it
+ await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible();
+
+ // Bob replies
+ await bobPage.getByLabel("Message").fill("Hey Alice!");
+ await bobPage.getByRole("button", { name: "Send" }).click();
+
+ // Alice sees it
+ await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible();
+
+ await aliceCtx.close();
+ await bobCtx.close();
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ----------------------------- | ----------------------------- | ---------------------------- |
+| Sharing context between users | State leaks, not isolated | Create separate contexts |
+| Not closing contexts | Memory leak, browser overload | Always close in cleanup |
+| Hardcoded timing for sync | Flaky tests | Use `expect().toBeVisible()` |
+| Testing only single user | Misses collaboration bugs | Test multi-user scenarios |
+
+## Related References
+
+- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup
+- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
diff --git a/.cursor/skills/playwright-testing/advanced/network-advanced.md b/.cursor/skills/playwright-testing/advanced/network-advanced.md
new file mode 100644
index 0000000000..fa017fe53f
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/network-advanced.md
@@ -0,0 +1,452 @@
+# Advanced Network Interception
+
+## Table of Contents
+
+1. [Request Modification](#request-modification)
+2. [GraphQL Mocking](#graphql-mocking)
+3. [HAR Recording & Playback](#har-recording--playback)
+4. [Conditional Mocking](#conditional-mocking)
+5. [Network Throttling](#network-throttling)
+
+## Request Modification
+
+### Modify Request Headers
+
+```typescript
+test("add auth header to requests", async ({ page }) => {
+ await page.route("**/api/**", (route) => {
+ const headers = {
+ ...route.request().headers(),
+ Authorization: "Bearer test-token",
+ "X-Test-Header": "test-value",
+ };
+ route.continue({ headers });
+ });
+
+ await page.goto("/dashboard");
+});
+```
+
+### Modify Request Body
+
+```typescript
+test("modify POST body", async ({ page }) => {
+ await page.route("**/api/orders", async (route) => {
+ if (route.request().method() === "POST") {
+ const postData = route.request().postDataJSON();
+
+ // Add test metadata
+ const modifiedData = {
+ ...postData,
+ testMode: true,
+ testTimestamp: Date.now(),
+ };
+
+ await route.continue({
+ postData: JSON.stringify(modifiedData),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Place Order" }).click();
+});
+```
+
+### Transform Response
+
+```typescript
+test("modify API response", async ({ page }) => {
+ await page.route("**/api/products", async (route) => {
+ // Fetch real response
+ const response = await route.fetch();
+ const json = await response.json();
+
+ // Modify response
+ const modified = json.map((product: any) => ({
+ ...product,
+ price: product.price * 0.9, // 10% discount
+ testMode: true,
+ }));
+
+ await route.fulfill({
+ response,
+ json: modified,
+ });
+ });
+
+ await page.goto("/products");
+});
+```
+
+## GraphQL Mocking
+
+### Mock by Operation Name
+
+```typescript
+test("mock GraphQL query", async ({ page }) => {
+ await page.route("**/graphql", async (route) => {
+ const postData = route.request().postDataJSON();
+
+ if (postData.operationName === "GetUser") {
+ return route.fulfill({
+ json: {
+ data: {
+ user: {
+ id: "1",
+ name: "Test User",
+ email: "test@example.com",
+ },
+ },
+ },
+ });
+ }
+
+ if (postData.operationName === "GetProducts") {
+ return route.fulfill({
+ json: {
+ data: {
+ products: [
+ { id: "1", name: "Product A", price: 29.99 },
+ { id: "2", name: "Product B", price: 49.99 },
+ ],
+ },
+ },
+ });
+ }
+
+ // Pass through unmocked operations
+ return route.continue();
+ });
+
+ await page.goto("/dashboard");
+});
+```
+
+### GraphQL Mock Fixture
+
+```typescript
+// fixtures/graphql.fixture.ts
+type GraphQLMock = {
+ operation: string;
+ variables?: Record;
+ response: { data?: any; errors?: any[] };
+};
+
+type GraphQLFixtures = {
+ mockGraphQL: (mocks: GraphQLMock[]) => Promise;
+};
+
+export const test = base.extend({
+ mockGraphQL: async ({ page }, use) => {
+ await use(async (mocks) => {
+ await page.route("**/graphql", async (route) => {
+ const postData = route.request().postDataJSON();
+
+ const mock = mocks.find((m) => {
+ if (m.operation !== postData.operationName) return false;
+
+ // Optionally match variables
+ if (m.variables) {
+ return (
+ JSON.stringify(m.variables) === JSON.stringify(postData.variables)
+ );
+ }
+ return true;
+ });
+
+ if (mock) {
+ return route.fulfill({ json: mock.response });
+ }
+
+ return route.continue();
+ });
+ });
+ },
+});
+
+// Usage
+test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => {
+ await mockGraphQL([
+ {
+ operation: "GetDashboardStats",
+ response: {
+ data: { stats: { users: 100, revenue: 50000 } },
+ },
+ },
+ {
+ operation: "GetUser",
+ variables: { id: "1" },
+ response: {
+ data: { user: { id: "1", name: "John" } },
+ },
+ },
+ ]);
+
+ await page.goto("/dashboard");
+ await expect(page.getByText("100 users")).toBeVisible();
+});
+```
+
+### Mock GraphQL Mutations
+
+```typescript
+test("mock GraphQL mutation", async ({ page }) => {
+ await page.route("**/graphql", async (route) => {
+ const postData = route.request().postDataJSON();
+
+ if (postData.operationName === "CreateOrder") {
+ const { input } = postData.variables;
+
+ return route.fulfill({
+ json: {
+ data: {
+ createOrder: {
+ id: "order-123",
+ status: "PENDING",
+ items: input.items,
+ total: input.items.reduce(
+ (sum: number, item: any) => sum + item.price * item.quantity,
+ 0,
+ ),
+ },
+ },
+ },
+ });
+ }
+
+ return route.continue();
+ });
+
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Place Order" }).click();
+
+ await expect(page.getByText("Order #order-123")).toBeVisible();
+});
+```
+
+## HAR Recording & Playback
+
+### Record HAR File
+
+```typescript
+// Record network traffic
+test("record HAR", async ({ page, context }) => {
+ // Start recording
+ await context.routeFromHAR("./recordings/checkout.har", {
+ update: true, // Create/update HAR file
+ url: "**/api/**",
+ });
+
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Place Order" }).click();
+
+ // HAR file is saved automatically
+});
+```
+
+### Playback HAR File
+
+```typescript
+// Use recorded HAR for offline testing
+test("playback HAR", async ({ page, context }) => {
+ await context.routeFromHAR("./recordings/checkout.har", {
+ url: "**/api/**",
+ update: false, // Don't update, just playback
+ });
+
+ await page.goto("/checkout");
+
+ // All API calls served from HAR file
+ await expect(page.getByText("Order confirmed")).toBeVisible();
+});
+```
+
+### HAR with Fallback
+
+```typescript
+test("HAR with live fallback", async ({ page, context }) => {
+ await context.routeFromHAR("./recordings/api.har", {
+ url: "**/api/**",
+ update: false,
+ notFound: "fallback", // Use real network if not in HAR
+ });
+
+ await page.goto("/dashboard");
+});
+```
+
+## Conditional Mocking
+
+### Mock Based on Request Body
+
+```typescript
+test("conditional mock by body", async ({ page }) => {
+ await page.route("**/api/search", async (route) => {
+ const body = route.request().postDataJSON();
+
+ if (body.query === "error") {
+ return route.fulfill({
+ status: 500,
+ json: { error: "Search failed" },
+ });
+ }
+
+ if (body.query === "empty") {
+ return route.fulfill({
+ json: { results: [] },
+ });
+ }
+
+ // Default response
+ return route.fulfill({
+ json: {
+ results: [{ id: 1, title: `Result for: ${body.query}` }],
+ },
+ });
+ });
+
+ await page.goto("/search");
+
+ // Test different scenarios
+ await page.getByLabel("Search").fill("error");
+ await page.getByLabel("Search").press("Enter");
+ await expect(page.getByText("Search failed")).toBeVisible();
+});
+```
+
+### Mock Nth Request
+
+```typescript
+test("different response on retry", async ({ page }) => {
+ let callCount = 0;
+
+ await page.route("**/api/status", (route) => {
+ callCount++;
+
+ if (callCount < 3) {
+ return route.fulfill({
+ status: 503,
+ json: { error: "Service unavailable" },
+ });
+ }
+
+ // Succeed on 3rd attempt
+ return route.fulfill({
+ json: { status: "ok" },
+ });
+ });
+
+ await page.goto("/dashboard");
+
+ // App should retry and eventually succeed
+ await expect(page.getByText("Connected")).toBeVisible();
+});
+```
+
+### Mock with Delay
+
+```typescript
+test("slow network simulation", async ({ page }) => {
+ await page.route("**/api/data", async (route) => {
+ // Simulate 2 second delay
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ return route.fulfill({
+ json: { data: "loaded" },
+ });
+ });
+
+ await page.goto("/dashboard");
+
+ // Loading state should appear
+ await expect(page.getByText("Loading...")).toBeVisible();
+
+ // Then data appears
+ await expect(page.getByText("loaded")).toBeVisible();
+});
+```
+
+## Network Throttling
+
+### Slow 3G Simulation
+
+```typescript
+test("slow network experience", async ({ page, context }) => {
+ // Create CDP session for network throttling
+ const client = await context.newCDPSession(page);
+
+ await client.send("Network.emulateNetworkConditions", {
+ offline: false,
+ downloadThroughput: (500 * 1024) / 8, // 500 Kbps
+ uploadThroughput: (500 * 1024) / 8,
+ latency: 400, // 400ms
+ });
+
+ await page.goto("/");
+
+ // Test loading states appear
+ await expect(page.getByTestId("skeleton-loader")).toBeVisible();
+});
+```
+
+### Offline Mode
+
+Use `context.setOffline(true/false)` to simulate network connectivity changes.
+
+> **For comprehensive offline testing patterns:**
+>
+> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)
+> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)
+
+### Network Throttling Fixture
+
+```typescript
+// fixtures/network.fixture.ts
+type NetworkCondition = "slow3g" | "fast3g" | "offline";
+
+const conditions = {
+ slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 },
+ fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 },
+};
+
+type NetworkFixtures = {
+ setNetworkCondition: (condition: NetworkCondition) => Promise;
+};
+
+export const test = base.extend({
+ setNetworkCondition: async ({ page, context }, use) => {
+ const client = await context.newCDPSession(page);
+
+ await use(async (condition) => {
+ if (condition === "offline") {
+ await context.setOffline(true);
+ } else {
+ await client.send("Network.emulateNetworkConditions", {
+ offline: false,
+ ...conditions[condition],
+ });
+ }
+ });
+
+ // Reset
+ await context.setOffline(false);
+ },
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ------------------------ | ------------------------------ | -------------------------------- |
+| Mocking all requests | Tests don't reflect reality | Mock only what's necessary |
+| No cleanup of routes | Routes persist across tests | Use fixtures with cleanup |
+| Ignoring request method | Mock applies to wrong requests | Check `route.request().method()` |
+| Hardcoded mock responses | Brittle, hard to maintain | Use factories for mock data |
+
+## Related References
+
+- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking
+- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
diff --git a/.cursor/skills/playwright-testing/advanced/third-party.md b/.cursor/skills/playwright-testing/advanced/third-party.md
new file mode 100644
index 0000000000..acf8ab8901
--- /dev/null
+++ b/.cursor/skills/playwright-testing/advanced/third-party.md
@@ -0,0 +1,464 @@
+# Third-Party Service Mocking
+
+## Table of Contents
+
+1. [OAuth/SSO Mocking](#oauthsso-mocking)
+2. [Payment Gateway Mocking](#payment-gateway-mocking)
+3. [Email Verification](#email-verification)
+4. [SMS Verification](#sms-verification)
+5. [Analytics & Tracking](#analytics--tracking)
+
+## OAuth/SSO Mocking
+
+### Mock Google OAuth
+
+```typescript
+test("Google OAuth login", async ({ page }) => {
+ // Mock the OAuth callback
+ await page.route("**/auth/google/callback**", (route) => {
+ const url = new URL(route.request().url());
+ // Simulate successful OAuth by redirecting with token
+ route.fulfill({
+ status: 302,
+ headers: {
+ Location: "/dashboard?token=mock-jwt-token",
+ },
+ });
+ });
+
+ // Mock the token verification endpoint
+ await page.route("**/api/auth/verify", (route) =>
+ route.fulfill({
+ json: {
+ valid: true,
+ user: {
+ id: "123",
+ email: "test@gmail.com",
+ name: "Test User",
+ },
+ },
+ }),
+ );
+
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Sign in with Google" }).click();
+
+ await expect(page.getByText("Welcome, Test User")).toBeVisible();
+});
+```
+
+### OAuth Fixture
+
+```typescript
+// fixtures/oauth.fixture.ts
+type OAuthProvider = "google" | "github" | "microsoft";
+
+type OAuthUser = {
+ id: string;
+ email: string;
+ name: string;
+ avatar?: string;
+};
+
+type OAuthFixtures = {
+ mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise;
+};
+
+export const test = base.extend({
+ mockOAuth: async ({ page }, use) => {
+ await use(async (provider, user) => {
+ // Mock callback redirect
+ await page.route(`**/auth/${provider}/callback**`, (route) =>
+ route.fulfill({
+ status: 302,
+ headers: { Location: `/auth/success?provider=${provider}` },
+ }),
+ );
+
+ // Mock session/user endpoint
+ await page.route("**/api/auth/session", (route) =>
+ route.fulfill({
+ json: { user, provider, authenticated: true },
+ }),
+ );
+
+ // Mock user info endpoint
+ await page.route("**/api/me", (route) => route.fulfill({ json: user }));
+ });
+ },
+});
+
+// Usage
+test("login with GitHub", async ({ page, mockOAuth }) => {
+ await mockOAuth("github", {
+ id: "gh-123",
+ email: "dev@github.com",
+ name: "GitHub User",
+ });
+
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Sign in with GitHub" }).click();
+
+ await expect(page.getByText("Welcome, GitHub User")).toBeVisible();
+});
+```
+
+### Mock SAML SSO
+
+```typescript
+test("SAML SSO login", async ({ page }) => {
+ // Mock SAML assertion consumer service
+ await page.route("**/saml/acs", async (route) => {
+ route.fulfill({
+ status: 302,
+ headers: {
+ Location: "/dashboard",
+ "Set-Cookie": "session=mock-saml-session; Path=/; HttpOnly",
+ },
+ });
+ });
+
+ // Mock session validation
+ await page.route("**/api/session", (route) =>
+ route.fulfill({
+ json: {
+ user: { email: "user@company.com", name: "SSO User" },
+ provider: "saml",
+ },
+ }),
+ );
+
+ await page.goto("/login");
+ await page.getByRole("button", { name: "SSO Login" }).click();
+
+ await expect(page).toHaveURL("/dashboard");
+});
+```
+
+## Payment Gateway Mocking
+
+### Mock Stripe
+
+```typescript
+test("Stripe checkout", async ({ page }) => {
+ // Mock Stripe.js
+ await page.addInitScript(() => {
+ (window as any).Stripe = () => ({
+ elements: () => ({
+ create: () => ({
+ mount: () => {},
+ on: () => {},
+ destroy: () => {},
+ }),
+ }),
+ confirmCardPayment: async () => ({
+ paymentIntent: { status: "succeeded", id: "pi_mock_123" },
+ }),
+ createPaymentMethod: async () => ({
+ paymentMethod: { id: "pm_mock_123" },
+ }),
+ });
+ });
+
+ // Mock backend payment endpoint
+ await page.route("**/api/create-payment-intent", (route) =>
+ route.fulfill({
+ json: { clientSecret: "pi_mock_123_secret_mock" },
+ }),
+ );
+
+ await page.route("**/api/confirm-payment", (route) =>
+ route.fulfill({
+ json: { success: true, orderId: "order-123" },
+ }),
+ );
+
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Pay $99.99" }).click();
+
+ await expect(page.getByText("Payment successful")).toBeVisible();
+});
+```
+
+### Mock PayPal
+
+```typescript
+test("PayPal checkout", async ({ page }) => {
+ // Mock PayPal SDK
+ await page.addInitScript(() => {
+ (window as any).paypal = {
+ Buttons: () => ({
+ render: () => Promise.resolve(),
+ isEligible: () => true,
+ }),
+ FUNDING: { PAYPAL: "paypal", CARD: "card" },
+ };
+ });
+
+ // Mock PayPal order creation
+ await page.route("**/api/paypal/create-order", (route) =>
+ route.fulfill({
+ json: { orderId: "PAYPAL-ORDER-123" },
+ }),
+ );
+
+ // Mock PayPal capture
+ await page.route("**/api/paypal/capture", (route) =>
+ route.fulfill({
+ json: { success: true, transactionId: "TXN-123" },
+ }),
+ );
+
+ await page.goto("/checkout");
+
+ // Simulate PayPal approval callback
+ await page.evaluate(() => {
+ (window as any).onPayPalApprove?.({ orderID: "PAYPAL-ORDER-123" });
+ });
+
+ await expect(page.getByText("Order confirmed")).toBeVisible();
+});
+```
+
+### Payment Fixture
+
+```typescript
+// fixtures/payment.fixture.ts
+type PaymentFixtures = {
+ mockStripe: (options?: { failPayment?: boolean }) => Promise;
+};
+
+export const test = base.extend({
+ mockStripe: async ({ page }, use) => {
+ await use(async (options = {}) => {
+ await page.addInitScript(
+ ([shouldFail]) => {
+ (window as any).Stripe = () => ({
+ elements: () => ({
+ create: () => ({
+ mount: () => {},
+ on: (event: string, handler: Function) => {
+ if (event === "ready") setTimeout(handler, 100);
+ },
+ destroy: () => {},
+ }),
+ }),
+ confirmCardPayment: async () => {
+ if (shouldFail) {
+ return { error: { message: "Card declined" } };
+ }
+ return { paymentIntent: { status: "succeeded" } };
+ },
+ });
+ },
+ [options.failPayment],
+ );
+ });
+ },
+});
+
+// Usage
+test("handles declined card", async ({ page, mockStripe }) => {
+ await mockStripe({ failPayment: true });
+
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Pay" }).click();
+
+ await expect(page.getByText("Card declined")).toBeVisible();
+});
+```
+
+## Email Verification
+
+### Mock Email API
+
+```typescript
+test("email verification flow", async ({ page, request }) => {
+ let verificationToken: string;
+
+ // Capture the verification email
+ await page.route("**/api/send-verification", async (route) => {
+ const body = route.request().postDataJSON();
+ verificationToken = `mock-token-${Date.now()}`;
+
+ // Don't actually send email, just store token
+ route.fulfill({
+ json: { sent: true, messageId: "msg-123" },
+ });
+ });
+
+ // Mock token verification
+ await page.route("**/api/verify-email**", (route) => {
+ const url = new URL(route.request().url());
+ const token = url.searchParams.get("token");
+
+ if (token === verificationToken) {
+ route.fulfill({ json: { verified: true } });
+ } else {
+ route.fulfill({ status: 400, json: { error: "Invalid token" } });
+ }
+ });
+
+ await page.goto("/signup");
+ await page.getByLabel("Email").fill("test@example.com");
+ await page.getByRole("button", { name: "Sign Up" }).click();
+
+ await expect(page.getByText("Check your email")).toBeVisible();
+
+ // Simulate clicking email link
+ await page.goto(`/verify?token=${verificationToken}`);
+
+ await expect(page.getByText("Email verified")).toBeVisible();
+});
+```
+
+### Use Mailinator/Temp Mail
+
+```typescript
+// fixtures/email.fixture.ts
+type EmailFixtures = {
+ getVerificationEmail: (inbox: string) => Promise<{ link: string }>;
+};
+
+export const test = base.extend({
+ getVerificationEmail: async ({ request }, use) => {
+ await use(async (inbox) => {
+ // Poll Mailinator API for new email
+ const response = await request.get(
+ `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,
+ {
+ headers: {
+ Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
+ },
+ },
+ );
+
+ const messages = await response.json();
+ const latest = messages.msgs[0];
+
+ // Get full message
+ const msgResponse = await request.get(
+ `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,
+ {
+ headers: {
+ Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
+ },
+ },
+ );
+
+ const message = await msgResponse.json();
+
+ // Extract verification link from HTML
+ const linkMatch = message.parts[0].body.match(
+ /href="([^"]*verify[^"]*)"/,
+ );
+ return { link: linkMatch?.[1] || "" };
+ });
+ },
+});
+```
+
+## SMS Verification
+
+### Mock SMS API
+
+```typescript
+test("SMS verification", async ({ page }) => {
+ let smsCode: string;
+
+ // Capture SMS send
+ await page.route("**/api/send-sms", (route) => {
+ smsCode = Math.random().toString().slice(2, 8); // 6-digit code
+
+ route.fulfill({
+ json: { sent: true, messageId: "sms-123" },
+ });
+ });
+
+ // Mock code verification
+ await page.route("**/api/verify-sms", (route) => {
+ const body = route.request().postDataJSON();
+
+ if (body.code === smsCode) {
+ route.fulfill({ json: { verified: true } });
+ } else {
+ route.fulfill({ status: 400, json: { error: "Invalid code" } });
+ }
+ });
+
+ await page.goto("/verify-phone");
+ await page.getByLabel("Phone").fill("+1234567890");
+ await page.getByRole("button", { name: "Send Code" }).click();
+
+ // Enter the code
+ await page.getByLabel("Verification Code").fill(smsCode);
+ await page.getByRole("button", { name: "Verify" }).click();
+
+ await expect(page.getByText("Phone verified")).toBeVisible();
+});
+```
+
+## Analytics & Tracking
+
+### Block Analytics in Tests
+
+```typescript
+test.beforeEach(async ({ page }) => {
+ // Block all analytics/tracking
+ await page.route(
+ /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,
+ (route) => route.abort(),
+ );
+});
+```
+
+### Mock Analytics for Verification
+
+```typescript
+test("tracks purchase event", async ({ page }) => {
+ const analyticsEvents: any[] = [];
+
+ // Capture analytics calls
+ await page.route("**/api/analytics/**", (route) => {
+ analyticsEvents.push(route.request().postDataJSON());
+ route.fulfill({ status: 200 });
+ });
+
+ // Mock analytics SDK
+ await page.addInitScript(() => {
+ (window as any).analytics = {
+ track: (event: string, props: any) => {
+ fetch("/api/analytics/track", {
+ method: "POST",
+ body: JSON.stringify({ event, props }),
+ });
+ },
+ };
+ });
+
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Complete Purchase" }).click();
+
+ // Verify analytics event was sent
+ expect(analyticsEvents).toContainEqual(
+ expect.objectContaining({
+ event: "Purchase Completed",
+ props: expect.objectContaining({ amount: expect.any(Number) }),
+ }),
+ );
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ------------------------- | ------------------------------ | ----------------------- |
+| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints |
+| Real payment processing | Charges real money, slow | Use test mode or mock |
+| Waiting for real emails | Very slow, unreliable | Mock email API |
+| Not mocking analytics | Pollutes analytics data | Block or mock analytics |
+
+## Related References
+
+- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns
+- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
diff --git a/.cursor/skills/playwright-testing/architecture/pom-vs-fixtures.md b/.cursor/skills/playwright-testing/architecture/pom-vs-fixtures.md
new file mode 100644
index 0000000000..eafb06f767
--- /dev/null
+++ b/.cursor/skills/playwright-testing/architecture/pom-vs-fixtures.md
@@ -0,0 +1,363 @@
+# Organizing Reusable Test Code
+
+## Table of Contents
+
+1. [Pattern Comparison](#pattern-comparison)
+2. [Selection Flowchart](#selection-flowchart)
+3. [Page Objects](#page-objects)
+4. [Custom Fixtures](#custom-fixtures)
+5. [Helper Functions](#helper-functions)
+6. [Combined Project Structure](#combined-project-structure)
+7. [Anti-Patterns](#anti-patterns)
+
+Use all three patterns together. Most projects benefit from a hybrid approach:
+
+- **Page objects** for UI interaction (pages/components with 5+ interactions)
+- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)
+- **Helper functions** for stateless utilities (generate data, format values, simple waits)
+
+If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.
+
+## Pattern Comparison
+
+| Aspect | Page Objects | Custom Fixtures | Helper Functions |
+|---|---|---|---|
+| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities |
+| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None |
+| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions |
+| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects |
+
+## Selection Flowchart
+
+```text
+What kind of reusable code?
+|
++-- Interacts with browser page/component?
+| |
+| +-- Has 5+ interactions (fill, click, navigate, assert)?
+| | +-- YES: Used in 3+ test files?
+| | | +-- YES --> PAGE OBJECT
+| | | +-- NO --> Inline or small helper
+| | +-- NO --> HELPER FUNCTION
+| |
+| +-- Needs setup before AND cleanup after test?
+| +-- YES --> CUSTOM FIXTURE
+| +-- NO --> PAGE OBJECT method or HELPER
+|
++-- Manages resource with lifecycle (create/destroy)?
+| +-- Examples: auth state, DB connection, API client, test user
+| +-- YES --> CUSTOM FIXTURE (always)
+|
++-- Stateless utility? (no browser, no side effects)
+| +-- Examples: random email, format date, build URL, parse response
+| +-- YES --> HELPER FUNCTION
+|
++-- Not sure?
+ +-- Start with HELPER FUNCTION
+ +-- Promote to PAGE OBJECT when interactions grow
+ +-- Promote to FIXTURE when lifecycle needed
+```
+
+## Page Objects
+
+Best for pages/components with 5+ interactions appearing in 3+ test files.
+
+```typescript
+// page-objects/booking.page.ts
+import { type Page, type Locator, expect } from '@playwright/test';
+
+export class BookingPage {
+ readonly page: Page;
+ readonly dateField: Locator;
+ readonly guestCount: Locator;
+ readonly roomType: Locator;
+ readonly reserveBtn: Locator;
+ readonly totalPrice: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.dateField = page.getByLabel('Check-in date');
+ this.guestCount = page.getByLabel('Guests');
+ this.roomType = page.getByLabel('Room type');
+ this.reserveBtn = page.getByRole('button', { name: 'Reserve' });
+ this.totalPrice = page.getByTestId('total-price');
+ }
+
+ async goto() {
+ await this.page.goto('/booking');
+ }
+
+ async fillDetails(opts: { date: string; guests: number; room: string }) {
+ await this.dateField.fill(opts.date);
+ await this.guestCount.fill(String(opts.guests));
+ await this.roomType.selectOption(opts.room);
+ }
+
+ async reserve() {
+ await this.reserveBtn.click();
+ await this.page.waitForURL('**/confirmation');
+ }
+
+ async expectPrice(amount: string) {
+ await expect(this.totalPrice).toHaveText(amount);
+ }
+}
+```
+
+```typescript
+// tests/booking/reservation.spec.ts
+import { test, expect } from '@playwright/test';
+import { BookingPage } from '../page-objects/booking.page';
+
+test('complete reservation with standard room', async ({ page }) => {
+ const booking = new BookingPage(page);
+ await booking.goto();
+ await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' });
+ await booking.reserve();
+ await expect(page.getByText('Reservation confirmed')).toBeVisible();
+});
+```
+
+**Page object principles:**
+- One class per logical page/component, not per URL
+- Constructor takes `Page`
+- Locators as `readonly` properties in constructor
+- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks
+- Navigation methods (`goto`) belong on the page object
+
+## Custom Fixtures
+
+Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.
+
+```typescript
+// fixtures/base.fixture.ts
+import { test as base, expect } from '@playwright/test';
+import { BookingPage } from '../page-objects/booking.page';
+import { generateMember } from '../helpers/data';
+
+type Fixtures = {
+ bookingPage: BookingPage;
+ member: { email: string; password: string; id: string };
+ loggedInPage: import('@playwright/test').Page;
+};
+
+export const test = base.extend({
+ bookingPage: async ({ page }, use) => {
+ await use(new BookingPage(page));
+ },
+
+ member: async ({ request }, use) => {
+ const data = generateMember();
+ const res = await request.post('/api/test/members', { data });
+ const member = await res.json();
+ await use(member);
+ await request.delete(`/api/test/members/${member.id}`);
+ },
+
+ loggedInPage: async ({ page, member }, use) => {
+ await page.goto('/login');
+ await page.getByLabel('Email').fill(member.email);
+ await page.getByLabel('Password').fill(member.password);
+ await page.getByRole('button', { name: 'Sign in' }).click();
+ await expect(page).toHaveURL('/dashboard');
+ await use(page);
+ },
+});
+
+export { expect } from '@playwright/test';
+```
+
+```typescript
+// tests/dashboard/overview.spec.ts
+import { test, expect } from '../../fixtures/base.fixture';
+
+test('member sees dashboard widgets', async ({ loggedInPage }) => {
+ await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
+ await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible();
+});
+
+test('new member sees welcome prompt', async ({ loggedInPage, member }) => {
+ await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible();
+});
+```
+
+**Fixture principles:**
+- Use `test.extend()` — never module-level variables
+- `use()` callback separates setup from teardown
+- Teardown runs even if test fails
+- Fixtures compose: one can depend on another
+- Fixtures are lazy: created only when requested
+- Wrap page objects in fixtures for lifecycle management
+
+## Helper Functions
+
+Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses.
+
+```typescript
+// helpers/data.ts
+import { randomUUID } from 'node:crypto';
+
+export function generateEmail(prefix = 'user'): string {
+ return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`;
+}
+
+export function generateMember(overrides: Partial = {}): Member {
+ return {
+ email: generateEmail(),
+ password: 'SecurePass456!',
+ name: 'Test Member',
+ ...overrides,
+ };
+}
+
+interface Member {
+ email: string;
+ password: string;
+ name: string;
+}
+
+export function formatPrice(cents: number): string {
+ return `$${(cents / 100).toFixed(2)}`;
+}
+```
+
+```typescript
+// helpers/assertions.ts
+import { type Page, expect } from '@playwright/test';
+
+export async function expectNotification(page: Page, message: string): Promise {
+ const notification = page.getByRole('alert').filter({ hasText: message });
+ await expect(notification).toBeVisible();
+ await expect(notification).toBeHidden({ timeout: 10000 });
+}
+```
+
+```typescript
+// tests/settings/account.spec.ts
+import { test, expect } from '@playwright/test';
+import { generateEmail } from '../../helpers/data';
+import { expectNotification } from '../../helpers/assertions';
+
+test('update account email', async ({ page }) => {
+ const newEmail = generateEmail('updated');
+ await page.goto('/settings/account');
+ await page.getByLabel('Email').fill(newEmail);
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expectNotification(page, 'Account updated');
+ await expect(page.getByLabel('Email')).toHaveValue(newEmail);
+});
+```
+
+**Helper principles:**
+- Pure functions with no side effects
+- No browser state — take `page` as parameter if needed
+- Promote to fixture if setup/teardown needed
+- Promote to page object if many page interactions grow
+- Keep small and focused
+
+## Combined Project Structure
+
+```text
+tests/
++-- fixtures/
+| +-- auth.fixture.ts
+| +-- db.fixture.ts
+| +-- base.fixture.ts
++-- page-objects/
+| +-- login.page.ts
+| +-- booking.page.ts
+| +-- components/
+| +-- data-table.component.ts
++-- helpers/
+| +-- data.ts
+| +-- assertions.ts
++-- e2e/
+| +-- auth/
+| | +-- login.spec.ts
+| +-- booking/
+| +-- reservation.spec.ts
+playwright.config.ts
+```
+
+**Layer responsibilities:**
+
+| Layer | Pattern | Responsibility |
+|---|---|---|
+| **Test file** | `test()` | Describes behavior, orchestrates layers |
+| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown |
+| **Page objects** | Classes | UI interaction — navigation, actions, locators |
+| **Helpers** | Functions | Utilities — data generation, formatting, assertions |
+
+## Anti-Patterns
+
+### Page object managing resources
+
+```typescript
+// BAD: page object handling API calls and database
+class LoginPage {
+ async createUser() { /* API call */ }
+ async deleteUser() { /* API call */ }
+ async signIn(email: string, password: string) { /* UI */ }
+}
+```
+
+Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.
+
+### Locator-only page objects
+
+```typescript
+// BAD: no methods, just locators
+class LoginPage {
+ emailInput = this.page.getByLabel('Email');
+ passwordInput = this.page.getByLabel('Password');
+ submitBtn = this.page.getByRole('button', { name: 'Sign in' });
+ constructor(private page: Page) {}
+}
+```
+
+Add intent-revealing methods or skip the page object entirely.
+
+### Monolithic fixtures
+
+```typescript
+// BAD: one fixture doing everything
+test.extend({
+ everything: async ({ page, request }, use) => {
+ const user = await createUser(request);
+ const products = await seedProducts(request, 50);
+ await setupPayment(request, user.id);
+ await page.goto('/dashboard');
+ await use({ user, products, page });
+ // massive teardown...
+ },
+});
+```
+
+Break into small, composable fixtures. Each fixture does one thing.
+
+### Helpers with side effects
+
+```typescript
+// BAD: module-level state
+let createdUserId: string;
+
+export async function createTestUser(request: APIRequestContext) {
+ const res = await request.post('/api/users', { data: { email: 'test@example.com' } });
+ const user = await res.json();
+ createdUserId = user.id; // shared across tests!
+ return user;
+}
+```
+
+Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.
+
+### Over-abstracting simple operations
+
+```typescript
+// BAD: helper for one-liner
+export async function clickButton(page: Page, name: string) {
+ await page.getByRole('button', { name }).click();
+}
+```
+
+Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions).
diff --git a/.cursor/skills/playwright-testing/architecture/test-architecture.md b/.cursor/skills/playwright-testing/architecture/test-architecture.md
new file mode 100644
index 0000000000..28b6f6c9c5
--- /dev/null
+++ b/.cursor/skills/playwright-testing/architecture/test-architecture.md
@@ -0,0 +1,369 @@
+# Choosing Test Types: E2E, Component, or API
+
+## Table of Contents
+
+1. [Decision Matrix](#decision-matrix)
+2. [API Tests](#api-tests)
+3. [Component Tests](#component-tests)
+4. [E2E Tests](#e2e-tests)
+5. [Layering Test Types](#layering-test-types)
+6. [Common Mistakes](#common-mistakes)
+7. [Related](#related)
+
+> **When to use**: Deciding which test type to write for a feature. Ask: "What's the cheapest test that gives confidence this works?"
+
+## Decision Matrix
+
+| Scenario | Recommended Type | Rationale |
+| --------------------------- | ---------------- | --------------------------------------------- |
+| Login / auth flow | E2E | Cross-page, cookies, redirects, session state |
+| Form submission | Component | Isolated validation logic, error states |
+| CRUD operations | API | Data integrity matters more than UI |
+| Search with results UI | Component + API | API for query logic; component for rendering |
+| Cross-page navigation | E2E | Routing, history, deep linking |
+| API error handling | API | Status codes, error shapes, edge cases |
+| UI error feedback | Component | Toast, banner, inline error rendering |
+| Accessibility | Component | ARIA roles, keyboard nav per-component |
+| Responsive layout | Component | Viewport-specific rendering without full app |
+| API contract validation | API | Response shapes, headers, auth |
+| WebSocket/real-time | E2E | Requires full browser environment |
+| Payment / checkout | E2E | Multi-step, third-party iframes |
+| Onboarding wizard | E2E | Multi-step, state persists across pages |
+| Widget behavior | Component | Toggle, accordion, date picker, modal |
+| Permissions / authorization | API | Role-based access is backend logic |
+
+## API Tests
+
+**Ideal for**:
+
+- CRUD operations (create, read, update, delete)
+- Input validation and error responses (400, 422)
+- Permission and authorization checks
+- Data integrity and business rules
+- API contract verification
+- Edge cases expensive to reproduce through UI
+- Test data setup/teardown for E2E tests
+
+**Avoid for**:
+
+- Testing how errors display to users
+- Browser-specific behavior (cookies, redirects)
+- Visual layout or responsive design
+- Flows requiring JavaScript execution or DOM interaction
+- Third-party iframe interactions
+
+```typescript
+import { test, expect } from "@playwright/test";
+
+test.describe("Products API", () => {
+ let token: string;
+
+ test.beforeAll(async ({ request }) => {
+ const res = await request.post("/api/auth/token", {
+ data: { email: "manager@shop.io", password: "mgr-secret" },
+ });
+ token = (await res.json()).accessToken;
+ });
+
+ test("creates product with valid payload", async ({ request }) => {
+ const res = await request.post("/api/products", {
+ headers: { Authorization: `Bearer ${token}` },
+ data: { name: "Widget Pro", sku: "WGT-100", price: 29.99 },
+ });
+
+ expect(res.status()).toBe(201);
+ const product = await res.json();
+ expect(product).toMatchObject({ name: "Widget Pro", sku: "WGT-100" });
+ expect(product).toHaveProperty("id");
+ });
+
+ test("rejects duplicate SKU with 409", async ({ request }) => {
+ const res = await request.post("/api/products", {
+ headers: { Authorization: `Bearer ${token}` },
+ data: { name: "Duplicate", sku: "WGT-100", price: 19.99 },
+ });
+
+ expect(res.status()).toBe(409);
+ expect((await res.json()).message).toContain("already exists");
+ });
+
+ test("returns 422 for missing required fields", async ({ request }) => {
+ const res = await request.post("/api/products", {
+ headers: { Authorization: `Bearer ${token}` },
+ data: { name: "Incomplete" },
+ });
+
+ expect(res.status()).toBe(422);
+ const err = await res.json();
+ expect(err.errors).toContainEqual(
+ expect.objectContaining({ field: "sku" })
+ );
+ });
+
+ test("staff role cannot delete products", async ({ request }) => {
+ const staffLogin = await request.post("/api/auth/token", {
+ data: { email: "staff@shop.io", password: "staff-pass" },
+ });
+ const staffToken = (await staffLogin.json()).accessToken;
+
+ const res = await request.delete("/api/products/123", {
+ headers: { Authorization: `Bearer ${staffToken}` },
+ });
+
+ expect(res.status()).toBe(403);
+ });
+
+ test("lists products with pagination", async ({ request }) => {
+ const res = await request.get("/api/products", {
+ headers: { Authorization: `Bearer ${token}` },
+ params: { page: "1", limit: "20" },
+ });
+
+ expect(res.status()).toBe(200);
+ const body = await res.json();
+ expect(body.items).toBeInstanceOf(Array);
+ expect(body.items.length).toBeLessThanOrEqual(20);
+ expect(body).toHaveProperty("totalCount");
+ });
+});
+```
+
+## Component Tests
+
+**Ideal for**:
+
+- Form validation (required fields, format rules, error messages)
+- Interactive widgets (modals, dropdowns, accordions, date pickers)
+- Conditional rendering (show/hide, loading states, empty states)
+- Accessibility per-component (ARIA attributes, keyboard navigation)
+- Responsive layout at different viewports
+- Visual states (hover, focus, disabled, selected)
+
+**Avoid for**:
+
+- Testing routing or navigation between pages
+- Flows requiring real cookies, sessions, or server-side state
+- Data persistence or API contract validation
+- Third-party iframe interactions
+- Anything requiring multiple pages or browser contexts
+
+```typescript
+import { test, expect } from "@playwright/experimental-ct-react";
+import { ContactForm } from "../src/components/ContactForm";
+
+test.describe("ContactForm component", () => {
+ test("displays validation errors on empty submit", async ({ mount }) => {
+ const component = await mount( {}} />);
+
+ await component.getByRole("button", { name: "Send message" }).click();
+
+ await expect(component.getByText("Name is required")).toBeVisible();
+ await expect(component.getByText("Email is required")).toBeVisible();
+ });
+
+ test("rejects malformed email", async ({ mount }) => {
+ const component = await mount( {}} />);
+
+ await component.getByLabel("Name").fill("Alex");
+ await component.getByLabel("Email").fill("invalid-email");
+ await component.getByLabel("Message").fill("Hello");
+ await component.getByRole("button", { name: "Send message" }).click();
+
+ await expect(component.getByText("Enter a valid email")).toBeVisible();
+ });
+
+ test("invokes onSubmit with form data", async ({ mount }) => {
+ const submissions: Array<{ name: string; email: string; message: string }> =
+ [];
+ const component = await mount(
+ submissions.push(data)} />
+ );
+
+ await component.getByLabel("Name").fill("Alex");
+ await component.getByLabel("Email").fill("alex@company.org");
+ await component.getByLabel("Message").fill("Inquiry about pricing");
+ await component.getByRole("button", { name: "Send message" }).click();
+
+ expect(submissions).toHaveLength(1);
+ expect(submissions[0]).toEqual({
+ name: "Alex",
+ email: "alex@company.org",
+ message: "Inquiry about pricing",
+ });
+ });
+
+ test("disables button during submission", async ({ mount }) => {
+ const component = await mount(
+ {}} submitting={true} />
+ );
+
+ await expect(
+ component.getByRole("button", { name: "Sending..." })
+ ).toBeDisabled();
+ });
+
+ test("associates labels with inputs for accessibility", async ({ mount }) => {
+ const component = await mount( {}} />);
+
+ await expect(
+ component.getByRole("textbox", { name: "Name" })
+ ).toBeVisible();
+ await expect(
+ component.getByRole("textbox", { name: "Email" })
+ ).toBeVisible();
+ });
+});
+```
+
+## E2E Tests
+
+**Ideal for**:
+
+- Critical user flows that generate revenue (checkout, signup)
+- Authentication flows (login, SSO, MFA, password reset)
+- Multi-page workflows where state carries across navigation
+- Flows involving third-party iframes (payment widgets)
+- Smoke tests validating the entire stack
+- Real-time collaboration requiring multiple browser contexts
+
+**Avoid for**:
+
+- Testing every form validation permutation
+- CRUD operations where UI is a thin wrapper
+- Verifying individual component states
+- Testing API response shapes or error codes
+- Responsive layout at every breakpoint
+- Edge cases that only affect the backend
+
+```typescript
+import { test, expect } from "@playwright/test";
+
+test.describe("subscription flow", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.request.post("/api/test/seed-account", {
+ data: { plan: "free", email: "subscriber@demo.io" },
+ });
+ await page.goto("/account/upgrade");
+ });
+
+ test("upgrades to premium plan", async ({ page }) => {
+ await test.step("select plan", async () => {
+ await expect(
+ page.getByRole("heading", { name: "Choose Your Plan" })
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Select Premium" }).click();
+ });
+
+ await test.step("enter billing details", async () => {
+ await page.getByLabel("Cardholder name").fill("Sam Johnson");
+ await page.getByLabel("Billing address").fill("456 Oak Ave");
+ await page.getByLabel("City").fill("Seattle");
+ await page.getByRole("combobox", { name: "State" }).selectOption("WA");
+ await page.getByLabel("Postal code").fill("98101");
+ await page.getByRole("button", { name: "Continue" }).click();
+ });
+
+ await test.step("complete payment", async () => {
+ const paymentFrame = page.frameLocator('iframe[title="Secure Payment"]');
+ await paymentFrame.getByLabel("Card number").fill("5555555555554444");
+ await paymentFrame.getByLabel("Expiry").fill("09/29");
+ await paymentFrame.getByLabel("CVV").fill("456");
+ await page.getByRole("button", { name: "Subscribe now" }).click();
+ });
+
+ await test.step("verify success", async () => {
+ await page.waitForURL("**/account/subscription/success**");
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Premium" })
+ ).toBeVisible();
+ await expect(page.getByText(/Subscription #\d+/)).toBeVisible();
+ });
+ });
+});
+```
+
+## Layering Test Types
+
+Effective test suites combine all three types. Example for an "inventory management" feature:
+
+### API Layer (60% of tests)
+
+Cover every backend logic permutation. Cheap to run and maintain.
+
+```
+tests/api/inventory.spec.ts
+ - creates item with valid data (201)
+ - rejects duplicate SKU (409)
+ - rejects invalid quantity format (422)
+ - rejects missing required fields (422)
+ - warehouse-staff cannot delete items (403)
+ - unauthenticated request returns 401
+ - lists items with pagination
+ - filters items by category
+ - updates item stock level
+ - archives an item
+ - prevents archiving items with pending orders
+```
+
+### Component Layer (30% of tests)
+
+Cover every visual state and interaction.
+
+```
+tests/components/InventoryForm.spec.tsx
+ - shows validation errors on empty submit
+ - shows inline error for invalid SKU format
+ - disables submit while saving
+ - calls onSubmit with form data
+ - resets form after successful save
+
+tests/components/InventoryTable.spec.tsx
+ - renders item rows from props
+ - shows empty state when no items
+ - handles archive confirmation modal
+ - sorts by column header click
+ - shows stock level badges with correct colors
+```
+
+### E2E Layer (10% of tests)
+
+Cover only critical paths proving full stack works.
+
+```
+tests/e2e/inventory.spec.ts
+ - manager creates item and sees it in list
+ - manager updates item stock level
+ - warehouse-staff cannot access admin settings
+```
+
+### Execution Profile
+
+For this feature:
+
+- **11 API tests** — ~2 seconds total, no browser
+- **10 component tests** — ~5 seconds total, real browser but no server
+- **3 E2E tests** — ~15 seconds total, full stack
+
+Total: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).
+
+## Common Mistakes
+
+| Anti-Pattern | Problem | Better Approach |
+| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
+| E2E for every validation rule | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display |
+| No API tests, all E2E | Slow suite, flaky from UI timing, hard to diagnose | API tests for data/logic, E2E for critical paths only |
+| Component tests mocking everything | Tests pass but app broken because mocks drift | Mock only external boundaries; API tests verify real contracts |
+| Same assertion in API, component, AND E2E | Triple maintenance cost | Each layer tests what it uniquely verifies |
+| E2E creating test data via UI | 2-minute test where 90 seconds is setup | Seed via API in `beforeEach`, test actual flow |
+| Testing third-party behavior | Testing that Stripe validates cards (Stripe's job) | Mock Stripe; trust their contract |
+| Skipping API layer | Can't tell if bug is frontend or backend | API tests isolate backend; component tests isolate frontend |
+| One giant E2E for entire feature | 5-minute test failing somewhere with no clear cause | Focused E2E per critical path; use `test.step()` |
+
+## Related
+
+- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming
+- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing
+- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests
+- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`
+- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services
+- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic
diff --git a/.cursor/skills/playwright-testing/architecture/when-to-mock.md b/.cursor/skills/playwright-testing/architecture/when-to-mock.md
new file mode 100644
index 0000000000..d5d5705b1f
--- /dev/null
+++ b/.cursor/skills/playwright-testing/architecture/when-to-mock.md
@@ -0,0 +1,383 @@
+# Mocking Strategy: Real vs Mock Services
+
+## Table of Contents
+
+1. [Core Principle](#core-principle)
+2. [Decision Matrix](#decision-matrix)
+3. [Decision Flowchart](#decision-flowchart)
+4. [Mocking Techniques](#mocking-techniques)
+5. [Real Service Strategies](#real-service-strategies)
+6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)
+7. [Validating Mock Accuracy](#validating-mock-accuracy)
+8. [Anti-Patterns](#anti-patterns)
+
+> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.
+
+## Core Principle
+
+**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.
+
+## Decision Matrix
+
+| Scenario | Mock? | Strategy |
+| --- | --- | --- |
+| Your own REST/GraphQL API | Never | Hit real API against staging or local dev |
+| Your database (through your API) | Never | Seed via API or fixtures |
+| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests |
+| Stripe / payment gateway | Always | `route.fulfill()` with expected responses |
+| SendGrid / email service | Always | Mock the API call, verify request payload |
+| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler |
+| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` |
+| Maps / geocoding APIs | Always | Mock with static responses |
+| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states |
+| CDN / static assets | Never | Let them load normally |
+| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment |
+| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config |
+
+## Decision Flowchart
+
+```text
+Is this service part of YOUR codebase?
+├── YES → Do NOT mock. Test the real integration.
+│ ├── Is it slow? → Optimize the service, not the test.
+│ └── Is it flaky? → Fix the service. Flaky infra is a bug.
+└── NO → It's a third-party service.
+ ├── Is it paid per call? → ALWAYS mock.
+ ├── Is it rate-limited? → ALWAYS mock.
+ ├── Is it slow or unreliable? → ALWAYS mock.
+ └── Is it a complex multi-step flow? → Mock with HAR recording.
+```
+
+## Mocking Techniques
+
+### Blocking Unwanted Requests
+
+Block third-party scripts that slow tests and add no coverage:
+
+```typescript
+test.beforeEach(async ({ page }) => {
+ await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {
+ route.abort();
+ });
+});
+
+test('dashboard renders without tracking scripts', async ({ page }) => {
+ await page.goto('/dashboard');
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
+});
+```
+
+### Full Mock (route.fulfill)
+
+Completely replace a third-party API response:
+
+```typescript
+test('order flow with mocked payment service', async ({ page }) => {
+ await page.route('**/api/charge', (route) => {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ transactionId: 'txn_mock_abc',
+ status: 'completed',
+ }),
+ });
+ });
+
+ await page.goto('/order/confirm');
+ await page.getByRole('button', { name: 'Complete Purchase' }).click();
+ await expect(page.getByText('Order confirmed')).toBeVisible();
+});
+
+test('display error on payment decline', async ({ page }) => {
+ await page.route('**/api/charge', (route) => {
+ route.fulfill({
+ status: 402,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ error: { code: 'insufficient_funds', message: 'Card declined.' },
+ }),
+ });
+ });
+
+ await page.goto('/order/confirm');
+ await page.getByRole('button', { name: 'Complete Purchase' }).click();
+ await expect(page.getByRole('alert')).toContainText('Card declined');
+});
+```
+
+### Partial Mock (Modify Responses)
+
+Let the real API call happen but tweak the response:
+
+```typescript
+test('display low inventory warning', async ({ page }) => {
+ await page.route('**/api/inventory/*', async (route) => {
+ const response = await route.fetch();
+ const data = await response.json();
+
+ data.quantity = 1;
+ data.lowStock = true;
+
+ await route.fulfill({
+ response,
+ body: JSON.stringify(data),
+ });
+ });
+
+ await page.goto('/products/widget-pro');
+ await expect(page.getByText('Only 1 remaining')).toBeVisible();
+});
+
+test('inject test notification into real response', async ({ page }) => {
+ await page.route('**/api/alerts', async (route) => {
+ const response = await route.fetch();
+ const data = await response.json();
+
+ data.items.push({
+ id: 'test-alert',
+ text: 'Report generated',
+ category: 'info',
+ });
+
+ await route.fulfill({
+ response,
+ body: JSON.stringify(data),
+ });
+ });
+
+ await page.goto('/home');
+ await expect(page.getByText('Report generated')).toBeVisible();
+});
+```
+
+### Record and Replay (HAR Files)
+
+For complex API sequences (OAuth flows, multi-step wizards):
+
+**Recording:**
+
+```typescript
+test('capture API traffic for admin panel', async ({ page }) => {
+ await page.routeFromHAR('tests/fixtures/admin-panel.har', {
+ url: '**/api/**',
+ update: true,
+ });
+
+ await page.goto('/admin');
+ await page.getByRole('tab', { name: 'Reports' }).click();
+ await page.getByRole('tab', { name: 'Settings' }).click();
+});
+```
+
+**Replaying:**
+
+```typescript
+test('admin panel loads with recorded data', async ({ page }) => {
+ await page.routeFromHAR('tests/fixtures/admin-panel.har', {
+ url: '**/api/**',
+ update: false,
+ });
+
+ await page.goto('/admin');
+ await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
+});
+```
+
+**HAR maintenance:**
+
+- Record against a known-good staging environment
+- Commit `.har` files to version control
+- Re-record when APIs change
+- Scope HAR to specific URL patterns
+
+## Real Service Strategies
+
+### Local Dev Server
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ },
+ use: {
+ baseURL: 'http://localhost:3000',
+ },
+});
+```
+
+### Staging Environment
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ use: {
+ baseURL: process.env.CI
+ ? 'https://staging.example.com'
+ : 'http://localhost:3000',
+ },
+});
+```
+
+### Test Containers
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ webServer: {
+ command: 'docker compose -f docker-compose.test.yml up --wait',
+ url: 'http://localhost:3000/health',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ globalTeardown: './tests/global-teardown.ts',
+});
+```
+
+```typescript
+// tests/global-teardown.ts
+import { execSync } from 'child_process';
+
+export default function globalTeardown() {
+ if (process.env.CI) {
+ execSync('docker compose -f docker-compose.test.yml down -v');
+ }
+}
+```
+
+## Hybrid Approach: Fixture-Based Mock Control
+
+Create fixtures that let individual tests opt into mocking specific services:
+
+```typescript
+// tests/fixtures/service-mocks.ts
+import { test as base } from '@playwright/test';
+
+type MockConfig = {
+ mockPayments: boolean;
+ mockNotifications: boolean;
+ mockAnalytics: boolean;
+};
+
+export const test = base.extend({
+ mockPayments: [true, { option: true }],
+ mockNotifications: [true, { option: true }],
+ mockAnalytics: [true, { option: true }],
+
+ page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => {
+ if (mockPayments) {
+ await page.route('**/api/billing/**', (route) => {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }),
+ });
+ });
+ }
+
+ if (mockNotifications) {
+ await page.route('**/api/notify', (route) => {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ delivered: true }),
+ });
+ });
+ }
+
+ if (mockAnalytics) {
+ await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {
+ route.abort();
+ });
+ }
+
+ await use(page);
+ },
+});
+
+export { expect } from '@playwright/test';
+```
+
+```typescript
+// tests/billing.spec.ts
+import { test, expect } from './fixtures/service-mocks';
+
+test('subscription renewal sends notification', async ({ page }) => {
+ await page.goto('/account/billing');
+ await page.getByRole('button', { name: 'Renew Now' }).click();
+ await expect(page.getByText('Subscription renewed')).toBeVisible();
+});
+
+test.describe('integration suite', () => {
+ test.use({ mockPayments: false });
+
+ test('real billing flow against test gateway', async ({ page }) => {
+ await page.goto('/account/billing');
+ await page.getByRole('button', { name: 'Renew Now' }).click();
+ await expect(page.getByText('Subscription renewed')).toBeVisible();
+ });
+});
+```
+
+### Environment-Based Test Projects
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ projects: [
+ {
+ name: 'ci-fast',
+ testMatch: '**/*.spec.ts',
+ use: { baseURL: 'http://localhost:3000' },
+ },
+ {
+ name: 'nightly-full',
+ testMatch: '**/*.integration.spec.ts',
+ use: { baseURL: 'https://staging.example.com' },
+ timeout: 120_000,
+ },
+ ],
+});
+```
+
+## Validating Mock Accuracy
+
+Guard against mock drift from real APIs:
+
+```typescript
+test.describe('contract validation', () => {
+ test('billing mock matches real API shape', async ({ request }) => {
+ const realResponse = await request.post('/api/billing/charge', {
+ data: { amount: 5000, currency: 'usd' },
+ });
+ const realBody = await realResponse.json();
+
+ const mockBody = {
+ status: 'paid',
+ id: 'inv_mock_789',
+ };
+
+ expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort());
+
+ for (const key of Object.keys(mockBody)) {
+ expect(typeof mockBody[key]).toBe(typeof realBody[key]);
+ }
+ });
+});
+```
+
+## Anti-Patterns
+
+| Don't Do This | Problem | Do This Instead |
+| --- | --- | --- |
+| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. |
+| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. |
+| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. |
+| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. |
+| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. |
+| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. |
+| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. |
diff --git a/.cursor/skills/playwright-testing/browser-apis/browser-apis.md b/.cursor/skills/playwright-testing/browser-apis/browser-apis.md
new file mode 100644
index 0000000000..cc4c269e8f
--- /dev/null
+++ b/.cursor/skills/playwright-testing/browser-apis/browser-apis.md
@@ -0,0 +1,391 @@
+# Browser APIs: Geolocation, Permissions & More
+
+## Table of Contents
+
+1. [Geolocation](#geolocation)
+2. [Permissions](#permissions)
+3. [Clipboard](#clipboard)
+4. [Notifications](#notifications)
+5. [Camera & Microphone](#camera--microphone)
+
+## Geolocation
+
+### Mock Location
+
+```typescript
+test("shows nearby stores", async ({ context }) => {
+ // Grant permission and set location
+ await context.grantPermissions(["geolocation"]);
+ await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); // San Francisco
+
+ const page = await context.newPage();
+ await page.goto("/store-finder");
+ await page.getByRole("button", { name: "Find Nearby" }).click();
+
+ await expect(page.getByText("San Francisco")).toBeVisible();
+});
+```
+
+### Geolocation Fixture
+
+```typescript
+// fixtures/geolocation.fixture.ts
+import { test as base } from "@playwright/test";
+
+type Coordinates = { latitude: number; longitude: number; accuracy?: number };
+
+type GeoFixtures = {
+ setLocation: (coords: Coordinates) => Promise;
+};
+
+export const test = base.extend({
+ setLocation: async ({ context }, use) => {
+ await context.grantPermissions(["geolocation"]);
+
+ await use(async (coords) => {
+ await context.setGeolocation({
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ accuracy: coords.accuracy ?? 100,
+ });
+ });
+ },
+});
+
+// Usage
+test("delivery zone check", async ({ page, setLocation }) => {
+ await setLocation({ latitude: 40.7128, longitude: -74.006 }); // NYC
+
+ await page.goto("/delivery");
+
+ await expect(page.getByText("Delivery available")).toBeVisible();
+});
+```
+
+### Test Location Changes
+
+```typescript
+test("tracks location updates", async ({ context }) => {
+ await context.grantPermissions(["geolocation"]);
+
+ const page = await context.newPage();
+ await page.goto("/tracking");
+
+ // Initial location
+ await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
+ await page.getByRole("button", { name: "Start Tracking" }).click();
+
+ await expect(page.getByTestId("location")).toContainText("37.7749");
+
+ // Move to new location
+ await context.setGeolocation({ latitude: 37.8044, longitude: -122.2712 });
+
+ // Trigger location update
+ await page.evaluate(() => {
+ navigator.geolocation.getCurrentPosition(() => {});
+ });
+
+ await expect(page.getByTestId("location")).toContainText("37.8044");
+});
+```
+
+### Test Geolocation Denial
+
+```typescript
+test("handles location denied", async ({ browser }) => {
+ // Create context without geolocation permission
+ const context = await browser.newContext({
+ permissions: [], // No permissions
+ });
+
+ const page = await context.newPage();
+ await page.goto("/store-finder");
+ await page.getByRole("button", { name: "Find Nearby" }).click();
+
+ await expect(page.getByText("Location access denied")).toBeVisible();
+ await expect(page.getByLabel("Enter ZIP code")).toBeVisible();
+
+ await context.close();
+});
+```
+
+## Permissions
+
+### Grant Permissions
+
+```typescript
+test("notifications with permission", async ({ context }) => {
+ await context.grantPermissions(["notifications"]);
+
+ const page = await context.newPage();
+ await page.goto("/alerts");
+
+ // Notification API should work
+ const permission = await page.evaluate(() => Notification.permission);
+ expect(permission).toBe("granted");
+});
+```
+
+### Test Permission Denied
+
+```typescript
+test("handles notification permission denied", async ({ browser }) => {
+ const context = await browser.newContext({
+ permissions: [], // Deny all
+ });
+
+ const page = await context.newPage();
+ await page.goto("/notifications");
+
+ await page.getByRole("button", { name: "Enable Notifications" }).click();
+
+ await expect(page.getByText("Please enable notifications")).toBeVisible();
+
+ await context.close();
+});
+```
+
+### Multiple Permissions
+
+```typescript
+test("video call with permissions", async ({ context }) => {
+ await context.grantPermissions(["camera", "microphone", "notifications"]);
+
+ const page = await context.newPage();
+ await page.goto("/video-call");
+
+ // All permissions should be granted
+ const permissions = await page.evaluate(async () => ({
+ camera: await navigator.permissions.query({
+ name: "camera" as PermissionName,
+ }),
+ microphone: await navigator.permissions.query({
+ name: "microphone" as PermissionName,
+ }),
+ }));
+
+ expect(permissions.camera.state).toBe("granted");
+ expect(permissions.microphone.state).toBe("granted");
+});
+```
+
+## Clipboard
+
+### Test Copy to Clipboard
+
+```typescript
+test("copy button works", async ({ page, context }) => {
+ // Grant clipboard permissions
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+
+ await page.goto("/share");
+
+ await page.getByRole("button", { name: "Copy Link" }).click();
+
+ // Read clipboard content
+ const clipboardContent = await page.evaluate(() =>
+ navigator.clipboard.readText(),
+ );
+
+ expect(clipboardContent).toContain("https://example.com/share/");
+});
+```
+
+### Test Paste from Clipboard
+
+```typescript
+test("paste from clipboard", async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+
+ await page.goto("/editor");
+
+ // Write to clipboard
+ await page.evaluate(() => navigator.clipboard.writeText("Pasted content"));
+
+ // Trigger paste
+ await page.getByLabel("Content").focus();
+ await page.keyboard.press("Control+V");
+
+ await expect(page.getByLabel("Content")).toHaveValue("Pasted content");
+});
+```
+
+### Clipboard Fixture
+
+```typescript
+// fixtures/clipboard.fixture.ts
+import { test as base } from "@playwright/test";
+
+type ClipboardFixtures = {
+ clipboard: {
+ write: (text: string) => Promise;
+ read: () => Promise;
+ };
+};
+
+export const test = base.extend({
+ clipboard: async ({ page, context }, use) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+
+ await use({
+ write: async (text) => {
+ await page.evaluate((t) => navigator.clipboard.writeText(t), text);
+ },
+ read: async () => {
+ return page.evaluate(() => navigator.clipboard.readText());
+ },
+ });
+ },
+});
+```
+
+## Notifications
+
+### Mock Notification API
+
+```typescript
+test("shows browser notification", async ({ page }) => {
+ const notifications: any[] = [];
+
+ // Mock Notification constructor
+ await page.addInitScript(() => {
+ (window as any).__notifications = [];
+ (window as any).Notification = class {
+ constructor(title: string, options?: NotificationOptions) {
+ (window as any).__notifications.push({ title, ...options });
+ }
+ static permission = "granted";
+ static requestPermission = async () => "granted";
+ };
+ });
+
+ await page.goto("/alerts");
+ await page.getByRole("button", { name: "Notify Me" }).click();
+
+ // Check notification was created
+ const created = await page.evaluate(() => (window as any).__notifications);
+ expect(created).toHaveLength(1);
+ expect(created[0].title).toBe("New Alert");
+});
+```
+
+### Test Notification Click
+
+```typescript
+test("notification click handler", async ({ page }) => {
+ await page.addInitScript(() => {
+ (window as any).Notification = class {
+ onclick: (() => void) | null = null;
+ constructor(title: string) {
+ // Simulate click after creation
+ setTimeout(() => this.onclick?.(), 100);
+ }
+ static permission = "granted";
+ static requestPermission = async () => "granted";
+ };
+ });
+
+ await page.goto("/messages");
+ await page.evaluate(() => {
+ new Notification("New Message");
+ });
+
+ // Should navigate to messages when notification clicked
+ await expect(page).toHaveURL(/\/messages/);
+});
+```
+
+## Camera & Microphone
+
+### Mock Media Devices
+
+```typescript
+test("video preview works", async ({ page, context }) => {
+ await context.grantPermissions(["camera"]);
+
+ // Mock getUserMedia
+ await page.addInitScript(() => {
+ navigator.mediaDevices.getUserMedia = async () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = 640;
+ canvas.height = 480;
+ return canvas.captureStream();
+ };
+ });
+
+ await page.goto("/video-settings");
+ await page.getByRole("button", { name: "Start Camera" }).click();
+
+ await expect(page.getByTestId("video-preview")).toBeVisible();
+});
+```
+
+### Test Media Device Selection
+
+```typescript
+test("switch camera", async ({ page }) => {
+ await page.addInitScript(() => {
+ navigator.mediaDevices.enumerateDevices = async () =>
+ [
+ {
+ deviceId: "cam1",
+ kind: "videoinput",
+ label: "Front Camera",
+ groupId: "1",
+ },
+ {
+ deviceId: "cam2",
+ kind: "videoinput",
+ label: "Back Camera",
+ groupId: "2",
+ },
+ ] as MediaDeviceInfo[];
+
+ navigator.mediaDevices.getUserMedia = async () => {
+ const canvas = document.createElement("canvas");
+ return canvas.captureStream();
+ };
+ });
+
+ await page.goto("/camera");
+
+ // Should show camera options
+ await expect(page.getByRole("combobox", { name: "Camera" })).toBeVisible();
+ await expect(page.getByText("Front Camera")).toBeVisible();
+ await expect(page.getByText("Back Camera")).toBeVisible();
+});
+```
+
+### Test Media Errors
+
+```typescript
+test("handles camera access error", async ({ page }) => {
+ await page.addInitScript(() => {
+ navigator.mediaDevices.getUserMedia = async () => {
+ throw new DOMException("Permission denied", "NotAllowedError");
+ };
+ });
+
+ await page.goto("/video-call");
+ await page.getByRole("button", { name: "Join Call" }).click();
+
+ await expect(page.getByText("Camera access denied")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Join Audio Only" }),
+ ).toBeVisible();
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ----------------------------- | --------------------------------- | ----------------------------------- |
+| Not granting permissions | Tests fail with permission errors | Use `context.grantPermissions()` |
+| Testing real geolocation | Flaky, environment-dependent | Mock with `setGeolocation()` |
+| Not testing permission denial | Misses error handling | Test both granted and denied states |
+| Using real camera/mic | CI has no devices | Mock `getUserMedia` |
+
+## Related References
+
+- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures
+- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation
diff --git a/.cursor/skills/playwright-testing/browser-apis/iframes.md b/.cursor/skills/playwright-testing/browser-apis/iframes.md
new file mode 100644
index 0000000000..155cc1c1b4
--- /dev/null
+++ b/.cursor/skills/playwright-testing/browser-apis/iframes.md
@@ -0,0 +1,403 @@
+# iFrame Testing
+
+## Table of Contents
+
+1. [Basic iFrame Access](#basic-iframe-access)
+2. [Cross-Origin iFrames](#cross-origin-iframes)
+3. [Nested iFrames](#nested-iframes)
+4. [Dynamic iFrames](#dynamic-iframes)
+5. [iFrame Navigation](#iframe-navigation)
+6. [Common Patterns](#common-patterns)
+
+## Basic iFrame Access
+
+### Using frameLocator
+
+```typescript
+// Access iframe by selector
+const frame = page.frameLocator("iframe#payment");
+await frame.getByRole("button", { name: "Pay" }).click();
+
+// Access by name attribute
+const namedFrame = page.frameLocator('iframe[name="checkout"]');
+await namedFrame.getByLabel("Card number").fill("4242424242424242");
+
+// Access by title
+const titledFrame = page.frameLocator('iframe[title="Payment Form"]');
+
+// Access by src (partial match)
+const srcFrame = page.frameLocator('iframe[src*="stripe.com"]');
+```
+
+### Frame vs FrameLocator
+
+```typescript
+// frameLocator - for locator-based operations (recommended)
+const frameLocator = page.frameLocator("#my-iframe");
+await frameLocator.getByRole("button").click();
+
+// frame() - for Frame object operations (navigation, evaluation)
+const frame = page.frame({ name: "my-frame" });
+if (frame) {
+ await frame.goto("https://example.com");
+ const title = await frame.title();
+}
+
+// Get all frames
+const frames = page.frames();
+for (const f of frames) {
+ console.log("Frame URL:", f.url());
+}
+```
+
+### Waiting for iFrame Content
+
+```typescript
+// Wait for iframe to load
+const frame = page.frameLocator("#dynamic-iframe");
+
+// Wait for element inside iframe
+await expect(frame.getByRole("heading")).toBeVisible({ timeout: 10000 });
+
+// Wait for iframe src to change
+await page.waitForFunction(() => {
+ const iframe = document.querySelector("iframe#my-frame") as HTMLIFrameElement;
+ return iframe?.src.includes("loaded");
+});
+```
+
+## Cross-Origin iFrames
+
+### Accessing Cross-Origin Content
+
+```typescript
+// Cross-origin iframes work seamlessly with frameLocator
+const thirdPartyFrame = page.frameLocator('iframe[src*="third-party.com"]');
+
+// Interact with elements inside cross-origin iframe
+await thirdPartyFrame.getByRole("textbox").fill("test@example.com");
+await thirdPartyFrame.getByRole("button", { name: "Submit" }).click();
+
+// Wait for cross-origin iframe to be ready
+await expect(thirdPartyFrame.locator("body")).toBeVisible();
+```
+
+### Payment Provider iFrames (Stripe, PayPal)
+
+```typescript
+test("Stripe payment iframe", async ({ page }) => {
+ await page.goto("/checkout");
+
+ // Stripe uses multiple iframes for each field
+ const cardFrame = page
+ .frameLocator('iframe[name*="__privateStripeFrame"]')
+ .first();
+
+ // Wait for Stripe to initialize
+ await expect(cardFrame.locator('[placeholder="Card number"]')).toBeVisible({
+ timeout: 15000,
+ });
+
+ // Fill card details
+ await cardFrame
+ .locator('[placeholder="Card number"]')
+ .fill("4242424242424242");
+ await cardFrame.locator('[placeholder="MM / YY"]').fill("12/30");
+ await cardFrame.locator('[placeholder="CVC"]').fill("123");
+});
+```
+
+### Handling OAuth in iFrames
+
+```typescript
+test("OAuth iframe flow", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Sign in with Google" }).click();
+
+ // If OAuth opens in iframe instead of popup
+ const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]');
+
+ // Wait for OAuth form
+ await expect(oauthFrame.getByLabel("Email")).toBeVisible({ timeout: 10000 });
+ await oauthFrame.getByLabel("Email").fill("test@gmail.com");
+});
+```
+
+## Nested iFrames
+
+### Accessing Nested Frames
+
+```typescript
+// Parent iframe contains child iframe
+const parentFrame = page.frameLocator("#outer-frame");
+const childFrame = parentFrame.frameLocator("#inner-frame");
+
+// Interact with deeply nested content
+await childFrame.getByRole("button", { name: "Submit" }).click();
+
+// Multiple levels of nesting
+const level1 = page.frameLocator("#level1");
+const level2 = level1.frameLocator("#level2");
+const level3 = level2.frameLocator("#level3");
+await level3.getByText("Deep content").click();
+```
+
+### Finding Elements Across Frame Hierarchy
+
+```typescript
+// Helper to search all frames for an element
+async function findInAnyFrame(
+ page: Page,
+ selector: string,
+): Promise {
+ // Check main page first
+ const mainCount = await page.locator(selector).count();
+ if (mainCount > 0) return page.locator(selector);
+
+ // Check all frames
+ for (const frame of page.frames()) {
+ const count = await frame.locator(selector).count();
+ if (count > 0) {
+ return frame.locator(selector);
+ }
+ }
+ return null;
+}
+
+test("find element in any frame", async ({ page }) => {
+ await page.goto("/complex-page");
+ const element = await findInAnyFrame(page, '[data-testid="submit-btn"]');
+ if (element) await element.click();
+});
+```
+
+## Dynamic iFrames
+
+### iFrames Created at Runtime
+
+```typescript
+test("handle dynamically created iframe", async ({ page }) => {
+ await page.goto("/dashboard");
+
+ // Click button that creates iframe
+ await page.getByRole("button", { name: "Open Widget" }).click();
+
+ // Wait for iframe to appear in DOM
+ await page.waitForSelector("iframe#widget-frame");
+
+ // Now access the frame
+ const widgetFrame = page.frameLocator("#widget-frame");
+ await expect(widgetFrame.getByText("Widget Loaded")).toBeVisible();
+});
+```
+
+### iFrames with Changing src
+
+```typescript
+test("iframe src changes", async ({ page }) => {
+ await page.goto("/multi-step");
+
+ const frame = page.frameLocator("#step-frame");
+
+ // Step 1
+ await expect(frame.getByText("Step 1")).toBeVisible();
+ await frame.getByRole("button", { name: "Next" }).click();
+
+ // Wait for iframe to reload with new content
+ await expect(frame.getByText("Step 2")).toBeVisible({ timeout: 10000 });
+ await frame.getByRole("button", { name: "Next" }).click();
+
+ // Step 3
+ await expect(frame.getByText("Step 3")).toBeVisible({ timeout: 10000 });
+});
+```
+
+### Lazy-Loaded iFrames
+
+```typescript
+test("lazy loaded iframe", async ({ page }) => {
+ await page.goto("/page-with-lazy-iframe");
+
+ // Scroll to trigger lazy load
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+
+ // Wait for iframe to load
+ const lazyFrame = page.frameLocator("#lazy-iframe");
+ await expect(lazyFrame.locator("body")).not.toBeEmpty({ timeout: 15000 });
+
+ // Interact with content
+ await lazyFrame.getByRole("button").click();
+});
+```
+
+## iFrame Navigation
+
+### Navigating Within iFrame
+
+```typescript
+test("iframe internal navigation", async ({ page }) => {
+ await page.goto("/app");
+
+ // Get frame object for navigation control
+ const frame = page.frame({ name: "content-frame" });
+ if (!frame) throw new Error("Frame not found");
+
+ // Navigate within iframe
+ await frame.goto("https://embedded-app.com/page2");
+
+ // Wait for navigation
+ await frame.waitForURL("**/page2");
+
+ // Verify content
+ await expect(frame.getByRole("heading")).toHaveText("Page 2");
+});
+```
+
+### Handling Frame Navigation Events
+
+```typescript
+test("track iframe navigation", async ({ page }) => {
+ const navigations: string[] = [];
+
+ // Listen to frame navigation
+ page.on("framenavigated", (frame) => {
+ if (frame.parentFrame()) {
+ // This is an iframe navigation
+ navigations.push(frame.url());
+ }
+ });
+
+ await page.goto("/with-iframe");
+ await page
+ .frameLocator("#nav-frame")
+ .getByRole("link", { name: "Page 2" })
+ .click();
+
+ // Verify navigation occurred
+ expect(navigations.some((url) => url.includes("page2"))).toBe(true);
+});
+```
+
+## Common Patterns
+
+### iFrame Fixture
+
+```typescript
+// fixtures.ts
+import { test as base, FrameLocator } from "@playwright/test";
+
+export const test = base.extend<{ paymentFrame: FrameLocator }>({
+ paymentFrame: async ({ page }, use) => {
+ await page.goto("/checkout");
+
+ // Wait for payment iframe to be ready
+ const frame = page.frameLocator('iframe[src*="payment"]');
+ await expect(frame.locator("body")).toBeVisible({ timeout: 15000 });
+
+ await use(frame);
+ },
+});
+
+// test file
+test("complete payment", async ({ paymentFrame }) => {
+ await paymentFrame.getByLabel("Card").fill("4242424242424242");
+ await paymentFrame.getByRole("button", { name: "Pay" }).click();
+});
+```
+
+### Debugging iFrame Issues
+
+```typescript
+test("debug iframe content", async ({ page }) => {
+ await page.goto("/page-with-iframes");
+
+ // List all frames
+ console.log("All frames:");
+ for (const frame of page.frames()) {
+ console.log(` - ${frame.name() || "(unnamed)"}: ${frame.url()}`);
+ }
+
+ // Screenshot specific iframe content
+ const frame = page.frame({ name: "target-frame" });
+ if (frame) {
+ const body = frame.locator("body");
+ await body.screenshot({ path: "iframe-content.png" });
+ }
+
+ // Get iframe HTML for debugging
+ const frameContent = page.frameLocator("#my-frame");
+ const html = await frameContent.locator("body").innerHTML();
+ console.log("iFrame HTML:", html.substring(0, 500));
+});
+```
+
+### Handling iFrame Load Failures
+
+```typescript
+test("handle iframe load failure", async ({ page }) => {
+ await page.goto("/page-with-unreliable-iframe");
+
+ const frame = page.frameLocator("#unreliable-frame");
+
+ try {
+ // Try to interact with iframe content
+ await expect(frame.getByRole("button")).toBeVisible({ timeout: 5000 });
+ await frame.getByRole("button").click();
+ } catch (error) {
+ // Fallback: refresh iframe
+ await page.evaluate(() => {
+ const iframe = document.querySelector(
+ "#unreliable-frame",
+ ) as HTMLIFrameElement;
+ if (iframe) iframe.src = iframe.src;
+ });
+
+ // Retry
+ await expect(frame.getByRole("button")).toBeVisible({ timeout: 10000 });
+ await frame.getByRole("button").click();
+ }
+});
+```
+
+### Mocking iFrame Content
+
+```typescript
+test("mock iframe response", async ({ page }) => {
+ // Intercept iframe src request
+ await page.route("**/embedded-widget**", (route) => {
+ route.fulfill({
+ contentType: "text/html",
+ body: `
+
+
+
+
Mocked Widget
+
Mocked widget content
+
+
+ `,
+ });
+ });
+
+ await page.goto("/page-with-widget");
+
+ const frame = page.frameLocator("#widget-frame");
+ await expect(frame.getByRole("heading")).toHaveText("Mocked Widget");
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ------------------------------------- | --------------------------------- | -------------------------------------------------- |
+| Using `page.frame()` for interactions | Less reliable than frameLocator | Use `page.frameLocator()` for element interactions |
+| Hardcoding iframe index | Fragile if DOM order changes | Use name, id, or src attribute selectors |
+| Not waiting for iframe load | Race conditions | Wait for element inside iframe to be visible |
+| Assuming same-origin | Cross-origin has different timing | Always wait for iframe content explicitly |
+| Ignoring nested iframes | Element not found | Chain frameLocator calls for nested frames |
+
+## Related References
+
+- **Locators**: See [locators.md](../core/locators.md) for selector strategies
+- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns
+- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues
diff --git a/.cursor/skills/playwright-testing/browser-apis/service-workers.md b/.cursor/skills/playwright-testing/browser-apis/service-workers.md
new file mode 100644
index 0000000000..7603de322e
--- /dev/null
+++ b/.cursor/skills/playwright-testing/browser-apis/service-workers.md
@@ -0,0 +1,504 @@
+# Service Worker Testing
+
+## Table of Contents
+
+1. [Service Worker Basics](#service-worker-basics)
+2. [Registration & Lifecycle](#registration--lifecycle)
+3. [Cache Testing](#cache-testing)
+4. [Offline Testing](#offline-testing)
+5. [Push Notifications](#push-notifications)
+6. [Background Sync](#background-sync)
+
+## Service Worker Basics
+
+### Waiting for Service Worker Registration
+
+```typescript
+test("service worker registers", async ({ page }) => {
+ await page.goto("/pwa-app");
+
+ // Wait for SW to register
+ const swRegistered = await page.evaluate(async () => {
+ if (!("serviceWorker" in navigator)) return false;
+
+ const registration = await navigator.serviceWorker.ready;
+ return !!registration.active;
+ });
+
+ expect(swRegistered).toBe(true);
+});
+```
+
+### Getting Service Worker State
+
+```typescript
+test("check SW state", async ({ page }) => {
+ await page.goto("/");
+
+ const swState = await page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.getRegistration();
+ if (!registration) return null;
+
+ return {
+ installing: !!registration.installing,
+ waiting: !!registration.waiting,
+ active: !!registration.active,
+ scope: registration.scope,
+ };
+ });
+
+ expect(swState?.active).toBe(true);
+ expect(swState?.scope).toContain(page.url());
+});
+```
+
+### Service Worker Context
+
+```typescript
+test("access service worker", async ({ context, page }) => {
+ await page.goto("/pwa-app");
+
+ // Get all service workers in context
+ const workers = context.serviceWorkers();
+
+ // Wait for service worker if not yet available
+ if (workers.length === 0) {
+ await context.waitForEvent("serviceworker");
+ }
+
+ const sw = context.serviceWorkers()[0];
+ expect(sw.url()).toContain("sw.js");
+});
+```
+
+## Registration & Lifecycle
+
+### Testing SW Update Flow
+
+```typescript
+test("service worker updates", async ({ page }) => {
+ await page.goto("/pwa-app");
+
+ // Check for update
+ const hasUpdate = await page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.ready;
+ await registration.update();
+
+ return new Promise((resolve) => {
+ if (registration.waiting) {
+ resolve(true);
+ } else {
+ registration.addEventListener("updatefound", () => {
+ resolve(true);
+ });
+ // Timeout if no update
+ setTimeout(() => resolve(false), 5000);
+ }
+ });
+ });
+
+ // If update found, test skip waiting flow
+ if (hasUpdate) {
+ await page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.ready;
+ registration.waiting?.postMessage({ type: "SKIP_WAITING" });
+ });
+
+ // Wait for controller change
+ await page.evaluate(() => {
+ return new Promise((resolve) => {
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ resolve();
+ });
+ });
+ });
+ }
+});
+```
+
+### Testing SW Installation
+
+```typescript
+test("verify SW install event", async ({ context, page }) => {
+ // Listen for service worker before navigating
+ const swPromise = context.waitForEvent("serviceworker");
+
+ await page.goto("/pwa-app");
+
+ const sw = await swPromise;
+
+ // Evaluate in SW context
+ const swVersion = await sw.evaluate(() => {
+ // Access SW globals
+ return (self as any).SW_VERSION || "unknown";
+ });
+
+ expect(swVersion).toBe("1.0.0");
+});
+```
+
+### Unregistering Service Workers
+
+```typescript
+test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+
+ // Unregister all service workers for clean state
+ await page.evaluate(async () => {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ await Promise.all(registrations.map((r) => r.unregister()));
+ });
+
+ // Clear caches
+ await page.evaluate(async () => {
+ const cacheNames = await caches.keys();
+ await Promise.all(cacheNames.map((name) => caches.delete(name)));
+ });
+});
+```
+
+## Cache Testing
+
+### Verifying Cached Resources
+
+```typescript
+test("assets are cached", async ({ page }) => {
+ await page.goto("/pwa-app");
+
+ // Wait for SW to cache assets
+ await page.evaluate(async () => {
+ await navigator.serviceWorker.ready;
+ });
+
+ // Check cache contents
+ const cachedUrls = await page.evaluate(async () => {
+ const cache = await caches.open("app-cache-v1");
+ const requests = await cache.keys();
+ return requests.map((r) => r.url);
+ });
+
+ expect(cachedUrls).toContain(expect.stringContaining("/styles.css"));
+ expect(cachedUrls).toContain(expect.stringContaining("/app.js"));
+});
+```
+
+### Testing Cache Strategies
+
+```typescript
+test("cache-first strategy", async ({ page }) => {
+ await page.goto("/pwa-app");
+
+ // Wait for initial cache
+ await page.waitForFunction(async () => {
+ const cache = await caches.open("app-cache-v1");
+ const keys = await cache.keys();
+ return keys.length > 0;
+ });
+
+ // Block network for cached resources
+ await page.route("**/styles.css", (route) => route.abort());
+
+ // Reload - should work from cache
+ await page.reload();
+
+ // Verify page still styled (CSS loaded from cache)
+ const hasStyles = await page.evaluate(() => {
+ const body = document.body;
+ const styles = window.getComputedStyle(body);
+ return styles.fontFamily !== ""; // Has custom font from CSS
+ });
+
+ expect(hasStyles).toBe(true);
+});
+```
+
+### Testing Cache Updates
+
+```typescript
+test("cache updates on new version", async ({ page }) => {
+ await page.goto("/pwa-app");
+
+ // Get initial cache
+ const initialCacheKeys = await page.evaluate(async () => {
+ const cache = await caches.open("app-cache-v1");
+ const keys = await cache.keys();
+ return keys.map((r) => r.url);
+ });
+
+ // Simulate app update by mocking SW response
+ await page.route("**/sw.js", (route) => {
+ route.fulfill({
+ contentType: "application/javascript",
+ body: `
+ const VERSION = 'v2';
+ self.addEventListener('install', (e) => {
+ e.waitUntil(caches.open('app-cache-v2'));
+ self.skipWaiting();
+ });
+ `,
+ });
+ });
+
+ // Trigger update
+ await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.ready;
+ await reg.update();
+ });
+
+ // Verify new cache exists
+ await page.waitForFunction(async () => {
+ return await caches.has("app-cache-v2");
+ });
+});
+```
+
+## Offline Testing
+
+This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).
+
+### Simulating Offline Mode
+
+```typescript
+test("app works offline", async ({ page, context }) => {
+ await page.goto("/pwa-app");
+
+ // Ensure SW is active and content cached
+ await page.evaluate(async () => {
+ await navigator.serviceWorker.ready;
+ });
+ await page.waitForTimeout(1000); // Allow caching to complete
+
+ // Go offline
+ await context.setOffline(true);
+
+ // Navigate to cached page
+ await page.reload();
+
+ // Verify content loads
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+
+ // Verify offline indicator
+ await expect(page.locator(".offline-badge")).toBeVisible();
+
+ // Go back online
+ await context.setOffline(false);
+ await expect(page.locator(".offline-badge")).not.toBeVisible();
+});
+```
+
+### Testing Offline Fallback
+
+```typescript
+test("shows offline page for uncached routes", async ({ page, context }) => {
+ await page.goto("/pwa-app");
+ await page.evaluate(() => navigator.serviceWorker.ready);
+
+ // Go offline
+ await context.setOffline(true);
+
+ // Navigate to uncached page
+ await page.goto("/uncached-page");
+
+ // Should show offline fallback
+ await expect(page.getByText("You are offline")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
+});
+```
+
+### Testing Offline Form Submission
+
+```typescript
+test("queues form submission offline", async ({ page, context }) => {
+ await page.goto("/pwa-app/form");
+
+ // Go offline
+ await context.setOffline(true);
+
+ // Submit form
+ await page.getByLabel("Message").fill("Offline message");
+ await page.getByRole("button", { name: "Send" }).click();
+
+ // Should show queued status
+ await expect(page.getByText("Queued for sync")).toBeVisible();
+
+ // Go online
+ await context.setOffline(false);
+
+ // Trigger sync (or wait for automatic)
+ await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.ready;
+ // Manually trigger sync for testing
+ await (reg as any).sync?.register("form-sync");
+ });
+
+ // Verify submission completed
+ await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 });
+});
+```
+
+## Push Notifications
+
+### Mocking Push Subscription
+
+```typescript
+test("handles push subscription", async ({ page, context }) => {
+ // Grant notification permission
+ await context.grantPermissions(["notifications"]);
+
+ await page.goto("/pwa-app");
+
+ // Subscribe to push
+ const subscription = await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.ready;
+ const sub = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: "test-key",
+ });
+ return sub.toJSON();
+ });
+
+ expect(subscription.endpoint).toBeDefined();
+});
+```
+
+### Testing Push Message Handling
+
+```typescript
+test("handles push notification", async ({ context, page }) => {
+ await context.grantPermissions(["notifications"]);
+ await page.goto("/pwa-app");
+
+ // Wait for SW
+ const swPromise = context.waitForEvent("serviceworker");
+ const sw = await swPromise;
+
+ // Simulate push message to service worker
+ await sw.evaluate(async () => {
+ // Dispatch push event
+ const pushEvent = new PushEvent("push", {
+ data: new PushMessageData(
+ JSON.stringify({ title: "Test", body: "Push message" }),
+ ),
+ });
+ self.dispatchEvent(pushEvent);
+ });
+
+ // Note: Actual notification display testing is limited in Playwright
+ // Focus on verifying the SW handles the push correctly
+});
+```
+
+### Testing Notification Click
+
+```typescript
+test("notification click opens page", async ({ context, page }) => {
+ await context.grantPermissions(["notifications"]);
+ await page.goto("/pwa-app");
+
+ // Store notification URL target
+ let notificationUrl = "";
+
+ // Listen for new pages (notification click opens new page)
+ context.on("page", (newPage) => {
+ notificationUrl = newPage.url();
+ });
+
+ // Trigger notification via SW
+ await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.ready;
+ await reg.showNotification("Test", {
+ body: "Click me",
+ data: { url: "/notification-target" },
+ });
+ });
+
+ // Simulate clicking notification (via SW)
+ const sw = context.serviceWorkers()[0];
+ await sw.evaluate(() => {
+ self.dispatchEvent(
+ new NotificationEvent("notificationclick", {
+ notification: { data: { url: "/notification-target" } } as any,
+ }),
+ );
+ });
+
+ // Verify navigation occurred
+ await page.waitForTimeout(1000);
+ // Check if new page opened or current page navigated
+});
+```
+
+## Background Sync
+
+### Testing Background Sync Registration
+
+```typescript
+test("registers background sync", async ({ page }) => {
+ await page.goto("/pwa-app");
+
+ // Register sync
+ const syncRegistered = await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.ready;
+ if (!("sync" in reg)) return false;
+
+ await (reg as any).sync.register("my-sync");
+ return true;
+ });
+
+ expect(syncRegistered).toBe(true);
+});
+```
+
+### Testing Sync Event
+
+```typescript
+test("sync event fires when online", async ({ context, page }) => {
+ await page.goto("/pwa-app");
+
+ // Queue data while offline
+ await context.setOffline(true);
+
+ await page.evaluate(async () => {
+ // Store data in IndexedDB for sync
+ const db = await openDB();
+ await db.put("sync-queue", { id: 1, data: "test" });
+
+ // Register sync
+ const reg = await navigator.serviceWorker.ready;
+ await (reg as any).sync.register("data-sync");
+ });
+
+ // Track sync completion
+ await page.evaluate(() => {
+ window.syncCompleted = false;
+ navigator.serviceWorker.addEventListener("message", (e) => {
+ if (e.data.type === "SYNC_COMPLETE") {
+ window.syncCompleted = true;
+ }
+ });
+ });
+
+ // Go online
+ await context.setOffline(false);
+
+ // Wait for sync to complete
+ await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 });
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ------------------------------ | ----------------------- | -------------------------------------------- |
+| Not clearing SW between tests | Tests affect each other | Unregister SW in beforeEach |
+| Not waiting for SW ready | Race conditions | Always await `navigator.serviceWorker.ready` |
+| Testing in isolation only | Misses real SW behavior | Test with actual caching |
+| Hardcoded timeouts for caching | Flaky tests | Wait for cache to populate |
+| Ignoring SW update cycle | Missing update bugs | Test install, activate, update flows |
+
+## Related References
+
+- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns
+- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions
+- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception
+- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns
diff --git a/.cursor/skills/playwright-testing/browser-apis/websockets.md b/.cursor/skills/playwright-testing/browser-apis/websockets.md
new file mode 100644
index 0000000000..075a997395
--- /dev/null
+++ b/.cursor/skills/playwright-testing/browser-apis/websockets.md
@@ -0,0 +1,403 @@
+# WebSocket & Real-Time Testing
+
+## Table of Contents
+
+1. [WebSocket Basics](#websocket-basics)
+2. [Mocking WebSocket Messages](#mocking-websocket-messages)
+3. [Testing Real-Time Features](#testing-real-time-features)
+4. [Server-Sent Events](#server-sent-events)
+5. [Reconnection Testing](#reconnection-testing)
+
+## WebSocket Basics
+
+### Wait for WebSocket Connection
+
+```typescript
+test("chat connects via websocket", async ({ page }) => {
+ // Listen for WebSocket connection
+ const wsPromise = page.waitForEvent("websocket");
+
+ await page.goto("/chat");
+
+ const ws = await wsPromise;
+ expect(ws.url()).toContain("/ws/chat");
+
+ // Wait for connection to be established
+ await ws.waitForEvent("framesent");
+});
+```
+
+### Monitor WebSocket Messages
+
+```typescript
+test("receives real-time updates", async ({ page }) => {
+ const messages: string[] = [];
+
+ // Set up listener before navigation
+ page.on("websocket", (ws) => {
+ ws.on("framereceived", (frame) => {
+ messages.push(frame.payload as string);
+ });
+ });
+
+ await page.goto("/dashboard");
+
+ // Wait for some messages
+ await expect.poll(() => messages.length).toBeGreaterThan(0);
+
+ // Verify message format
+ const data = JSON.parse(messages[0]);
+ expect(data).toHaveProperty("type");
+});
+```
+
+### Capture Sent Messages
+
+```typescript
+test("sends correct message format", async ({ page }) => {
+ const sentMessages: string[] = [];
+
+ page.on("websocket", (ws) => {
+ ws.on("framesent", (frame) => {
+ sentMessages.push(frame.payload as string);
+ });
+ });
+
+ await page.goto("/chat");
+ await page.getByLabel("Message").fill("Hello!");
+ await page.getByRole("button", { name: "Send" }).click();
+
+ // Verify sent message
+ await expect.poll(() => sentMessages.length).toBeGreaterThan(0);
+
+ const sent = JSON.parse(sentMessages[sentMessages.length - 1]);
+ expect(sent).toEqual({
+ type: "message",
+ content: "Hello!",
+ });
+});
+```
+
+## Mocking WebSocket Messages
+
+### Inject Messages via Page Evaluate
+
+```typescript
+test("displays incoming chat message", async ({ page }) => {
+ await page.goto("/chat");
+
+ // Wait for WebSocket to be ready
+ await page.waitForFunction(
+ () => (window as any).chatSocket?.readyState === 1,
+ );
+
+ // Simulate incoming message
+ await page.evaluate(() => {
+ const event = new MessageEvent("message", {
+ data: JSON.stringify({
+ type: "message",
+ from: "Alice",
+ content: "Hello there!",
+ }),
+ });
+ (window as any).chatSocket.dispatchEvent(event);
+ });
+
+ await expect(page.getByText("Alice: Hello there!")).toBeVisible();
+});
+```
+
+### Mock WebSocket with Route Handler
+
+```typescript
+test("mock websocket entirely", async ({ page, context }) => {
+ // Intercept the WebSocket upgrade
+ await context.route("**/ws/**", async (route) => {
+ // For WebSocket routes, we can't fulfill directly
+ // Instead, use page.evaluate to mock the client-side
+ });
+
+ // Alternative: Mock at application level
+ await page.addInitScript(() => {
+ const OriginalWebSocket = window.WebSocket;
+ (window as any).WebSocket = function (url: string) {
+ const ws = {
+ readyState: 1,
+ send: (data: string) => {
+ console.log("WS Send:", data);
+ },
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ };
+ setTimeout(() => ws.onopen?.(), 100);
+ return ws;
+ };
+ });
+
+ await page.goto("/chat");
+});
+```
+
+### WebSocket Mock Fixture
+
+```typescript
+// fixtures/websocket.fixture.ts
+import { test as base, Page } from "@playwright/test";
+
+type WsMessage = { type: string; [key: string]: any };
+
+type WebSocketFixtures = {
+ mockWebSocket: {
+ injectMessage: (message: WsMessage) => Promise;
+ getSentMessages: () => Promise;
+ };
+};
+
+export const test = base.extend({
+ mockWebSocket: async ({ page }, use) => {
+ const sentMessages: WsMessage[] = [];
+
+ // Capture sent messages
+ await page.addInitScript(() => {
+ (window as any).__wsSent = [];
+ const OriginalWebSocket = window.WebSocket;
+ window.WebSocket = function (url: string) {
+ const ws = new OriginalWebSocket(url);
+ const originalSend = ws.send.bind(ws);
+ ws.send = (data: string) => {
+ (window as any).__wsSent.push(JSON.parse(data));
+ originalSend(data);
+ };
+ (window as any).__ws = ws;
+ return ws;
+ } as any;
+ });
+
+ await use({
+ injectMessage: async (message) => {
+ await page.evaluate((msg) => {
+ const event = new MessageEvent("message", {
+ data: JSON.stringify(msg),
+ });
+ (window as any).__ws?.dispatchEvent(event);
+ }, message);
+ },
+ getSentMessages: async () => {
+ return page.evaluate(() => (window as any).__wsSent || []);
+ },
+ });
+ },
+});
+
+// Usage
+test("chat with mocked websocket", async ({ page, mockWebSocket }) => {
+ await page.goto("/chat");
+
+ // Inject incoming message
+ await mockWebSocket.injectMessage({
+ type: "message",
+ from: "Bob",
+ content: "Hi!",
+ });
+
+ await expect(page.getByText("Bob: Hi!")).toBeVisible();
+
+ // Send a reply
+ await page.getByLabel("Message").fill("Hello Bob!");
+ await page.getByRole("button", { name: "Send" }).click();
+
+ // Verify sent message
+ const sent = await mockWebSocket.getSentMessages();
+ expect(sent).toContainEqual(
+ expect.objectContaining({ content: "Hello Bob!" }),
+ );
+});
+```
+
+## Testing Real-Time Features
+
+### Live Notifications
+
+```typescript
+test("displays live notification", async ({ page }) => {
+ await page.goto("/dashboard");
+
+ // Simulate notification via WebSocket
+ await page.evaluate(() => {
+ const event = new MessageEvent("message", {
+ data: JSON.stringify({
+ type: "notification",
+ title: "New Order",
+ message: "Order #123 received",
+ }),
+ });
+ (window as any).notificationSocket.dispatchEvent(event);
+ });
+
+ await expect(page.getByRole("alert")).toContainText("Order #123 received");
+});
+```
+
+### Live Data Updates
+
+```typescript
+test("updates stock price in real-time", async ({ page }) => {
+ await page.goto("/stocks/AAPL");
+
+ const priceElement = page.getByTestId("stock-price");
+ const initialPrice = await priceElement.textContent();
+
+ // Simulate price update
+ await page.evaluate(() => {
+ const event = new MessageEvent("message", {
+ data: JSON.stringify({
+ type: "price_update",
+ symbol: "AAPL",
+ price: 150.25,
+ }),
+ });
+ (window as any).stockSocket.dispatchEvent(event);
+ });
+
+ await expect(priceElement).not.toHaveText(initialPrice!);
+ await expect(priceElement).toContainText("150.25");
+});
+```
+
+### Collaborative Editing
+
+```typescript
+test("shows collaborator cursor", async ({ page }) => {
+ await page.goto("/document/123");
+
+ // Simulate another user's cursor position
+ await page.evaluate(() => {
+ const event = new MessageEvent("message", {
+ data: JSON.stringify({
+ type: "cursor",
+ userId: "user-456",
+ userName: "Alice",
+ position: { x: 100, y: 200 },
+ }),
+ });
+ (window as any).docSocket.dispatchEvent(event);
+ });
+
+ await expect(page.getByTestId("cursor-user-456")).toBeVisible();
+ await expect(page.getByText("Alice")).toBeVisible();
+});
+```
+
+## Server-Sent Events
+
+### Test SSE Updates
+
+```typescript
+test("receives SSE updates", async ({ page }) => {
+ // Mock SSE endpoint
+ await page.route("**/api/events", (route) => {
+ route.fulfill({
+ status: 200,
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ },
+ body: `data: {"type":"update","value":42}\n\n`,
+ });
+ });
+
+ await page.goto("/live-data");
+
+ await expect(page.getByTestId("value")).toHaveText("42");
+});
+```
+
+### Simulate Multiple SSE Events
+
+```typescript
+test("handles multiple SSE events", async ({ page }) => {
+ await page.route("**/api/events", async (route) => {
+ const encoder = new TextEncoder();
+ const events = [
+ `data: {"count":1}\n\n`,
+ `data: {"count":2}\n\n`,
+ `data: {"count":3}\n\n`,
+ ];
+
+ route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ body: events.join(""),
+ });
+ });
+
+ await page.goto("/counter");
+
+ // Should receive all events
+ await expect(page.getByTestId("count")).toHaveText("3");
+});
+```
+
+## Reconnection Testing
+
+### Test Connection Loss
+
+```typescript
+test("handles connection loss gracefully", async ({ page }) => {
+ await page.goto("/chat");
+
+ // Simulate connection close
+ await page.evaluate(() => {
+ (window as any).chatSocket.close();
+ });
+
+ // Should show disconnected state
+ await expect(page.getByText("Reconnecting...")).toBeVisible();
+});
+```
+
+### Test Reconnection
+
+```typescript
+test("reconnects after connection loss", async ({ page }) => {
+ await page.goto("/chat");
+
+ // Simulate disconnect
+ await page.evaluate(() => {
+ (window as any).chatSocket.close();
+ });
+
+ await expect(page.getByText("Reconnecting...")).toBeVisible();
+
+ // Simulate reconnection
+ await page.evaluate(() => {
+ const event = new Event("open");
+ (window as any).chatSocket = { readyState: 1 };
+ (window as any).chatSocket.dispatchEvent?.(event);
+ });
+
+ // Force component to re-check connection
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event("online"));
+ });
+
+ await expect(page.getByText("Connected")).toBeVisible();
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ------------------------------------- | ----------------------------- | ---------------------------------- |
+| Not waiting for WebSocket ready | Messages sent too early | Wait for `readyState === 1` |
+| Testing against real WebSocket server | Flaky, timing-dependent | Mock WebSocket messages |
+| Ignoring connection state | Tests pass but feature broken | Test connected/disconnected states |
+| No cleanup of listeners | Memory leaks in tests | Clean up event listeners |
+
+## Related References
+
+- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns
+- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns
+- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users
diff --git a/.cursor/skills/playwright-testing/core/annotations.md b/.cursor/skills/playwright-testing/core/annotations.md
new file mode 100644
index 0000000000..ac0f890a5e
--- /dev/null
+++ b/.cursor/skills/playwright-testing/core/annotations.md
@@ -0,0 +1,424 @@
+# Test Annotations & Organization
+
+## Table of Contents
+
+1. [Skip Annotations](#skip-annotations)
+2. [Fixme & Fail Annotations](#fixme--fail-annotations)
+3. [Slow Tests](#slow-tests)
+4. [Test Steps](#test-steps)
+5. [Custom Annotations](#custom-annotations)
+6. [Conditional Annotations](#conditional-annotations)
+
+## Skip Annotations
+
+### Basic Skip
+
+```typescript
+// Skip unconditionally
+test.skip("feature not implemented", async ({ page }) => {
+ // This test won't run
+});
+
+// Skip with reason
+test("payment flow", async ({ page }) => {
+ test.skip(true, "Payment gateway in maintenance");
+ // Test body won't execute
+});
+```
+
+### Conditional Skip
+
+```typescript
+test("webkit-specific feature", async ({ page, browserName }) => {
+ test.skip(browserName !== "webkit", "This feature only works in WebKit");
+
+ await page.goto("/webkit-feature");
+});
+
+test("production only", async ({ page }) => {
+ test.skip(process.env.ENV !== "production", "Only runs against production");
+
+ await page.goto("/prod-feature");
+});
+```
+
+### Skip by Platform
+
+```typescript
+test("windows-specific", async ({ page }) => {
+ test.skip(process.platform !== "win32", "Windows only");
+});
+
+test("not on CI", async ({ page }) => {
+ test.skip(!!process.env.CI, "Skipped in CI environment");
+});
+```
+
+### Skip Describe Block
+
+```typescript
+test.describe("Admin features", () => {
+ test.skip(
+ ({ browserName }) => browserName === "firefox",
+ "Firefox admin bug",
+ );
+
+ test("admin dashboard", async ({ page }) => {
+ // Skipped in Firefox
+ });
+
+ test("admin settings", async ({ page }) => {
+ // Skipped in Firefox
+ });
+});
+```
+
+## Fixme & Fail Annotations
+
+### Fixme - Known Issues
+
+```typescript
+// Mark test as needing fix (skips the test)
+test.fixme("broken after refactor", async ({ page }) => {
+ // Test won't run but is tracked
+});
+
+// Conditional fixme
+test("flaky on CI", async ({ page }) => {
+ test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123");
+
+ await page.goto("/flaky-feature");
+});
+```
+
+### Fail - Expected Failures
+
+```typescript
+// Test is expected to fail (runs but expects failure)
+test("known bug", async ({ page }) => {
+ test.fail();
+
+ await page.goto("/buggy-page");
+ // If this passes, the test fails (bug was fixed!)
+ await expect(page.getByText("Working")).toBeVisible();
+});
+
+// Conditional fail
+test("fails on webkit", async ({ page, browserName }) => {
+ test.fail(browserName === "webkit", "WebKit rendering bug #456");
+
+ await page.goto("/render-test");
+ await expect(page.getByTestId("element")).toHaveCSS("width", "100px");
+});
+```
+
+### Difference Between Skip, Fixme, Fail
+
+| Annotation | Runs? | Use Case |
+| -------------- | ----- | -------------------------------- |
+| `test.skip()` | No | Feature not applicable |
+| `test.fixme()` | No | Known bug, needs investigation |
+| `test.fail()` | Yes | Expected to fail, tracking a bug |
+
+## Slow Tests
+
+### Mark Slow Tests
+
+```typescript
+// Triple the default timeout
+test("large data import", async ({ page }) => {
+ test.slow();
+
+ await page.goto("/import");
+ await page.setInputFiles("#file", "large-file.csv");
+ await page.getByRole("button", { name: "Import" }).click();
+
+ await expect(page.getByText("Import complete")).toBeVisible();
+});
+
+// Conditional slow
+test("video processing", async ({ page, browserName }) => {
+ test.slow(browserName === "webkit", "WebKit video processing is slow");
+
+ await page.goto("/video-editor");
+});
+```
+
+### Custom Timeout
+
+```typescript
+test("very long operation", async ({ page }) => {
+ // Set specific timeout (in milliseconds)
+ test.setTimeout(120000); // 2 minutes
+
+ await page.goto("/long-operation");
+});
+
+// Timeout for describe block
+test.describe("Integration tests", () => {
+ test.describe.configure({ timeout: 60000 });
+
+ test("test 1", async ({ page }) => {
+ // Has 60 second timeout
+ });
+});
+```
+
+## Test Steps
+
+### Basic Steps
+
+```typescript
+test("checkout flow", async ({ page }) => {
+ await test.step("Add item to cart", async () => {
+ await page.goto("/products");
+ await page.getByRole("button", { name: "Add to Cart" }).click();
+ });
+
+ await test.step("Go to checkout", async () => {
+ await page.getByRole("link", { name: "Cart" }).click();
+ await page.getByRole("button", { name: "Checkout" }).click();
+ });
+
+ await test.step("Fill shipping info", async () => {
+ await page.getByLabel("Address").fill("123 Test St");
+ await page.getByLabel("City").fill("Test City");
+ });
+
+ await test.step("Complete payment", async () => {
+ await page.getByLabel("Card").fill("4242424242424242");
+ await page.getByRole("button", { name: "Pay" }).click();
+ });
+
+ await expect(page.getByText("Order confirmed")).toBeVisible();
+});
+```
+
+### Nested Steps
+
+```typescript
+test("user registration", async ({ page }) => {
+ await test.step("Fill registration form", async () => {
+ await page.goto("/register");
+
+ await test.step("Personal info", async () => {
+ await page.getByLabel("Name").fill("John Doe");
+ await page.getByLabel("Email").fill("john@example.com");
+ });
+
+ await test.step("Security", async () => {
+ await page.getByLabel("Password").fill("SecurePass123");
+ await page.getByLabel("Confirm Password").fill("SecurePass123");
+ });
+ });
+
+ await test.step("Submit and verify", async () => {
+ await page.getByRole("button", { name: "Register" }).click();
+ await expect(page.getByText("Welcome")).toBeVisible();
+ });
+});
+```
+
+### Steps with Return Values
+
+```typescript
+test("verify order", async ({ page }) => {
+ const orderId = await test.step("Create order", async () => {
+ await page.goto("/checkout");
+ await page.getByRole("button", { name: "Place Order" }).click();
+
+ // Return value from step
+ return await page.getByTestId("order-id").textContent();
+ });
+
+ await test.step("Verify order details", async () => {
+ await page.goto(`/orders/${orderId}`);
+ await expect(page.getByText(`Order #${orderId}`)).toBeVisible();
+ });
+});
+```
+
+### Step in Page Object
+
+```typescript
+// pages/checkout.page.ts
+export class CheckoutPage {
+ async fillShippingInfo(address: string, city: string) {
+ await test.step("Fill shipping information", async () => {
+ await this.page.getByLabel("Address").fill(address);
+ await this.page.getByLabel("City").fill(city);
+ });
+ }
+
+ async completePayment(cardNumber: string) {
+ await test.step("Complete payment", async () => {
+ await this.page.getByLabel("Card").fill(cardNumber);
+ await this.page.getByRole("button", { name: "Pay" }).click();
+ });
+ }
+}
+```
+
+## Custom Annotations
+
+### Add Annotations
+
+```typescript
+test("important feature", async ({ page }, testInfo) => {
+ // Add custom annotation
+ testInfo.annotations.push({
+ type: "priority",
+ description: "high",
+ });
+
+ testInfo.annotations.push({
+ type: "ticket",
+ description: "JIRA-123",
+ });
+
+ await page.goto("/feature");
+});
+```
+
+### Annotation Fixture
+
+```typescript
+// fixtures/annotations.fixture.ts
+import { test as base, TestInfo } from "@playwright/test";
+
+type AnnotationFixtures = {
+ annotate: {
+ ticket: (id: string) => void;
+ priority: (level: "low" | "medium" | "high") => void;
+ owner: (name: string) => void;
+ };
+};
+
+export const test = base.extend({
+ annotate: async ({}, use, testInfo) => {
+ await use({
+ ticket: (id) => {
+ testInfo.annotations.push({ type: "ticket", description: id });
+ },
+ priority: (level) => {
+ testInfo.annotations.push({ type: "priority", description: level });
+ },
+ owner: (name) => {
+ testInfo.annotations.push({ type: "owner", description: name });
+ },
+ });
+ },
+});
+
+// Usage
+test("critical feature", async ({ page, annotate }) => {
+ annotate.ticket("JIRA-456");
+ annotate.priority("high");
+ annotate.owner("Alice");
+
+ await page.goto("/critical");
+});
+```
+
+### Read Annotations in Reporter
+
+```typescript
+// reporters/annotation-reporter.ts
+import { Reporter, TestCase, TestResult } from "@playwright/test/reporter";
+
+class AnnotationReporter implements Reporter {
+ onTestEnd(test: TestCase, result: TestResult) {
+ const ticket = test.annotations.find((a) => a.type === "ticket");
+ const priority = test.annotations.find((a) => a.type === "priority");
+
+ if (ticket) {
+ console.log(`Test linked to: ${ticket.description}`);
+ }
+
+ if (priority?.description === "high" && result.status === "failed") {
+ console.log(`HIGH PRIORITY FAILURE: ${test.title}`);
+ }
+ }
+}
+
+export default AnnotationReporter;
+```
+
+## Conditional Annotations
+
+### Annotation Helper
+
+```typescript
+// helpers/test-annotations.ts
+import { test } from "@playwright/test";
+
+export function skipInCI(reason = "Skipped in CI") {
+ test.skip(!!process.env.CI, reason);
+}
+
+export function skipInBrowser(browser: string, reason: string) {
+ test.beforeEach(({ browserName }) => {
+ test.skip(browserName === browser, reason);
+ });
+}
+
+export function onlyInEnv(env: string) {
+ test.skip(process.env.ENV !== env, `Only runs in ${env}`);
+}
+```
+
+```typescript
+// tests/feature.spec.ts
+import { skipInCI, onlyInEnv } from "../helpers/test-annotations";
+
+test("local only feature", async ({ page }) => {
+ skipInCI("Uses local resources");
+
+ await page.goto("/local-feature");
+});
+
+test("production check", async ({ page }) => {
+ onlyInEnv("production");
+
+ await page.goto("/prod-only");
+});
+```
+
+### Describe-Level Conditions
+
+```typescript
+test.describe("Mobile features", () => {
+ test.beforeEach(({ isMobile }) => {
+ test.skip(!isMobile, "Mobile only tests");
+ });
+
+ test("touch gestures", async ({ page }) => {
+ // Only runs on mobile
+ });
+});
+
+test.describe("Desktop features", () => {
+ test.beforeEach(({ isMobile }) => {
+ test.skip(isMobile, "Desktop only tests");
+ });
+
+ test("hover interactions", async ({ page }) => {
+ // Only runs on desktop
+ });
+});
+```
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| --------------------------- | ---------------------- | -------------------------------- |
+| Skipping without reason | Hard to track why | Always provide description |
+| Too many skipped tests | Test debt accumulates | Review and clean up regularly |
+| Using skip instead of fixme | Loses intent | Use fixme for bugs, skip for N/A |
+| Not using steps | Hard to debug failures | Group logical actions in steps |
+
+## Related References
+
+- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`
+- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
+- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting
diff --git a/.cursor/skills/playwright-testing/core/assertions-waiting.md b/.cursor/skills/playwright-testing/core/assertions-waiting.md
new file mode 100644
index 0000000000..bd03dd8a4d
--- /dev/null
+++ b/.cursor/skills/playwright-testing/core/assertions-waiting.md
@@ -0,0 +1,361 @@
+# Assertions & Waiting
+
+## Table of Contents
+
+1. [Web-First Assertions](#web-first-assertions)
+2. [Generic Assertions](#generic-assertions)
+3. [Soft Assertions](#soft-assertions)
+4. [Waiting Strategies](#waiting-strategies)
+5. [Polling & Retrying](#polling--retrying)
+6. [Custom Matchers](#custom-matchers)
+
+## Web-First Assertions
+
+Auto-retry until condition is met or timeout. Always prefer these over generic assertions.
+
+### Locator Assertions
+
+```typescript
+import { expect } from "@playwright/test";
+
+// Visibility
+await expect(page.getByRole("button")).toBeVisible();
+await expect(page.getByRole("button")).toBeHidden();
+await expect(page.getByRole("button")).not.toBeVisible();
+
+// Enabled/Disabled
+await expect(page.getByRole("button")).toBeEnabled();
+await expect(page.getByRole("button")).toBeDisabled();
+
+// Text content
+await expect(page.getByRole("heading")).toHaveText("Welcome");
+await expect(page.getByRole("heading")).toHaveText(/welcome/i);
+await expect(page.getByRole("heading")).toContainText("Welcome");
+
+// Count
+await expect(page.getByRole("listitem")).toHaveCount(5);
+
+// Attributes
+await expect(page.getByRole("link")).toHaveAttribute("href", "/home");
+await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i);
+
+// CSS
+await expect(page.getByRole("button")).toHaveClass(/primary/);
+await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)");
+
+// Input values
+await expect(page.getByLabel("Email")).toHaveValue("user@example.com");
+await expect(page.getByLabel("Email")).toBeEmpty();
+
+// Focus
+await expect(page.getByLabel("Email")).toBeFocused();
+
+// Checked state
+await expect(page.getByRole("checkbox")).toBeChecked();
+await expect(page.getByRole("checkbox")).not.toBeChecked();
+
+// Editable state
+await expect(page.getByLabel("Name")).toBeEditable();
+```
+
+### Page Assertions
+
+```typescript
+// URL
+await expect(page).toHaveURL("/dashboard");
+await expect(page).toHaveURL(/\/dashboard/);
+
+// Title
+await expect(page).toHaveTitle("Dashboard - MyApp");
+await expect(page).toHaveTitle(/dashboard/i);
+```
+
+### Response Assertions
+
+```typescript
+const response = await page.request.get("/api/users");
+await expect(response).toBeOK();
+await expect(response).not.toBeOK();
+```
+
+## Generic Assertions
+
+Use for non-UI values. Do NOT retry - execute immediately.
+
+```typescript
+// Equality
+expect(value).toBe(5);
+expect(object).toEqual({ name: "Test" });
+expect(array).toContain("item");
+
+// Truthiness
+expect(value).toBeTruthy();
+expect(value).toBeFalsy();
+expect(value).toBeNull();
+expect(value).toBeUndefined();
+expect(value).toBeDefined();
+
+// Numbers
+expect(value).toBeGreaterThan(5);
+expect(value).toBeLessThanOrEqual(10);
+expect(value).toBeCloseTo(5.5, 1);
+
+// Strings
+expect(string).toMatch(/pattern/);
+expect(string).toContain("substring");
+
+// Arrays/Objects
+expect(array).toHaveLength(3);
+expect(object).toHaveProperty("key", "value");
+
+// Exceptions
+expect(() => fn()).toThrow();
+expect(() => fn()).toThrow("error message");
+await expect(asyncFn()).rejects.toThrow();
+```
+
+## Soft Assertions
+
+Continue test execution after failure, report all failures at end.
+
+```typescript
+test("check multiple elements", async ({ page }) => {
+ await page.goto("/dashboard");
+
+ // Won't stop on first failure
+ await expect.soft(page.getByRole("heading")).toHaveText("Dashboard");
+ await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled();
+ await expect.soft(page.getByText("Welcome")).toBeVisible();
+
+ // Test continues; all failures reported at end
+});
+```
+
+### Soft Assertions with Early Exit
+
+```typescript
+test("check form", async ({ page }) => {
+ await expect.soft(page.getByRole("form")).toBeVisible();
+
+ // Exit early if form not visible (pointless to check fields)
+ if (expect.soft.hasFailures()) {
+ return;
+ }
+
+ await expect.soft(page.getByLabel("Name")).toBeVisible();
+ await expect.soft(page.getByLabel("Email")).toBeVisible();
+});
+```
+
+## Waiting Strategies
+
+### Auto-Waiting (Default)
+
+Actions automatically wait for:
+
+- Element to be attached to DOM
+- Element to be visible
+- Element to be stable (no animations)
+- Element to be enabled
+- Element to receive events
+
+```typescript
+// These auto-wait
+await page.click("button");
+await page.fill("input", "text");
+await page.getByRole("button").click();
+```
+
+### Wait for Navigation
+
+```typescript
+// Wait for URL change
+await page.waitForURL("/dashboard");
+await page.waitForURL(/\/dashboard/);
+
+// Wait for navigation after action
+await Promise.all([
+ page.waitForURL("**/dashboard"),
+ page.click('a[href="/dashboard"]'),
+]);
+
+// Or without Promise.all
+const urlPromise = page.waitForURL("**/dashboard");
+await page.click("a");
+await urlPromise;
+```
+
+### Wait for Network
+
+```typescript
+// Wait for specific response
+const responsePromise = page.waitForResponse("**/api/users");
+await page.click("button");
+const response = await responsePromise;
+expect(response.status()).toBe(200);
+
+// Wait for request
+const requestPromise = page.waitForRequest("**/api/submit");
+await page.click("button");
+const request = await requestPromise;
+
+// Wait for no network activity
+await page.waitForLoadState("networkidle");
+```
+
+### Wait for Element State
+
+```typescript
+// Wait for element to appear
+await page.getByRole("dialog").waitFor({ state: "visible" });
+
+// Wait for element to disappear
+await page.getByText("Loading...").waitFor({ state: "hidden" });
+
+// Wait for element to be attached
+await page.getByTestId("result").waitFor({ state: "attached" });
+
+// Wait for element to be detached
+await page.getByTestId("modal").waitFor({ state: "detached" });
+```
+
+### Wait for Function
+
+```typescript
+// Wait for arbitrary condition
+await page.waitForFunction(() => {
+ return document.querySelector(".loaded") !== null;
+});
+
+// With arguments
+await page.waitForFunction(
+ (selector) => document.querySelector(selector)?.textContent === "Ready",
+ ".status",
+);
+```
+
+## Polling & Retrying
+
+### toPass() for Polling
+
+Retry until block passes or times out:
+
+```typescript
+await expect(async () => {
+ const response = await page.request.get("/api/status");
+ expect(response.status()).toBe(200);
+
+ const data = await response.json();
+ expect(data.ready).toBe(true);
+}).toPass({
+ intervals: [1000, 2000, 5000], // Retry intervals
+ timeout: 30000,
+});
+```
+
+### expect.poll()
+
+Poll a function until assertion passes:
+
+```typescript
+// Poll API until condition met
+await expect
+ .poll(
+ async () => {
+ const response = await page.request.get("/api/job/123");
+ return (await response.json()).status;
+ },
+ {
+ intervals: [1000, 2000, 5000],
+ timeout: 30000,
+ },
+ )
+ .toBe("completed");
+
+// Poll DOM value
+await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10");
+```
+
+## Custom Matchers
+
+```typescript
+// playwright.config.ts or fixtures
+import { expect } from "@playwright/test";
+
+expect.extend({
+ async toHaveDataLoaded(page: Page) {
+ const locator = page.getByTestId("data-container");
+ let pass = false;
+ let message = "";
+
+ try {
+ await expect(locator).toBeVisible();
+ await expect(locator).not.toContainText("Loading");
+ pass = true;
+ } catch (e) {
+ message = `Expected data to be loaded but found loading state`;
+ }
+
+ return { pass, message: () => message };
+ },
+});
+
+// Extend TypeScript types
+declare global {
+ namespace PlaywrightTest {
+ interface Matchers {
+ toHaveDataLoaded(): Promise;
+ }
+ }
+}
+
+// Usage
+await expect(page).toHaveDataLoaded();
+```
+
+## Timeouts
+
+### Configure Timeouts
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ timeout: 30000, // Test timeout
+ expect: {
+ timeout: 5000, // Assertion timeout
+ },
+});
+
+// Per-test timeout
+test("long test", async ({ page }) => {
+ test.setTimeout(60000);
+ // ...
+});
+
+// Per-assertion timeout
+await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 });
+```
+
+## Best Practices
+
+| Do | Don't |
+| ------------------------------ | ------------------------------ |
+| Use web-first assertions | Use generic assertions for DOM |
+| Let auto-waiting work | Add unnecessary explicit waits |
+| Use `toPass()` for polling | Write manual retry loops |
+| Configure appropriate timeouts | Use `waitForTimeout()` |
+| Check specific conditions | Wait for arbitrary time |
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |
+| `await page.waitForTimeout(5000)` | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse` |
+| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above | Use `waitForResponse` or element state waits |
+| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with `expect()` |
+
+## Related References
+
+- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
+- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions
+- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
diff --git a/.cursor/skills/playwright-testing/core/configuration.md b/.cursor/skills/playwright-testing/core/configuration.md
new file mode 100644
index 0000000000..66b9d33849
--- /dev/null
+++ b/.cursor/skills/playwright-testing/core/configuration.md
@@ -0,0 +1,452 @@
+# Playwright Configuration
+
+## Table of Contents
+
+1. [CLI Quick Reference](#cli-quick-reference)
+2. [Decision Guide](#decision-guide)
+3. [Production-Ready Config](#production-ready-config)
+4. [Patterns](#patterns)
+5. [Anti-Patterns](#anti-patterns)
+6. [Troubleshooting](#troubleshooting)
+7. [Related](#related)
+
+> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.
+
+## CLI Quick Reference
+
+```bash
+npx playwright init # scaffold config + first test
+npx playwright test --config=custom.config.ts # use alternate config
+npx playwright test --project=chromium # run single project
+npx playwright test --reporter=html # override reporter
+npx playwright test --grep @smoke # run tests tagged @smoke
+npx playwright test --grep-invert @slow # exclude @slow tests
+npx playwright show-report # open last HTML report
+DEBUG=pw:api npx playwright test # verbose logging
+```
+
+## Decision Guide
+
+### Timeout Selection
+
+| Symptom | Setting | Default | Recommended |
+|---------|---------|---------|-------------|
+| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) |
+| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s |
+| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s |
+| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s |
+| Dev server slow to start | `webServer.timeout` | 60s | 60-180s |
+
+### Server Management
+
+| Scenario | Approach |
+|----------|----------|
+| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` |
+| Separate repos | Manual start or Docker Compose |
+| Testing deployed environment | No `webServer`; set `baseURL` via env |
+| Multiple services | Array of `webServer` entries |
+
+### Single vs Multi-Project
+
+| Scenario | Approach |
+|----------|----------|
+| Early development | Single project (chromium only) |
+| Pre-release validation | Multi-project: chromium + firefox + webkit |
+| Mobile-responsive app | Add mobile projects alongside desktop |
+| Auth + non-auth tests | Setup project with dependencies |
+| Tight CI budget | Chromium on PRs; all browsers on main |
+
+### globalSetup vs Setup Projects vs Fixtures
+
+| Need | Use |
+|------|-----|
+| One-time DB seed | `globalSetup` |
+| Shared browser auth | Setup project with `dependencies` |
+| Per-test isolated state | Custom fixture via `test.extend()` |
+| Cleanup after all tests | `globalTeardown` |
+
+## Production-Ready Config
+
+```ts
+// playwright.config.ts
+import { defineConfig, devices } from '@playwright/test';
+import dotenv from 'dotenv';
+import path from 'path';
+
+dotenv.config({ path: path.resolve(__dirname, '.env') });
+
+export default defineConfig({
+ testDir: './e2e',
+ testMatch: '**/*.spec.ts',
+
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? '50%' : undefined,
+
+ reporter: process.env.CI
+ ? [['html', { open: 'never' }], ['github']]
+ : [['html', { open: 'on-failure' }]],
+
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+
+ use: {
+ baseURL: process.env.BASE_URL || 'http://localhost:4000',
+ actionTimeout: 10_000,
+ navigationTimeout: 15_000,
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ locale: 'en-US',
+ timezoneId: 'America/Los_Angeles',
+ },
+
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
+ { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
+ { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
+ ],
+
+ webServer: {
+ command: 'npm run start',
+ url: 'http://localhost:4000',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+});
+```
+
+## Patterns
+
+### Environment-Specific Configuration
+
+**Use when**: Tests run against dev, staging, and production environments.
+
+```ts
+// playwright.config.ts
+import { defineConfig } from '@playwright/test';
+import dotenv from 'dotenv';
+import path from 'path';
+
+const ENV = process.env.TEST_ENV || 'local';
+dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) });
+
+const envConfig: Record = {
+ local: { baseURL: 'http://localhost:4000', retries: 0 },
+ staging: { baseURL: 'https://staging.myapp.com', retries: 2 },
+ prod: { baseURL: 'https://myapp.com', retries: 2 },
+};
+
+export default defineConfig({
+ testDir: './e2e',
+ retries: envConfig[ENV].retries,
+ use: { baseURL: envConfig[ENV].baseURL },
+});
+```
+
+```bash
+TEST_ENV=staging npx playwright test
+TEST_ENV=prod npx playwright test --grep @smoke
+```
+
+### Setup Project with Dependencies
+
+**Use when**: Tests need shared authentication state before running.
+
+```ts
+// playwright.config.ts
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ projects: [
+ {
+ name: 'setup',
+ testMatch: /auth\.setup\.ts/,
+ },
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ storageState: 'playwright/.auth/session.json',
+ },
+ dependencies: ['setup'],
+ },
+ {
+ name: 'firefox',
+ use: {
+ ...devices['Desktop Firefox'],
+ storageState: 'playwright/.auth/session.json',
+ },
+ dependencies: ['setup'],
+ },
+ ],
+});
+```
+
+```ts
+// e2e/auth.setup.ts
+import { test as setup, expect } from '@playwright/test';
+
+const authFile = 'playwright/.auth/session.json';
+
+setup('authenticate', async ({ page }) => {
+ await page.goto('/login');
+ await page.getByLabel('Username').fill('testuser@example.com');
+ await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
+ await page.getByRole('button', { name: 'Log in' }).click();
+ await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
+ await page.context().storageState({ path: authFile });
+});
+```
+
+### webServer with Build Step
+
+**Use when**: Tests need a running application server managed by Playwright.
+
+```ts
+// playwright.config.ts
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ use: { baseURL: 'http://localhost:4000' },
+ webServer: {
+ command: process.env.CI
+ ? 'npm run build && npm run preview'
+ : 'npm run dev',
+ url: 'http://localhost:4000',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ env: {
+ NODE_ENV: 'test',
+ DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',
+ },
+ },
+});
+```
+
+### globalSetup / globalTeardown
+
+**Use when**: One-time non-browser work like seeding a database. Runs once per test run.
+
+```ts
+// playwright.config.ts
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ globalSetup: './e2e/setup.ts',
+ globalTeardown: './e2e/teardown.ts',
+});
+```
+
+```ts
+// e2e/setup.ts
+import { FullConfig } from '@playwright/test';
+
+export default async function globalSetup(config: FullConfig) {
+ const { execSync } = await import('child_process');
+ execSync('npx prisma db seed', { stdio: 'inherit' });
+ process.env.TEST_RUN_ID = `run-${Date.now()}`;
+}
+```
+
+```ts
+// e2e/teardown.ts
+import { FullConfig } from '@playwright/test';
+
+export default async function globalTeardown(config: FullConfig) {
+ const { execSync } = await import('child_process');
+ execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
+}
+```
+
+### Environment Variables with .env
+
+**Use when**: Managing secrets, URLs, or feature flags without hardcoding.
+
+```bash
+# .env.example (commit this)
+BASE_URL=http://localhost:4000
+TEST_PASSWORD=
+API_KEY=
+
+# .env.local (gitignored)
+BASE_URL=http://localhost:4000
+TEST_PASSWORD=secret123
+API_KEY=dev-key-abc
+
+# .env.staging (gitignored)
+BASE_URL=https://staging.myapp.com
+TEST_PASSWORD=staging-pass
+API_KEY=staging-key-xyz
+```
+
+```bash
+# .gitignore
+.env
+.env.local
+.env.staging
+.env.production
+playwright/.auth/
+```
+
+Install dotenv:
+
+```bash
+npm install -D dotenv
+```
+
+### Tag-Based Test Filtering
+
+**Use when**: Running subsets of tests in different CI stages (PR vs nightly).
+
+```ts
+// playwright.config.ts
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+
+ // Filter by tags in CI
+ grep: process.env.CI ? /@smoke|@critical/ : undefined,
+ grepInvert: process.env.CI ? /@flaky/ : undefined,
+});
+```
+
+**Project-specific filtering:**
+
+```ts
+// playwright.config.ts
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ projects: [
+ {
+ name: 'smoke',
+ grep: /@smoke/,
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'regression',
+ grepInvert: /@smoke/,
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'critical-only',
+ grep: /@critical/,
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
+```
+
+```bash
+# Run specific project
+npx playwright test --project=smoke
+npx playwright test --project=regression
+```
+
+### Artifact Collection Strategy
+
+| Setting | Local | CI | Reason |
+|---------|-------|-----|--------|
+| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only |
+| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging |
+| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests |
+
+```ts
+// playwright.config.ts
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ use: {
+ trace: process.env.CI ? 'on-first-retry' : 'off',
+ screenshot: process.env.CI ? 'only-on-failure' : 'off',
+ video: process.env.CI ? 'retain-on-failure' : 'off',
+ },
+});
+```
+
+## Anti-Patterns
+
+| Don't | Problem | Do Instead |
+|-------|---------|------------|
+| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default |
+| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths |
+| All browsers on every PR | 3x CI time | Chromium on PRs; all on main |
+| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` |
+| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` |
+| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config |
+| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI |
+| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` |
+| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies |
+| Committing `.env` with credentials | Security risk | Commit `.env.example` only |
+
+## Troubleshooting
+
+### baseURL Not Working
+
+**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.
+
+```ts
+// Wrong - ignores baseURL
+await page.goto('http://localhost:4000/dashboard');
+
+// Correct - uses baseURL
+await page.goto('/dashboard');
+```
+
+### webServer Starts But Tests Get Connection Refused
+
+**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.
+
+```ts
+webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:4000/api/health', // use real endpoint
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+},
+```
+
+### Tests Pass Locally But Timeout in CI
+
+**Cause**: CI machines are slower. Increase timeouts and reduce workers:
+
+```ts
+export default defineConfig({
+ workers: process.env.CI ? '50%' : undefined,
+ use: {
+ navigationTimeout: process.env.CI ? 30_000 : 15_000,
+ actionTimeout: process.env.CI ? 15_000 : 10_000,
+ },
+});
+```
+
+### "Target page, context or browser has been closed"
+
+**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.
+
+**Fix**: Don't increase global timeout. Find slow step using trace:
+
+```bash
+npx playwright test --trace on
+npx playwright show-report
+```
+
+## Related
+
+- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`
+- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state
+- [test-suite-structure.md](test-suite-structure.md) - file structure and naming
+- [authentication.md](../advanced/authentication.md) - setup projects for shared auth
+- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns
diff --git a/.cursor/skills/playwright-testing/core/fixtures-hooks.md b/.cursor/skills/playwright-testing/core/fixtures-hooks.md
new file mode 100644
index 0000000000..ff9dc93f71
--- /dev/null
+++ b/.cursor/skills/playwright-testing/core/fixtures-hooks.md
@@ -0,0 +1,417 @@
+# Fixtures & Hooks
+
+## Table of Contents
+
+1. [Built-in Fixtures](#built-in-fixtures)
+2. [Custom Fixtures](#custom-fixtures)
+3. [Fixture Scopes](#fixture-scopes)
+4. [Hooks](#hooks)
+5. [Authentication Patterns](#authentication-patterns)
+6. [Database Fixtures](#database-fixtures)
+
+## Built-in Fixtures
+
+### Core Fixtures
+
+```typescript
+test("example", async ({
+ page, // Isolated page instance
+ context, // Browser context (cookies, localStorage)
+ browser, // Browser instance
+ browserName, // 'chromium', 'firefox', or 'webkit'
+ request, // API request context
+}) => {
+ // Each test gets fresh instances
+});
+```
+
+### Request Fixture
+
+```typescript
+test("API call", async ({ request }) => {
+ const response = await request.get("/api/users");
+ await expect(response).toBeOK();
+
+ const users = await response.json();
+ expect(users).toHaveLength(5);
+});
+```
+
+## Custom Fixtures
+
+### Basic Custom Fixture
+
+```typescript
+// fixtures.ts
+import { test as base } from "@playwright/test";
+
+// Declare fixture types
+type MyFixtures = {
+ todoPage: TodoPage;
+ apiClient: ApiClient;
+};
+
+export const test = base.extend({
+ // Fixture with setup and teardown
+ todoPage: async ({ page }, use) => {
+ const todoPage = new TodoPage(page);
+ await todoPage.goto();
+
+ await use(todoPage); // Test runs here
+
+ // Teardown (optional)
+ await todoPage.clearTodos();
+ },
+
+ // Simple fixture
+ apiClient: async ({ request }, use) => {
+ await use(new ApiClient(request));
+ },
+});
+
+export { expect } from "@playwright/test";
+```
+
+### Fixture with Options
+
+```typescript
+type Options = {
+ defaultUser: { email: string; password: string };
+};
+
+type Fixtures = {
+ authenticatedPage: Page;
+};
+
+export const test = base.extend({
+ // Define option with default
+ defaultUser: [
+ { email: "test@example.com", password: "pass123" },
+ { option: true },
+ ],
+
+ // Use option in fixture
+ authenticatedPage: async ({ page, defaultUser }, use) => {
+ await page.goto("/login");
+ await page.getByLabel("Email").fill(defaultUser.email);
+ await page.getByLabel("Password").fill(defaultUser.password);
+ await page.getByRole("button", { name: "Sign in" }).click();
+ await use(page);
+ },
+});
+
+// Override in config
+export default defineConfig({
+ use: {
+ defaultUser: { email: "admin@example.com", password: "admin123" },
+ },
+});
+```
+
+### Automatic Fixtures
+
+```typescript
+export const test = base.extend<{}, { setupDb: void }>({
+ // Auto-fixture runs for every test without explicit usage
+ setupDb: [
+ async ({}, use) => {
+ await seedDatabase();
+ await use();
+ await cleanDatabase();
+ },
+ { auto: true },
+ ],
+});
+```
+
+## Fixture Scopes
+
+### Test Scope (Default)
+
+Created fresh for each test:
+
+```typescript
+test.extend({
+ page: async ({ browser }, use) => {
+ const page = await browser.newPage();
+ await use(page);
+ await page.close();
+ },
+});
+```
+
+### Worker Scope
+
+Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):
+
+```typescript
+type WorkerFixtures = {
+ sharedAccount: Account;
+};
+
+export const test = base.extend<{}, WorkerFixtures>({
+ sharedAccount: [
+ async ({ browser }, use) => {
+ // Expensive setup - runs once per worker
+ const account = await createTestAccount();
+ await use(account);
+ await deleteTestAccount(account);
+ },
+ { scope: "worker" },
+ ],
+});
+```
+
+### Isolate test data between parallel workers
+
+When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:
+
+```typescript
+import { test as baseTest } from "@playwright/test";
+
+type WorkerFixtures = {
+ dbUserName: string;
+};
+
+export const test = baseTest.extend<{}, WorkerFixtures>({
+ dbUserName: [
+ async ({}, use, testInfo) => {
+ const userName = `user-${testInfo.workerIndex}`;
+ await createUserInTestDatabase(userName);
+ await use(userName);
+ await deleteUserFromTestDatabase(userName);
+ },
+ { scope: "worker" },
+ ],
+});
+```
+
+Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.
+
+## Hooks
+
+### beforeEach / afterEach
+
+```typescript
+test.beforeEach(async ({ page }) => {
+ // Runs before each test in file
+ await page.goto("/");
+});
+
+test.afterEach(async ({ page }, testInfo) => {
+ // Runs after each test
+ if (testInfo.status !== "passed") {
+ await page.screenshot({ path: `failed-${testInfo.title}.png` });
+ }
+});
+```
+
+### beforeAll / afterAll
+
+```typescript
+test.beforeAll(async ({ browser }) => {
+ // Runs once before all tests in file
+ // Note: Cannot use page fixture here
+});
+
+test.afterAll(async () => {
+ // Runs once after all tests in file
+});
+```
+
+### Describe-Level Hooks
+
+```typescript
+test.describe("User Management", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/users");
+ });
+
+ test("can list users", async ({ page }) => {
+ // Starts at /users
+ });
+
+ test("can add user", async ({ page }) => {
+ // Starts at /users
+ });
+});
+```
+
+## Authentication Patterns
+
+### Global Setup with Storage State
+
+```typescript
+// auth.setup.ts
+import { test as setup, expect } from "@playwright/test";
+
+const authFile = ".auth/user.json";
+
+setup("authenticate", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
+ await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
+ await page.getByRole("button", { name: "Sign in" }).click();
+
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+ await page.context().storageState({ path: authFile });
+});
+```
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ projects: [
+ { name: "setup", testMatch: /.*\.setup\.ts/ },
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ storageState: ".auth/user.json",
+ },
+ dependencies: ["setup"],
+ },
+ ],
+});
+```
+
+### Multiple Auth States
+
+```typescript
+// auth.setup.ts
+setup("admin auth", async ({ page }) => {
+ await login(page, "admin@example.com", "adminpass");
+ await page.context().storageState({ path: ".auth/admin.json" });
+});
+
+setup("user auth", async ({ page }) => {
+ await login(page, "user@example.com", "userpass");
+ await page.context().storageState({ path: ".auth/user.json" });
+});
+```
+
+```typescript
+// playwright.config.ts
+projects: [
+ {
+ name: "admin tests",
+ testMatch: /.*admin.*\.spec\.ts/,
+ use: { storageState: ".auth/admin.json" },
+ dependencies: ["setup"],
+ },
+ {
+ name: "user tests",
+ testMatch: /.*user.*\.spec\.ts/,
+ use: { storageState: ".auth/user.json" },
+ dependencies: ["setup"],
+ },
+];
+```
+
+### Auth Fixture
+
+```typescript
+// fixtures/auth.fixture.ts
+export const test = base.extend<{ adminPage: Page; userPage: Page }>({
+ adminPage: async ({ browser }, use) => {
+ const context = await browser.newContext({
+ storageState: ".auth/admin.json",
+ });
+ const page = await context.newPage();
+ await use(page);
+ await context.close();
+ },
+
+ userPage: async ({ browser }, use) => {
+ const context = await browser.newContext({
+ storageState: ".auth/user.json",
+ });
+ const page = await context.newPage();
+ await use(page);
+ await context.close();
+ },
+});
+```
+
+## Database Fixtures
+
+This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:
+
+- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
+- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
+
+### Transaction Rollback Pattern
+
+```typescript
+import { test as base } from "@playwright/test";
+import { db } from "../db";
+
+export const test = base.extend<{ dbTransaction: Transaction }>({
+ dbTransaction: async ({}, use) => {
+ const transaction = await db.beginTransaction();
+
+ await use(transaction);
+
+ await transaction.rollback(); // Clean slate for next test
+ },
+});
+```
+
+### Seed Data Fixture
+
+```typescript
+type TestData = {
+ testUser: User;
+ testProducts: Product[];
+};
+
+export const test = base.extend({
+ testUser: async ({}, use) => {
+ const user = await db.users.create({
+ email: `test-${Date.now()}@example.com`,
+ name: "Test User",
+ });
+
+ await use(user);
+
+ await db.users.delete(user.id);
+ },
+
+ testProducts: async ({ testUser }, use) => {
+ const products = await db.products.createMany([
+ { name: "Product A", ownerId: testUser.id },
+ { name: "Product B", ownerId: testUser.id },
+ ]);
+
+ await use(products);
+
+ await db.products.deleteMany(products.map((p) => p.id));
+ },
+});
+```
+
+## Fixture Tips
+
+| Tip | Explanation |
+| ------------------ | ------------------------------------------- |
+| Fixtures are lazy | Only created when used |
+| Compose fixtures | Use other fixtures as dependencies |
+| Keep setup minimal | Do heavy lifting in worker-scoped fixtures |
+| Clean up resources | Use teardown in fixtures, not afterEach |
+| Avoid shared state | Each fixture instance should be independent |
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation |
+| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup |
+| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions |
+| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |
+| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker |
+
+## Related References
+
+- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns
+- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure
+- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
diff --git a/.cursor/skills/playwright-testing/core/global-setup.md b/.cursor/skills/playwright-testing/core/global-setup.md
new file mode 100644
index 0000000000..a0335226e7
--- /dev/null
+++ b/.cursor/skills/playwright-testing/core/global-setup.md
@@ -0,0 +1,434 @@
+# Global Setup & Teardown
+
+## Table of Contents
+
+1. [Global Setup](#global-setup)
+2. [Global Teardown](#global-teardown)
+3. [Database Patterns](#database-patterns)
+4. [Environment Provisioning](#environment-provisioning)
+5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)
+6. [Parallel Execution Caveats](#parallel-execution-caveats)
+
+## Global Setup
+
+### Basic Global Setup
+
+```typescript
+// global-setup.ts
+import { FullConfig } from "@playwright/test";
+
+async function globalSetup(config: FullConfig) {
+ console.log("Running global setup...");
+ // Perform one-time setup: start services, run migrations, etc.
+}
+
+export default globalSetup;
+```
+
+### Configure Global Setup
+
+```typescript
+// playwright.config.ts
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ globalSetup: require.resolve("./global-setup"),
+ globalTeardown: require.resolve("./global-teardown"),
+});
+```
+
+> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.
+
+### Global Setup with Return Value
+
+```typescript
+// global-setup.ts
+async function globalSetup(config: FullConfig): Promise<() => Promise> {
+ const server = await startTestServer();
+
+ // Return cleanup function (alternative to globalTeardown)
+ return async () => {
+ await server.stop();
+ };
+}
+
+export default globalSetup;
+```
+
+### Access Config in Global Setup
+
+```typescript
+// global-setup.ts
+import { FullConfig } from "@playwright/test";
+
+async function globalSetup(config: FullConfig) {
+ const { baseURL } = config.projects[0].use;
+ console.log(`Setting up for ${baseURL}`);
+
+ // Access custom config
+ const workers = config.workers;
+ const timeout = config.timeout;
+
+ // Access environment
+ const isCI = !!process.env.CI;
+}
+
+export default globalSetup;
+```
+
+## Global Teardown
+
+### Basic Global Teardown
+
+```typescript
+// global-teardown.ts
+import { FullConfig } from "@playwright/test";
+import fs from "fs";
+
+async function globalTeardown(config: FullConfig) {
+ console.log("Running global teardown...");
+
+ // Clean up auth files
+ if (fs.existsSync(".auth")) {
+ fs.rmSync(".auth", { recursive: true });
+ }
+
+ // Clean up test data
+ await cleanupTestDatabase();
+
+ // Stop services
+ await stopTestServices();
+}
+
+export default globalTeardown;
+```
+
+### Conditional Teardown
+
+```typescript
+// global-teardown.ts
+async function globalTeardown(config: FullConfig) {
+ // Skip cleanup in CI (containers are discarded anyway)
+ if (process.env.CI) {
+ console.log("Skipping teardown in CI");
+ return;
+ }
+
+ // Local cleanup
+ await cleanupLocalTestData();
+}
+
+export default globalTeardown;
+```
+
+## Database Patterns
+
+This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:
+
+- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
+- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
+
+### Database Migration in Setup
+
+```typescript
+// global-setup.ts
+import { execSync } from "child_process";
+
+async function globalSetup() {
+ console.log("Running database migrations...");
+
+ // Run migrations
+ execSync("npx prisma migrate deploy", { stdio: "inherit" });
+
+ // Seed test data
+ execSync("npx prisma db seed", { stdio: "inherit" });
+}
+
+export default globalSetup;
+```
+
+### Database Snapshot Pattern
+
+```typescript
+// global-setup.ts
+import { execSync } from "child_process";
+import fs from "fs";
+
+const SNAPSHOT_PATH = "./test-db-snapshot.sql";
+
+async function globalSetup() {
+ // Check if snapshot exists
+ if (fs.existsSync(SNAPSHOT_PATH)) {
+ console.log("Restoring database from snapshot...");
+ execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" });
+ return;
+ }
+
+ // First run: migrate and create snapshot
+ console.log("Creating database snapshot...");
+ execSync("npx prisma migrate deploy", { stdio: "inherit" });
+ execSync("npx prisma db seed", { stdio: "inherit" });
+ execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" });
+}
+
+export default globalSetup;
+```
+
+### Test Database per Worker
+
+```typescript
+// global-setup.ts
+async function globalSetup(config: FullConfig) {
+ const workerCount = config.workers || 1;
+
+ // Create a database for each worker
+ for (let i = 0; i < workerCount; i++) {
+ const dbName = `test_db_worker_${i}`;
+ await createDatabase(dbName);
+ await runMigrations(dbName);
+ await seedDatabase(dbName);
+ }
+}
+
+// global-teardown.ts
+async function globalTeardown(config: FullConfig) {
+ const workerCount = config.workers || 1;
+
+ for (let i = 0; i < workerCount; i++) {
+ await dropDatabase(`test_db_worker_${i}`);
+ }
+}
+```
+
+## Environment Provisioning
+
+### Start Services in Setup
+
+```typescript
+// global-setup.ts
+import { execSync, spawn } from "child_process";
+
+let serverProcess: any;
+
+async function globalSetup() {
+ // Start backend server
+ serverProcess = spawn("npm", ["run", "start:test"], {
+ stdio: "pipe",
+ detached: true,
+ });
+
+ // Wait for server to be ready
+ await waitForServer("http://localhost:3000/health", 30000);
+
+ // Store PID for teardown
+ process.env.SERVER_PID = serverProcess.pid.toString();
+}
+
+async function waitForServer(url: string, timeout: number) {
+ const start = Date.now();
+
+ while (Date.now() - start < timeout) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) return;
+ } catch {
+ // Server not ready yet
+ }
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+
+ throw new Error(`Server did not start within ${timeout}ms`);
+}
+
+export default globalSetup;
+```
+
+### Docker Compose Setup
+
+```typescript
+// global-setup.ts
+import { execSync } from "child_process";
+
+async function globalSetup() {
+ console.log("Starting Docker services...");
+
+ execSync("docker-compose -f docker-compose.test.yml up -d", {
+ stdio: "inherit",
+ });
+
+ // Wait for services to be healthy
+ execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", {
+ stdio: "inherit",
+ });
+}
+
+export default globalSetup;
+```
+
+```typescript
+// global-teardown.ts
+import { execSync } from "child_process";
+
+async function globalTeardown() {
+ console.log("Stopping Docker services...");
+
+ execSync("docker-compose -f docker-compose.test.yml down -v", {
+ stdio: "inherit",
+ });
+}
+
+export default globalTeardown;
+```
+
+### Environment Variables Setup
+
+```typescript
+// global-setup.ts
+import dotenv from "dotenv";
+import path from "path";
+
+async function globalSetup() {
+ // Load test-specific environment
+ const envFile = process.env.CI ? ".env.ci" : ".env.test";
+ dotenv.config({ path: path.resolve(process.cwd(), envFile) });
+
+ // Validate required variables
+ const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"];
+ for (const key of required) {
+ if (!process.env[key]) {
+ throw new Error(`Missing required environment variable: ${key}`);
+ }
+ }
+}
+
+export default globalSetup;
+```
+
+## Setup Projects vs Global Setup
+
+### When to Use Each
+
+| Use Global Setup | Use Setup Projects |
+| ------------------------------------- | ---------------------------------------- |
+| One-time setup (migrations, services) | Per-project setup (auth states) |
+| No access to Playwright fixtures | Need page, request fixtures |
+| Runs once before all projects | Can run per-project or have dependencies |
+| Shared across all workers | Can be parallelized |
+
+### Setup Project Pattern
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ projects: [
+ // Setup project
+ {
+ name: "setup",
+ testMatch: /.*\.setup\.ts/,
+ },
+ // Test projects depend on setup
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ dependencies: ["setup"],
+ },
+ {
+ name: "firefox",
+ use: { ...devices["Desktop Firefox"] },
+ dependencies: ["setup"],
+ },
+ ],
+});
+```
+
+> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
+
+### Combining Both
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+ // Global: Start services, run migrations
+ globalSetup: require.resolve("./global-setup"),
+ globalTeardown: require.resolve("./global-teardown"),
+
+ projects: [
+ // Setup project: Create auth states
+ { name: "setup", testMatch: /.*\.setup\.ts/ },
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ storageState: ".auth/user.json",
+ },
+ dependencies: ["setup"],
+ },
+ ],
+});
+```
+
+## Parallel Execution Caveats
+
+### Understanding Global Setup Execution
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ globalSetup runs ONCE │
+│ ↓ │
+│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
+│ │ Worker 1│ │ Worker 2│ │ Worker 3│ │ Worker 4│ │
+│ │ tests │ │ tests │ │ tests │ │ tests │ │
+│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
+│ ↓ │
+│ globalTeardown runs ONCE │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**Key implications:**
+
+- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)
+- State created in global setup is **shared** across all workers
+- If tests **modify** shared state, they may conflict with parallel workers
+- Global setup **cannot** react to individual test needs
+
+### When to Prefer Worker-Scoped Fixtures
+
+Use **worker-scoped fixtures** instead of globalSetup when:
+
+| Scenario | Why Fixtures Are Better |
+| ------------------------------------ | ---------------------------------------------------- |
+| Each worker needs isolated resources | Fixtures can create per-worker databases, servers |
+| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` |
+| Setup depends on test configuration | Fixtures receive test context and options |
+| Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits |
+
+### Common Parallel Pitfall
+
+```typescript
+// ❌ BAD: Global setup creates ONE user, all workers fight over it
+async function globalSetup() {
+ await createUser({ email: "test@example.com" }); // Shared!
+}
+
+// ✅ GOOD: Each worker gets its own user via worker-scoped fixture
+// Uses workerInfo.workerIndex to create unique data per worker
+```
+
+> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Problem | Solution |
+| ------------------------------ | -------------------------------- | ------------------------------------------ |
+| Heavy setup in globalSetup | Slow test startup | Use setup projects for parallelizable work |
+| Not cleaning up in teardown | Leaks resources, flaky CI | Always clean up or use containers |
+| Hardcoded URLs in setup | Breaks in different environments | Use config.projects[0].use.baseURL |
+| No timeout on service wait | Hangs forever if service fails | Add timeout with clear error |
+| Shared mutable state | Race conditions in parallel | Use worker-scoped fixtures for isolation |
+| Global setup for per-test data | Tests conflict | Use test-scoped fixtures |
+
+## Related References
+
+- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns
+- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns
+- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration
diff --git a/.cursor/skills/playwright-testing/core/locators.md b/.cursor/skills/playwright-testing/core/locators.md
new file mode 100644
index 0000000000..afe3af3616
--- /dev/null
+++ b/.cursor/skills/playwright-testing/core/locators.md
@@ -0,0 +1,242 @@
+# Locator Strategies
+
+## Table of Contents
+
+1. [Priority Order](#priority-order)
+2. [User-Facing Locators](#user-facing-locators)
+3. [Filtering & Chaining](#filtering--chaining)
+4. [Dynamic Content](#dynamic-content)
+5. [Shadow DOM](#shadow-dom)
+6. [Iframes](#iframes)
+
+## Priority Order
+
+Use locators in this order of preference:
+
+1. **Role-based** (most resilient): `getByRole`
+2. **Label-based**: `getByLabel`, `getByPlaceholder`
+3. **Text-based**: `getByText`, `getByTitle`
+4. **Test IDs** (when semantic locators aren't possible): `getByTestId`
+5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`
+
+## User-Facing Locators
+
+### getByRole
+
+Most robust approach - matches how users and assistive technology perceive the page.
+
+```typescript
+// Buttons
+page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name
+page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match
+
+// Links
+page.getByRole("link", { name: "Home" });
+
+// Form elements
+page.getByRole("textbox", { name: "Email" });
+page.getByRole("checkbox", { name: "Remember me" });
+page.getByRole("combobox", { name: "Country" });
+page.getByRole("radio", { name: "Option A" });
+
+// Headings
+page.getByRole("heading", { name: "Welcome", level: 1 });
+
+// Lists & items
+page.getByRole("list").getByRole("listitem");
+
+// Navigation & regions
+page.getByRole("navigation");
+page.getByRole("main");
+page.getByRole("dialog");
+page.getByRole("alert");
+```
+
+### getByLabel
+
+For form elements with associated labels.
+
+```typescript
+// Input with
**
+ ```html
+
+
+ ```
+
+ ### Validation Steps
+
+ 1. **~~schema validator**
+ - Test your live URL or paste code
+ - Check for errors and warnings
+
+ 2. **Schema.org Validator**
+ - URL: https://validator.schema.org/
+ - Validates against Schema.org spec
+
+ 3. **~~search console**
+ - Monitor rich results in ~~search console
+ - Check Enhancements reports for issues
+
+ ### Validation Checklist
+
+ - [ ] JSON syntax is valid (no trailing commas)
+ - [ ] All required properties present
+ - [ ] URLs are absolute, not relative
+ - [ ] Dates are in ISO 8601 format
+ - [ ] Content matches visible page content
+ - [ ] No policy violations
+ ```
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Page URL or content provided
+- [ ] Schema type appropriate for content (Article for blog, Product for e-commerce, etc.)
+- [ ] All required data available (author, dates, prices, etc. depending on schema type)
+- [ ] Content eligibility for rich results confirmed
+
+### Output Validation
+- [ ] JSON syntax validates (no trailing commas, proper quotes)
+- [ ] All required properties present for chosen schema type
+- [ ] URLs are absolute, not relative
+- [ ] Dates in ISO 8601 format (YYYY-MM-DDTHH:MM:SS+00:00)
+- [ ] Schema content matches visible page content exactly
+- [ ] Passes ~~schema validator with no errors
+- [ ] Source of each data point clearly stated (~~web crawler extraction, user-provided, or manual entry)
+
+## Example
+
+**User**: "Generate FAQ schema for a page about SEO with 3 questions"
+
+**Output**:
+
+```markdown
+## FAQ Schema for SEO Page
+
+### Schema Markup
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ "mainEntity": [
+ {
+ "@type": "Question",
+ "name": "What is SEO?",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "SEO (Search Engine Optimization) is the practice of optimizing websites and content to rank higher in search engine results pages (SERPs). It involves technical optimizations, content creation, and link building to increase organic visibility and drive qualified traffic to your website."
+ }
+ },
+ {
+ "@type": "Question",
+ "name": "How long does SEO take to work?",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "SEO typically takes 3-6 months to show significant results, though this varies based on competition, domain authority, and effort invested. New websites may take 6-12 months, while established sites with existing authority can see improvements in 1-3 months for less competitive keywords."
+ }
+ },
+ {
+ "@type": "Question",
+ "name": "Is SEO better than paid advertising?",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "SEO and paid advertising serve different purposes. SEO provides sustainable, long-term traffic without per-click costs but takes time to build. Paid advertising delivers immediate results but stops when you stop paying. Most successful businesses use both: paid ads for immediate leads and SEO for long-term growth."
+ }
+ }
+ ]
+}
+```
+
+_Implementation: Wrap the above JSON-LD in `` and place in `
` or before ``. Test with ~~schema validator._
+
+### SERP Preview
+
+```
+SEO Guide: Complete Beginner's Tutorial
+yoursite.com/seo-guide/
+Learn SEO from scratch with our comprehensive guide...
+
+▼ What is SEO?
+ SEO (Search Engine Optimization) is the practice of optimizing...
+▼ How long does SEO take to work?
+ SEO typically takes 3-6 months to show significant results...
+▼ Is SEO better than paid advertising?
+ SEO and paid advertising serve different purposes...
+```
+```
+
+## Schema Type Quick Reference
+
+| Content Type | Schema Type | Key Properties |
+|--------------|-------------|----------------|
+| Blog Post | BlogPosting/Article | headline, datePublished, author |
+| Product | Product | name, price, availability |
+| FAQ | FAQPage | Question, Answer |
+| How-To | HowTo | step, totalTime |
+| Local Business | LocalBusiness | address, geo, openingHours |
+| Recipe | Recipe | ingredients, cookTime |
+| Event | Event | startDate, location |
+| Video | VideoObject | uploadDate, duration |
+| Course | Course | provider, name |
+| Review | Review | itemReviewed, ratingValue |
+
+## Tips for Success
+
+1. **Match visible content** - Schema must reflect what users see
+2. **Don't spam** - Only add schema for relevant content
+3. **Keep updated** - Update dates and prices when they change
+4. **Test thoroughly** - Validate before deploying
+5. **Monitor Search Console** - Watch for errors and warnings
+
+## Schema Type Decision Tree
+
+> **Reference**: See [references/schema-decision-tree.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/schema-decision-tree.md) for the full decision tree (content-to-schema mapping), industry-specific recommendations, implementation priority tiers (P0-P4), and validation quick reference.
+
+
+### Save Results
+
+After delivering content or optimization output to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/content/YYYY-MM-DD-.md` containing:
+- One-line description of what was created
+- Target keyword and content type
+- Open loops or items needing review
+- Source data references
+
+**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Schema Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/schema-templates.md) - Copy-ready JSON-LD templates for all schema types
+- [Validation Guide](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/validation-guide.md) - Common errors, required properties, testing workflow
+
+## Next Best Skill
+
+- **Primary**: [technical-seo-checker](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/SKILL.md) — verify implementation quality and deployment readiness.
diff --git a/.cursor/skills/schema-markup-generator/references/schema-decision-tree.md b/.cursor/skills/schema-markup-generator/references/schema-decision-tree.md
new file mode 100644
index 0000000000..6ba8a175ea
--- /dev/null
+++ b/.cursor/skills/schema-markup-generator/references/schema-decision-tree.md
@@ -0,0 +1,65 @@
+# Schema Type Decision Tree
+
+Guidelines for selecting the right schema types based on content, industry, and implementation priority.
+
+---
+
+## When to Use Which Schema
+
+| Your Content | Primary Schema | Add If Applicable | Rich Result Eligibility |
+|-------------|---------------|-------------------|----------------------|
+| Blog post / article | Article | FAQ, HowTo, Speakable | Article carousel, FAQ rich result |
+| Product page | Product | Review, Offer, AggregateRating | Product snippet with price/rating |
+| Service page | Service | FAQ, LocalBusiness | Service snippet |
+| How-to guide | HowTo | Article, FAQ | How-to rich result with steps |
+| FAQ page | FAQPage | Article | FAQ accordion in SERP |
+| Recipe | Recipe | Video, AggregateRating | Recipe carousel |
+| Event | Event | Offer, Organization | Event snippet with date/location |
+| Video | VideoObject | Article | Video carousel, key moments |
+| Local business | LocalBusiness | Review, OpeningHoursSpecification | Local pack, knowledge panel |
+| Person/author | Person | Organization | Knowledge panel |
+| Organization | Organization | ContactPoint, Logo | Knowledge panel |
+| Course | Course | Organization | Course rich result |
+| Job posting | JobPosting | Organization | Google for Jobs listing |
+| Breadcrumb | BreadcrumbList | (Always add alongside other schema) | Breadcrumb trail in SERP |
+| Software/App | SoftwareApplication | Review, Offer | App snippet |
+
+---
+
+## Industry-Specific Schema Recommendations
+
+| Industry | Essential Schema | High-Value Additions |
+|----------|-----------------|---------------------|
+| E-commerce | Product, BreadcrumbList, Organization | AggregateRating, FAQ, Review |
+| SaaS | SoftwareApplication, FAQPage, Organization | HowTo, VideoObject, Review |
+| Local Services | LocalBusiness, Service | FAQ, Review, Event |
+| Publishing/Media | Article, Person, Organization | FAQ, Speakable, VideoObject |
+| Education | Course, Organization | FAQ, HowTo, Event |
+| Healthcare | MedicalWebPage, Organization | FAQ, Physician, MedicalClinic |
+| Real Estate | RealEstateListing, Organization | LocalBusiness, FAQ |
+| Restaurants | Restaurant, Menu | Review, Event, FAQ |
+
+---
+
+## Schema Implementation Priority
+
+| Priority | Schema Types | Why |
+|----------|-------------|-----|
+| P0 -- Always | Organization, BreadcrumbList, WebSite (SearchAction) | Foundation for all sites |
+| P1 -- Content | Article, FAQPage, HowTo | Direct rich result eligibility |
+| P2 -- Commercial | Product, Review, AggregateRating, Offer | Revenue-impacting rich results |
+| P3 -- Authority | Person, SameAs, Speakable | E-E-A-T signals, AI citation |
+| P4 -- Specialized | Industry-specific types | Niche rich results |
+
+---
+
+## Schema Validation Quick Reference
+
+| Issue | Impact | Fix |
+|-------|--------|-----|
+| Missing required property | Schema ignored by Google | Add all required fields (check schema.org) |
+| Invalid date format | Warning, may lose rich result | Use ISO 8601: "2026-02-11" |
+| Incorrect @type | Schema misinterpreted | Match @type exactly to schema.org |
+| Self-referencing sameAs | Warning | sameAs should link to EXTERNAL profiles |
+| Missing image for Article | Loses article rich result | Add image property with valid URL |
+| Review without itemReviewed | Review not connected | Nest review inside Product/Service/etc. |
diff --git a/.cursor/skills/schema-markup-generator/references/schema-templates.md b/.cursor/skills/schema-markup-generator/references/schema-templates.md
new file mode 100644
index 0000000000..bb4925cf5a
--- /dev/null
+++ b/.cursor/skills/schema-markup-generator/references/schema-templates.md
@@ -0,0 +1,631 @@
+# Schema.org JSON-LD Templates
+
+Complete, copy-ready structured data templates for all major schema types. Customize the bracketed values to match your content.
+
+## FAQPage Schema
+
+For pages with frequently asked questions. Minimum 2 Q&A pairs required.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ "mainEntity": [
+ {
+ "@type": "Question",
+ "name": "[Question text - exactly as shown on page]",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "[Complete answer text - must match visible content]"
+ }
+ },
+ {
+ "@type": "Question",
+ "name": "[Question 2]",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "[Answer 2]"
+ }
+ },
+ {
+ "@type": "Question",
+ "name": "[Question 3]",
+ "acceptedAnswer": {
+ "@type": "Answer",
+ "text": "[Answer 3]"
+ }
+ }
+ ]
+}
+```
+
+**Requirements**: Questions must be complete questions, answers must be comprehensive, content must match visible page content.
+
+---
+
+## HowTo Schema
+
+For step-by-step instructional content.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "HowTo",
+ "name": "[How-to title - what will users learn]",
+ "description": "[Brief description of what this tutorial teaches]",
+ "image": {
+ "@type": "ImageObject",
+ "url": "[Main image URL]",
+ "height": "[height in pixels]",
+ "width": "[width in pixels]"
+ },
+ "totalTime": "PT[X]H[Y]M",
+ "estimatedCost": {
+ "@type": "MonetaryAmount",
+ "currency": "USD",
+ "value": "[estimated cost or 0]"
+ },
+ "supply": [
+ {
+ "@type": "HowToSupply",
+ "name": "[Supply item 1]"
+ },
+ {
+ "@type": "HowToSupply",
+ "name": "[Supply item 2]"
+ }
+ ],
+ "tool": [
+ {
+ "@type": "HowToTool",
+ "name": "[Tool 1]"
+ },
+ {
+ "@type": "HowToTool",
+ "name": "[Tool 2]"
+ }
+ ],
+ "step": [
+ {
+ "@type": "HowToStep",
+ "position": 1,
+ "name": "[Step 1 title]",
+ "text": "[Step 1 detailed instructions]",
+ "url": "[Page URL]#step1",
+ "image": "[Step 1 image URL - optional]"
+ },
+ {
+ "@type": "HowToStep",
+ "position": 2,
+ "name": "[Step 2 title]",
+ "text": "[Step 2 detailed instructions]",
+ "url": "[Page URL]#step2",
+ "image": "[Step 2 image URL - optional]"
+ },
+ {
+ "@type": "HowToStep",
+ "position": 3,
+ "name": "[Step 3 title]",
+ "text": "[Step 3 detailed instructions]",
+ "url": "[Page URL]#step3",
+ "image": "[Step 3 image URL - optional]"
+ }
+ ]
+}
+```
+
+**Time format**: PT[X]H[Y]M where X = hours, Y = minutes. Example: PT1H30M = 1 hour 30 minutes.
+
+---
+
+## Article / BlogPosting / NewsArticle Schema
+
+For blog posts, articles, and news content.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "BlogPosting",
+ "headline": "[Article title - max 110 characters for best display]",
+ "description": "[Article summary or excerpt]",
+ "image": [
+ "[Featured image URL - 1200px wide recommended]",
+ "[Alternative image URL - 4:3 ratio]",
+ "[Alternative image URL - 16:9 ratio]"
+ ],
+ "datePublished": "[ISO 8601 date: 2024-01-15T08:00:00+00:00]",
+ "dateModified": "[ISO 8601 date - same as published if never modified]",
+ "author": {
+ "@type": "Person",
+ "name": "[Author Full Name]",
+ "url": "[Author profile URL]",
+ "jobTitle": "[Author job title - optional]"
+ },
+ "publisher": {
+ "@type": "Organization",
+ "name": "[Publisher/Company Name]",
+ "logo": {
+ "@type": "ImageObject",
+ "url": "[Publisher logo URL - max 600px wide, 60px high]",
+ "width": "[width]",
+ "height": "[height]"
+ }
+ },
+ "mainEntityOfPage": {
+ "@type": "WebPage",
+ "@id": "[Canonical URL of this article]"
+ },
+ "articleBody": "[Full article text - optional but recommended]",
+ "wordCount": "[word count - optional]"
+}
+```
+
+**Type variants**: Use `Article` for general articles, `BlogPosting` for blog posts, `NewsArticle` for news content, `TechArticle` for technical documentation.
+
+---
+
+## Product Schema
+
+For e-commerce product pages.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Product",
+ "name": "[Product Name]",
+ "image": [
+ "[Product image URL 1]",
+ "[Product image URL 2]",
+ "[Product image URL 3]"
+ ],
+ "description": "[Product description]",
+ "sku": "[SKU code]",
+ "mpn": "[Manufacturer Part Number - optional]",
+ "brand": {
+ "@type": "Brand",
+ "name": "[Brand Name]"
+ },
+ "offers": {
+ "@type": "Offer",
+ "url": "[Product page URL]",
+ "priceCurrency": "USD",
+ "price": "[Price as number: 29.99]",
+ "priceValidUntil": "[Date price is valid until: 2024-12-31]",
+ "availability": "https://schema.org/InStock",
+ "seller": {
+ "@type": "Organization",
+ "name": "[Seller/Store Name]"
+ },
+ "shippingDetails": {
+ "@type": "OfferShippingDetails",
+ "shippingRate": {
+ "@type": "MonetaryAmount",
+ "value": "[shipping cost]",
+ "currency": "USD"
+ },
+ "shippingDestination": {
+ "@type": "DefinedRegion",
+ "addressCountry": "US"
+ }
+ }
+ },
+ "aggregateRating": {
+ "@type": "AggregateRating",
+ "ratingValue": "[4.5]",
+ "reviewCount": "[89]",
+ "bestRating": "5",
+ "worstRating": "1"
+ },
+ "review": [
+ {
+ "@type": "Review",
+ "reviewRating": {
+ "@type": "Rating",
+ "ratingValue": "[5]",
+ "bestRating": "5"
+ },
+ "author": {
+ "@type": "Person",
+ "name": "[Reviewer Name]"
+ },
+ "reviewBody": "[Review text]",
+ "datePublished": "[Review date: 2024-01-15]"
+ }
+ ]
+}
+```
+
+**Availability options**: `InStock`, `OutOfStock`, `PreOrder`, `Discontinued`, `LimitedAvailability`, `OnlineOnly`, `InStoreOnly`, `SoldOut`
+
+---
+
+## LocalBusiness Schema
+
+For local business pages with physical locations.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "LocalBusiness",
+ "name": "[Business Name]",
+ "image": "[Business image or logo URL]",
+ "@id": "[Business page URL]",
+ "url": "[Website URL]",
+ "telephone": "[Phone number: +1-555-555-5555]",
+ "priceRange": "[$$$ or price range like $10-$50]",
+ "address": {
+ "@type": "PostalAddress",
+ "streetAddress": "[Street address]",
+ "addressLocality": "[City]",
+ "addressRegion": "[State/Province]",
+ "postalCode": "[ZIP/Postal code]",
+ "addressCountry": "US"
+ },
+ "geo": {
+ "@type": "GeoCoordinates",
+ "latitude": "[latitude as number: 40.7128]",
+ "longitude": "[longitude as number: -74.0060]"
+ },
+ "openingHoursSpecification": [
+ {
+ "@type": "OpeningHoursSpecification",
+ "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
+ "opens": "09:00",
+ "closes": "17:00"
+ },
+ {
+ "@type": "OpeningHoursSpecification",
+ "dayOfWeek": "Saturday",
+ "opens": "10:00",
+ "closes": "15:00"
+ }
+ ],
+ "aggregateRating": {
+ "@type": "AggregateRating",
+ "ratingValue": "[4.5]",
+ "reviewCount": "[123]"
+ },
+ "servesCuisine": "[Cuisine type - for restaurants only]"
+}
+```
+
+**Type variants**: Use more specific types when applicable: `Restaurant`, `Store`, `AutoRepair`, `HealthAndBeautyBusiness`, `LegalService`, etc.
+
+---
+
+## Organization Schema
+
+For brand/company homepage.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ "name": "[Organization Name]",
+ "url": "[Website URL]",
+ "logo": "[Logo URL - recommended 112x112px or larger]",
+ "description": "[Company description]",
+ "sameAs": [
+ "[Facebook URL]",
+ "[Twitter URL]",
+ "[LinkedIn URL]",
+ "[Instagram URL]",
+ "[YouTube URL]"
+ ],
+ "contactPoint": {
+ "@type": "ContactPoint",
+ "telephone": "[Phone number]",
+ "contactType": "customer service",
+ "email": "[Email address]",
+ "availableLanguage": ["English", "Spanish"],
+ "areaServed": "US"
+ },
+ "founder": {
+ "@type": "Person",
+ "name": "[Founder name - optional]"
+ },
+ "foundingDate": "[YYYY-MM-DD - optional]",
+ "address": {
+ "@type": "PostalAddress",
+ "streetAddress": "[Street address]",
+ "addressLocality": "[City]",
+ "addressRegion": "[State]",
+ "postalCode": "[ZIP]",
+ "addressCountry": "US"
+ }
+}
+```
+
+---
+
+## BreadcrumbList Schema
+
+For navigation breadcrumbs.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ "itemListElement": [
+ {
+ "@type": "ListItem",
+ "position": 1,
+ "name": "Home",
+ "item": "[Homepage URL]"
+ },
+ {
+ "@type": "ListItem",
+ "position": 2,
+ "name": "[Category Name]",
+ "item": "[Category URL]"
+ },
+ {
+ "@type": "ListItem",
+ "position": 3,
+ "name": "[Subcategory Name]",
+ "item": "[Subcategory URL]"
+ },
+ {
+ "@type": "ListItem",
+ "position": 4,
+ "name": "[Current Page Name]",
+ "item": "[Current Page URL]"
+ }
+ ]
+}
+```
+
+**Important**: Position numbers must be sequential starting from 1. Last item should be the current page.
+
+---
+
+## VideoObject Schema
+
+For video content.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "VideoObject",
+ "name": "[Video title]",
+ "description": "[Video description]",
+ "thumbnailUrl": "[Video thumbnail URL - minimum 160x90px]",
+ "uploadDate": "[ISO 8601 date: 2024-01-15T08:00:00+00:00]",
+ "duration": "PT[X]M[Y]S",
+ "contentUrl": "[Video file URL]",
+ "embedUrl": "[Video embed URL]",
+ "interactionStatistic": {
+ "@type": "InteractionCounter",
+ "interactionType": { "@type": "WatchAction" },
+ "userInteractionCount": "[view count]"
+ }
+}
+```
+
+**Duration format**: PT[X]M[Y]S where X = minutes, Y = seconds. Example: PT5M30S = 5 minutes 30 seconds.
+
+---
+
+## Event Schema
+
+For events, conferences, concerts, etc.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Event",
+ "name": "[Event Name]",
+ "description": "[Event description]",
+ "image": "[Event image URL]",
+ "startDate": "[ISO 8601 date: 2024-06-15T19:00:00-05:00]",
+ "endDate": "[ISO 8601 date: 2024-06-15T22:00:00-05:00]",
+ "eventStatus": "https://schema.org/EventScheduled",
+ "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
+ "location": {
+ "@type": "Place",
+ "name": "[Venue Name]",
+ "address": {
+ "@type": "PostalAddress",
+ "streetAddress": "[Street address]",
+ "addressLocality": "[City]",
+ "addressRegion": "[State]",
+ "postalCode": "[ZIP]",
+ "addressCountry": "US"
+ }
+ },
+ "offers": {
+ "@type": "Offer",
+ "url": "[Ticket purchase URL]",
+ "price": "[ticket price]",
+ "priceCurrency": "USD",
+ "availability": "https://schema.org/InStock",
+ "validFrom": "[Sale start date]"
+ },
+ "organizer": {
+ "@type": "Organization",
+ "name": "[Organizer name]",
+ "url": "[Organizer website]"
+ }
+}
+```
+
+**Event status options**: `EventScheduled`, `EventCancelled`, `EventPostponed`, `EventRescheduled`, `EventMovedOnline`
+
+**Attendance mode**: `OfflineEventAttendanceMode`, `OnlineEventAttendanceMode`, `MixedEventAttendanceMode`
+
+---
+
+## Course Schema
+
+For online courses and educational content.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Course",
+ "name": "[Course Name]",
+ "description": "[Course description]",
+ "provider": {
+ "@type": "Organization",
+ "name": "[Provider name]",
+ "sameAs": "[Provider URL]"
+ },
+ "offers": {
+ "@type": "Offer",
+ "category": "Paid",
+ "price": "[price]",
+ "priceCurrency": "USD"
+ },
+ "hasCourseInstance": {
+ "@type": "CourseInstance",
+ "courseMode": "online",
+ "courseWorkload": "PT[X]H",
+ "instructor": {
+ "@type": "Person",
+ "name": "[Instructor name]"
+ }
+ }
+}
+```
+
+---
+
+## Recipe Schema
+
+For cooking recipes.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Recipe",
+ "name": "[Recipe name]",
+ "image": "[Recipe image URL]",
+ "author": {
+ "@type": "Person",
+ "name": "[Author name]"
+ },
+ "datePublished": "[ISO 8601 date]",
+ "description": "[Recipe description]",
+ "prepTime": "PT[X]M",
+ "cookTime": "PT[X]M",
+ "totalTime": "PT[X]M",
+ "recipeYield": "[Servings: e.g., '4 servings']",
+ "recipeCategory": "[Category: e.g., 'Dinner']",
+ "recipeCuisine": "[Cuisine: e.g., 'Italian']",
+ "keywords": "[comma, separated, keywords]",
+ "nutrition": {
+ "@type": "NutritionInformation",
+ "calories": "[calories per serving]"
+ },
+ "recipeIngredient": [
+ "[Ingredient 1 with quantity]",
+ "[Ingredient 2 with quantity]",
+ "[Ingredient 3 with quantity]"
+ ],
+ "recipeInstructions": [
+ {
+ "@type": "HowToStep",
+ "text": "[Step 1 instructions]"
+ },
+ {
+ "@type": "HowToStep",
+ "text": "[Step 2 instructions]"
+ }
+ ],
+ "aggregateRating": {
+ "@type": "AggregateRating",
+ "ratingValue": "[4.5]",
+ "reviewCount": "[number of reviews]"
+ }
+}
+```
+
+---
+
+## SoftwareApplication Schema
+
+For software, apps, and tools.
+
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "SoftwareApplication",
+ "name": "[Software name]",
+ "operatingSystem": "[Windows, macOS, iOS, Android, Web]",
+ "applicationCategory": "BusinessApplication",
+ "offers": {
+ "@type": "Offer",
+ "price": "[price or 0 for free]",
+ "priceCurrency": "USD"
+ },
+ "aggregateRating": {
+ "@type": "AggregateRating",
+ "ratingValue": "[4.5]",
+ "reviewCount": "[number of reviews]"
+ },
+ "screenshot": "[Screenshot URL - optional]",
+ "softwareVersion": "[version number]",
+ "fileSize": "[file size with units: e.g., '50MB']",
+ "datePublished": "[Release date]",
+ "downloadUrl": "[Download URL - optional]"
+}
+```
+
+---
+
+## Multiple Schema Types (Combined Array)
+
+To include multiple schema types on one page, wrap them in an array:
+
+```html
+
+```
+
+---
+
+## Implementation Notes
+
+- Always validate schema at https://validator.schema.org/ and https://search.google.com/test/rich-results
+- Remove bracketed placeholders and replace with actual content
+- Use absolute URLs, not relative paths
+- Dates must be in ISO 8601 format
+- Schema must match visible page content (Google policy requirement)
+- No trailing commas in JSON (invalid syntax)
diff --git a/.cursor/skills/schema-markup-generator/references/validation-guide.md b/.cursor/skills/schema-markup-generator/references/validation-guide.md
new file mode 100644
index 0000000000..d060eec21d
--- /dev/null
+++ b/.cursor/skills/schema-markup-generator/references/validation-guide.md
@@ -0,0 +1,444 @@
+# Schema Markup Validation Guide
+
+Complete reference for validating, testing, and troubleshooting structured data.
+
+## Validation Tools
+
+### Google Rich Results Test
+- **URL**: https://search.google.com/test/rich-results
+- **Purpose**: Check if your schema is eligible for Google rich results
+- **Tests**: Live URL or code snippet
+- **Output**: Errors, warnings, eligible rich result types
+
+### Schema.org Validator
+- **URL**: https://validator.schema.org/
+- **Purpose**: Validate against official Schema.org specification
+- **Tests**: URL, code snippet, or microdata
+- **Output**: Technical validation errors
+
+### Google Search Console
+- **Location**: Search Console → Enhancements section
+- **Purpose**: Monitor rich results performance and errors at scale
+- **Reports**: Rich results status, coverage, issues over time
+
+---
+
+## Common JSON-LD Syntax Errors
+
+### Trailing Commas
+
+**Error**: Invalid JSON syntax
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "headline": "Title", ← Trailing comma here
+}
+```
+
+**Fix**: Remove the comma after the last property
+```json
+{
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "headline": "Title"
+}
+```
+
+### Missing Required Quotes
+
+**Error**: Property names must be quoted
+```json
+{
+ @context: "https://schema.org"
+}
+```
+
+**Fix**: Quote all property names
+```json
+{
+ "@context": "https://schema.org"
+}
+```
+
+### Incorrect Date Format
+
+**Error**: Invalid date format
+```json
+{
+ "datePublished": "01/15/2024"
+}
+```
+
+**Fix**: Use ISO 8601 format
+```json
+{
+ "datePublished": "2024-01-15T08:00:00+00:00"
+}
+```
+
+### Relative URLs Instead of Absolute
+
+**Error**: Relative URLs are not allowed
+```json
+{
+ "image": "/images/photo.jpg"
+}
+```
+
+**Fix**: Use absolute URLs
+```json
+{
+ "image": "https://example.com/images/photo.jpg"
+}
+```
+
+### Incorrect Array Syntax
+
+**Error**: Multiple values not in array
+```json
+{
+ "image": "url1.jpg", "url2.jpg"
+}
+```
+
+**Fix**: Use array brackets for multiple values
+```json
+{
+ "image": ["url1.jpg", "url2.jpg"]
+}
+```
+
+---
+
+## Required vs Recommended Properties
+
+### FAQPage Schema
+
+| Property | Status | Notes |
+|----------|--------|-------|
+| @type | Required | Must be "FAQPage" |
+| mainEntity | Required | Array of Question objects |
+| Question.name | Required | The question text |
+| Answer.text | Required | The answer text |
+
+**Minimum**: 2 Q&A pairs
+
+### HowTo Schema
+
+| Property | Status | Notes |
+|----------|--------|-------|
+| @type | Required | Must be "HowTo" |
+| name | Required | Title of the how-to |
+| step | Required | Array of HowToStep objects |
+| step.text | Required | Step instructions |
+| image | Recommended | Improves visibility |
+| totalTime | Recommended | Shows duration in results |
+| supply | Recommended | Lists materials needed |
+| tool | Recommended | Lists tools needed |
+
+**Minimum**: 2 steps with text
+
+### Article Schema
+
+| Property | Status | Notes |
+|----------|--------|-------|
+| @type | Required | Article/BlogPosting/NewsArticle |
+| headline | Required | Max 110 characters |
+| image | Required | Minimum 1200px wide |
+| datePublished | Required | ISO 8601 format |
+| author | Required | Person or Organization |
+| publisher | Required | Organization with logo |
+| publisher.logo | Required | Max 600px wide, 60px high |
+| dateModified | Recommended | Update when content changes |
+| description | Recommended | Improves display |
+
+### Product Schema
+
+| Property | Status | Notes |
+|----------|--------|-------|
+| @type | Required | Must be "Product" |
+| name | Required | Product name |
+| image | Required | Product images |
+| description | Recommended | Product description |
+| offers | Recommended | Required for price display |
+| offers.price | Recommended | Required for price display |
+| offers.priceCurrency | Recommended | Required for price display |
+| offers.availability | Recommended | Stock status |
+| aggregateRating | Recommended | Required for star ratings |
+| review | Recommended | Individual reviews |
+| sku | Recommended | Product identifier |
+| brand | Recommended | Brand information |
+
+### LocalBusiness Schema
+
+| Property | Status | Notes |
+|----------|--------|-------|
+| @type | Required | LocalBusiness or subtype |
+| name | Required | Business name |
+| address | Required | PostalAddress object |
+| address.streetAddress | Required | Street address |
+| address.addressLocality | Required | City |
+| address.addressRegion | Required | State/province |
+| address.postalCode | Required | ZIP/postal code |
+| address.addressCountry | Required | Country code |
+| geo | Recommended | Latitude/longitude |
+| telephone | Recommended | Phone number |
+| openingHoursSpecification | Recommended | Business hours |
+| priceRange | Recommended | Price range indicator |
+| aggregateRating | Recommended | Customer ratings |
+
+### Organization Schema
+
+| Property | Status | Notes |
+|----------|--------|-------|
+| @type | Required | Must be "Organization" |
+| name | Required | Organization name |
+| url | Required | Website URL |
+| logo | Recommended | Brand logo |
+| sameAs | Recommended | Social media profiles |
+| contactPoint | Recommended | Contact information |
+
+---
+
+## Google Rich Result Eligibility Requirements
+
+### FAQ Rich Results
+
+**Eligibility checklist**:
+- [ ] Minimum 2 Q&A pairs
+- [ ] Questions are actual questions (contain "?")
+- [ ] Answers are complete and comprehensive
+- [ ] Content matches visible page content exactly
+- [ ] Not a forum or Q&A page where users can submit answers
+- [ ] Not advertising or promotional in nature
+- [ ] Not for medical, legal, or financial advice without proper E-E-A-T
+
+**Ineligible content**:
+- Medical advice without credentials
+- Legal advice
+- Product/service comparisons that are promotional
+- User-generated Q&A (use QAPage instead)
+
+### How-To Rich Results
+
+**Eligibility checklist**:
+- [ ] Minimum 2 steps with clear instructions
+- [ ] Complete process from start to finish
+- [ ] Each step has meaningful text (not just a title)
+- [ ] Not advertising or promotional
+- [ ] Not harmful or dangerous content
+- [ ] Steps are actionable and practical
+
+**Ineligible content**:
+- Single-step processes
+- Recipes (use Recipe schema instead)
+- Promotional tutorials
+
+### Product Rich Results
+
+**For price display**:
+- [ ] Valid Product schema
+- [ ] `offers` with `price` property
+- [ ] `priceCurrency` specified
+- [ ] `availability` status
+
+**For review stars**:
+- [ ] Valid `aggregateRating` OR individual `review`
+- [ ] Minimum 1 review for individual review display
+- [ ] Honest, unbiased reviews (not paid/incentivized)
+
+**For product markup**:
+- [ ] `name` property present
+- [ ] At least one `image`
+- [ ] Valid product type (not person, organization, etc.)
+
+### Article Rich Results
+
+**Eligibility checklist**:
+- [ ] Valid Article/BlogPosting/NewsArticle schema
+- [ ] High-quality, original content
+- [ ] Proper `publisher` with valid logo
+- [ ] Valid `author` information
+- [ ] Images meet size requirements (1200px wide)
+- [ ] Not short-form content (minimum ~300 words)
+
+---
+
+## Testing Workflow
+
+### Initial Implementation
+
+1. **Add schema to development/staging environment**
+2. **Validate syntax at validator.schema.org**
+ - Paste code or test URL
+ - Fix all errors before proceeding
+3. **Test at Google Rich Results Test**
+ - Check for Google-specific issues
+ - Verify eligible rich result types
+4. **Visual inspection**
+ - View page source to confirm schema is present
+ - Check JSON formatting in browser
+
+### Pre-Launch Testing
+
+1. **Test on staging URL with Rich Results Test**
+2. **Verify all required properties present**
+3. **Confirm content matches visible page content**
+4. **Check for policy violations**
+5. **Test multiple schema types if combining**
+6. **Validate images are accessible and meet size requirements**
+
+### Post-Launch Monitoring
+
+1. **Submit sitemap to Google Search Console**
+2. **Monitor Enhancements reports**
+ - Check for validation errors
+ - Watch for policy violations
+ - Track rich result impressions
+3. **Re-test pages if content changes**
+4. **Update `dateModified` when updating content**
+5. **Fix errors within 30 days to avoid rich result removal**
+
+---
+
+## Common Policy Violations
+
+### Content Mismatch
+
+**Violation**: Schema content doesn't match visible page content
+
+**Example**: FAQ schema includes Q&A pairs not visible on page
+
+**Fix**: Ensure all structured data reflects actual page content exactly
+
+### Deceptive Content
+
+**Violation**: Schema contains misleading information
+
+**Example**: Product reviews that are fake or incentivized
+
+**Fix**: Only include genuine, verifiable information
+
+### Spammy Markup
+
+**Violation**: Excessive or irrelevant schema
+
+**Example**: Adding Product schema to every blog post
+
+**Fix**: Only use schema types relevant to page content
+
+### Hidden Content
+
+**Violation**: Schema references content hidden from users
+
+**Example**: FAQ answers only in schema, not visible on page
+
+**Fix**: Make all schema content visible to users
+
+### Promotional Content in FAQ
+
+**Violation**: Using FAQ schema for promotional purposes
+
+**Example**: Questions like "Why is [Brand] the best?"
+
+**Fix**: Use neutral, informational questions
+
+---
+
+## Debugging Common Issues
+
+### Schema Not Appearing in Rich Results Test
+
+**Possible causes**:
+- JSON syntax error (validate at validator.schema.org)
+- Schema in incorrect location (should be in `` or `
`)
+- Script tag missing `type="application/ld+json"`
+- Content served dynamically after page load (bot can't see it)
+
+**Debug steps**:
+1. View page source (not inspect element)
+2. Search for `"@type"`
+3. Copy JSON to validator.schema.org
+4. Fix syntax errors
+
+### Rich Results Not Showing in Search
+
+**Possible causes**:
+- Schema is new (can take days/weeks to appear)
+- Page not indexed by Google
+- Schema has errors in Search Console
+- Content doesn't meet quality guidelines
+- Competition for rich results is high
+
+**Debug steps**:
+1. Check Search Console → Enhancements
+2. Use URL Inspection tool to request indexing
+3. Verify schema passes Rich Results Test
+4. Check for manual actions
+
+### Warnings vs Errors
+
+**Errors** (must fix):
+- Invalid syntax
+- Missing required properties
+- Invalid property values
+- Schema type doesn't exist
+
+**Warnings** (should fix when possible):
+- Missing recommended properties
+- Suboptimal property values
+- Non-standard extensions
+- Property not recognized for this type
+
+---
+
+## Schema Maintenance Checklist
+
+### Monthly
+- [ ] Check Search Console for new errors
+- [ ] Verify rich results are still appearing
+- [ ] Update `dateModified` on changed content
+
+### Quarterly
+- [ ] Audit all schema implementations
+- [ ] Test key pages with Rich Results Test
+- [ ] Update any outdated information (prices, dates, etc.)
+- [ ] Check for new schema types relevant to your content
+
+### After Content Changes
+- [ ] Update schema to match new content
+- [ ] Update `dateModified` timestamp
+- [ ] Re-validate with Rich Results Test
+- [ ] Request re-indexing in Search Console if major changes
+
+### After Site Migration
+- [ ] Verify schema preserved on new URLs
+- [ ] Update all absolute URLs in schema
+- [ ] Submit new sitemap
+- [ ] Monitor for errors in new domain's Search Console
+
+---
+
+## Quick Reference: Error Messages and Fixes
+
+| Error Message | Cause | Fix |
+|---------------|-------|-----|
+| "Missing required field" | Required property not included | Add the required property |
+| "Invalid date format" | Date not in ISO 8601 | Use format: 2024-01-15T08:00:00+00:00 |
+| "URL is not absolute" | Relative URL used | Add full URL with https:// |
+| "Unexpected token" | JSON syntax error | Check for missing quotes, brackets, commas |
+| "This markup is not eligible for rich results" | Schema type or content doesn't qualify | Review eligibility requirements |
+| "Image too small" | Image doesn't meet size requirements | Use image at least 1200px wide |
+| "The attribute price is required" | Product missing price | Add offers.price property |
+| "Logo must be 600x60 or smaller" | Publisher logo too large | Resize logo to meet requirements |
+
+---
+
+## Resources
+
+- **Schema.org Documentation**: https://schema.org/
+- **Google Search Central**: https://developers.google.com/search/docs/appearance/structured-data
+- **Rich Results Test**: https://search.google.com/test/rich-results
+- **Schema Validator**: https://validator.schema.org/
+- **JSON-LD Playground**: https://json-ld.org/playground/
diff --git a/.cursor/skills/seo-content-writer/SKILL.md b/.cursor/skills/seo-content-writer/SKILL.md
new file mode 100644
index 0000000000..6478e4e6be
--- /dev/null
+++ b/.cursor/skills/seo-content-writer/SKILL.md
@@ -0,0 +1,456 @@
+---
+name: seo-content-writer
+description: 'Write SEO blog posts, articles, landing pages with keyword integration, header optimization, and snippet targeting. SEO文章写作/内容优化'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when writing SEO-optimized articles, blog posts, landing pages, or product descriptions. Also when the user asks to create content targeting a specific keyword."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "medium"
+ tags:
+ - seo
+ - content-writing
+ - blog-writing
+ - seo-copywriting
+ - content-creation
+ - featured-snippet-optimization
+ - article-writing
+ - landing-page
+ - surferSEO-alternative
+ - clearscope-alternative
+ - SEO文章
+ - 博客写作
+ - SEOライティング
+ - SEO글쓰기
+ - redaccion-seo
+ triggers:
+ # EN-formal
+ - "write SEO content"
+ - "create blog post"
+ - "write an article"
+ - "content writing"
+ - "draft optimized content"
+ - "write for SEO"
+ - "SEO copywriting"
+ - "blog writing"
+ # EN-casual
+ - "write me a blog post"
+ - "write me an article"
+ - "help me write about"
+ - "draft an article about"
+ - "I need a blog post"
+ - "create content for my site"
+ # EN-question
+ - "how do I write content that ranks"
+ - "how to write SEO friendly content"
+ - "what makes content rank well"
+ # EN-competitor
+ - "SurferSEO alternative"
+ - "Clearscope alternative"
+ - "Jasper AI alternative for SEO"
+ # ZH-pro
+ - "SEO文章写作"
+ - "SEO内容创作"
+ - "博客写作"
+ - "内容优化"
+ - "内容创作"
+ # ZH-casual
+ - "帮我写文章"
+ - "写一篇博客"
+ - "排名上不去"
+ - "帮我写SEO文章"
+ - "写一篇SEO文章"
+ # JA
+ - "SEOライティング"
+ - "SEO記事作成"
+ - "ブログ記事作成"
+ - "SEOコンテンツ"
+ # KO
+ - "SEO 글쓰기"
+ - "블로그 작성"
+ - "SEO 콘텐츠 작성"
+ - "블로그 글 작성해줘"
+ - "이 주제로 글 써봐"
+ # ES
+ - "redacción SEO"
+ - "escribir artículo SEO"
+ - "contenido optimizado"
+ # PT
+ - "redação SEO"
+ - "escrever artigo SEO"
+ # Misspellings
+ - "SEO copywritting"
+ - "writting SEO content"
+---
+
+# SEO Content Writer
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill creates search-engine-optimized content that ranks well while providing genuine value to readers. It applies proven SEO copywriting techniques, proper keyword integration, and optimal content structure.
+
+**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
+
+- Writing blog posts targeting specific keywords
+- Creating landing pages optimized for search
+- Developing pillar content for topic clusters
+- Writing product descriptions for e-commerce
+- Creating service pages for local SEO
+- Producing how-to guides and tutorials
+- Writing comparison and review articles
+
+## What This Skill Does
+
+1. **Keyword Integration**: Naturally incorporates target and related keywords
+2. **Structure Optimization**: Creates scannable, well-organized content
+3. **Title & Meta Creation**: Writes compelling, click-worthy titles
+4. **Header Optimization**: Uses strategic H1-H6 hierarchy
+5. **Internal Linking**: Suggests relevant internal link opportunities
+6. **Readability Enhancement**: Ensures content is accessible and engaging
+7. **Featured Snippet Optimization**: Formats for SERP feature opportunities
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Basic Content Creation
+
+```
+Write an SEO-optimized article about [topic] targeting the keyword [keyword]
+```
+
+```
+Create a blog post for [topic] with these keywords: [keyword list]
+```
+
+### With Specific Requirements
+
+```
+Write a 2,000-word guide about [topic] targeting [keyword],
+include FAQ section for featured snippets
+```
+
+### Content Briefs
+
+```
+Here's my content brief: [brief]. Write SEO-optimized content following this outline.
+```
+
+## Skill Contract
+
+**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
+
+- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
+- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
+- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~search console connected:**
+Automatically pull keyword metrics (search volume, difficulty, CPC), competitor content analysis (top-ranking pages, content length, common topics), SERP features (featured snippets, PAA questions), and keyword opportunities (related keywords, question-based queries).
+
+**With manual data only:**
+Ask the user to provide:
+1. Target primary keyword and 3-5 secondary keywords
+2. Target audience and search intent (informational/commercial/transactional)
+3. Target word count and desired tone
+4. Any competitor URLs or content examples to reference
+
+Proceed with the full workflow using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests SEO content:
+
+1. **Gather Requirements**
+
+ Confirm or ask for:
+
+ ```markdown
+ ### Content Requirements
+
+ **Primary Keyword**: [main keyword]
+ **Secondary Keywords**: [2-5 related keywords]
+ **Target Word Count**: [length]
+ **Content Type**: [blog/guide/landing page/etc.]
+ **Target Audience**: [who is this for]
+ **Search Intent**: [informational/commercial/transactional]
+ **Tone**: [professional/casual/technical/friendly]
+ **CTA Goal**: [what action should readers take]
+ **Competitor URLs**: [top ranking content to beat]
+ ```
+
+2. **Load CORE-EEAT Quality Constraints**
+
+ Before writing, load content quality standards from the [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md):
+
+ ```markdown
+ ### CORE-EEAT Pre-Write Checklist
+
+ **Content Type**: [identified from requirements above]
+ **Loaded Constraints** (high-weight items for this content type):
+
+ Apply these standards while writing:
+
+ | ID | Standard | How to Apply |
+ |----|----------|-------------|
+ | C01 | Intent Alignment | Title promise must match content delivery |
+ | C02 | Direct Answer | Core answer in first 150 words |
+ | C06 | Audience Targeting | State "this article is for..." |
+ | C10 | Semantic Closure | Conclusion answers opening question + next steps |
+ | O01 | Heading Hierarchy | H1→H2→H3, no level skipping |
+ | O02 | Summary Box | Include TL;DR or Key Takeaways |
+ | O06 | Section Chunking | Each section single topic; paragraphs 3–5 sentences |
+ | O09 | Information Density | No filler; consistent terminology |
+ | R01 | Data Precision | ≥5 precise numbers with units |
+ | R02 | Citation Density | ≥1 external citation per 500 words |
+ | R04 | Evidence-Claim Mapping | Every claim backed by evidence |
+ | R07 | Entity Precision | Full names for people/orgs/products |
+ | C03 | Query Coverage | Cover ≥3 query variants (synonyms, long-tail) |
+ | O08 | Anchor Navigation | Table of contents with jump links |
+ | O10 | Multimedia Structure | Images/videos have captions and carry information |
+ | E07 | Practical Tools | Include downloadable templates, checklists, or calculators |
+
+ _These 16 items apply across all content types. For content-type-specific dimension weights, see the Content-Type Weight Table in [core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)._
+ _Full 80-item benchmark: [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)_
+ _For complete content quality audit: use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
+ ```
+
+3. **Research and Plan**
+
+ Before writing:
+
+ ```markdown
+ ### Content Research
+
+ **SERP Analysis**:
+ - Top results format: [what's ranking]
+ - Average word count: [X] words
+ - Common sections: [list]
+ - SERP features: [snippets, PAA, etc.]
+
+ **Keyword Map**:
+ - Primary: [keyword] - use in title, H1, intro, conclusion
+ - Secondary: [keywords] - use in H2s, body paragraphs
+ - LSI/Related: [terms] - sprinkle naturally throughout
+ - Questions: [PAA questions] - use as H2/H3s or FAQ
+
+ **Content Angle**:
+ [What unique perspective or value will this content provide?]
+ ```
+
+4. **Create Optimized Title**
+
+ ```markdown
+ ### Title Optimization
+
+ **Requirements**:
+ - Include primary keyword (preferably at start)
+ - Under 60 characters for full SERP display
+ - Compelling and click-worthy
+ - Match search intent
+
+ **Title Options**:
+
+ 1. [Title option 1] ([X] chars)
+ - Keyword position: [front/middle]
+ - Power words: [list]
+
+ 2. [Title option 2] ([X] chars)
+ - Keyword position: [front/middle]
+ - Power words: [list]
+
+ **Recommended**: [Best option with reasoning]
+ ```
+
+5. **Write Meta Description**
+
+ ```markdown
+ ### Meta Description
+
+ **Requirements**:
+ - 150-160 characters
+ - Include primary keyword naturally
+ - Include call-to-action
+ - Compelling and specific
+
+ **Meta Description**:
+ "[Description text]" ([X] characters)
+
+ **Elements included**:
+ - ✅ Primary keyword
+ - ✅ Value proposition
+ - ✅ CTA or curiosity hook
+ ```
+
+6. **Structure Content and Write**
+
+ Structure: H1 (primary keyword, one per page) > Introduction (100-150 words, hook + promise + keyword in first 100 words) > H2 sections (secondary keywords/questions) > H3 sub-topics > FAQ section > Conclusion (summary + keyword + CTA).
+
+7. **Apply On-Page SEO Best Practices**
+
+ Follow the on-page SEO checklist (keyword placement, content quality, readability, technical elements) and the content writing template (H1 with keyword, hook, sections with H2/H3, FAQ, conclusion with CTA).
+
+ > **Reference**: See [references/seo-writing-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/seo-writing-checklist.md) for the full on-page SEO checklist, content writing template, and featured snippet optimization patterns.
+
+ Key requirements while writing:
+ - Primary keyword in title, H1, first 100 words, at least one H2, and conclusion
+ - Paragraphs of 3-5 sentences; varied sentence length; bullet points and bold key phrases
+ - Internal links (2-5) and external authoritative links (2-3)
+ - FAQ section with 40-60 word answers for featured snippet opportunity
+ - Optimize for definition, list, table, and how-to snippets where applicable
+
+8. **Add Internal/External Links**
+
+ ```markdown
+ ### Link Recommendations
+
+ **Internal Links** (include 2-5):
+ 1. "[anchor text]" → [/your-page-url] (relevant because: [reason])
+ 2. "[anchor text]" → [/your-page-url] (relevant because: [reason])
+
+ **External Links** (include 2-3 authoritative sources):
+ 1. "[anchor text]" → [authoritative-source.com] (supports: [claim])
+ 2. "[anchor text]" → [authoritative-source.com] (supports: [claim])
+ ```
+
+9. **Final SEO Review and CORE-EEAT Self-Check**
+
+ Score content across 10 SEO factors (title, meta description, H1, keyword placement, H2s, internal links, external links, FAQ, readability, word count) and produce an Overall SEO Score out of 10.
+
+ Then verify the 16 CORE-EEAT pre-write constraints (C01, C02, C06, C10, O01, O02, O06, O09, R01, R02, R04, R07, C03, O08, O10, E07) with pass/warning/fail status. List items needing attention.
+
+ _For full 80-item audit, use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
+
+#### Issue Classification
+
+When the self-check reveals issues, classify and handle them:
+
+**Auto-correct (fix silently, then document in a `### Changes Made` block after the final content):**
+- Meta description exceeds 160 characters → rewrite to fit while preserving primary keyword and CTA
+- Title tag exceeds 60 characters → shorten while preserving primary keyword
+- Missing alt text on images → generate descriptive alt text
+- Duplicate H2 headings → differentiate with modifiers
+- Keyword density above 2% → replace some instances with semantic variants
+- Missing table of contents → generate TOC with anchor links for articles with 3+ H2 sections
+- Paragraphs exceeding 5 sentences → split at the most natural break point
+
+Use this format for the Changes Made block:
+
+```markdown
+### Changes Made During Self-Check
+
+| Item | Original | Fixed |
+|------|----------|-------|
+| Meta description | 185 chars | 158 chars — removed non-essential qualifier |
+| Keyword density | 2.4% | 1.8% — replaced 3 instances with semantic variants |
+```
+
+**Needs your decision (ask before changing):**
+- H1 wording changes (may affect brand voice)
+- Keyword density below 0.5% (may need structural rewrite)
+- Tone adjustments (formal ↔ casual)
+- Claim strength (e.g., "best" → "top-rated" for compliance)
+- Content length significantly above/below target (±30%)
+- Removing/replacing external links
+- Statistics or data claims that cannot be verified against the cited source
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Primary keyword confirmed and matches search intent
+- [ ] Target word count specified (minimum 800 words for substantive content)
+- [ ] Content type and audience clearly defined
+- [ ] Competitor URLs reviewed or target SERP features identified
+
+### Output Validation
+- [ ] Keyword density within 1-2% for primary keyword (Note: Keyword density is a guideline, not a hard rule. Modern search engines prioritize semantic relevance and natural language over exact density targets. Focus on covering the topic comprehensively with semantic variants rather than hitting a specific percentage.)
+- [ ] All sections from outline covered completely
+- [ ] Internal links included (2-5 relevant links)
+- [ ] FAQ section present with at least 3 questions
+- [ ] Readability score appropriate for target audience
+- [ ] Source of each data point clearly stated (~~SEO tool data, user-provided, or estimated)
+
+## Example
+
+**User**: "Write an SEO-optimized article about 'email marketing best practices' targeting small businesses"
+
+> **Reference**: See [references/seo-writing-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/seo-writing-checklist.md) for the full example output demonstrating a complete SEO article with meta description, H1/H2/H3 hierarchy, statistics with citations, comparison tables, FAQ section, and conclusion with CTA.
+
+The example output demonstrates: keyword in H1 and first 100 words, statistics with sources (DMA, Emarsys), comparison tables, bullet-point lists, pro tips, FAQ section with 40-60 word answers, and a clear CTA in the conclusion.
+
+## Content Type Templates
+
+### How-To Guide
+
+```
+Write a how-to guide for [task] targeting [keyword]
+```
+
+### Comparison Article
+
+```
+Write a comparison article: [Option A] vs [Option B] for [keyword]
+```
+
+### Listicle
+
+```
+Write a list post: "X Best [Items] for [Audience/Purpose]" targeting [keyword]
+```
+
+### Ultimate Guide
+
+```
+Write an ultimate guide about [topic] (3,000+ words) targeting [keyword]
+```
+
+## Tips for Success
+
+1. **Match search intent** - Informational queries need guides, not sales pages
+2. **Front-load value** - Put key information early for readers and snippets
+3. **Use data and examples** - Specific beats generic every time
+4. **Write for humans first** - SEO optimization should feel natural
+5. **Include visual elements** - Break up text with images, tables, lists
+6. **Update regularly** - Fresh content signals to search engines
+
+
+### Save Results
+
+After delivering content or optimization output to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/content/YYYY-MM-DD-.md` containing:
+- One-line description of what was created
+- Target keyword and content type
+- Open loops or items needing review
+- Source data references
+
+**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Title Formulas](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/title-formulas.md) - Proven headline formulas, power words, CTR patterns
+- [Content Structure Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/content-structure-templates.md) - Templates for blog posts, comparisons, listicles, how-tos, pillar pages
+
+## Next Best Skill
+
+- **Primary**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) — gate the draft before publishing or handing it off.
diff --git a/.cursor/skills/seo-content-writer/references/content-structure-templates.md b/.cursor/skills/seo-content-writer/references/content-structure-templates.md
new file mode 100644
index 0000000000..35a5e29ffa
--- /dev/null
+++ b/.cursor/skills/seo-content-writer/references/content-structure-templates.md
@@ -0,0 +1,875 @@
+# Content Structure Templates
+
+Markdown templates for common SEO content types. Customize section headings and content while maintaining the structural framework.
+
+## Blog Post (Informational)
+
+**Target word count**: 1,200-1,800 words
+**Primary goal**: Educate and inform, build authority, rank for informational queries
+
+```markdown
+# [Primary Keyword in H1] - [Benefit or Hook]
+
+[Opening hook - 1 compelling sentence that grabs attention]
+
+[Problem statement - describe the pain point or challenge your reader faces]
+
+[Promise - explain what readers will learn or gain from this post]
+
+In this guide, you'll discover:
+- [Key takeaway 1]
+- [Key takeaway 2]
+- [Key takeaway 3]
+
+## What Is [Topic]?
+
+[Clear definition in 40-60 words - GEO-optimized]
+
+[Expanded explanation with context]
+
+[Why this matters - relevance to reader]
+
+## Why [Topic] Matters
+
+[Benefit 1 with supporting evidence]
+
+[Benefit 2 with supporting evidence]
+
+[Benefit 3 with supporting evidence]
+
+> **Key Insight**: [Highlighted important point or statistic]
+
+## [Secondary Keyword or Main Section 1]
+
+[Introduction to this section]
+
+### [Sub-topic 1]
+
+[Detailed explanation]
+
+**Key points**:
+- Point 1
+- Point 2
+- Point 3
+
+### [Sub-topic 2]
+
+[Detailed explanation]
+
+[Example or case study]
+
+## [Secondary Keyword or Main Section 2]
+
+[Section introduction]
+
+| Factor | Description | Impact |
+|--------|-------------|--------|
+| [Factor 1] | [Description] | High/Medium/Low |
+| [Factor 2] | [Description] | High/Medium/Low |
+| [Factor 3] | [Description] | High/Medium/Low |
+
+[Additional explanation]
+
+## [Secondary Keyword or Main Section 3]
+
+[Content for this section]
+
+**Best practices**:
+1. [Practice 1 with explanation]
+2. [Practice 2 with explanation]
+3. [Practice 3 with explanation]
+
+## Common Mistakes to Avoid
+
+### [Mistake 1]
+
+[Why this is a mistake]
+[How to avoid it]
+
+### [Mistake 2]
+
+[Why this is a mistake]
+[How to avoid it]
+
+### [Mistake 3]
+
+[Why this is a mistake]
+[How to avoid it]
+
+## Frequently Asked Questions
+
+### [Question from PAA or common query]?
+
+[Direct answer in 40-60 words]
+
+[Optional: Additional context]
+
+### [Question 2]?
+
+[Direct answer]
+
+### [Question 3]?
+
+[Direct answer]
+
+## Conclusion
+
+[Recap key points - include primary keyword]
+
+[Final insight or takeaway]
+
+[Clear call-to-action: next step for reader]
+
+---
+
+**Related reading**: [Internal link 1] | [Internal link 2] | [Internal link 3]
+```
+
+**Internal links**: 3-5 contextual links to related content
+**External links**: 2-3 links to authoritative sources
+**Keywords**: Primary in H1, first 100 words, conclusion; secondary in H2s
+**GEO-optimization**: Definition block, FAQ section, quotable statistics
+
+---
+
+## Comparison Article ("[A] vs [B]")
+
+**Target word count**: 1,500-2,500 words
+**Primary goal**: Help readers choose between options, rank for comparison queries
+
+```markdown
+# [Option A] vs [Option B]: Which Is Better for [Use Case]?
+
+[Hook - present the comparison dilemma]
+
+[Context - why this comparison matters]
+
+[Promise - what readers will understand after reading]
+
+**Quick answer**: [Brief verdict for those who want immediate answer]
+
+## [Option A] vs [Option B]: Quick Comparison
+
+| Feature | [Option A] | [Option B] |
+|---------|-----------|-----------|
+| [Feature 1] | [Details] | [Details] |
+| [Feature 2] | [Details] | [Details] |
+| [Feature 3] | [Details] | [Details] |
+| [Feature 4] | [Details] | [Details] |
+| **Best for** | [Use case] | [Use case] |
+| **Starting price** | [Price] | [Price] |
+
+## What Is [Option A]?
+
+[Clear definition]
+
+[Key features and capabilities]
+
+**Pros**:
+- [Advantage 1]
+- [Advantage 2]
+- [Advantage 3]
+
+**Cons**:
+- [Disadvantage 1]
+- [Disadvantage 2]
+
+## What Is [Option B]?
+
+[Clear definition]
+
+[Key features and capabilities]
+
+**Pros**:
+- [Advantage 1]
+- [Advantage 2]
+- [Advantage 3]
+
+**Cons**:
+- [Disadvantage 1]
+- [Disadvantage 2]
+
+## [Option A] vs [Option B]: Detailed Comparison
+
+### [Feature Category 1]
+
+**[Option A]**: [Detailed explanation with examples]
+
+**[Option B]**: [Detailed explanation with examples]
+
+**Winner**: [A/B/Tie] - [Brief reasoning]
+
+### [Feature Category 2]
+
+**[Option A]**: [Details]
+
+**[Option B]**: [Details]
+
+**Winner**: [A/B/Tie] - [Brief reasoning]
+
+### [Feature Category 3]
+
+[Continue pattern for key comparison points]
+
+## Pricing: [Option A] vs [Option B]
+
+### [Option A] Pricing
+
+- [Tier 1]: $[X]/month - [What's included]
+- [Tier 2]: $[X]/month - [What's included]
+- [Tier 3]: $[X]/month - [What's included]
+
+### [Option B] Pricing
+
+- [Tier 1]: $[X]/month - [What's included]
+- [Tier 2]: $[X]/month - [What's included]
+- [Tier 3]: $[X]/month - [What's included]
+
+**Value winner**: [Which offers better value and why]
+
+## Which Should You Choose?
+
+### Choose [Option A] if:
+- [Criteria 1]
+- [Criteria 2]
+- [Criteria 3]
+
+### Choose [Option B] if:
+- [Criteria 1]
+- [Criteria 2]
+- [Criteria 3]
+
+## Frequently Asked Questions
+
+### Is [Option A] better than [Option B]?
+
+[Nuanced answer - depends on use case]
+
+### Can you use [Option A] and [Option B] together?
+
+[Answer with explanation]
+
+### Which is easier for beginners: [Option A] or [Option B]?
+
+[Answer with reasoning]
+
+## Final Verdict: [Option A] vs [Option B]
+
+[Balanced conclusion]
+
+[Recommendation based on different use cases]
+
+[Clear call-to-action]
+```
+
+**Internal links**: Links to detailed reviews of each option, related comparisons
+**External links**: Official websites of both options, third-party reviews
+**Keywords**: Both options mentioned throughout, comparison keywords in H2s
+
+---
+
+## Listicle ("Top N [Items]")
+
+**Target word count**: 1,500-2,500 words (depending on list length)
+**Primary goal**: Provide curated recommendations, rank for "best [topic]" queries
+
+```markdown
+# [Number] Best [Items] for [Audience/Purpose] ([Year])
+
+[Hook - establish why this list matters]
+
+[Context - what makes these the "best"]
+
+[Promise - what readers will gain]
+
+After testing [X] different [items], we've identified the [number] that deliver the best results for [audience/purpose].
+
+## Quick Summary: Top [Number] [Items]
+
+| Rank | [Item] | Best For | Price |
+|------|--------|----------|-------|
+| 1 | [Item 1] | [Use case] | [Price] |
+| 2 | [Item 2] | [Use case] | [Price] |
+| 3 | [Item 3] | [Use case] | [Price] |
+
+## How We Chose These [Items]
+
+Our selection criteria:
+- [Criterion 1] - [Why it matters]
+- [Criterion 2] - [Why it matters]
+- [Criterion 3] - [Why it matters]
+
+## 1. [Item Name] - Best for [Specific Use Case]
+
+![Alt text: [Item name] screenshot/image]
+
+**What it is**: [Brief description]
+
+**Why we love it**: [Key benefits and features]
+
+**Key features**:
+- [Feature 1]
+- [Feature 2]
+- [Feature 3]
+
+**Pricing**: [Price details]
+
+**Pros**:
+- [Pro 1]
+- [Pro 2]
+
+**Cons**:
+- [Con 1]
+- [Con 2]
+
+**Best for**: [Ideal user or use case]
+
+[Link to item]
+
+---
+
+## 2. [Item Name] - Best for [Specific Use Case]
+
+[Follow same structure as #1]
+
+---
+
+## 3. [Item Name] - Best for [Specific Use Case]
+
+[Continue pattern for all items in list]
+
+---
+
+## Comparison: Which [Item] Is Right for You?
+
+| Feature | [Item 1] | [Item 2] | [Item 3] |
+|---------|----------|----------|----------|
+| [Feature 1] | ✓/✗ | ✓/✗ | ✓/✗ |
+| [Feature 2] | ✓/✗ | ✓/✗ | ✓/✗ |
+| [Feature 3] | ✓/✗ | ✓/✗ | ✓/✗ |
+
+## How to Choose the Best [Item] for Your Needs
+
+### Consider [Factor 1]
+
+[Explanation of how this factor affects choice]
+
+### Consider [Factor 2]
+
+[Explanation]
+
+### Consider [Factor 3]
+
+[Explanation]
+
+## Frequently Asked Questions
+
+### What is the best [item] for beginners?
+
+[Answer with specific recommendation]
+
+### Which [item] offers the best value?
+
+[Answer with reasoning]
+
+### Are paid [items] better than free ones?
+
+[Balanced answer]
+
+## Conclusion: Our Top Pick
+
+[Restate #1 recommendation]
+
+[Why it's the best overall choice]
+
+[Call-to-action]
+```
+
+**Internal links**: Related guides, comparison articles, category pages
+**External links**: Links to each recommended item, review sources
+**Keywords**: "Best [topic]", individual item names, use case keywords
+
+---
+
+## How-To Guide (Step-by-Step)
+
+**Target word count**: 1,200-2,000 words
+**Primary goal**: Teach a specific process, rank for "how to [task]" queries
+
+```markdown
+# How to [Achieve Goal]: [Number]-Step Guide [for Audience]
+
+[Hook - present the outcome readers want]
+
+[Current problem - why this is challenging]
+
+[Promise - what readers will accomplish]
+
+By following this step-by-step guide, you'll be able to [specific outcome] in [timeframe].
+
+## What You'll Need
+
+**Tools**:
+- [Tool 1]
+- [Tool 2]
+- [Tool 3]
+
+**Time required**: [Estimated time]
+
+**Skill level**: [Beginner/Intermediate/Advanced]
+
+## Why [Task] Matters
+
+[Explain the benefits of completing this task]
+
+[Provide context and importance]
+
+## Step 1: [Action Title]
+
+![Alt text: Screenshot showing step 1]
+
+[Clear instruction for this step]
+
+[Why this step is important]
+
+**How to do it**:
+1. [Sub-step 1]
+2. [Sub-step 2]
+3. [Sub-step 3]
+
+> **Pro Tip**: [Helpful tip to make this step easier]
+
+**Common mistakes**:
+- [Mistake to avoid]
+- [Another mistake]
+
+---
+
+## Step 2: [Action Title]
+
+![Alt text: Screenshot showing step 2]
+
+[Instruction for this step]
+
+[Additional context or explanation]
+
+**Example**: [Concrete example showing this step]
+
+---
+
+## Step 3: [Action Title]
+
+[Continue pattern for all steps]
+
+---
+
+## Step [Final Number]: [Action Title]
+
+[Final step instruction]
+
+[What success looks like]
+
+**You'll know it's working when**: [Success indicators]
+
+---
+
+## What to Do If [Common Problem]
+
+### Problem: [Issue 1]
+
+**Solution**: [How to fix it]
+
+### Problem: [Issue 2]
+
+**Solution**: [How to fix it]
+
+## Advanced Tips
+
+Once you've mastered the basics:
+
+1. **[Advanced tip 1]** - [Explanation]
+2. **[Advanced tip 2]** - [Explanation]
+3. **[Advanced tip 3]** - [Explanation]
+
+## Frequently Asked Questions
+
+### How long does it take to [complete task]?
+
+[Realistic timeframe with variables]
+
+### Do I need [specific tool/skill] to [complete task]?
+
+[Answer with alternatives if applicable]
+
+### What if [specific concern]?
+
+[Address concern with solution]
+
+## Conclusion
+
+[Recap the process]
+
+[Encourage reader to take action]
+
+[Clear next step or CTA]
+
+**Next steps**: [Link to related guide or advanced tutorial]
+```
+
+**Internal links**: Links to tool reviews, prerequisite guides, related tutorials
+**External links**: Tool documentation, additional resources
+**Keywords**: "How to [task]", action words in each step heading
+
+---
+
+## Product Review
+
+**Target word count**: 1,500-2,000 words
+**Primary goal**: Help buyers make informed decisions, rank for "[product] review" queries
+
+```markdown
+# [Product Name] Review: Is It Worth It in [Year]?
+
+[Hook - establish the product's promise or popularity]
+
+[Context - who this review is for]
+
+After using [Product] for [timeframe], here's my honest assessment of whether it lives up to the hype.
+
+## [Product Name] Overview
+
+**What it is**: [Clear description]
+
+**Price**: [Pricing details]
+
+**Best for**: [Ideal user or use case]
+
+**Rating**: ★★★★☆ (4/5)
+
+## Pros and Cons at a Glance
+
+### Pros
+- [Major advantage 1]
+- [Major advantage 2]
+- [Major advantage 3]
+
+### Cons
+- [Main limitation 1]
+- [Main limitation 2]
+
+## What Is [Product Name]?
+
+[Detailed product description]
+
+[Who makes it and company background]
+
+[Product positioning and target market]
+
+## Key Features
+
+### [Feature 1]
+
+[Detailed explanation of feature]
+
+[How it works in practice]
+
+[Our experience with this feature]
+
+### [Feature 2]
+
+[Continue pattern for main features]
+
+## Performance Testing
+
+### [Test Category 1]
+
+**What we tested**: [Testing methodology]
+
+**Results**: [Specific results with data]
+
+**Verdict**: [Assessment]
+
+### [Test Category 2]
+
+[Continue testing pattern]
+
+## Pricing and Plans
+
+| Plan | Price | What's Included |
+|------|-------|-----------------|
+| [Tier 1] | $[X]/month | [Features] |
+| [Tier 2] | $[X]/month | [Features] |
+| [Tier 3] | $[X]/month | [Features] |
+
+**Value assessment**: [Whether pricing is justified]
+
+## [Product] vs Competitors
+
+| Feature | [Product] | [Competitor 1] | [Competitor 2] |
+|---------|-----------|----------------|----------------|
+| [Feature 1] | [Details] | [Details] | [Details] |
+| [Feature 2] | [Details] | [Details] | [Details] |
+| [Feature 3] | [Details] | [Details] | [Details] |
+
+## Who Should Buy [Product]?
+
+### Perfect for:
+- [User type 1] because [reason]
+- [User type 2] because [reason]
+
+### Not ideal for:
+- [User type] because [reason]
+- [User type] because [reason]
+
+## Frequently Asked Questions
+
+### Is [Product] worth the price?
+
+[Honest assessment with context]
+
+### How does [Product] compare to [main competitor]?
+
+[Balanced comparison]
+
+### What's the learning curve like?
+
+[Assessment with timeframe]
+
+## Final Verdict: Should You Buy [Product]?
+
+[Balanced conclusion]
+
+[Specific recommendation based on use case]
+
+**Our rating**: ★★★★☆ (4/5)
+
+[Link to product with disclosure if affiliate]
+```
+
+**Disclosure**: Include affiliate disclosure if applicable
+**Internal links**: Related product reviews, comparison articles, category pages
+**External links**: Product website, official documentation
+
+---
+
+## Pillar Page (Comprehensive Guide)
+
+**Target word count**: 3,000-5,000+ words
+**Primary goal**: Become definitive resource, rank for head terms, support topic cluster
+
+```markdown
+# [Topic]: The Complete Guide [for Audience] ([Year])
+
+[Compelling hook - establish the importance of this topic]
+
+[Problem - what challenges exist in this space]
+
+[Promise - what this comprehensive guide delivers]
+
+This is the most comprehensive guide to [topic] on the web. You'll learn everything from fundamentals to advanced strategies.
+
+## Table of Contents
+
+- [Chapter 1]
+- [Chapter 2]
+- [Chapter 3]
+- [Chapter 4]
+- [Chapter 5]
+
+## Chapter 1: [Topic] Fundamentals
+
+### What Is [Topic]?
+
+[Comprehensive definition - GEO-optimized]
+
+[History and evolution]
+
+[Current state and importance]
+
+### Why [Topic] Matters
+
+[Multiple benefits with evidence]
+
+[Industry statistics and trends]
+
+### Key Concepts and Terminology
+
+| Term | Definition |
+|------|------------|
+| [Term 1] | [Definition] |
+| [Term 2] | [Definition] |
+| [Term 3] | [Definition] |
+
+## Chapter 2: [Major Sub-Topic]
+
+[Comprehensive section on major aspect]
+
+[Multiple sub-sections with H3 headings]
+
+[Examples, case studies, data]
+
+[Internal links to detailed cluster content]
+
+> **Deep dive**: For more on [sub-topic], see our complete guide: [link]
+
+## Chapter 3: [Major Sub-Topic]
+
+[Continue pattern with substantial content sections]
+
+## Chapter 4: [Advanced/Tactical Section]
+
+[Strategic or tactical content]
+
+[Actionable frameworks and processes]
+
+## Chapter 5: [Implementation/Case Studies]
+
+[Real-world applications]
+
+[Success stories]
+
+[Implementation roadmap]
+
+## Frequently Asked Questions
+
+[Comprehensive FAQ section with 8-10 questions]
+
+## Key Takeaways
+
+[Summarize main points from each chapter]
+
+## What's Next?
+
+[Recommend next steps]
+
+[Links to related cluster content]
+
+## Resources
+
+**Recommended tools**:
+- [Tool 1] - [Use case]
+- [Tool 2] - [Use case]
+
+**Further reading**:
+- [Internal link to cluster content]
+- [Internal link to cluster content]
+- [External resource]
+```
+
+**Internal links**: 8-15 links to related cluster content
+**External links**: 5-10 authoritative sources
+**Update frequency**: Quarterly for pillar pages
+
+---
+
+## FAQ Page
+
+**Target word count**: 1,000-1,500 words
+**Primary goal**: Answer common questions, rank for question queries, enable FAQ rich results
+
+```markdown
+# [Topic]: Frequently Asked Questions
+
+[Introduction explaining what questions this page answers]
+
+[Who this FAQ is for]
+
+## General Questions
+
+### What is [topic]?
+
+[Comprehensive answer with definition]
+
+### How does [topic] work?
+
+[Clear explanation with steps if applicable]
+
+### Why is [topic] important?
+
+[Benefits and context]
+
+## Getting Started
+
+### How do I get started with [topic]?
+
+[Step-by-step guidance]
+
+### What do I need to [accomplish goal]?
+
+[Requirements and prerequisites]
+
+### How long does it take to [achieve result]?
+
+[Realistic timeframe]
+
+## Common Problems
+
+### Why isn't [expected result] happening?
+
+[Troubleshooting guidance]
+
+### What should I do if [problem occurs]?
+
+[Solution with steps]
+
+### How do I fix [specific error]?
+
+[Fix instructions]
+
+## Comparison Questions
+
+### [Option A] vs [Option B]: which is better?
+
+[Balanced comparison answer]
+
+### Should I [action 1] or [action 2]?
+
+[Guidance based on scenarios]
+
+## Pricing and Plans
+
+### How much does [topic] cost?
+
+[Pricing information with context]
+
+### Is there a free version?
+
+[Information about free options]
+
+## Advanced Questions
+
+### Can I [advanced action]?
+
+[Answer with technical details]
+
+### How do I [complex task]?
+
+[Guidance with link to detailed guide]
+
+## Still Have Questions?
+
+[Call-to-action for additional support]
+
+[Contact information or link to contact page]
+```
+
+**Schema markup**: Add FAQPage schema to enable rich results
+**Internal links**: Link to detailed guides for complex answers
+**Keywords**: Include question keywords naturally
+
+---
+
+## Implementation Checklist
+
+For any template:
+
+- [ ] Customize all [bracketed placeholders]
+- [ ] Include primary keyword in H1 and first 100 words
+- [ ] Place secondary keywords in H2/H3 headings
+- [ ] Add 3-5 internal links to related content
+- [ ] Include 2-3 external links to authoritative sources
+- [ ] Create GEO-optimized definition blocks
+- [ ] Add FAQ section for featured snippet opportunities
+- [ ] Include relevant images with descriptive alt text
+- [ ] Write compelling meta description (150-160 chars)
+- [ ] Optimize title tag (50-60 chars with primary keyword)
diff --git a/.cursor/skills/seo-content-writer/references/seo-writing-checklist.md b/.cursor/skills/seo-content-writer/references/seo-writing-checklist.md
new file mode 100644
index 0000000000..225e9fe350
--- /dev/null
+++ b/.cursor/skills/seo-content-writer/references/seo-writing-checklist.md
@@ -0,0 +1,250 @@
+# SEO Writing Checklist and Content Template
+
+On-page SEO checklist, content writing template, and featured snippet optimization patterns.
+
+---
+
+## On-Page SEO Checklist
+
+**Keyword Placement**:
+- [ ] Primary keyword in title
+- [ ] Primary keyword in H1
+- [ ] Primary keyword in first 100 words
+- [ ] Primary keyword in at least one H2
+- [ ] Primary keyword in conclusion
+- [ ] Primary keyword in meta description
+- [ ] Secondary keywords in H2s/H3s
+- [ ] Related terms throughout body
+
+**Content Quality**:
+- [ ] Comprehensive coverage of topic
+- [ ] Original insights or data
+- [ ] Actionable takeaways
+- [ ] Examples and illustrations
+- [ ] Expert quotes or citations (for E-E-A-T)
+
+**Readability**:
+- [ ] Paragraphs of 3-5 sentences (per CORE-EEAT O06 Section Chunking standard)
+- [ ] Varied sentence length
+- [ ] Bullet points and lists
+- [ ] Bold key phrases
+- [ ] Table of contents for long content
+
+**Technical**:
+- [ ] Internal links to relevant pages (2-5)
+- [ ] External links to authoritative sources (2-3)
+- [ ] Image alt text with keywords
+- [ ] URL slug includes keyword
+
+---
+
+## Content Writing Template
+
+```markdown
+# [H1 with Primary Keyword]
+
+[Hook sentence that grabs attention]
+
+[Problem statement or context - why this matters]
+
+[Promise - what the reader will learn/gain] [Include primary keyword naturally]
+
+[Brief overview of what's covered - can be bullet points for scanability]
+
+## [H2 - First Main Section with Secondary Keyword]
+
+[Introduction to section - 1-2 sentences]
+
+[Main content with valuable information]
+
+[Examples, data, or evidence to support points]
+
+[Transition to next section]
+
+### [H3 - Sub-section if needed]
+
+[Detailed content]
+
+[Key points in bullet format]:
+- Point 1
+- Point 2
+- Point 3
+
+## [H2 - Second Main Section]
+
+[Continue with valuable content...]
+
+> **Pro Tip**: [Highlighted tip or key insight]
+
+| Column 1 | Column 2 | Column 3 |
+|----------|----------|----------|
+| Data | Data | Data |
+
+## [H2 - Additional Sections as Needed]
+
+[Content...]
+
+## Frequently Asked Questions
+
+### [Question from PAA or common query]?
+
+[Direct, concise answer in 40-60 words for featured snippet opportunity]
+
+### [Question 2]?
+
+[Answer]
+
+### [Question 3]?
+
+[Answer]
+
+## Conclusion
+
+[Summary of key points - include primary keyword]
+
+[Final thought or insight]
+
+[Clear call-to-action: what should reader do next?]
+```
+
+---
+
+## Featured Snippet Optimization
+
+**For Definition Snippets**:
+"[Term] is [clear, concise definition in 40-60 words]"
+
+**For List Snippets**:
+Create clear, numbered or bulleted lists under H2s
+
+**For Table Snippets**:
+Use comparison tables with clear headers
+
+**For How-To Snippets**:
+Number each step clearly: "Step 1:", "Step 2:", etc.
+
+---
+
+## Example: Full SEO Article
+
+**User**: "Write an SEO-optimized article about 'email marketing best practices' targeting small businesses"
+
+**Output**:
+
+```markdown
+# Email Marketing Best Practices: A Complete Guide for Small Businesses
+
+**Meta Description**: Discover proven email marketing best practices that help small businesses increase open rates, boost engagement, and drive sales. Start improving your campaigns today. (158 chars)
+
+---
+
+Want to know the secret behind small businesses that consistently turn subscribers into loyal customers? It's not luck---it's strategic email marketing.
+
+Email marketing remains one of the most effective channels for small businesses, delivering an average ROI of $42 for every $1 spent. But with inboxes more crowded than ever, following email marketing best practices isn't optional---it's essential for survival.
+
+In this guide, you'll learn:
+- How to build a quality email list that converts
+- Proven strategies to increase open and click rates
+- Advanced personalization techniques that drive results
+- Common mistakes that kill email performance
+
+Let's dive into the strategies that will transform your email marketing.
+
+## Why Email Marketing Matters for Small Businesses
+
+Before we explore the best practices, let's understand why email deserves your attention.
+
+Unlike social media where algorithms control who sees your content, email gives you direct access to your audience. You own your email list---no platform can take it away.
+
+**Key email marketing statistics for small businesses**:
+- 81% of SMBs rely on email as their primary customer acquisition channel
+- Email subscribers are 3x more likely to share content on social media
+- Personalized emails generate 6x higher transaction rates
+
+## Building a High-Quality Email List
+
+### Use Strategic Opt-in Incentives
+
+The foundation of effective email marketing is a quality list. Here's how to grow yours:
+
+**Lead magnets that convert**:
+- Industry-specific templates
+- Exclusive discounts or early access
+- Free tools or calculators
+- Educational email courses
+
+> **Pro Tip**: The best lead magnets solve a specific, immediate problem for your target audience.
+
+### Implement Double Opt-in
+
+Double opt-in confirms subscriber intent and improves deliverability. Yes, you'll have fewer subscribers, but they'll be more engaged.
+
+| Single Opt-in | Double Opt-in |
+|---------------|---------------|
+| More subscribers | Fewer subscribers |
+| Higher bounce rates | Lower bounce rates |
+| Lower engagement | Higher engagement |
+| Spam risk | Better deliverability |
+
+## Crafting Emails That Get Opened
+
+### Write Compelling Subject Lines
+
+Your subject line determines whether emails get opened or ignored.
+
+**Subject line best practices**:
+1. Keep it under 50 characters
+2. Create urgency without being spammy
+3. Use numbers and specifics
+4. Personalize when possible
+5. A/B test consistently
+
+### Optimize Preview Text
+
+The preview text (preheader) is your second chance to convince someone to open. Use it to expand on your subject line, not repeat it.
+
+## Personalization Strategies That Work
+
+Generic emails perform poorly. Here's how to personalize effectively:
+
+### Segment Your Email List
+
+Divide your list based on:
+- Purchase history
+- Engagement level
+- Demographics
+- Interests or preferences
+- Customer lifecycle stage
+
+### Use Dynamic Content
+
+Send the same email but show different content blocks based on subscriber data.
+
+## Frequently Asked Questions
+
+### How often should small businesses send marketing emails?
+
+For most small businesses, sending 1-2 emails per week strikes the right balance. This keeps you top-of-mind without overwhelming subscribers. Monitor your unsubscribe rates---if they spike after increasing frequency, scale back.
+
+### What is a good email open rate for small businesses?
+
+A good email open rate for small businesses is 20-25%. However, this varies by industry. E-commerce averages 15-20%, while B2B services often see 25-30%. Focus on improving your own benchmarks rather than industry averages.
+
+### Should I buy email lists to grow faster?
+
+Never buy email lists. Purchased lists violate GDPR and CAN-SPAM regulations, damage your sender reputation, and result in poor engagement. Organic list growth, while slower, produces much better results.
+
+## Conclusion
+
+Implementing these email marketing best practices will help your small business build stronger customer relationships and drive consistent revenue. Remember: quality always beats quantity in email marketing.
+
+Start with one improvement today. Whether it's segmenting your list, testing subject lines, or cleaning inactive subscribers, small changes compound into significant results.
+
+**Ready to level up your email marketing?** Download our free Email Marketing Checklist to ensure every campaign you send follows these best practices.
+
+---
+
+*Further reading: [Internal link: "How to Write Email Copy That Converts"]*
+
+*Sources: [Litmus Email Marketing Statistics](https://litmus.com), [Campaign Monitor Email Benchmarks](https://campaignmonitor.com)*
+```
diff --git a/.cursor/skills/seo-content-writer/references/title-formulas.md b/.cursor/skills/seo-content-writer/references/title-formulas.md
new file mode 100644
index 0000000000..73017696a1
--- /dev/null
+++ b/.cursor/skills/seo-content-writer/references/title-formulas.md
@@ -0,0 +1,339 @@
+# Title and Headline Formulas
+
+Proven title structures that drive clicks and rankings. All formulas target 50-60 characters for optimal SERP display.
+
+## Numbered List Formulas
+
+**Pattern**: [Number] [Adjective] [Topic] [Qualifier/Benefit]
+
+### Examples
+- "7 Ways to Improve Your SEO Rankings in 2024"
+- "15 Content Marketing Strategies That Actually Work"
+- "10 Free Tools for Keyword Research (Beginner-Friendly)"
+- "21 Email Marketing Tips from Industry Experts"
+- "5 Quick Wins for Better Site Performance"
+
+**Why it works**: Numbers promise specific value, create curiosity, set clear expectations.
+
+**Best practices**:
+- Odd numbers (7, 9, 15) often outperform even numbers
+- Use specific numbers (13 vs "over 10")
+- Keep total under 30 for credibility
+- Match number to actual content sections
+
+---
+
+## How-To Formulas
+
+**Pattern 1**: How to [Achieve Goal] in [Timeframe]
+- "How to Rank on Google in 30 Days"
+- "How to Write SEO Content in Under 2 Hours"
+- "How to Build Backlinks in 2024"
+
+**Pattern 2**: How to [Achieve Goal] Without [Common Objection]
+- "How to Learn SEO Without Spending Money"
+- "How to Increase Traffic Without Paid Ads"
+- "How to Write Blog Posts Without Writer's Block"
+
+**Pattern 3**: How to [Achieve Goal] (Even If [Limitation])
+- "How to Rank #1 (Even If You're a New Website)"
+- "How to Get Backlinks (Even With Zero Audience)"
+- "How to Write SEO Content (Even If You Hate Writing)"
+
+**Pattern 4**: How to [Achieve Goal] Like [Authority/Expert]
+- "How to Write Email Copy Like a Professional Copywriter"
+- "How to Do Keyword Research Like an SEO Pro"
+- "How to Create Content Like Top Marketing Agencies"
+
+---
+
+## Question Formulas
+
+**Pattern 1**: What Is [Topic]? [Benefit/Hook]
+- "What Is Technical SEO? A Complete Guide"
+- "What Is GEO? Everything You Need to Know"
+- "What Is Content Marketing? (And Why It Matters)"
+
+**Pattern 2**: Why [Action] [Result]
+- "Why Most Content Marketing Strategies Fail"
+- "Why Your Website Isn't Ranking"
+- "Why Email Still Beats Social Media for ROI"
+
+**Pattern 3**: Should You [Action]? [Quick Answer]
+- "Should You Buy Backlinks? (Spoiler: No)"
+- "Should You Hire an SEO Agency? A Cost-Benefit Analysis"
+- "Should You Rewrite Old Content? When It Makes Sense"
+
+**Pattern 4**: When Should You [Action]
+- "When Should You Publish Blog Posts for Maximum Traffic"
+- "When Should You Update Your SEO Strategy"
+- "When Should You Remove Content from Your Website"
+
+---
+
+## Comparison Formulas
+
+**Pattern 1**: [Option A] vs [Option B]: Which Is Better?
+- "WordPress vs Webflow: Which Is Better for SEO?"
+- "Ahrefs vs SEMrush: Complete Comparison 2024"
+- "In-House SEO vs Agency: Which Should You Choose?"
+
+**Pattern 2**: [Option A] vs [Option B] (and the Winner Is...)
+- "Free Tools vs Paid SEO Software (and the Winner Is...)"
+- "Long-Form vs Short-Form Content (and the Winner Is...)"
+
+**Pattern 3**: [Option A] or [Option B] for [Audience/Goal]
+- "Shopify or WooCommerce for Small Business SEO"
+- "YouTube or TikTok for B2B Marketing"
+
+---
+
+## Ultimate Guide Formulas
+
+**Pattern 1**: The [Complete/Definitive] Guide to [Topic]
+- "The Complete Guide to Technical SEO"
+- "The Definitive Guide to Link Building in 2024"
+
+**Pattern 2**: [Topic]: The Ultimate Guide for [Audience]
+- "Content Marketing: The Ultimate Guide for Beginners"
+- "Local SEO: The Ultimate Guide for Small Businesses"
+
+**Pattern 3**: Everything You Need to Know About [Topic]
+- "Everything You Need to Know About Core Web Vitals"
+- "Everything You Need to Know About Schema Markup"
+
+---
+
+## Power Word Formulas
+
+**Pattern**: [Power Word] [Topic] [Qualifier]
+
+### Power Word Categories
+
+**Authority words**:
+- Proven, Tested, Verified, Certified, Official
+- Example: "Proven Strategies to Double Your Organic Traffic"
+
+**Urgency words**:
+- Now, Today, Fast, Quick, Instantly, Immediately
+- Example: "Quick Wins: Improve SEO Performance Today"
+
+**Value words**:
+- Free, Complete, Essential, Ultimate, Comprehensive
+- Example: "The Essential SEO Checklist for 2024"
+
+**Exclusivity words**:
+- Secret, Hidden, Little-Known, Insider, Exclusive
+- Example: "7 Little-Known SEO Tactics That Actually Work"
+
+**Emotional words**:
+- Amazing, Powerful, Effortless, Simple, Easy
+- Example: "Simple SEO Strategies for Busy Entrepreneurs"
+
+---
+
+## Before/After Formulas
+
+**Pattern 1**: From [Bad State] to [Good State]
+- "From Zero to 10K Monthly Visitors: A Case Study"
+- "From Page 5 to Position 1: How We Ranked a Competitive Keyword"
+
+**Pattern 2**: How I/We [Achieved Result] in [Timeframe]
+- "How I Increased Organic Traffic by 300% in 6 Months"
+- "How We Built 100 Backlinks in 30 Days"
+
+**Pattern 3**: [Bad State] → [Good State]: [Method]
+- "500 Visitors → 50K: Our Content Strategy Revealed"
+- "Struggling → Thriving: SEO Transformation Story"
+
+---
+
+## Mistake/Problem Formulas
+
+**Pattern 1**: [Number] [Topic] Mistakes [Qualifier]
+- "5 SEO Mistakes Killing Your Rankings"
+- "10 Content Marketing Mistakes to Avoid in 2024"
+- "3 Technical SEO Mistakes Even Pros Make"
+
+**Pattern 2**: Stop [Wrong Action]. Do [Right Action] Instead
+- "Stop Keyword Stuffing. Do This Instead"
+- "Stop Buying Backlinks. Try These 7 Tactics Instead"
+
+**Pattern 3**: Why Your [Topic] Isn't Working (And How to Fix It)
+- "Why Your Content Isn't Ranking (And How to Fix It)"
+- "Why Your SEO Strategy Isn't Working (And How to Fix It)"
+
+---
+
+## Checklist Formulas
+
+**Pattern 1**: [Topic] Checklist: [Number] Must-Have [Items]
+- "Technical SEO Checklist: 23 Must-Check Items"
+- "Blog Post Checklist: 15 Things to Check Before Publishing"
+
+**Pattern 2**: The [Timeframe] [Topic] Checklist
+- "The 2024 SEO Audit Checklist"
+- "The Pre-Launch Website Checklist"
+
+---
+
+## Curiosity Gap Formulas
+
+**Pattern 1**: The [Adjective] Reason [Unexpected Fact]
+- "The Surprising Reason Long Content Ranks Better"
+- "The Real Reason Your Blog Posts Aren't Getting Traffic"
+
+**Pattern 2**: [Number] Things About [Topic] That [Surprising Fact]
+- "7 Things About SEO That Google Doesn't Tell You"
+- "5 Things About Content Marketing That Changed in 2024"
+
+**Pattern 3**: What [Expert/Group] Know About [Topic] (That You Don't)
+- "What Top SEO Agencies Know About Link Building (That You Don't)"
+- "What Professional Copywriters Know About Headlines"
+
+---
+
+## Benefit-Driven Formulas
+
+**Pattern 1**: [Action] to [Specific Benefit]
+- "Optimize Your Title Tags to Double Your CTR"
+- "Write Better Meta Descriptions to Increase Organic Traffic"
+
+**Pattern 2**: [Action] for [Outcome] Without [Objection]
+- "Scale Content Production for Better Rankings Without Hiring Writers"
+- "Improve Site Speed for Higher Rankings Without Technical Skills"
+
+---
+
+## Authority/Credibility Formulas
+
+**Pattern 1**: [Expert/Authority] Says [Action/Tip]
+- "Google's John Mueller Says Internal Linking Is Underrated"
+- "SEO Experts Reveal Their Top Ranking Factor for 2024"
+
+**Pattern 2**: [Number] [Experts/Companies] Share [Topic]
+- "50 SEO Experts Share Their #1 Ranking Tip"
+- "10 Successful Brands Share Their Content Strategy"
+
+---
+
+## CTR Optimization Patterns
+
+### Elements That Increase CTR
+
+**Year/Timeframe** (signals freshness):
+- "SEO Trends for 2024"
+- "Best Practices [Updated January 2024]"
+
+**Brackets/Parentheticals** (adds extra context):
+- "Email Marketing Tips (That Actually Work)"
+- "SEO Tools [Free & Paid Options]"
+
+**Numbers** (promise specific value):
+- "7 Ways...", "15 Tools...", "3 Reasons..."
+
+**Power words** (emotional trigger):
+- Ultimate, Essential, Complete, Proven, Secret
+
+**Target audience** (relevance signal):
+- "...for Beginners", "...for Small Businesses", "...for B2B"
+
+### Elements That Decrease CTR
+
+- All caps (looks spammy)
+- Excessive punctuation (!!!, ???)
+- Clickbait without substance
+- Misleading promises
+- Generic titles ("SEO Tips", "Marketing Guide")
+
+---
+
+## Title Length Guidelines
+
+### Google SERP
+- **Optimal**: 50-60 characters
+- **Maximum**: ~70 characters before truncation
+- **Mobile**: ~55 characters (truncates earlier)
+
+### Social Media
+- **Twitter/X**: 70-100 characters for best engagement
+- **Facebook**: 40-50 characters (shorter performs better)
+- **LinkedIn**: 70-100 characters
+- **Pinterest**: 40-60 characters
+
+### Email Subject Lines
+- **Optimal**: 40-50 characters
+- **Mobile**: 30-40 characters (truncates earlier)
+- **Desktop**: Up to 60 characters
+
+---
+
+## Before/After Title Examples
+
+### Generic → Optimized
+
+**Before**: "SEO Tips"
+**After**: "7 SEO Tips That Increased Our Traffic by 300%"
+- Added: Number, specificity, proof
+
+**Before**: "How to Do Keyword Research"
+**After**: "How to Do Keyword Research (Free Tools & 5-Step Process)"
+- Added: Benefit, specificity, process indicator
+
+**Before**: "Content Marketing Guide"
+**After**: "The Complete Content Marketing Guide for Small Businesses [2024]"
+- Added: Completeness indicator, target audience, year
+
+**Before**: "What Is Technical SEO"
+**After**: "What Is Technical SEO? A Beginner's Guide to Better Rankings"
+- Added: Audience level, benefit
+
+**Before**: "WordPress vs Shopify"
+**After**: "WordPress vs Shopify: Which Is Better for SEO? (2024 Comparison)"
+- Added: Specific comparison angle, year
+
+**Before**: "Link Building Strategies"
+**After**: "15 White-Hat Link Building Strategies That Still Work in 2024"
+- Added: Number, quality signal, time relevance
+
+---
+
+## Title Writing Checklist
+
+Before finalizing any title, check:
+
+- [ ] Includes primary keyword naturally
+- [ ] Length is 50-60 characters
+- [ ] Matches search intent (informational/transactional/navigational)
+- [ ] Contains power word or emotional trigger
+- [ ] Specific and concrete (not vague)
+- [ ] Promises clear value or benefit
+- [ ] Accurate representation of content
+- [ ] No clickbait or misleading claims
+- [ ] Unique compared to competitors
+- [ ] Works when truncated on mobile
+
+---
+
+## Testing and Optimization
+
+### A/B Test These Variables
+- Number variations (7 vs 10 vs 15)
+- Power word placement (beginning vs end)
+- Question vs statement format
+- With/without year
+- With/without brackets
+- Different benefit framing
+
+### Track These Metrics
+- Click-through rate (CTR) in Search Console
+- Average position in SERPs
+- Time on page (engagement indicator)
+- Bounce rate (relevance indicator)
+
+### When to Rewrite Titles
+- CTR below 2% for top 3 positions
+- CTR below 5% for positions 4-10
+- Content is updated (add [Updated 2024])
+- Competitors have better titles
+- Ranking but not getting clicks
diff --git a/.cursor/skills/serp-analysis/SKILL.md b/.cursor/skills/serp-analysis/SKILL.md
new file mode 100644
index 0000000000..d9b35be636
--- /dev/null
+++ b/.cursor/skills/serp-analysis/SKILL.md
@@ -0,0 +1,284 @@
+---
+name: serp-analysis
+description: 'Analyze SERPs: ranking factors, features, intent patterns, AI overviews, featured snippets. SERP分析/搜索结果'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+allowed-tools: WebFetch
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when analyzing search engine results pages, SERP features, featured snippets, People Also Ask, or understanding ranking patterns for a query."
+argument-hint: ""
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "high"
+ tags:
+ - seo
+ - geo
+ - serp-analysis
+ - serp-features
+ - featured-snippet
+ - ai-overview
+ - people-also-ask
+ - search-intent
+ - SERP分析
+ - 検索結果分析
+ - 검색결과
+ - analisis-serp
+ triggers:
+ # EN-formal
+ - "analyze search results"
+ - "SERP analysis"
+ - "what ranks for"
+ - "SERP features"
+ - "why does this page rank"
+ - "featured snippets"
+ - "AI overviews"
+ # EN-casual
+ - "what's on page one for this query"
+ - "who ranks for this keyword"
+ - "what does Google show for"
+ - "what shows up for this search"
+ - "who is on page one"
+ # EN-question
+ - "why does this page rank first"
+ - "what SERP features appear for"
+ # ZH-pro
+ - "SERP分析"
+ - "搜索结果分析"
+ - "精选摘要"
+ - "AI概览"
+ # ZH-casual
+ - "谁排第一"
+ - "搜索结果长什么样"
+ - "谁排在前面"
+ # JA
+ - "検索結果ページ分析"
+ - "検索結果分析"
+ - "強調スニペット"
+ # KO
+ - "검색 결과 분석"
+ - "SERP 분석"
+ # ES
+ - "análisis SERP"
+ - "análisis de resultados de búsqueda"
+ # PT
+ - "análise de SERP"
+ # Misspellings
+ - "serp anaylsis"
+---
+
+# SERP Analysis
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill analyzes Search Engine Results Pages to reveal what's working for ranking content, which SERP features appear, and what triggers AI-generated answers. Understand the battlefield before creating content.
+
+**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
+
+- Before creating content for a target keyword
+- Understanding why certain pages rank #1
+- Identifying SERP feature opportunities (featured snippets, PAA)
+- Analyzing AI Overview/SGE patterns
+- Evaluating keyword difficulty more accurately
+- Planning content format based on what ranks
+- Identifying ranking factors for specific queries
+
+## What This Skill Does
+
+1. **SERP Composition Analysis**: Maps what appears on the results page
+2. **Ranking Factor Identification**: Reveals why top results rank
+3. **SERP Feature Mapping**: Identifies featured snippets, PAA, knowledge panels
+4. **AI Overview Analysis**: Examines when and how AI answers appear
+5. **Intent Signal Detection**: Confirms user intent from SERP composition
+6. **Content Format Recommendations**: Suggests optimal format based on SERP
+7. **Difficulty Assessment**: Evaluates realistic ranking potential
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Basic SERP Analysis
+
+```
+Analyze the SERP for [keyword]
+```
+
+```
+What does it take to rank for [keyword]?
+```
+
+### Feature-Specific Analysis
+
+```
+Analyze featured snippet opportunities for [keyword list]
+```
+
+```
+Which of these keywords trigger AI Overviews? [keyword list]
+```
+
+### Competitive SERP Analysis
+
+```
+Why does [URL] rank #1 for [keyword]?
+```
+
+## Skill Contract
+
+**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
+
+- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
+- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
+- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
+
+## Data Sources
+
+> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~SEO tool + ~~search console + ~~AI monitor connected:**
+Automatically fetch SERP snapshots for target keywords, extract ranking page metrics (domain authority, backlinks, content length), pull SERP feature data, and check AI Overview presence using ~~AI monitor. Historical SERP change data and mobile vs. desktop variations can be retrieved automatically.
+
+**With manual data only:**
+Ask the user to provide:
+1. Target keyword(s) to analyze
+2. SERP screenshots or detailed descriptions of search results
+3. URLs of top 10 ranking pages
+4. Search location and device type (mobile/desktop)
+5. Any observations about SERP features (featured snippets, PAA, AI Overviews)
+
+Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
+
+## Instructions
+
+When a user requests SERP analysis:
+
+1. **Understand the Query**
+
+ Clarify if needed:
+ - Target keyword(s) to analyze
+ - Search location/language
+ - Device type (mobile/desktop)
+ - Specific questions about the SERP
+
+2. **Map SERP Composition**
+
+ Document all elements appearing on the results page: AI Overview, ads, featured snippet, organic results, PAA, knowledge panel, image pack, video results, local pack, shopping results, news results, sitelinks, and related searches.
+
+3. **Analyze Top Ranking Pages**
+
+ For each of the top 10 results, document: URL, domain, domain authority, content type, word count, publish/update dates, on-page factors (title, meta description, H1, URL structure), content structure (headings, media, tables, FAQ), estimated metrics (backlinks, referring domains), and why it ranks.
+
+4. **Identify Ranking Patterns**
+
+ Analyze common characteristics across top 5 results: word count, domain authority, backlinks, content freshness, HTTPS, mobile optimization. Document content format distribution, domain type distribution, and key success factors.
+
+5. **Analyze SERP Features**
+
+ For each present SERP feature: analyze the current holder, content format, and strategy to win. Cover Featured Snippet (type, content, winning strategy), PAA (questions, current answers, optimization approach), and AI Overview (sources cited, content patterns, citation strategy).
+
+6. **Determine Search Intent**
+
+ Confirm primary intent from SERP composition. Document evidence, intent breakdown percentages, and content format implications (format, tone, CTA).
+
+7. **Calculate True Difficulty**
+
+ Score overall difficulty (1-100) based on: top 10 domain authority, page authority, backlinks required, content quality bar, and SERP stability. Provide realistic assessments for new, growing, and established sites, plus easier alternatives.
+
+8. **Generate Recommendations**
+
+ Produce a summary with: Key Findings, Content Requirements to Rank (minimum requirements + differentiators), SERP Feature Strategy, Recommended Content Outline, and Next Steps.
+
+ > **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/analysis-templates.md) for detailed templates for each step.
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Target keyword(s) clearly specified
+- [ ] Search location and device type confirmed
+- [ ] SERP data is current (date confirmed)
+- [ ] Top 10 ranking URLs identified or provided
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] SERP composition mapped with all features documented
+- [ ] Ranking factors identified from actual top 10 analysis (not assumptions)
+- [ ] Content requirements based on observed patterns in current SERP
+- [ ] Source of each data point clearly stated (~~SEO tool data, ~~AI monitor data, user-provided, or manual observation)
+
+## Example
+
+> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/example-report.md) for a complete example analyzing the SERP for "how to start a podcast".
+
+## Advanced Analysis
+
+### Multi-Keyword SERP Comparison
+
+```
+Compare SERPs for [keyword 1], [keyword 2], [keyword 3]
+```
+
+### Historical SERP Changes
+
+```
+How has the SERP for [keyword] changed over time?
+```
+
+### Local SERP Variations
+
+```
+Compare SERP for [keyword] in [location 1] vs [location 2]
+```
+
+### Mobile vs Desktop SERP
+
+```
+Analyze mobile vs desktop SERP differences for [keyword]
+```
+
+## Tips for Success
+
+1. **Always check SERP before writing** - Don't assume, verify
+2. **Match content format to SERP** - If lists rank, write lists
+3. **Identify SERP feature opportunities** - Lower competition than #1
+4. **Note SERP volatility** - Stable SERPs are harder to break into
+5. **Study the outliers** - Why does a weaker site rank? Opportunity!
+6. **Consider AI Overview optimization** - Growing importance
+
+
+
+### Save Results
+
+After delivering findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/research/serp-analysis/YYYY-MM-DD-.md` containing:
+- One-line headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
+
+## Reference Materials
+
+- [Analysis Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/analysis-templates.md) — Detailed templates for each analysis step (SERP composition, top results, ranking patterns, features, intent, difficulty, recommendations)
+- [SERP Feature Taxonomy](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/serp-feature-taxonomy.md) — Complete taxonomy of SERP features with trigger conditions, AI overview framework, intent signals, and volatility assessment
+- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/example-report.md) — Complete example analyzing the SERP for "how to start a podcast"
+
+## Next Best Skill
+
+- **Primary**: [seo-content-writer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/SKILL.md) — turn SERP patterns into a content brief or page structure.
diff --git a/.cursor/skills/serp-analysis/references/analysis-templates.md b/.cursor/skills/serp-analysis/references/analysis-templates.md
new file mode 100644
index 0000000000..8f1a030b29
--- /dev/null
+++ b/.cursor/skills/serp-analysis/references/analysis-templates.md
@@ -0,0 +1,297 @@
+# SERP Analysis — Analysis Templates
+
+Templates for each step of the SERP analysis workflow. Use these to structure your output.
+
+## SERP Composition Template
+
+```markdown
+## SERP Analysis: "[keyword]"
+
+**Search Details**
+- Keyword: [keyword]
+- Location: [location]
+- Device: [mobile/desktop]
+- Date: [date]
+
+### SERP Layout Overview
+
+```
+┌─────────────────────────────────────────┐
+│ [AI Overview / SGE] (if present) │
+├─────────────────────────────────────────┤
+│ [Ads] - [X] ads above fold │
+├─────────────────────────────────────────┤
+│ [Featured Snippet] (if present) │
+├─────────────────────────────────────────┤
+│ [Organic Result #1] │
+│ [Organic Result #2] │
+│ [People Also Ask] (if present) │
+│ [Organic Result #3] │
+│ ... │
+├─────────────────────────────────────────┤
+│ [Related Searches] │
+└─────────────────────────────────────────┘
+```
+
+### SERP Features Present
+
+| Feature | Present | Position | Opportunity |
+|---------|---------|----------|-------------|
+| AI Overview | Yes/No | Top | [analysis] |
+| Featured Snippet | Yes/No | [pos] | [analysis] |
+| People Also Ask | Yes/No | [pos] | [analysis] |
+| Knowledge Panel | Yes/No | Right | [analysis] |
+| Image Pack | Yes/No | [pos] | [analysis] |
+| Video Results | Yes/No | [pos] | [analysis] |
+| Local Pack | Yes/No | [pos] | [analysis] |
+| Shopping Results | Yes/No | [pos] | [analysis] |
+| News Results | Yes/No | [pos] | [analysis] |
+| Sitelinks | Yes/No | [pos] | [analysis] |
+```
+
+## Top Results Analysis Template
+
+```markdown
+### Top 10 Organic Results Analysis
+
+#### Position #1: [Title]
+
+**URL**: [url]
+**Domain**: [domain]
+**Domain Authority**: [DA]
+
+**Content Analysis**:
+- Type: [Blog/Product/Guide/etc.]
+- Word Count: [X] words
+- Publish Date: [date]
+- Last Updated: [date]
+
+**On-Page Factors**:
+- Title: [exact title]
+- Title contains keyword: Yes/No
+- Meta description: [description]
+- H1: [heading]
+- URL structure: [clean/keyword-rich/etc.]
+
+**Content Structure**:
+- Headings (H2s): [list key sections]
+- Media: [X] images, [X] videos
+- Tables/Lists: Yes/No
+- FAQ section: Yes/No
+
+**Estimated Metrics**:
+- Page backlinks: [X]
+- Referring domains: [X]
+- Social shares: [X]
+
+**Why It Ranks #1**:
+1. [Factor 1]
+2. [Factor 2]
+3. [Factor 3]
+
+[Repeat for positions #2-10]
+```
+
+## Ranking Patterns Template
+
+```markdown
+### Ranking Patterns Analysis
+
+**Common Characteristics of Top 5 Results**:
+
+| Factor | Avg/Common Value | Importance |
+|--------|-----------------|------------|
+| Word Count | [X] words | High/Med/Low |
+| Domain Authority | [X] | High/Med/Low |
+| Page Backlinks | [X] | High/Med/Low |
+| Content Freshness | [timeframe] | High/Med/Low |
+| HTTPS | [X]% | High/Med/Low |
+| Mobile Optimized | [X]% | High/Med/Low |
+
+**Content Format Distribution**:
+- How-to guides: [X]/10
+- Listicles: [X]/10
+- In-depth articles: [X]/10
+- Product pages: [X]/10
+- Other: [X]/10
+
+**Domain Type Distribution**:
+- Brand/Company sites: [X]/10
+- Media/News sites: [X]/10
+- Niche blogs: [X]/10
+- Aggregators: [X]/10
+
+**Key Success Factors Identified**:
+
+1. **[Factor 1]**: [Explanation + evidence]
+2. **[Factor 2]**: [Explanation + evidence]
+3. **[Factor 3]**: [Explanation + evidence]
+```
+
+## SERP Features Analysis Template
+
+```markdown
+### Featured Snippet Analysis
+
+**Current Snippet Holder**: [URL]
+**Snippet Type**: [Paragraph/List/Table/Video]
+**Snippet Content**:
+> [Exact text/description of snippet]
+
+**How to Win This Snippet**:
+1. [Strategy based on current snippet]
+2. [Content format recommendation]
+3. [Structure recommendation]
+
+---
+
+### People Also Ask (PAA) Analysis
+
+**Questions Appearing**:
+1. [Question 1] → Currently answered by: [URL]
+2. [Question 2] → Currently answered by: [URL]
+3. [Question 3] → Currently answered by: [URL]
+4. [Question 4] → Currently answered by: [URL]
+
+**PAA Optimization Strategy**:
+- Include these questions as H2/H3 headings
+- Provide direct, concise answers (40-60 words)
+- Use FAQ schema markup
+
+---
+
+### AI Overview Analysis
+
+**AI Overview Present**: Yes/No
+**AI Overview Type**: [Summary/List/Comparison/etc.]
+
+**Sources Cited in AI Overview**:
+1. [Source 1] - [Why cited]
+2. [Source 2] - [Why cited]
+3. [Source 3] - [Why cited]
+
+**AI Overview Content Patterns**:
+- Pulls definitions from: [source type]
+- Lists information as: [format]
+- Cites statistics from: [source type]
+
+**How to Get Cited in AI Overview**:
+1. [Specific recommendation]
+2. [Specific recommendation]
+3. [Specific recommendation]
+```
+
+## Search Intent Template
+
+```markdown
+### Search Intent Analysis
+
+**Primary Intent**: [Informational/Commercial/Transactional/Navigational]
+
+**Evidence**:
+- SERP features suggest: [analysis]
+- Top results are: [content types]
+- User likely wants: [description]
+
+**Intent Breakdown**:
+- Informational signals: [X]%
+- Commercial signals: [X]%
+- Transactional signals: [X]%
+
+**Content Format Implication**:
+Based on intent, your content should:
+- Format: [recommendation]
+- Tone: [recommendation]
+- CTA: [recommendation]
+```
+
+## Difficulty Assessment Template
+
+```markdown
+### Difficulty Assessment
+
+**Overall Difficulty Score**: [X]/100
+
+**Difficulty Factors**:
+
+| Factor | Score | Weight | Impact |
+|--------|-------|--------|--------|
+| Top 10 Domain Authority | [avg] | 25% | [High/Med/Low] |
+| Top 10 Page Authority | [avg] | 20% | [High/Med/Low] |
+| Backlinks Required | [est.] | 20% | [High/Med/Low] |
+| Content Quality Bar | [rating] | 20% | [High/Med/Low] |
+| SERP Stability | [rating] | 15% | [High/Med/Low] |
+
+**Realistic Assessment**:
+
+- **New site (DA <20)**: [Can rank?] [Timeframe]
+- **Growing site (DA 20-40)**: [Can rank?] [Timeframe]
+- **Established site (DA 40+)**: [Can rank?] [Timeframe]
+
+**Easier Alternatives**:
+If too difficult, consider:
+- [Alternative keyword 1] - Difficulty: [X]
+- [Alternative keyword 2] - Difficulty: [X]
+```
+
+## Recommendations Template
+
+```markdown
+## SERP Analysis Summary & Recommendations
+
+### Key Findings
+
+1. [Most important finding]
+2. [Second important finding]
+3. [Third important finding]
+
+### Content Requirements to Rank
+
+To compete for "[keyword]", you need:
+
+**Minimum Requirements**:
+- [ ] Word count: [X]+ words
+- [ ] Backlinks: [X]+ referring domains
+- [ ] Domain Authority: [X]+
+- [ ] Content format: [type]
+- [ ] Include: [specific elements]
+
+**Differentiators to Win**:
+- [ ] [Unique angle from analysis]
+- [ ] [Missing element in current results]
+- [ ] [SERP feature opportunity]
+
+### SERP Feature Strategy
+
+| Feature | Winnable? | Strategy |
+|---------|-----------|----------|
+| Featured Snippet | Yes/No | [strategy] |
+| PAA | Yes/No | [strategy] |
+| AI Overview | Yes/No | [strategy] |
+
+### Recommended Content Outline
+
+Based on SERP analysis:
+
+```
+Title: [Optimized title]
+
+H1: [Main heading]
+
+[Introduction - address intent immediately]
+
+H2: [Section based on PAA/top results]
+H2: [Section based on PAA/top results]
+H2: [Section based on PAA/top results]
+
+[FAQ section for PAA optimization]
+
+[Conclusion with CTA]
+```
+
+### Next Steps
+
+1. [Immediate action]
+2. [Content creation action]
+3. [Optimization action]
+```
diff --git a/.cursor/skills/serp-analysis/references/example-report.md b/.cursor/skills/serp-analysis/references/example-report.md
new file mode 100644
index 0000000000..772b5c8847
--- /dev/null
+++ b/.cursor/skills/serp-analysis/references/example-report.md
@@ -0,0 +1,85 @@
+# SERP Analysis — Example Report
+
+This is a complete example output showing the expected format for SERP analysis deliverables.
+
+## Prompt
+
+**User**: "Analyze the SERP for 'how to start a podcast'"
+
+## Example Output
+
+```markdown
+# SERP Analysis: "how to start a podcast"
+
+## SERP Overview
+
+**Intent**: Informational (100%)
+**Difficulty**: 72/100 (High)
+**AI Overview**: Yes - comprehensive guide format
+
+### SERP Features Present
+
+| Feature | Present | Analysis |
+|---------|---------|----------|
+| AI Overview | ✅ | Lists steps, cites 3 sources |
+| Featured Snippet | ✅ | Step-by-step list from Buzzsprout |
+| People Also Ask | ✅ | 4 questions visible, expandable |
+| Video Results | ✅ | 3 YouTube videos mid-page |
+| Image Pack | ❌ | |
+
+### Top 5 Results Analysis
+
+| Pos | Domain | DA | Word Count | Format | Backlinks |
+|-----|--------|-----|------------|--------|-----------|
+| 1 | Buzzsprout | 71 | 8,500 | Ultimate Guide | 2,400 |
+| 2 | Spotify | 93 | 3,200 | How-to Guide | 890 |
+| 3 | Podcastinsights | 58 | 12,000 | Mega Guide | 1,800 |
+| 4 | Transistor | 62 | 5,500 | Tutorial | 720 |
+| 5 | HubSpot | 91 | 6,200 | Complete Guide | 1,100 |
+
+### Why #1 Ranks First
+
+Buzzsprout's guide succeeds because:
+1. **Comprehensive** - Covers every step in detail
+2. **Updated** - Current year in title, recent updates
+3. **Structured** - Clear numbered steps (owns featured snippet)
+4. **Authoritative** - Podcast hosting company (topical authority)
+5. **Supporting content** - Links to detailed sub-guides
+
+### Featured Snippet Opportunity
+
+**Current format**: Ordered list (steps)
+**Current holder**: Buzzsprout
+
+**To win snippet**:
+- Create cleaner, more scannable list format
+- Keep steps to 8-10 items max
+- Start each step with action verb
+- Include "how to start a podcast" in H2
+
+### AI Overview Analysis
+
+**Sources cited**:
+1. Buzzsprout - "Choose your podcast topic"
+2. Spotify for Podcasters - "Record and edit"
+3. Wikipedia - Definition of podcasting
+
+**Pattern**: AI pulls step-by-step instructions from guides with clear structure
+
+### Content Requirements
+
+To rank on page 1:
+- **Word count**: 5,000+ words minimum
+- **Format**: Step-by-step ultimate guide
+- **Backlinks**: 500+ from relevant domains
+- **Updates**: Must show current year
+- **Unique angle**: Equipment comparisons, cost breakdowns, or specific niche focus
+
+### Recommended Strategy
+
+Given high difficulty, consider:
+1. Target long-tail: "how to start a podcast for free" (Difficulty: 45)
+2. Target niche: "how to start a podcast about [topic]" (Difficulty: 30)
+3. Create supporting video content for video carousel
+4. Focus on PAA optimization for quick wins
+```
diff --git a/.cursor/skills/serp-analysis/references/serp-feature-taxonomy.md b/.cursor/skills/serp-analysis/references/serp-feature-taxonomy.md
new file mode 100644
index 0000000000..15e16d8610
--- /dev/null
+++ b/.cursor/skills/serp-analysis/references/serp-feature-taxonomy.md
@@ -0,0 +1,404 @@
+# SERP Feature Taxonomy
+
+A comprehensive reference covering every SERP feature type, trigger conditions, optimization techniques, monitoring approaches, and AI Overview patterns. Use this to plan which SERP features to target and how to win them.
+
+## Overview
+
+Modern Search Engine Results Pages are far more than ten blue links. Google displays 20+ distinct feature types that can dramatically affect click-through rates, visibility, and traffic. Understanding which features appear for your target keywords -- and how to optimize for them -- is essential to any SEO or GEO strategy.
+
+---
+
+## SERP Feature Categories
+
+SERP features fall into five broad categories:
+
+| Category | Features | Controlled By |
+|----------|---------|--------------|
+| **Knowledge Features** | Knowledge Panel, AI Overview, Featured Snippet | Content quality + structured data |
+| **Engagement Features** | People Also Ask, Related Searches, Things to Know | Content relevance + question coverage |
+| **Rich Results** | FAQ, How-To, Review Stars, Recipe, Event, Product | Schema markup + content format |
+| **Media Features** | Image Pack, Video Carousel, Web Stories | Media optimization + hosting platform |
+| **Commerce Features** | Shopping Results, Local Pack, Ads | Merchant feeds + Google Business Profile + ad spend |
+
+---
+
+## Complete Feature Reference
+
+### 1. Featured Snippet
+
+**What it is:** An extracted answer displayed at Position 0 (above organic results) in a box.
+
+**Sub-types:**
+
+| Sub-type | Format | Typical Trigger | Example Query |
+|---------|--------|----------------|---------------|
+| Paragraph | 40-60 word text block | "What is", "Why is", definitions | "what is domain authority" |
+| Ordered List | Numbered steps | "How to", process queries | "how to submit a sitemap" |
+| Unordered List | Bulleted list | "Types of", "best", collections | "types of schema markup" |
+| Table | Data in rows/columns | Comparison, data, pricing | "HTTP status codes list" |
+| Video | YouTube clip with timestamp | "How to" with visual component | "how to use Google Search Console" |
+
+**Optimization Playbook:**
+
+1. **Identify snippet-eligible keywords** -- Check if a snippet already exists for your target keyword
+2. **Match the existing format** -- If current snippet is a list, create a list; if paragraph, write a concise paragraph
+3. **Place the answer immediately after the triggering heading** -- Use H2/H3 with the target question, then answer directly below
+4. **Keep paragraph snippets to 40-60 words** -- Concise, complete answers win
+5. **Use proper HTML structure** -- Ordered lists use ``, tables use `
`, not just visual formatting
+6. **Include the target query in the heading** -- The H2/H3 should closely match the search query
+7. **Provide context after the snippet answer** -- Elaborate below to demonstrate depth
+
+**Monitoring:**
+- Track featured snippet ownership weekly for target keywords
+- Monitor snippet format changes (Google may switch from paragraph to list)
+- Watch for snippet loss after content updates
+
+---
+
+### 2. People Also Ask (PAA)
+
+**What it is:** An expandable accordion of related questions with brief answers pulled from web pages.
+
+**Trigger conditions:**
+- Almost all informational queries
+- Many commercial investigation queries
+- Questions beget more questions -- clicking one PAA reveals additional questions
+
+**Optimization Playbook:**
+
+1. **Mine PAA questions for content ideas** -- Each PAA question is a validated search query
+2. **Answer PAA questions within your content** -- Use the exact question as an H2 or H3
+3. **Keep answers concise (40-60 words)** -- PAA answers are short excerpts
+4. **Use FAQ schema markup** -- Increases eligibility for PAA and FAQ rich results
+5. **Create dedicated FAQ sections** -- Group 5-10 related questions at the end of articles
+6. **Target the cascade** -- When users click one PAA, new questions appear; cover those too
+
+**PAA Mining Workflow:**
+1. Search your target keyword
+2. Note all visible PAA questions (4 initially)
+3. Click each one to reveal 2-4 more
+4. Repeat to collect 15-20 related questions
+5. Group questions by subtopic
+6. Create content addressing each cluster
+
+---
+
+### 3. AI Overview (formerly SGE)
+
+**What it is:** An AI-generated summary at the top of the SERP that synthesizes information from multiple sources, with cited links.
+
+**Trigger conditions:**
+- Informational queries (highest trigger rate)
+- Some commercial investigation queries
+- Question-format queries
+- Definitional and explanatory queries
+- Lower trigger rate for navigational and transactional queries
+
+**AI Overview Formats:**
+
+| Format | Description | Trigger Pattern |
+|--------|-----------|----------------|
+| Summary paragraph | Synthesized text answer | Definitional and explanatory queries |
+| Bulleted list | Key points extracted from sources | "Benefits of", "reasons for", multi-factor answers |
+| Step-by-step | Ordered process | "How to" queries |
+| Comparison | Side-by-side analysis | "X vs Y", "difference between" |
+| Table | Structured data comparison | Data comparison, pricing, specifications |
+
+**Optimization Playbook:**
+
+1. **Write clear, citable sentences** -- AI systems extract well-formed statements of fact
+2. **Front-load key information** -- Place the most important answer in the first 1-2 sentences of each section
+3. **Use structured data** -- Schema markup helps AI systems understand your content
+4. **Establish topical authority** -- AI overviews prefer citing authoritative sources on a topic
+5. **Include original data and statistics** -- Unique data points are highly citable
+6. **Create comparison content** -- AI loves to cite well-structured comparison tables
+7. **Update content regularly** -- Recency signals influence AI source selection
+8. **Use clear section headings** -- AI systems use headings to understand content structure
+
+**Source Citation Patterns:**
+
+| What Gets Cited | Why | How to Optimize |
+|----------------|-----|----------------|
+| Definitions | AI needs authoritative definitions | Write clear, complete definitions in first paragraph |
+| Statistics | AI cites specific data points | Include original research, cite sources |
+| Step-by-step processes | AI extracts structured sequences | Use numbered lists with clear step headers |
+| Comparison data | AI synthesizes multi-source comparisons | Create comparison tables with clear labels |
+| Expert quotes | AI values authoritative voices | Include expert attribution with credentials |
+
+---
+
+### 4. Knowledge Panel
+
+**What it is:** A large information box (typically right sidebar on desktop) showing structured entity information from Google's Knowledge Graph.
+
+**Trigger conditions:**
+- Brand/entity queries
+- Notable person queries
+- Place/organization queries
+- Product/service entities
+
+**Optimization Playbook:**
+
+1. **Establish a Google Knowledge Graph entity** -- Ensure your brand exists as a recognized entity
+2. **Claim and verify your Knowledge Panel** -- Use the "Claim this knowledge panel" option
+3. **Maintain consistent NAP** -- Name, Address, Phone across all web properties
+4. **Build Wikipedia presence** -- Knowledge Panels pull heavily from Wikipedia/Wikidata
+5. **Use Organization schema markup** -- Help Google understand your entity
+6. **Maintain active social profiles** -- Connected social accounts appear in Knowledge Panel
+7. **Get featured in authoritative sources** -- Mentions in news, industry publications, and databases
+
+---
+
+### 5. Image Pack
+
+**What it is:** A row of image thumbnails within organic results, linking to Google Images.
+
+**Trigger conditions:**
+- Visual queries ("what does X look like")
+- Product queries
+- Design/inspiration queries
+- Some informational queries with visual components
+
+**Optimization Playbook:**
+
+1. **Use descriptive file names** -- `seo-audit-checklist-template.png` not `IMG_4523.png`
+2. **Write complete alt text** -- Describe the image content and context accurately
+3. **Optimize image file size** -- Compress without losing quality (WebP format preferred)
+4. **Use original images** -- Stock photos rarely rank; original screenshots, diagrams, and photos perform better
+5. **Add image structured data** -- ImageObject schema when applicable
+6. **Place images near relevant text** -- Context from surrounding content helps ranking
+7. **Create image sitemaps** -- Help Google discover all your images
+8. **Use responsive images** -- Serve appropriate sizes for different devices
+
+---
+
+### 6. Video Carousel / Video Results
+
+**What it is:** A horizontal carousel of video thumbnails, typically from YouTube, or individual video results with thumbnails in organic listings.
+
+**Trigger conditions:**
+- "How to" queries
+- Tutorial and instructional queries
+- Entertainment queries
+- Review queries
+- Any query where video content provides superior user experience
+
+**Optimization Playbook:**
+
+1. **Host on YouTube** -- YouTube videos dominate video carousels
+2. **Optimize video title** -- Include target keyword naturally
+3. **Write detailed descriptions** -- First 2-3 lines appear in search; include keywords and summary
+4. **Add chapters/timestamps** -- Key Moments markup helps Google surface specific sections
+5. **Create transcripts** -- Closed captions and transcripts provide indexable text
+6. **Use VideoObject schema** -- On your own site pages embedding video
+7. **Design compelling thumbnails** -- Higher CTR from search results
+8. **Target video-intent keywords** -- "How to" and tutorial queries have highest video potential
+
+---
+
+### 7. Local Pack (Map Pack)
+
+**What it is:** A map with 3 local business listings showing name, rating, address, and hours.
+
+**Trigger conditions:**
+- "[service] near me" queries
+- "[service] in [location]" queries
+- Queries with implicit local intent
+- Service-based business queries
+
+**Optimization Playbook:**
+
+1. **Claim and optimize Google Business Profile** -- Complete every field
+2. **Build consistent local citations** -- NAP consistency across directories
+3. **Collect and respond to reviews** -- Volume and recency of reviews matter
+4. **Add photos regularly** -- Active profiles rank higher
+5. **Use local business schema** -- LocalBusiness structured data on website
+6. **Create location-specific pages** -- If multiple locations, each needs its own page
+7. **Build local backlinks** -- Local news, chambers of commerce, community sites
+8. **Post Google Business updates** -- Regular posts signal activity
+
+---
+
+### 8. Shopping Results
+
+**What it is:** Product listing ads and free product listings with images, prices, and store names.
+
+**Trigger conditions:**
+- Product purchase queries
+- Product name queries
+- "Buy [product]" queries
+- Price comparison queries
+
+**Optimization Playbook:**
+
+1. **Submit product feed to Google Merchant Center** -- Required for shopping results
+2. **Optimize product titles** -- Include key attributes (brand, color, size, model)
+3. **Use high-quality product images** -- White background, multiple angles
+4. **Implement Product schema** -- Structured data for price, availability, reviews
+5. **Keep pricing accurate** -- Mismatches between feed and landing page cause disapproval
+6. **Collect product reviews** -- Aggregate ratings appear in shopping results
+7. **Optimize landing pages** -- Fast, mobile-friendly, clear purchase path
+
+---
+
+### 9. Sitelinks
+
+**What it is:** Additional links beneath a search result that point to specific pages within the same domain.
+
+**Sub-types:**
+
+| Sub-type | Appearance | Trigger |
+|---------|-----------|---------|
+| Full sitelinks | 4-6 two-column links with descriptions | Brand/navigational queries for authoritative sites |
+| Inline sitelinks | 2-4 single-line links | Semi-navigational queries |
+| Search box sitelinks | Site-specific search box | Large, well-structured sites |
+
+**Optimization Playbook:**
+
+1. **Build clear site architecture** -- Logical hierarchy with descriptive navigation
+2. **Use descriptive page titles** -- Each page should have a unique, clear title
+3. **Implement breadcrumb schema** -- Helps Google understand site structure
+4. **Create a comprehensive sitemap** -- XML sitemap submitted to Search Console
+5. **Build internal links** -- Strong internal linking reinforces page importance
+6. **Use SearchAction schema** -- Enables the sitelinks search box
+
+---
+
+### 10. Rich Results (Schema-Dependent)
+
+These features depend on specific structured data markup:
+
+| Rich Result | Schema Required | Content Type | Visual Impact |
+|------------|----------------|-------------|--------------|
+| FAQ | FAQPage | FAQ sections on any page | Expandable Q&A below listing |
+| How-To | HowTo | Step-by-step instructions | Steps with optional images |
+| Review Stars | Review / AggregateRating | Product/service reviews | Star rating in snippet |
+| Recipe | Recipe | Food/cooking content | Image, cook time, calories |
+| Event | Event | Event listings | Date, location, price |
+| Job Posting | JobPosting | Job listings | Salary, location, company |
+| Course | Course | Educational content | Provider, description, rating |
+| Breadcrumb | BreadcrumbList | Any page with hierarchy | Path display replacing URL |
+
+**General Rich Result Optimization:**
+
+1. **Validate with Rich Results Test** -- Test every page before publishing
+2. **Follow Google's structured data guidelines** -- No cloaking or misleading markup
+3. **Keep markup accurate** -- Schema content must match visible page content
+4. **Monitor in Search Console** -- Check Enhancement reports for errors
+5. **Don't over-mark** -- Only add schema for content types genuinely on the page
+
+---
+
+### 11. Related Searches / People Also Search For
+
+**What it is:** Related query suggestions at the bottom of the SERP ("Related searches") or shown after a user clicks a result and returns ("People also search for").
+
+**Value for SEO:**
+- Keyword discovery -- reveals semantically related queries
+- Content gap identification -- topics users explore after your target query
+- Topic cluster planning -- natural subtopics to cover
+
+**How to Use:**
+1. Mine related searches for content ideas and internal linking opportunities
+2. Cover related topics within your content to demonstrate comprehensiveness
+3. Use related search terms as H2/H3 headings in long-form content
+
+---
+
+### 12. "Things to Know" / Key Moments
+
+**What it is:** Carousel cards showing key aspects of a topic, or key moments within a video.
+
+**Trigger conditions:**
+- Broad informational queries
+- Multi-faceted topics
+- Video content with chapters
+
+**Optimization:**
+- Cover multiple aspects of a topic comprehensively
+- Use clear section headings that match common subtopics
+- For video: add chapter markers with timestamps
+
+---
+
+## SERP Feature Prioritization Matrix
+
+Not all SERP features deserve equal attention. Prioritize based on your content type and goals:
+
+| SERP Feature | Traffic Impact | Effort to Win | Best For |
+|-------------|---------------|--------------|---------|
+| Featured Snippet | Very High | Medium | Informational content sites |
+| AI Overview citation | High (growing) | Medium-High | Authority/expertise sites |
+| People Also Ask | Medium-High | Low-Medium | FAQ-rich content |
+| Video Carousel | High | High (video production) | Tutorial/how-to content |
+| Local Pack | Very High (local) | Medium | Local businesses |
+| Rich Results (FAQ) | Medium | Low | Any content with Q&A |
+| Rich Results (Review) | Medium-High | Low-Medium | Product/service reviews |
+| Image Pack | Medium | Low-Medium | Visual content creators |
+| Shopping Results | Very High (ecommerce) | Medium | Product sellers |
+| Knowledge Panel | Medium (brand) | High (long-term) | Established brands |
+| Sitelinks | Low (brand already ranking) | Low (structural) | Large, structured sites |
+
+---
+
+## SERP Feature Monitoring Framework
+
+### What to Track
+
+| Metric | Frequency | Tool Category | Action Threshold |
+|--------|-----------|--------------|-----------------|
+| Featured snippet ownership | Weekly | ~~SEO tool | Lost snippet → investigate within 48 hours |
+| AI Overview citation rate | Weekly | ~~AI monitor | Citation loss → review content freshness |
+| PAA presence for target keywords | Monthly | ~~SEO tool | New PAA questions → create content |
+| SERP feature composition changes | Monthly | ~~SEO tool | New feature appearing → optimize for it |
+| Rich result errors | Weekly | Search Console | Any error → fix immediately |
+| Local Pack ranking | Weekly | ~~SEO tool | Drop below position 3 → investigate |
+
+### SERP Feature Change Analysis
+
+When SERP features change for your target keywords, investigate:
+
+| Change | Possible Causes | Recommended Action |
+|--------|----------------|-------------------|
+| Featured snippet disappeared | Google removed snippet for this query; competitor won it | Check if snippet still exists; create better snippet-targeted content |
+| AI Overview appeared (new) | Google expanded AI Overviews to this query type | Optimize content for AI citation |
+| AI Overview disappeared | Query type removed from AI Overview program | Refocus on traditional SERP features |
+| Video carousel appeared | Google detected video intent for this query | Create video content for the keyword |
+| Local Pack appeared | Google detected local intent shift | Consider local SEO if relevant |
+| Shopping results appeared | Google detected commercial intent shift | Consider product markup or adjust content angle |
+
+---
+
+## SERP Feature Combination Patterns
+
+Certain SERP feature combinations indicate specific opportunities:
+
+| SERP Combination | What It Signals | Opportunity |
+|-----------------|----------------|-------------|
+| AI Overview + Featured Snippet | Google sees this as high-information query | Optimize for both -- structured content with clear answers |
+| Video + PAA + Featured Snippet | Multi-format informational query | Create comprehensive guide with video and FAQ |
+| Shopping + Ads + Reviews | Strong commercial intent | Product optimization, review content |
+| Local Pack + Ads | Local commercial intent | Google Business Profile optimization |
+| No features (just blue links) | Low-feature query (or very new topic) | Potential early-mover advantage for rich results |
+| PAA only (no snippet) | Snippet opportunity not yet captured | Create snippet-optimized content |
+
+---
+
+## AI Overview vs. Traditional SERP Feature Strategy
+
+The rise of AI Overviews changes how to prioritize SERP features:
+
+| Scenario | Traditional Strategy | AI-Era Strategy |
+|---------|---------------------|----------------|
+| Informational query | Win featured snippet | Win AI Overview citation AND featured snippet |
+| Comparison query | Create comparison content | Create structured comparison tables (AI prefers these) |
+| Definition query | Write clear definition for snippet | Write authoritative, citable definition with evidence |
+| How-to query | Create step-by-step list | Create steps with unique insights AI can synthesize |
+| List query | Create comprehensive ranked list | Create list with original data/reasoning AI can cite |
+
+### Key Difference
+
+- **Traditional SERP features** reward **format optimization** (structure your content to match the feature)
+- **AI Overviews** reward **authority and uniqueness** (be the source AI trusts for accurate, original information)
+
+Optimizing for both requires content that is both structurally sound AND substantively authoritative.
diff --git a/.cursor/skills/technical-seo-checker/SKILL.md b/.cursor/skills/technical-seo-checker/SKILL.md
new file mode 100644
index 0000000000..5e0cc76da1
--- /dev/null
+++ b/.cursor/skills/technical-seo-checker/SKILL.md
@@ -0,0 +1,397 @@
+---
+name: technical-seo-checker
+description: 'Technical SEO audit: Core Web Vitals, crawl, indexing, mobile, speed, architecture, redirects. 技术SEO/网站速度'
+version: "6.0.0"
+license: Apache-2.0
+compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
+homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
+when_to_use: "Use when checking technical SEO health: site speed, Core Web Vitals, indexing, crawlability, robots.txt, sitemaps, or canonical tags."
+argument-hint: ""
+allowed-tools: WebFetch
+metadata:
+ author: aaron-he-zhu
+ version: "6.0.0"
+ geo-relevance: "low"
+ tags:
+ - seo
+ - technical-seo
+ - core-web-vitals
+ - page-speed
+ - crawlability
+ - indexability
+ - mobile-seo
+ - site-health
+ - lcp
+ - cls
+ - inp
+ - robots-txt
+ - xml-sitemap
+ - 技术SEO
+ - 网站速度
+ - テクニカルSEO
+ - 기술SEO
+ - seo-tecnico
+ triggers:
+ # EN-formal
+ - "technical SEO audit"
+ - "check page speed"
+ - "Core Web Vitals"
+ - "crawl issues"
+ - "site indexing problems"
+ - "canonical tag issues"
+ - "duplicate content"
+ - "mobile-friendly check"
+ - "site speed"
+ - "site health check"
+ # EN-casual
+ - "my site is slow"
+ - "Google can't crawl my site"
+ - "Google can't find my pages"
+ - "mobile issues"
+ - "indexing problems"
+ - "why is my site slow"
+ # EN-question
+ - "how do I fix my page speed"
+ - "why is my site not indexed"
+ - "how to improve Core Web Vitals"
+ - "why did my site disappear from Google"
+ # EN-competitor
+ - "PageSpeed Insights alternative"
+ - "GTmetrix alternative"
+ - "Sitebulb alternative"
+ # ZH-pro
+ - "技术SEO检查"
+ - "网站速度优化"
+ - "核心网页指标"
+ - "爬虫问题"
+ - "索引问题"
+ - "网站收录"
+ - "sitemap提交"
+ - "robots设置"
+ # ZH-casual
+ - "网站加载太慢"
+ - "网站太慢了"
+ - "Google找不到我的页面"
+ - "手机端有问题"
+ - "收录不了"
+ - "Google收录少"
+ # JA
+ - "テクニカルSEO"
+ - "サイト速度"
+ - "コアウェブバイタル"
+ - "クロール問題"
+ - "インデックス登録"
+ - "モバイル最適化"
+ # KO
+ - "기술 SEO"
+ - "사이트 속도"
+ - "코어 웹 바이탈"
+ - "크롤링 문제"
+ - "사이트 왜 이렇게 느려?"
+ # ES
+ - "auditoría SEO técnica"
+ - "velocidad del sitio"
+ - "problemas de indexación"
+ - "mi sitio no aparece en Google"
+ - "velocidad de carga"
+ # PT
+ - "auditoria SEO técnica"
+ - "meu site não aparece no Google"
+ - "velocidade de carregamento"
+ # Misspellings
+ - "techincal SEO"
+ - "core web vitalls"
+---
+
+# Technical SEO Checker
+
+
+> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
+> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
+
+
+This skill performs comprehensive technical SEO audits to identify issues that may prevent search engines from properly crawling, indexing, and ranking your site.
+
+**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
+
+## When This Must Trigger
+
+Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
+
+Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
+
+- Launching a new website
+- Diagnosing ranking drops
+- Pre-migration SEO audits
+- Regular technical health checks
+- Identifying crawl and index issues
+- Improving site performance
+- Fixing Core Web Vitals issues
+
+## What This Skill Does
+
+1. **Crawlability Audit**: Checks robots.txt, sitemaps, crawl issues
+2. **Indexability Review**: Analyzes index status and blockers
+3. **Site Speed Analysis**: Evaluates Core Web Vitals and performance
+4. **Mobile-Friendliness**: Checks mobile optimization
+5. **Security Check**: Reviews HTTPS and security headers
+6. **Structured Data Audit**: Validates schema markup
+7. **URL Structure Analysis**: Reviews URL patterns and redirects
+8. **International SEO**: Checks hreflang and localization
+
+## Quick Start
+
+Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
+
+### Full Technical Audit
+
+```
+Perform a technical SEO audit for [URL/domain]
+```
+
+### Specific Issue Check
+
+```
+Check Core Web Vitals for [URL]
+```
+
+```
+Audit crawlability and indexability for [domain]
+```
+
+### Pre-Migration Audit
+
+```
+Technical SEO checklist for migrating [old domain] to [new domain]
+```
+
+## Skill Contract
+
+**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
+
+- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
+- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
+- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
+- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
+
+## Data Sources
+
+> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
+
+**With ~~web crawler + ~~page speed tool + ~~CDN connected:**
+Claude can automatically crawl the entire site structure via ~~web crawler, pull Core Web Vitals and performance metrics from ~~page speed tool, analyze caching headers from ~~CDN, and fetch mobile-friendliness data. This enables comprehensive automated technical audits.
+
+**With manual data only:**
+Ask the user to provide:
+1. Site URL(s) to audit
+2. PageSpeed Insights screenshots or reports
+3. robots.txt file content
+4. sitemap.xml URL or file
+
+Proceed with the full audit using provided data. Note in the output which findings are from automated crawl vs. manual review.
+
+## Instructions
+
+When a user requests a technical SEO audit:
+
+1. **Audit Crawlability**
+
+ ```markdown
+ ## Crawlability Analysis
+
+ ### Robots.txt Review
+
+ **URL**: [domain]/robots.txt
+ **Status**: [Found/Not Found/Error]
+
+ **Current Content**:
+ ```
+ [robots.txt content]
+ ```
+
+ | Check | Status | Notes |
+ |-------|--------|-------|
+ | File exists | ✅/❌ | [notes] |
+ | Valid syntax | ✅/⚠️/❌ | [errors found] |
+ | Sitemap declared | ✅/❌ | [sitemap URL] |
+ | Important pages blocked | ✅/⚠️/❌ | [blocked paths] |
+ | Assets blocked | ✅/⚠️/❌ | [CSS/JS blocked?] |
+ | Correct user-agents | ✅/⚠️/❌ | [notes] |
+
+ **Issues Found**:
+ - [Issue 1]
+ - [Issue 2]
+
+ **Recommended robots.txt**:
+ ```
+ User-agent: *
+ Allow: /
+ Disallow: /admin/
+ Disallow: /private/
+
+ Sitemap: https://example.com/sitemap.xml
+ ```
+
+ ---
+
+ ### XML Sitemap Review
+
+ **Sitemap URL**: [URL]
+ **Status**: [Found/Not Found/Error]
+
+ | Check | Status | Notes |
+ |-------|--------|-------|
+ | Sitemap exists | ✅/❌ | [notes] |
+ | Valid XML format | ✅/⚠️/❌ | [errors] |
+ | In robots.txt | ✅/❌ | [notes] |
+ | Submitted to ~~search console | ✅/⚠️/❌ | [notes] |
+ | URLs count | [X] | [appropriate?] |
+ | Only indexable URLs | ✅/⚠️/❌ | [notes] |
+ | Includes priority | ✅/⚠️ | [notes] |
+ | Includes lastmod | ✅/⚠️ | [accurate?] |
+
+ **Issues Found**:
+ - [Issue 1]
+
+ ---
+
+ ### Crawl Budget Analysis
+
+ | Factor | Status | Impact |
+ |--------|--------|--------|
+ | Crawl errors | [X] errors | [Low/Med/High] |
+ | Duplicate content | [X] pages | [Low/Med/High] |
+ | Thin content | [X] pages | [Low/Med/High] |
+ | Redirect chains | [X] found | [Low/Med/High] |
+ | Orphan pages | [X] found | [Low/Med/High] |
+
+ **Crawlability Score**: [X]/10
+ ```
+
+2. **Audit Indexability**
+
+ ```markdown
+ ## Indexability Analysis
+
+ ### Index Status Overview
+
+ | Metric | Count | Notes |
+ |--------|-------|-------|
+ | Pages in sitemap | [X] | |
+ | Pages indexed | [X] | [source: site: search] |
+ | Index coverage ratio | [X]% | [good if >90%] |
+
+ ### Index Blockers Check
+
+ | Blocker Type | Found | Pages Affected |
+ |--------------|-------|----------------|
+ | noindex meta tag | [X] | [list or "none"] |
+ | noindex X-Robots | [X] | [list or "none"] |
+ | Robots.txt blocked | [X] | [list or "none"] |
+ | Canonical to other | [X] | [list or "none"] |
+ | 4xx/5xx errors | [X] | [list or "none"] |
+ | Redirect loops | [X] | [list or "none"] |
+
+ ### Canonical Tags Audit
+
+ | Check | Status | Notes |
+ |-------|--------|-------|
+ | Canonicals present | ✅/⚠️/❌ | [X]% of pages |
+ | Self-referencing | ✅/⚠️/❌ | [notes] |
+ | Consistent (HTTP/HTTPS) | ✅/⚠️/❌ | [notes] |
+ | Consistent (www/non-www) | ✅/⚠️/❌ | [notes] |
+ | No conflicting signals | ✅/⚠️/❌ | [notes] |
+
+ ### Duplicate Content Issues
+
+ | Issue Type | Count | Examples |
+ |------------|-------|----------|
+ | Exact duplicates | [X] | [URLs] |
+ | Near duplicates | [X] | [URLs] |
+ | Parameter duplicates | [X] | [URLs] |
+ | WWW/non-WWW | [X] | [notes] |
+ | HTTP/HTTPS | [X] | [notes] |
+
+ **Indexability Score**: [X]/10
+ ```
+
+3. **Audit Site Speed & Core Web Vitals** — CWV metrics (LCP/FID/CLS/INP), additional performance metrics (TTFB/FCP/Speed Index/TBT), resource loading breakdown, optimization recommendations
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the performance analysis template (Step 3).
+
+4. **Audit Mobile-Friendliness** — Mobile-friendly test, responsive design check, mobile-first indexing verification
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the mobile optimization template (Step 4).
+
+5. **Audit Security & HTTPS** — SSL certificate, HTTPS enforcement, mixed content, HSTS, security headers (CSP, X-Frame-Options, etc.)
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the security analysis template (Step 5).
+
+6. **Audit URL Structure** — URL patterns, issues (dynamic params, session IDs, uppercase), redirect analysis (chains, loops, 302s)
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the URL structure template (Step 6).
+
+7. **Audit Structured Data** — Schema markup validation, missing schema opportunities. CORE-EEAT alignment: maps to O05.
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the structured data template (Step 7).
+
+8. **Audit International SEO (if applicable)** — Hreflang implementation, language/region targeting
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the international SEO template (Step 8).
+
+9. **Generate Technical Audit Summary** — Overall health score with visual breakdown, critical/high/medium issues, quick wins, implementation roadmap (weeks 1-4+), monitoring recommendations
+
+ > **Reference**: See [references/technical-audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) for the audit summary template (Step 9).
+
+## Validation Checkpoints
+
+### Input Validation
+- [ ] Site URL or domain clearly specified
+- [ ] Access to technical data (robots.txt, sitemap, or crawl results)
+- [ ] Performance metrics available (via ~~page speed tool or screenshots)
+
+### Output Validation
+- [ ] Every recommendation cites specific data points (not generic advice)
+- [ ] All issues include affected URLs or page counts
+- [ ] Performance metrics include actual numbers with units (seconds, KB, etc.)
+- [ ] Source of each data point clearly stated (~~web crawler data, ~~page speed tool, user-provided, or estimated)
+
+## Example
+
+> **Reference**: See [references/technical-audit-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-example.md) for a full worked example (cloudhosting.com technical audit) and the comprehensive technical SEO checklist.
+
+## Tips for Success
+
+1. **Prioritize by impact** - Fix critical issues first
+2. **Monitor continuously** - Use ~~search console alerts
+3. **Test changes** - Verify fixes work before deploying widely
+4. **Document everything** - Track changes for troubleshooting
+5. **Regular audits** - Schedule quarterly technical reviews
+
+> **Technical reference**: For issue severity framework, prioritization matrix, and Core Web Vitals optimization quick reference, see [references/http-status-codes.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/http-status-codes.md).
+
+
+### Save Results
+
+After delivering audit or optimization findings to the user, ask:
+
+> "Save these results for future sessions?"
+
+If yes, write a dated summary to `memory/audits/technical-seo-checker/YYYY-MM-DD-.md` containing:
+- One-line verdict or headline finding
+- Top 3-5 actionable items
+- Open loops or blockers
+- Source data references
+
+If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
+
+## Reference Materials
+
+- [robots.txt Reference](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/robots-txt-reference.md) — Syntax guide, templates, common configurations
+- [HTTP Status Codes](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/http-status-codes.md) — SEO impact of each status code, redirect best practices
+- [Technical Audit Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-templates.md) — Detailed output templates for steps 3-9 (CWV, mobile, security, URL structure, structured data, international, audit summary)
+- [Technical Audit Example & Checklist](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/references/technical-audit-example.md) — Full worked example and comprehensive technical SEO checklist
+
+## Next Best Skill
+
+- **Primary**: [on-page-seo-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/SKILL.md) — continue from infrastructure issues into page-level remediation.
diff --git a/.cursor/skills/technical-seo-checker/references/http-status-codes.md b/.cursor/skills/technical-seo-checker/references/http-status-codes.md
new file mode 100644
index 0000000000..32fa482435
--- /dev/null
+++ b/.cursor/skills/technical-seo-checker/references/http-status-codes.md
@@ -0,0 +1,705 @@
+# HTTP Status Codes for SEO
+
+SEO-relevant HTTP status codes, their implications, and how to diagnose and fix issues.
+
+## Status Code Categories
+
+- **2xx**: Success - Request succeeded
+- **3xx**: Redirection - Further action needed
+- **4xx**: Client Error - Problem with the request
+- **5xx**: Server Error - Server failed to fulfill request
+
+---
+
+## 2xx Success Codes
+
+### 200 OK
+
+**What it means**: Request succeeded, content returned normally.
+
+**SEO impact**: Positive - page is accessible and indexable.
+
+**When to use**: Standard response for all working pages.
+
+**When it's a problem**: When different URLs return 200 for same content (should use 301 redirect).
+
+---
+
+### 204 No Content
+
+**What it means**: Request succeeded but no content to return.
+
+**SEO impact**: Neutral - rarely used for pages meant to be indexed.
+
+**Common use**: API responses, AJAX requests.
+
+---
+
+## 3xx Redirection Codes
+
+### 301 Moved Permanently
+
+**What it means**: Resource permanently moved to new URL. All link equity transfers.
+
+**SEO impact**: Positive when used correctly - passes 90-99% of link equity.
+
+**When to use**:
+- Permanently changing URL structure
+- Consolidating duplicate content
+- Moving to new domain
+- Changing HTTP to HTTPS
+- Changing www to non-www (or vice versa)
+
+**Example header**:
+```
+HTTP/1.1 301 Moved Permanently
+Location: https://example.com/new-page
+```
+
+**Common mistakes**:
+- Using 302 instead of 301 for permanent changes
+- Creating redirect chains (A→B→C)
+- Redirecting to irrelevant pages
+- Not redirecting HTTP to HTTPS
+
+**How to implement**:
+- **.htaccess** (Apache): `Redirect 301 /old-page /new-page`
+- **nginx**: `rewrite ^/old-page$ /new-page permanent;`
+- **Server-side**: Set Location header with 301 status
+
+---
+
+### 302 Found (Temporary Redirect)
+
+**What it means**: Resource temporarily at different URL. Original URL should still be used.
+
+**SEO impact**: Neutral to negative - does NOT pass full link equity. Search engines keep indexing original URL.
+
+**When to use**:
+- A/B testing
+- Temporary promotions
+- Maintenance redirects
+- Geolocation redirects (sometimes)
+
+**When NOT to use**: Permanent URL changes (use 301).
+
+**Warning**: Google may treat long-standing 302s as 301s, but better to be explicit.
+
+---
+
+### 303 See Other
+
+**What it means**: Response can be found at another URI using GET.
+
+**SEO impact**: Minimal - rarely used for SEO purposes.
+
+**Common use**: After form submissions, redirect to results page.
+
+---
+
+### 307 Temporary Redirect
+
+**What it means**: Temporary redirect that preserves request method (POST stays POST).
+
+**SEO impact**: Similar to 302 - temporary, doesn't pass full link equity.
+
+**Difference from 302**: Guarantees request method won't change (more precise than 302).
+
+**When to use**: Temporary redirects where HTTP method preservation matters.
+
+---
+
+### 308 Permanent Redirect
+
+**What it means**: Permanent redirect that preserves request method.
+
+**SEO impact**: Similar to 301 - passes link equity.
+
+**Difference from 301**: Guarantees request method won't change (POST stays POST).
+
+**When to use**: Permanent redirects where method preservation matters (rare for SEO).
+
+---
+
+### Redirect Chain Issues
+
+**Problem**: Multiple redirects before reaching final destination.
+
+**Example chain**:
+```
+http://example.com/page
+ → https://example.com/page (redirect 1)
+ → https://www.example.com/page (redirect 2)
+ → https://www.example.com/new-page (redirect 3)
+```
+
+**SEO impact**:
+- Slows page load (each redirect = new HTTP request)
+- Dilutes link equity with each hop
+- Wastes crawl budget
+- Poor user experience
+
+**How to fix**: Redirect directly from original URL to final destination.
+
+**Fixed version**:
+```
+http://example.com/page
+ → https://www.example.com/new-page (single redirect)
+```
+
+---
+
+### Redirect Loops
+
+**Problem**: Redirects create infinite loop.
+
+**Example**:
+```
+/page-a → /page-b
+/page-b → /page-a
+```
+
+**SEO impact**: Severe - page completely inaccessible.
+
+**Symptoms**:
+- Browser shows "Too many redirects" error
+- Page never loads
+- Search Console shows crawl errors
+
+**How to diagnose**:
+1. Use redirect checker tool
+2. Check .htaccess or nginx config for conflicting rules
+3. Review server-side redirect logic
+
+**How to fix**:
+1. Identify conflicting redirect rules
+2. Remove or correct the loop
+3. Test thoroughly
+4. Request recrawl in Search Console
+
+---
+
+## 4xx Client Error Codes
+
+### 404 Not Found
+
+**What it means**: Requested resource doesn't exist.
+
+**SEO impact**: Neutral to negative depending on context.
+
+**When 404s are OK**:
+- Legitimately deleted pages with no equivalent
+- Never-existed URLs from typos
+- Temporary content that expired (old promotions)
+- Intentionally removed low-quality content
+
+**When 404s are problems**:
+- Pages that should exist are returning 404
+- Previously working pages now broken
+- Important pages missing from navigation
+- High-traffic pages deleted without redirect
+
+**How to fix**:
+1. **If content moved**: Set up 301 redirect to new location
+2. **If content deleted**: Either keep 404 or redirect to relevant category
+3. **If never existed**: Leave as 404
+4. **If important**: Restore the page
+
+**Monitoring 404s**:
+- Check Search Console → Coverage → Not found (404)
+- Review referrer data to see what's linking to 404s
+- Fix high-value 404s first (most traffic/backlinks)
+
+**Soft 404s** (BAD):
+- Page returns 200 but shows "not found" message
+- Search engines may keep page indexed
+- Creates duplicate content issues
+- Fix: Return proper 404 status code
+
+---
+
+### 410 Gone
+
+**What it means**: Resource permanently deleted, never coming back.
+
+**SEO impact**: Stronger signal than 404 - tells search engines not to return.
+
+**When to use**:
+- Discontinued products
+- Expired promotions
+- Permanently removed content
+- Outdated information
+
+**Difference from 404**:
+- 404: "Not found" (might exist at another URL)
+- 410: "Gone forever" (don't look for it)
+
+**When to use 410 vs 301**:
+- Use 410: No equivalent replacement exists
+- Use 301: Relevant alternative exists
+
+**How search engines respond**:
+- Faster de-indexing than 404
+- Stop crawling sooner
+- Better for crawl budget
+
+---
+
+### 403 Forbidden
+
+**What it means**: Server understood request but refuses to authorize it.
+
+**SEO impact**: Negative - page inaccessible and won't be indexed.
+
+**Common causes**:
+- Permission restrictions
+- IP blocking
+- .htaccess restrictions
+- File permissions (chmod)
+- Authentication required
+
+**When it's intentional**:
+- Admin areas
+- Member-only content
+- Geographic restrictions
+
+**When it's a problem**:
+- Public pages returning 403
+- Search engine bots blocked
+- Accidental permission changes
+
+**How to diagnose**:
+1. Check .htaccess for IP restrictions
+2. Verify file permissions (should be 644 for files, 755 for directories)
+3. Check server-level access rules
+4. Test with different IPs/user-agents
+
+**How to fix**:
+1. Adjust file permissions: `chmod 644 filename`
+2. Remove blocking rules from .htaccess
+3. Whitelist search engine bots
+4. Review server firewall rules
+
+---
+
+### 401 Unauthorized
+
+**What it means**: Authentication required but not provided or failed.
+
+**SEO impact**: Negative - page won't be indexed.
+
+**Common causes**:
+- Password-protected pages
+- HTTP Basic Authentication
+- Expired sessions
+- Missing credentials
+
+**When it's intentional**: Member areas, staging sites, admin panels.
+
+**How to handle for SEO**:
+- Don't password-protect pages you want indexed
+- Use separate staging domain with 401
+- For members-only content, show teaser with meta robots noindex
+
+---
+
+### 429 Too Many Requests
+
+**What it means**: User/bot sent too many requests in given timeframe (rate limiting).
+
+**SEO impact**: Negative if search engines can't crawl.
+
+**Common causes**:
+- Aggressive crawling
+- DDoS protection triggered
+- API rate limits
+- Server throttling
+
+**How to handle**:
+1. Check Googlebot isn't being rate-limited (use Search Console)
+2. Whitelist verified search engine bots
+3. Configure rate limits appropriately
+4. Monitor crawl rate in Search Console
+
+---
+
+## 5xx Server Error Codes
+
+### 500 Internal Server Error
+
+**What it means**: Generic server error, something went wrong.
+
+**SEO impact**: Very negative if persistent - prevents indexing and ranking.
+
+**Common causes**:
+- PHP/code errors
+- Database connection issues
+- .htaccess syntax errors
+- Resource limits exceeded
+- Plugin/theme conflicts (WordPress)
+
+**How to diagnose**:
+1. Check server error logs
+2. Review recent code/config changes
+3. Test locally or on staging
+4. Disable plugins one by one (if CMS)
+5. Check .htaccess syntax
+
+**How to fix**:
+1. Review error logs for specific error
+2. Roll back recent changes
+3. Fix code errors
+4. Increase resource limits if needed
+5. Test thoroughly before re-deploying
+
+**Monitoring**: Set up alerts for 500 errors (sudden spike = problem).
+
+---
+
+### 502 Bad Gateway
+
+**What it means**: Server received invalid response from upstream server.
+
+**SEO impact**: Negative if persistent - prevents crawling/indexing.
+
+**Common causes**:
+- Proxy/load balancer issues
+- Upstream server down
+- Timeout issues
+- Firewall blocking
+
+**Common scenarios**:
+- CDN can't reach origin server
+- Application server crashed
+- Database server unresponsive
+
+**How to fix**:
+1. Check upstream server status
+2. Verify firewall rules
+3. Check timeout settings
+4. Restart proxy/load balancer if needed
+5. Review CDN configuration
+
+---
+
+### 503 Service Unavailable
+
+**What it means**: Server temporarily unable to handle request.
+
+**SEO impact**: Neutral if truly temporary with Retry-After header. Negative if prolonged.
+
+**Common causes**:
+- Maintenance mode
+- Server overload
+- Database down
+- Resource exhaustion
+
+**Proper use for maintenance**:
+```
+HTTP/1.1 503 Service Unavailable
+Retry-After: 3600
+```
+
+**Best practices for maintenance**:
+1. Use 503 (not 404 or 500)
+2. Include Retry-After header
+3. Keep maintenance brief (<24 hours)
+4. Schedule during low-traffic times
+5. Inform users with clear message
+
+**How search engines handle 503**:
+- Short-term (hours): Will retry, no ranking impact
+- Long-term (days+): May drop rankings, de-index pages
+
+---
+
+### 504 Gateway Timeout
+
+**What it means**: Server didn't receive timely response from upstream server.
+
+**SEO impact**: Negative - prevents crawling.
+
+**Common causes**:
+- Slow database queries
+- External API timeouts
+- Insufficient server resources
+- Network issues
+
+**How to fix**:
+1. Optimize slow queries
+2. Increase timeout limits
+3. Add caching
+4. Scale server resources
+5. Review external dependencies
+
+---
+
+## Status Code Decision Flowchart
+
+### Content Moved Permanently?
+→ YES: Use **301 redirect**
+→ NO: Continue
+
+### Content Moved Temporarily?
+→ YES: Use **302 redirect**
+→ NO: Continue
+
+### Content Deleted with No Replacement?
+→ YES: Use **404** (or **410** if permanently gone)
+→ NO: Continue
+
+### Content Exists at This URL?
+→ YES: Use **200 OK**
+→ NO: Use **404**
+
+### Need Authentication?
+→ YES: Use **401**
+→ NO: Continue
+
+### Access Forbidden?
+→ YES: Use **403**
+→ NO: Continue
+
+### Server Error?
+→ YES: Use **500**, **502**, **503**, or **504** depending on cause
+→ NO: Use **200 OK**
+
+---
+
+## Diagnosing Status Code Issues
+
+### Tools
+
+**Browser DevTools**:
+1. Open DevTools (F12)
+2. Go to Network tab
+3. Reload page
+4. Check status code in first request
+
+**cURL command**:
+```bash
+curl -I https://example.com/page
+```
+
+**Online checkers**:
+- httpstatus.io
+- redirect-checker.org
+- websiteplanet.com/webtools/redirects/
+
+**Google Search Console**:
+- Coverage report → Error/Excluded sections
+- URL Inspection tool → Check specific URLs
+
+---
+
+### Common Diagnostic Scenarios
+
+### "Page Won't Index"
+
+**Check**:
+1. Status code (should be 200)
+2. Redirects (shouldn't redirect away)
+3. 4xx/5xx errors
+4. robots.txt blocking
+5. noindex meta tag
+
+### "Page Disappeared from Results"
+
+**Check**:
+1. Returns 404/410/5xx
+2. Redirecting elsewhere (301/302)
+3. Changed to 403/401
+4. Server timing out (504)
+
+### "Traffic Dropped After Migration"
+
+**Check**:
+1. Old URLs return 404 (should be 301)
+2. Redirect chains (should be direct)
+3. Redirect loops
+4. Wrong redirect type (302 vs 301)
+5. Incorrect redirect targets
+
+---
+
+## Status Codes and Crawl Budget
+
+### Impact on Crawl Budget
+
+**Efficient (minimal impact)**:
+- 200 OK
+- 301 redirects (if minimal chains)
+- 410 Gone (removes from crawl queue)
+
+**Moderate impact**:
+- 302 redirects (search engine may keep checking)
+- 404 errors (search engines periodically recheck)
+- Redirect chains (multiple requests per URL)
+
+**High impact (wasteful)**:
+- 5xx errors (search engines retry frequently)
+- Redirect loops (waste crawl budget)
+- Soft 404s (search engine confused, keeps crawling)
+- 429 rate limiting (prevents efficient crawling)
+
+---
+
+## SEO Status Code Best Practices
+
+### For Migrations
+
+- [ ] Use 301 redirects for all permanently moved pages
+- [ ] Redirect directly to final destination (no chains)
+- [ ] Test all redirects before launching
+- [ ] Keep redirects in place for at least 1 year
+- [ ] Monitor 404 errors in Search Console post-launch
+- [ ] Map 1:1 where possible (old URL → equivalent new URL)
+
+### For Deleted Content
+
+- [ ] Use 301 if relevant replacement exists
+- [ ] Use 404 if no replacement and might return
+- [ ] Use 410 if permanently gone, never returning
+- [ ] Don't redirect to irrelevant pages (creates soft 404)
+- [ ] Create custom 404 page with search and navigation
+
+### For Maintenance
+
+- [ ] Use 503 with Retry-After header
+- [ ] Keep maintenance window brief (<24 hours)
+- [ ] Create user-friendly maintenance page
+- [ ] Inform users of expected downtime
+- [ ] Monitor Search Console for crawl issues
+
+### For Performance
+
+- [ ] Minimize redirect chains
+- [ ] Fix redirect loops immediately
+- [ ] Monitor 5xx errors closely
+- [ ] Set up alerts for sudden status code changes
+- [ ] Optimize to reduce 504 timeouts
+
+---
+
+## Status Code Monitoring
+
+### Key Metrics to Track
+
+**In Search Console**:
+- Crawl errors by type
+- Server errors (5xx) trend
+- Not found (404) trend
+- Redirect errors
+
+**In analytics**:
+- 404 page views
+- Entry pages with high exit rate (might be errors)
+- Sudden traffic drops (could indicate status code issues)
+
+**Server logs**:
+- Status code distribution
+- 5xx error frequency
+- Unusual patterns
+
+### Setting Up Alerts
+
+**Alert on**:
+- Sudden increase in 5xx errors
+- Increase in 404 errors
+- New redirect chains
+- Crawl error spikes in Search Console
+
+**Tools**:
+- Google Search Console email alerts
+- Server monitoring (UptimeRobot, Pingdom)
+- Log analysis tools
+- Custom scripts for log monitoring
+
+---
+
+## Quick Reference Table
+
+| Code | Name | SEO Impact | Use When | Passes Link Equity? |
+|------|------|------------|----------|---------------------|
+| 200 | OK | ✅ Positive | Page works normally | N/A (original URL) |
+| 301 | Moved Permanently | ✅ Positive | Permanent URL change | ✅ Yes (~90-99%) |
+| 302 | Found | ⚠️ Neutral | Temporary redirect | ❌ No |
+| 307 | Temporary Redirect | ⚠️ Neutral | Temporary (method preserved) | ❌ No |
+| 308 | Permanent Redirect | ✅ Positive | Permanent (method preserved) | ✅ Yes |
+| 404 | Not Found | ⚠️ Neutral | Content doesn't exist | N/A |
+| 410 | Gone | ⚠️ Neutral | Permanent deletion | N/A |
+| 403 | Forbidden | ❌ Negative | Access denied | N/A |
+| 401 | Unauthorized | ❌ Negative | Auth required | N/A |
+| 500 | Internal Server Error | ❌ Negative | Server error | N/A |
+| 502 | Bad Gateway | ❌ Negative | Upstream error | N/A |
+| 503 | Service Unavailable | ⚠️ Neutral | Temporary downtime | N/A |
+| 504 | Gateway Timeout | ❌ Negative | Timeout error | N/A |
+
+---
+
+## Status Code Testing Checklist
+
+Before launching site changes:
+
+- [ ] Test all redirects return correct status codes
+- [ ] Verify no redirect chains exist
+- [ ] Check no redirect loops present
+- [ ] Confirm important pages return 200
+- [ ] Ensure deleted pages return 404/410 (not 200)
+- [ ] Verify 301s point to correct destinations
+- [ ] Test with multiple user-agents
+- [ ] Check status codes in Search Console
+- [ ] Monitor server logs for unusual patterns
+- [ ] Set up alerts for error spikes
+
+---
+
+## Technical SEO Severity Framework
+
+### Issue Classification
+
+| Severity | Impact Description | Examples | Response Time |
+|----------|-------------------|---------|---------------|
+| **Critical** | Prevents indexation or causes site-wide issues | Robots.txt blocking site, noindex on key pages, site-wide 500 errors | Same day |
+| **High** | Significantly impacts rankings or user experience | Slow page speed, missing hreflang, duplicate content, redirect chains | Within 1 week |
+| **Medium** | Affects specific pages or has moderate impact | Missing schema, suboptimal canonicals, thin content pages | Within 1 month |
+| **Low** | Minor optimization opportunities | Image compression, minor CLS issues, non-essential schema missing | Next quarter |
+
+### Technical Debt Prioritization Matrix
+
+| Factor | Weight | Assessment |
+|--------|--------|-----------|
+| Pages affected | 30% | Site-wide > Section > Single page |
+| Revenue impact | 25% | Revenue pages > Blog > Utility pages |
+| Fix difficulty | 20% | Config change < Template change < Code rewrite |
+| Competitive impact | 15% | Competitors passing you > parity > you ahead |
+| Crawl budget waste | 10% | High waste > Moderate > Minimal |
+
+## Core Web Vitals Optimization Quick Reference
+
+### LCP (Largest Contentful Paint) Optimization
+
+| Root Cause | Detection | Fix |
+|-----------|-----------|-----|
+| Large hero image | PageSpeed Insights | Serve WebP, resize to container, add loading="lazy" |
+| Render-blocking CSS/JS | DevTools Coverage | Defer non-critical, inline critical CSS |
+| Slow server response | TTFB >800ms | CDN, server-side caching, upgrade hosting |
+| Third-party scripts | DevTools Network | Defer/async, use facade pattern |
+
+### CLS (Cumulative Layout Shift) Optimization
+
+| Root Cause | Detection | Fix |
+|-----------|-----------|-----|
+| Images without dimensions | DevTools | Add explicit width/height attributes |
+| Ads/embeds without reserved space | Visual inspection | Set min-height on containers |
+| Web fonts causing FOUT | DevTools | font-display: swap + preload fonts |
+| Dynamic content injection | Visual inspection | Reserve space with CSS |
+
+### INP (Interaction to Next Paint) Optimization
+
+| Root Cause | Detection | Fix |
+|-----------|-----------|-----|
+| Long JavaScript tasks | DevTools Performance | Break into smaller tasks, use requestIdleCallback |
+| Heavy event handlers | DevTools | Debounce/throttle, use passive listeners |
+| Main thread blocking | DevTools | Web workers for heavy computation |
+
diff --git a/.cursor/skills/technical-seo-checker/references/robots-txt-reference.md b/.cursor/skills/technical-seo-checker/references/robots-txt-reference.md
new file mode 100644
index 0000000000..589952dec0
--- /dev/null
+++ b/.cursor/skills/technical-seo-checker/references/robots-txt-reference.md
@@ -0,0 +1,717 @@
+# Robots.txt Reference Guide
+
+Complete reference for creating, testing, and troubleshooting robots.txt files.
+
+## Syntax Guide
+
+### Basic Structure
+
+```
+User-agent: [bot name]
+Disallow: [path to block]
+Allow: [path to allow]
+Sitemap: [sitemap URL]
+Crawl-delay: [seconds]
+```
+
+---
+
+## Core Directives
+
+### User-agent
+
+Specifies which bot the rules apply to.
+
+**Syntax**: `User-agent: [bot-name]`
+
+**Common user-agents**:
+```
+User-agent: * # All bots
+User-agent: Googlebot # Google's crawler
+User-agent: Bingbot # Bing's crawler
+User-agent: GPTBot # OpenAI's crawler
+User-agent: CCBot # Common Crawl bot
+User-agent: anthropic-ai # Anthropic's crawler
+User-agent: PerplexityBot # Perplexity AI crawler
+User-agent: ClaudeBot # Claude's web crawler
+```
+
+**Multiple user-agents**: Group rules by leaving no blank lines between user-agent declarations.
+
+```
+User-agent: Googlebot
+User-agent: Bingbot
+Disallow: /admin/
+```
+
+---
+
+### Disallow
+
+Blocks bots from crawling specified paths.
+
+**Syntax**: `Disallow: [path]`
+
+**Examples**:
+```
+Disallow: / # Block entire site
+Disallow: /admin/ # Block admin directory
+Disallow: /private # Block private directory (and subdirectories)
+Disallow: /*.pdf$ # Block all PDF files
+Disallow: /*? # Block all URLs with parameters
+Disallow: # Allow everything (empty disallow)
+```
+
+**Path matching**:
+- `/` at end = block directory and all subdirectories
+- Without `/` at end = block all paths starting with string
+- `*` = wildcard, matches any sequence
+- `$` = end of URL
+
+---
+
+### Allow
+
+Explicitly allows crawling (overrides Disallow).
+
+**Syntax**: `Allow: [path]`
+
+**Common use**: Allow specific subdirectories within blocked parent.
+
+```
+User-agent: *
+Disallow: /admin/
+Allow: /admin/public/
+```
+
+**Note**: Allow is not standard but supported by Google, Bing, and most major crawlers.
+
+---
+
+### Sitemap
+
+Specifies location of XML sitemap.
+
+**Syntax**: `Sitemap: [absolute URL]`
+
+**Examples**:
+```
+Sitemap: https://example.com/sitemap.xml
+Sitemap: https://example.com/sitemap_index.xml
+Sitemap: https://example.com/blog/sitemap.xml
+```
+
+**Best practices**:
+- Use absolute URLs (not relative)
+- Can include multiple Sitemap directives
+- Place at end of file
+- Submit same sitemap(s) to Google Search Console
+
+---
+
+### Crawl-delay
+
+Adds delay between requests (seconds).
+
+**Syntax**: `Crawl-delay: [seconds]`
+
+**Example**:
+```
+User-agent: *
+Crawl-delay: 10
+```
+
+**Warning**: Not supported by Googlebot (use Search Console rate limiting instead). Supported by Bing, Yandex, and others.
+
+---
+
+## Common Configurations
+
+### 1. Allow All Bots (Default)
+
+```
+User-agent: *
+Disallow:
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+Use when you want all bots to crawl entire site.
+
+---
+
+### 2. Block All Bots
+
+```
+User-agent: *
+Disallow: /
+```
+
+Use for development/staging sites or private content.
+
+---
+
+### 3. Block Specific Directories
+
+```
+User-agent: *
+Disallow: /admin/
+Disallow: /private/
+Disallow: /temp/
+Disallow: /cgi-bin/
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+Standard configuration blocking admin and utility directories.
+
+---
+
+### 4. Block All AI Crawlers
+
+```
+# Block OpenAI
+User-agent: GPTBot
+Disallow: /
+
+# Block Anthropic
+User-agent: anthropic-ai
+User-agent: ClaudeBot
+Disallow: /
+
+# Block Common Crawl
+User-agent: CCBot
+Disallow: /
+
+# Block Perplexity
+User-agent: PerplexityBot
+Disallow: /
+
+# Block Google-Extended (Bard training)
+User-agent: Google-Extended
+Disallow: /
+
+# Allow search engines
+User-agent: Googlebot
+Disallow:
+
+User-agent: Bingbot
+Disallow:
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+Use when you want search indexing but not AI training.
+
+---
+
+### 5. Allow Search Engines, Block Everything Else
+
+```
+# Block all by default
+User-agent: *
+Disallow: /
+
+# Allow Google
+User-agent: Googlebot
+Disallow:
+
+# Allow Bing
+User-agent: Bingbot
+Disallow:
+
+# Allow DuckDuckGo
+User-agent: DuckDuckBot
+Disallow:
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+---
+
+### 6. Block URL Parameters
+
+```
+User-agent: *
+Disallow: /*? # Block all URLs with parameters
+Allow: /? # Allow homepage with parameters
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+Prevents duplicate content from parameter variations.
+
+---
+
+### 7. Block File Types
+
+```
+User-agent: *
+Disallow: /*.pdf$
+Disallow: /*.doc$
+Disallow: /*.xls$
+Disallow: /*.zip$
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+---
+
+### 8. E-commerce Configuration
+
+```
+User-agent: *
+# Block search/filter pages
+Disallow: /*?q=
+Disallow: /*?sort=
+Disallow: /*?filter=
+
+# Block account pages
+Disallow: /account/
+Disallow: /cart/
+Disallow: /checkout/
+
+# Block admin
+Disallow: /admin/
+
+# Allow product pages
+Allow: /products/
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+---
+
+### 9. WordPress Configuration
+
+```
+User-agent: *
+# WordPress core
+Disallow: /wp-admin/
+Allow: /wp-admin/admin-ajax.php
+
+# WordPress directories
+Disallow: /wp-includes/
+Disallow: /wp-content/plugins/
+Disallow: /wp-content/themes/
+
+# Allow uploads
+Allow: /wp-content/uploads/
+
+# Block parameter pages
+Disallow: /?s=
+Disallow: /feed/
+Disallow: /trackback/
+
+Sitemap: https://example.com/sitemap_index.xml
+```
+
+---
+
+### 10. Shopify Configuration
+
+```
+User-agent: *
+# Block admin and account
+Disallow: /admin
+Disallow: /account
+Disallow: /cart
+Disallow: /checkout
+
+# Block search
+Disallow: /search
+
+# Block collections with filters
+Disallow: /collections/*+*
+Disallow: /collections/*?*
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+---
+
+## Platform-Specific Templates
+
+### Wix
+
+```
+User-agent: *
+Disallow: /_api/
+Disallow: /_partials/
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+### Squarespace
+
+```
+User-agent: *
+Disallow: /config/
+Disallow: /search
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+### Webflow
+
+```
+User-agent: *
+Allow: /
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+### Drupal
+
+```
+User-agent: *
+Disallow: /admin/
+Disallow: /user/
+Disallow: /node/add/
+Disallow: /?q=
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+---
+
+## Testing and Validation
+
+### Google Search Console Robots.txt Tester
+
+1. Go to: Search Console → Settings → robots.txt
+2. View current robots.txt
+3. Test specific URLs
+4. See which user-agents are affected
+
+### Manual Testing
+
+Test URL pattern: `https://example.com/robots.txt`
+
+Check file is:
+- Accessible (returns 200 status)
+- Plain text format
+- UTF-8 encoded
+- Located at root domain
+- No more than 500KB (Google limit)
+
+### Common Testing Scenarios
+
+Test these URLs in tester:
+- Homepage: `/`
+- Product page: `/products/example`
+- Admin page: `/admin/`
+- Parameter page: `/search?q=test`
+- File: `/document.pdf`
+
+---
+
+## Common Mistakes and Fixes
+
+### Mistake 1: Blocking CSS/JS Files
+
+**Wrong**:
+```
+User-agent: *
+Disallow: /css/
+Disallow: /js/
+```
+
+**Why it's wrong**: Google needs CSS/JS to render pages properly.
+
+**Fix**:
+```
+User-agent: *
+Allow: /css/
+Allow: /js/
+```
+
+---
+
+### Mistake 2: Using Relative URLs for Sitemap
+
+**Wrong**:
+```
+Sitemap: /sitemap.xml
+```
+
+**Fix**:
+```
+Sitemap: https://example.com/sitemap.xml
+```
+
+---
+
+### Mistake 3: Spaces in Directives
+
+**Wrong**:
+```
+User-agent : Googlebot
+Disallow : /admin/
+```
+
+**Fix** (no spaces before colons):
+```
+User-agent: Googlebot
+Disallow: /admin/
+```
+
+---
+
+### Mistake 4: Forgetting Trailing Slash
+
+**Intention**: Block /admin directory
+
+**Wrong**:
+```
+Disallow: /admin
+```
+
+**Result**: Also blocks /admin-panel, /administrator, etc.
+
+**Fix**:
+```
+Disallow: /admin/
+```
+
+---
+
+### Mistake 5: Blocking Entire Site Accidentally
+
+**Wrong**:
+```
+User-agent: *
+Disallow: /
+Allow: /blog/
+```
+
+**Why it's wrong**: Many bots don't support Allow directive.
+
+**Fix**: Use noindex meta tags for pages you don't want indexed, not robots.txt.
+
+---
+
+### Mistake 6: Not Blocking Development Environments
+
+**Wrong**: No robots.txt on staging.example.com
+
+**Result**: Staging site gets indexed.
+
+**Fix**:
+```
+User-agent: *
+Disallow: /
+```
+
+On all non-production environments.
+
+---
+
+### Mistake 7: Case Sensitivity Errors
+
+**Note**: Directives are case-insensitive, but paths are case-sensitive.
+
+**Example**:
+```
+Disallow: /Admin/ # Blocks /Admin/ but not /admin/
+```
+
+**Fix**: Block both if needed:
+```
+Disallow: /admin/
+Disallow: /Admin/
+```
+
+---
+
+## Advanced Patterns
+
+### Wildcard Examples
+
+```
+# Block all PDFs
+Disallow: /*.pdf$
+
+# Block all URLs with parameters
+Disallow: /*?
+
+# Block all URLs ending in .php
+Disallow: /*.php$
+
+# Block all admin paths regardless of location
+Disallow: /*/admin/
+```
+
+### Multiple Sitemaps
+
+```
+Sitemap: https://example.com/sitemap-pages.xml
+Sitemap: https://example.com/sitemap-posts.xml
+Sitemap: https://example.com/sitemap-products.xml
+```
+
+### Bot-Specific Rules
+
+```
+# Aggressive bot - slow it down
+User-agent: BadBot
+Crawl-delay: 60
+Disallow: /
+
+# Good bots - full access
+User-agent: Googlebot
+User-agent: Bingbot
+Disallow:
+
+# Default for others
+User-agent: *
+Crawl-delay: 10
+Disallow: /admin/
+```
+
+---
+
+## Robots.txt vs Meta Robots vs X-Robots-Tag
+
+### When to use each:
+
+**Robots.txt**:
+- Block crawling of entire directories
+- Reduce crawl budget waste
+- Block parameter variations
+- Does NOT prevent indexing if page is linked from elsewhere
+
+**Meta robots tag**:
+- Prevent specific pages from being indexed
+- Control snippet display
+- Control following links
+- Example: ``
+
+**X-Robots-Tag HTTP header**:
+- Control non-HTML files (PDFs, images)
+- Server-level control
+- Example: `X-Robots-Tag: noindex`
+
+**Important**: If you don't want a page indexed, use noindex (meta tag or header), NOT robots.txt.
+
+---
+
+## Monitoring and Maintenance
+
+### Regular Checks
+
+**Monthly**:
+- [ ] Verify robots.txt is accessible
+- [ ] Check Search Console for blocked URLs
+- [ ] Review crawl stats for blocked resources
+
+**Quarterly**:
+- [ ] Audit blocked paths - still relevant?
+- [ ] Check for new admin/private sections to block
+- [ ] Review AI crawler landscape (new bots?)
+
+**After site changes**:
+- [ ] Update robots.txt if URL structure changed
+- [ ] Test new sections (should they be blocked?)
+- [ ] Verify sitemaps still referenced
+
+### Search Console Monitoring
+
+Check these reports:
+- **Coverage** → Excluded by robots.txt
+- **Settings** → Crawl stats
+- **URL Inspection** → Test specific URLs
+
+---
+
+## Robots.txt Checklist
+
+Before deploying:
+
+- [ ] File is named exactly `robots.txt` (lowercase)
+- [ ] Located at root domain (`example.com/robots.txt`)
+- [ ] Plain text format (not HTML or PDF)
+- [ ] UTF-8 encoding
+- [ ] No HTML tags in file
+- [ ] All paths start with `/`
+- [ ] Sitemap URLs are absolute
+- [ ] No spaces before colons
+- [ ] Tested in Search Console robots.txt tester
+- [ ] Not blocking important CSS/JS/images
+- [ ] Not blocking content you want indexed
+- [ ] Trailing slashes used correctly for directories
+- [ ] Wildcard patterns tested
+- [ ] File size under 500KB
+
+---
+
+## Emergency Fixes
+
+### Accidentally Blocked Entire Site
+
+**Symptom**: All pages blocked in Search Console
+
+**Fix**:
+1. Edit robots.txt to:
+```
+User-agent: *
+Disallow:
+
+Sitemap: https://example.com/sitemap.xml
+```
+2. Test in Search Console
+3. Request urgent recrawl for key pages
+4. Monitor Coverage report for recovery
+
+**Recovery time**: 1-7 days
+
+---
+
+### Blocked CSS/JS Files
+
+**Symptom**: "Blocked by robots.txt" in Mobile-Friendly Test
+
+**Fix**:
+1. Add Allow directives:
+```
+User-agent: *
+Allow: /css/
+Allow: /js/
+Allow: /wp-content/uploads/
+```
+2. Test in robots.txt tester
+3. Request re-render in URL Inspection tool
+
+---
+
+### Staging Site Indexed
+
+**Symptom**: staging.example.com appears in search results
+
+**Fix**:
+1. Add to staging robots.txt:
+```
+User-agent: *
+Disallow: /
+```
+2. Add noindex meta tag to all staging pages
+3. Remove staging URLs in Search Console (Removals tool)
+
+---
+
+## Resources and Tools
+
+**Testing**:
+- Google Search Console robots.txt tester
+- Bing Webmaster Tools robots.txt analyzer
+- Technical SEO browser extensions
+
+**Validation**:
+- https://www.google.com/webmasters/tools/robots-testing-tool
+- https://en.ryte.com/free-tools/robots-txt/
+- https://technicalseo.com/tools/robots-txt/
+
+**Documentation**:
+- Google: https://developers.google.com/search/docs/crawling-indexing/robots/intro
+- Bing: https://www.bing.com/webmasters/help/robots-txt-validation
+- Robots.txt spec: https://www.robotstxt.org/
diff --git a/.cursor/skills/technical-seo-checker/references/technical-audit-example.md b/.cursor/skills/technical-seo-checker/references/technical-audit-example.md
new file mode 100644
index 0000000000..5f2d6ad434
--- /dev/null
+++ b/.cursor/skills/technical-seo-checker/references/technical-audit-example.md
@@ -0,0 +1,169 @@
+# Technical SEO Checker — Worked Example & Checklist
+
+Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/SKILL.md).
+
+---
+
+## Worked Example
+
+**User**: "Check the technical SEO of cloudhosting.com"
+
+**Output**:
+
+```markdown
+# Technical SEO Audit Report
+
+**Domain**: cloudhosting.com
+**Audit Date**: 2024-09-15
+**Pages Analyzed**: 312
+
+## Crawlability Analysis
+
+### Robots.txt Review
+
+**URL**: cloudhosting.com/robots.txt
+**Status**: Found
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| File exists | ✅ | 200 response |
+| Valid syntax | ⚠️ | Wildcard pattern `Disallow: /*?` too aggressive — blocks faceted pages |
+| Sitemap declared | ❌ | No Sitemap directive in robots.txt |
+| Important pages blocked | ⚠️ | /pricing/ blocked by `Disallow: /pricing` rule |
+| Assets blocked | ✅ | CSS/JS accessible |
+
+**Issues Found**:
+- Sitemap URL not declared in robots.txt
+- `/pricing/` inadvertently blocked — high-value commercial page
+
+### XML Sitemap Review
+
+**Sitemap URL**: cloudhosting.com/sitemap.xml
+**Status**: Found (not referenced in robots.txt)
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| Sitemap exists | ✅ | Valid XML, 287 URLs |
+| Only indexable URLs | ❌ | 23 noindex URLs included |
+| Includes lastmod | ⚠️ | All dates set to 2023-01-01 — not accurate |
+
+**Crawlability Score**: 5/10
+
+## Performance Analysis
+
+### Core Web Vitals
+
+| Metric | Mobile | Desktop | Target | Status |
+|--------|--------|---------|--------|--------|
+| LCP (Largest Contentful Paint) | 4.8s | 2.1s | <2.5s | ❌ Mobile / ✅ Desktop |
+| FID (First Input Delay) | 45ms | 12ms | <100ms | ✅ / ✅ |
+| CLS (Cumulative Layout Shift) | 0.24 | 0.08 | <0.1 | ❌ Mobile / ✅ Desktop |
+| INP (Interaction to Next Paint) | 380ms | 140ms | <200ms | ❌ Mobile / ✅ Desktop |
+
+### Additional Performance Metrics
+
+| Metric | Value | Status |
+|--------|-------|--------|
+| Time to First Byte (TTFB) | 1,240ms | ❌ |
+| Page Size | 3.8MB | ❌ |
+| Requests | 94 | ⚠️ |
+
+**LCP Issues**:
+- Uncompressed hero image (2.4MB PNG): Convert to WebP, est. save 1.9MB
+- No CDN detected: TTFB 1,240ms from origin server
+
+**CLS Issues**:
+- Ad banner at top of page injects without reserved height (0.18 shift contribution)
+
+**Performance Score**: 3/10
+
+## Security Analysis
+
+### HTTPS Status
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| SSL certificate valid | ✅ | Expires: 2025-03-22 |
+| HTTPS enforced | ⚠️ | http://cloudhosting.com returns 200 instead of 301 redirect |
+| Mixed content | ❌ | 7 images loaded over HTTP on /features/ page |
+| HSTS enabled | ❌ | Header not present |
+
+**Security Score**: 5/10
+
+## Structured Data Analysis
+
+### Schema Markup Found
+
+| Schema Type | Pages | Valid | Errors |
+|-------------|-------|-------|--------|
+| Organization | 1 (homepage) | ✅ | None |
+| Article | 0 | — | Missing on 48 blog posts |
+| Product | 0 | — | Missing on 5 plan pages |
+| FAQ | 0 | — | Missing on 12 pages with FAQ content |
+
+**Structured Data Score**: 3/10
+
+## Overall Technical Health: 42/100
+
+```
+Score Breakdown:
+█████░░░░░ Crawlability: 5/10
+██████░░░░ Indexability: 6/10
+███░░░░░░░ Performance: 3/10
+██████░░░░ Mobile: 6/10
+█████░░░░░ Security: 5/10
+██████░░░░ URL Structure: 6/10
+███░░░░░░░ Structured Data: 3/10
+```
+
+## Priority Issues
+
+### 🔴 Critical (Fix Immediately)
+1. **Mobile LCP 4.8s (target <2.5s)** — Compress hero image to WebP (est. save 1.9MB) and implement a CDN to reduce TTFB from 1,240ms to <400ms.
+
+### 🟡 Important (Fix Soon)
+2. **HTTP not redirecting to HTTPS** — Add 301 redirect from http:// to https:// and enable HSTS header. 7 mixed-content images on /features/ need URL updates.
+
+### 🟢 Minor (Optimize)
+3. **No Article/FAQ schema on blog posts** — Add Article schema to 48 blog posts and FAQ schema to 12 FAQ pages for rich result eligibility.
+```
+
+---
+
+## Technical SEO Checklist
+
+```markdown
+### Crawlability
+- [ ] robots.txt is valid and not blocking important content
+- [ ] XML sitemap exists and is submitted to ~~search console
+- [ ] No crawl errors in ~~search console
+- [ ] No redirect chains or loops
+
+### Indexability
+- [ ] Important pages are indexable
+- [ ] Canonical tags are correct
+- [ ] No duplicate content issues
+- [ ] Pagination is handled correctly
+
+### Performance
+- [ ] Core Web Vitals pass
+- [ ] Page speed under 3 seconds
+- [ ] Images are optimized
+- [ ] JS/CSS are minified
+
+### Mobile
+- [ ] Mobile-friendly test passes
+- [ ] Viewport is configured
+- [ ] Touch elements are properly sized
+
+### Security
+- [ ] HTTPS is enforced
+- [ ] SSL certificate is valid
+- [ ] No mixed content
+- [ ] Security headers present
+
+### Structure
+- [ ] URLs are clean and descriptive
+- [ ] Site architecture is logical
+- [ ] Internal linking is strong
+```
diff --git a/.cursor/skills/technical-seo-checker/references/technical-audit-templates.md b/.cursor/skills/technical-seo-checker/references/technical-audit-templates.md
new file mode 100644
index 0000000000..71af860ccf
--- /dev/null
+++ b/.cursor/skills/technical-seo-checker/references/technical-audit-templates.md
@@ -0,0 +1,311 @@
+# Technical SEO Checker — Output Templates
+
+Detailed output templates for technical-seo-checker steps 3-9. Referenced from [SKILL.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/SKILL.md).
+
+---
+
+## Step 3: Audit Site Speed & Core Web Vitals
+
+```markdown
+## Performance Analysis
+
+### Core Web Vitals
+
+| Metric | Mobile | Desktop | Target | Status |
+|--------|--------|---------|--------|--------|
+| LCP (Largest Contentful Paint) | [X]s | [X]s | <2.5s | ✅/⚠️/❌ |
+| FID (First Input Delay) | [X]ms | [X]ms | <100ms | ✅/⚠️/❌ |
+| CLS (Cumulative Layout Shift) | [X] | [X] | <0.1 | ✅/⚠️/❌ |
+| INP (Interaction to Next Paint) | [X]ms | [X]ms | <200ms | ✅/⚠️/❌ |
+
+### Additional Performance Metrics
+
+| Metric | Value | Status |
+|--------|-------|--------|
+| Time to First Byte (TTFB) | [X]ms | ✅/⚠️/❌ |
+| First Contentful Paint (FCP) | [X]s | ✅/⚠️/❌ |
+| Speed Index | [X] | ✅/⚠️/❌ |
+| Total Blocking Time | [X]ms | ✅/⚠️/❌ |
+| Page Size | [X]MB | ✅/⚠️/❌ |
+| Requests | [X] | ✅/⚠️/❌ |
+
+### Performance Issues
+
+**LCP Issues**:
+- [Issue]: [Impact] - [Solution]
+- [Issue]: [Impact] - [Solution]
+
+**CLS Issues**:
+- [Issue]: [Impact] - [Solution]
+
+**Resource Loading**:
+| Resource Type | Count | Size | Issues |
+|---------------|-------|------|--------|
+| Images | [X] | [X]MB | [notes] |
+| JavaScript | [X] | [X]MB | [notes] |
+| CSS | [X] | [X]KB | [notes] |
+| Fonts | [X] | [X]KB | [notes] |
+
+### Optimization Recommendations
+
+**High Impact**:
+1. [Recommendation] - Est. improvement: [X]s
+2. [Recommendation] - Est. improvement: [X]s
+
+**Medium Impact**:
+1. [Recommendation]
+2. [Recommendation]
+
+**Performance Score**: [X]/10
+```
+
+---
+
+## Step 4: Audit Mobile-Friendliness
+
+```markdown
+## Mobile Optimization Analysis
+
+### Mobile-Friendly Test
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| Mobile-friendly overall | ✅/❌ | [notes] |
+| Viewport configured | ✅/❌ | [viewport tag] |
+| Text readable | ✅/⚠️/❌ | Font size: [X]px |
+| Tap targets sized | ✅/⚠️/❌ | [notes] |
+| Content fits viewport | ✅/❌ | [notes] |
+| No horizontal scroll | ✅/❌ | [notes] |
+
+### Responsive Design Check
+
+| Element | Desktop | Mobile | Issues |
+|---------|---------|--------|--------|
+| Navigation | [status] | [status] | [notes] |
+| Images | [status] | [status] | [notes] |
+| Forms | [status] | [status] | [notes] |
+| Tables | [status] | [status] | [notes] |
+| Videos | [status] | [status] | [notes] |
+
+### Mobile-First Indexing
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| Mobile version has all content | ✅/⚠️/❌ | [notes] |
+| Mobile has same structured data | ✅/⚠️/❌ | [notes] |
+| Mobile has same meta tags | ✅/⚠️/❌ | [notes] |
+| Mobile images have alt text | ✅/⚠️/❌ | [notes] |
+
+**Mobile Score**: [X]/10
+```
+
+---
+
+## Step 5: Audit Security & HTTPS
+
+```markdown
+## Security Analysis
+
+### HTTPS Status
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| SSL certificate valid | ✅/❌ | Expires: [date] |
+| HTTPS enforced | ✅/❌ | [redirects properly?] |
+| Mixed content | ✅/⚠️/❌ | [X] issues |
+| HSTS enabled | ✅/⚠️ | [notes] |
+| Certificate chain | ✅/⚠️/❌ | [notes] |
+
+### Security Headers
+
+| Header | Present | Value | Recommended |
+|--------|---------|-------|-------------|
+| Content-Security-Policy | ✅/❌ | [value] | [recommendation] |
+| X-Frame-Options | ✅/❌ | [value] | DENY or SAMEORIGIN |
+| X-Content-Type-Options | ✅/❌ | [value] | nosniff |
+| X-XSS-Protection | ✅/❌ | [value] | 1; mode=block |
+| Referrer-Policy | ✅/❌ | [value] | [recommendation] |
+
+**Security Score**: [X]/10
+```
+
+---
+
+## Step 6: Audit URL Structure
+
+```markdown
+## URL Structure Analysis
+
+### URL Pattern Review
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| HTTPS URLs | ✅/⚠️/❌ | [X]% HTTPS |
+| Lowercase URLs | ✅/⚠️/❌ | [notes] |
+| No special characters | ✅/⚠️/❌ | [notes] |
+| Readable/descriptive | ✅/⚠️/❌ | [notes] |
+| Appropriate length | ✅/⚠️/❌ | Avg: [X] chars |
+| Keywords in URLs | ✅/⚠️/❌ | [notes] |
+| Consistent structure | ✅/⚠️/❌ | [notes] |
+
+### URL Issues Found
+
+| Issue Type | Count | Examples |
+|------------|-------|----------|
+| Dynamic parameters | [X] | [URLs] |
+| Session IDs in URLs | [X] | [URLs] |
+| Uppercase characters | [X] | [URLs] |
+| Special characters | [X] | [URLs] |
+| Very long URLs (>100) | [X] | [URLs] |
+
+### Redirect Analysis
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| Redirect chains | [X] found | [max chain length] |
+| Redirect loops | [X] found | [URLs] |
+| 302 → 301 needed | [X] found | [URLs] |
+| Broken redirects | [X] found | [URLs] |
+
+**URL Score**: [X]/10
+```
+
+---
+
+## Step 7: Audit Structured Data
+
+> **CORE-EEAT alignment**: Schema markup quality maps to O05 (Schema Markup) in the CORE-EEAT benchmark. See [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) for full content quality audit.
+
+```markdown
+## Structured Data Analysis
+
+### Schema Markup Found
+
+| Schema Type | Pages | Valid | Errors |
+|-------------|-------|-------|--------|
+| [Type 1] | [X] | ✅/❌ | [errors] |
+| [Type 2] | [X] | ✅/❌ | [errors] |
+
+### Validation Results
+
+**Errors**:
+- [Error 1]: [affected pages] - [solution]
+- [Error 2]: [affected pages] - [solution]
+
+**Warnings**:
+- [Warning 1]: [notes]
+
+### Missing Schema Opportunities
+
+| Page Type | Current Schema | Recommended |
+|-----------|----------------|-------------|
+| Blog posts | [current] | Article + FAQ |
+| Products | [current] | Product + Review |
+| Homepage | [current] | Organization |
+
+**Structured Data Score**: [X]/10
+```
+
+---
+
+## Step 8: Audit International SEO (if applicable)
+
+```markdown
+## International SEO Analysis
+
+### Hreflang Implementation
+
+| Check | Status | Notes |
+|-------|--------|-------|
+| Hreflang tags present | ✅/❌ | [notes] |
+| Self-referencing | ✅/⚠️/❌ | [notes] |
+| Return tags present | ✅/⚠️/❌ | [notes] |
+| Valid language codes | ✅/⚠️/❌ | [notes] |
+| x-default tag | ✅/⚠️ | [notes] |
+
+### Language/Region Targeting
+
+| Language | URL | Hreflang | Status |
+|----------|-----|----------|--------|
+| [en-US] | [URL] | [tag] | ✅/⚠️/❌ |
+| [es-ES] | [URL] | [tag] | ✅/⚠️/❌ |
+
+**International Score**: [X]/10
+```
+
+---
+
+## Step 9: Generate Technical Audit Summary
+
+```markdown
+# Technical SEO Audit Report
+
+**Domain**: [domain]
+**Audit Date**: [date]
+**Pages Analyzed**: [X]
+
+## Overall Technical Health: [X]/100
+
+```
+Score Breakdown:
+████████░░ Crawlability: 8/10
+███████░░░ Indexability: 7/10
+█████░░░░░ Performance: 5/10
+████████░░ Mobile: 8/10
+█████████░ Security: 9/10
+██████░░░░ URL Structure: 6/10
+█████░░░░░ Structured Data: 5/10
+```
+
+## Critical Issues (Fix Immediately)
+
+1. **[Issue]**: [Impact]
+ - Affected: [pages/scope]
+ - Solution: [specific fix]
+ - Priority: 🔴 Critical
+
+2. **[Issue]**: [Impact]
+ - Affected: [pages/scope]
+ - Solution: [specific fix]
+ - Priority: 🔴 Critical
+
+## High Priority Issues
+
+1. **[Issue]**: [Solution]
+2. **[Issue]**: [Solution]
+
+## Medium Priority Issues
+
+1. **[Issue]**: [Solution]
+2. **[Issue]**: [Solution]
+
+## Quick Wins
+
+These can be fixed quickly for immediate improvement:
+
+1. [Quick fix 1]
+2. [Quick fix 2]
+3. [Quick fix 3]
+
+## Implementation Roadmap
+
+### Week 1: Critical Fixes
+- [ ] [Task 1]
+- [ ] [Task 2]
+
+### Week 2-3: High Priority
+- [ ] [Task 1]
+- [ ] [Task 2]
+
+### Week 4+: Optimization
+- [ ] [Task 1]
+- [ ] [Task 2]
+
+## Monitoring Recommendations
+
+Set up alerts for:
+- Core Web Vitals drops
+- Crawl error spikes
+- Index coverage changes
+- Security issues
+```
diff --git a/.cursor/skills/ugc-brief/.skillfish.json b/.cursor/skills/ugc-brief/.skillfish.json
new file mode 100644
index 0000000000..426656d0a6
--- /dev/null
+++ b/.cursor/skills/ugc-brief/.skillfish.json
@@ -0,0 +1,10 @@
+{
+ "version": 2,
+ "name": "ugc-brief",
+ "owner": "walkerhi11",
+ "repo": "mediabuy-plugins",
+ "path": ".claude/skills/ugc-brief",
+ "branch": "main",
+ "sha": "98bdcf49353c883665432be36f81b94aee6f188d",
+ "source": "manual"
+}
\ No newline at end of file
diff --git a/.cursor/skills/ugc-brief/SKILL.md b/.cursor/skills/ugc-brief/SKILL.md
new file mode 100644
index 0000000000..35b1c6f24a
--- /dev/null
+++ b/.cursor/skills/ugc-brief/SKILL.md
@@ -0,0 +1,260 @@
+---
+name: ugc-brief
+description: Create briefs for UGC creators including product info, hook requirements, talking points, and authenticity guidelines. Use when working with UGC creators, preparing creator packages, or scaling content production with external talent.
+---
+
+# UGC Brief Generator
+
+Create creator-ready briefs for authentic UGC content.
+
+## Process
+
+### Step 1: Define Product & Key Messages
+
+**Product Overview:**
+- Product name and category
+- Key benefits (top 3)
+- Unique mechanism
+- Price point/offer
+- Target customer
+
+**Core Messages:**
+- Primary claim/promise
+- Secondary proof points
+- Must-mention features
+- Compliance requirements
+
+### Step 2: Specify Hook Requirements
+
+**Hook Type Needed:**
+- [ ] Greed (savings, value)
+- [ ] Relevancy (timely, seasonal)
+- [ ] Emotion (reaction, story)
+- [ ] Demographic (call out audience)
+- [ ] Cliffhanger (curiosity gap)
+
+**Hook Examples to Reference:**
+- "[Winning hook from brand]"
+- "[Competitor hook working well]"
+
+**Hook Don'ts:**
+- No scripted openings
+- No "Hey guys" generic starts
+- No product name in first 3 seconds
+
+### Step 3: List Talking Points
+
+**Required Elements:**
+1. Problem acknowledgment
+2. Discovery story or proof
+3. Product solution
+4. Specific benefit/result
+5. Call to action
+
+**Proof Points to Include:**
+- [ ] Personal result/experience
+- [ ] Specific numbers if applicable
+- [ ] Before/after if relevant
+- [ ] Endorsement of mechanism
+
+**Optional Elements:**
+- Unboxing moment
+- Application/usage demo
+- Comparison to alternative
+- Response to objection
+
+### Step 4: Include Authenticity Guidelines
+
+**DO:**
+- Speak naturally, not scripted
+- Use your own words
+- Show genuine reactions
+- Include imperfections
+- Film in natural lighting
+- Use phone (not professional camera)
+- Look at camera like talking to friend
+
+**DON'T:**
+- Read from script (bullet points OK)
+- Over-polish or heavily edit
+- Use professional lighting/studio
+- Add music or effects
+- Sound like a commercial
+- Fake enthusiasm
+- Use brand jargon unnaturally
+
+**Authenticity Test:**
+"Would you actually send this video to a friend recommending the product?"
+
+### Step 5: Output Creator Brief
+
+```
+## UGC BRIEF: [Product Name]
+
+### CREATOR DETAILS
+- Creator name: [If assigned]
+- Platform: [TikTok/IG/FB]
+- Format: [Vertical 9:16]
+- Length: [15-60 seconds]
+- Deliverable: [Raw footage / Edited]
+
+---
+
+### PRODUCT INFO
+
+**Product:** [Name]
+**What it does:** [One sentence]
+**Key benefit:** [Primary promise]
+**Price/Offer:** [Current deal]
+**Website:** [URL]
+
+**Unique selling point:**
+[What makes this different]
+
+---
+
+### YOUR ANGLE
+
+**You are:** [Avatar description]
+- Age range: [X-X]
+- Situation: [Context]
+- Problem you had: [Pain point]
+- How you found product: [Discovery]
+
+---
+
+### HOOK (First 3 seconds)
+
+**Type:** [Hook category]
+
+**Examples to inspire (don't copy exactly):**
+- "[Example hook 1]"
+- "[Example hook 2]"
+
+**Requirements:**
+- Stop the scroll immediately
+- Don't mention product name yet
+- Create curiosity or relate to problem
+
+---
+
+### TALKING POINTS (Middle)
+
+Cover these in your own words:
+
+1. **Problem:** [Describe the struggle]
+ - Your words: _______________
+
+2. **Discovery:** [How you found it]
+ - Your words: _______________
+
+3. **Experience:** [What happened when you tried it]
+ - Your words: _______________
+
+4. **Proof:** [Specific result or benefit]
+ - Your words: _______________
+
+**Must mention:**
+- [ ] [Required element 1]
+- [ ] [Required element 2]
+
+**Nice to include:**
+- [ ] [Optional element]
+
+---
+
+### CALL TO ACTION (Last 5 seconds)
+
+**Say something like:**
+- "Link is in my bio"
+- "Tap the link to try it"
+- "I put the link below"
+
+**Add urgency if natural:**
+- "They're running a sale right now"
+- "I heard they sell out fast"
+
+---
+
+### FILMING GUIDELINES
+
+**Setting:**
+- [ ] Natural environment (home, outside)
+- [ ] Good natural lighting (face a window)
+- [ ] Clean but not perfect background
+- [ ] Vertical format (phone camera)
+
+**Style:**
+- [ ] Talking to a friend, not performing
+- [ ] Eye contact with camera
+- [ ] Natural energy (match your personality)
+- [ ] OK to have mistakes/restarts in raw footage
+
+**Technical:**
+- [ ] Film in 1080p or 4K
+- [ ] Clear audio (quiet space)
+- [ ] Multiple takes encouraged
+- [ ] Send raw footage (we'll edit)
+
+---
+
+### DO NOT
+
+- Script word-for-word
+- Use professional equipment
+- Add filters or effects
+- Sound like an infomercial
+- Make claims we can't substantiate
+- Mention competitors by name
+
+---
+
+### DELIVERABLES
+
+**Send:**
+- [ ] Raw video file(s)
+- [ ] Multiple takes if available
+- [ ] Any B-roll of product
+
+**Format:** [MP4/MOV]
+**Due:** [Date]
+**Send to:** [Email/Platform]
+
+---
+
+### PAYMENT & RIGHTS
+
+**Compensation:** [Amount/Terms]
+**Usage rights:** [Perpetual/Limited]
+**Exclusivity:** [Yes/No/Terms]
+
+---
+
+### REFERENCE VIDEOS
+[Links to examples of style wanted]
+
+### QUESTIONS?
+Contact: [Name/Email]
+```
+
+## UGC Best Practices (LeadsIcon/Kamal)
+
+**Why Native Beats Polish:**
+- Audiences smell BS instantly
+- Platform algorithms favor native content
+- Trust comes from relatability
+- Perfect = ad = skip
+
+**Creator Selection:**
+- Match target demographic
+- Natural on-camera presence
+- Actually would use product
+- Not over-produced style
+
+**Volume Strategy:**
+- Multiple creators same brief
+- Each brings unique delivery
+- Mix and match best clips
+- Keep testing new faces
+
+Source: LeadsIcon, Kamal FounderAds
diff --git a/.cursor/skills/vercel-react-best-practices/AGENTS.md b/.cursor/skills/vercel-react-best-practices/AGENTS.md
index 94c3c84413..2b839ab516 100644
--- a/.cursor/skills/vercel-react-best-practices/AGENTS.md
+++ b/.cursor/skills/vercel-react-best-practices/AGENTS.md
@@ -549,6 +549,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
**Example: preload on hover/focus**
```tsx
+import { Button } from '@/components/ui/button'
+
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
@@ -557,13 +559,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
}
return (
-
+
)
}
```
@@ -1239,11 +1241,12 @@ function StaticContent() {
**For mutations:**
```tsx
+import { Button } from '@/components/ui/button'
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
- return
+ return
}
```
@@ -1369,6 +1372,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
**Incorrect: subscribes to all searchParams changes**
```tsx
+import { Button } from '@/components/ui/button'
+
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
@@ -1377,13 +1382,15 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref })
}
- return
+ return
}
```
**Correct: reads on demand, no subscription**
```tsx
+import { Button } from '@/components/ui/button'
+
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
@@ -1391,7 +1398,7 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref })
}
- return
+ return
}
```
@@ -1549,6 +1556,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
**Incorrect: event modeled as state + effect**
```tsx
+import { Button } from '@/components/ui/button'
+
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
@@ -1560,13 +1569,15 @@ function Form() {
}
}, [submitted, theme])
- return
+ return
}
```
**Correct: do it in the handler**
```tsx
+import { Button } from '@/components/ui/button'
+
function Form() {
const theme = useContext(ThemeContext)
@@ -1575,7 +1586,7 @@ function Form() {
showToast('Registered', theme)
}
- return
+ return
}
```
diff --git a/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md b/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md
index 7000504065..0662ef81bd 100644
--- a/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md
+++ b/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md
@@ -12,6 +12,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
**Example (preload on hover/focus):**
```tsx
+import { Button } from "@/components/ui/button"
+
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
@@ -20,13 +22,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
}
return (
-
+
)
}
```
diff --git a/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md b/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md
index 2a430f27fd..22d419bcaf 100644
--- a/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md
+++ b/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md
@@ -45,11 +45,12 @@ function StaticContent() {
**For mutations:**
```tsx
+import { Button } from '@/components/ui/button'
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
- return
+ return
}
```
diff --git a/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md b/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md
index e867c95f02..94410bc5b9 100644
--- a/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md
+++ b/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md
@@ -12,6 +12,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
**Incorrect (subscribes to all searchParams changes):**
```tsx
+import { Button } from '@/components/ui/button'
+
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
@@ -20,13 +22,15 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref })
}
- return
+ return
}
```
**Correct (reads on demand, no subscription):**
```tsx
+import { Button } from '@/components/ui/button'
+
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
@@ -34,6 +38,6 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref })
}
- return
+ return
}
```
diff --git a/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md b/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md
index dd58a1af09..299815d697 100644
--- a/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md
+++ b/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md
@@ -12,6 +12,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
**Incorrect (event modeled as state + effect):**
```tsx
+import { Button } from '@/components/ui/button'
+
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
@@ -23,13 +25,15 @@ function Form() {
}
}, [submitted, theme])
- return
+ return
}
```
**Correct (do it in the handler):**
```tsx
+import { Button } from '@/components/ui/button'
+
function Form() {
const theme = useContext(ThemeContext)
@@ -38,7 +42,7 @@ function Form() {
showToast('Registered', theme)
}
- return
+ return
}
```
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index b5536eb34d..ad1c128bcf 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -22,6 +22,7 @@ on:
permissions:
contents: write
+ id-token: write
jobs:
build:
@@ -58,6 +59,22 @@ jobs:
fi
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
+ - name: Detect Windows signing eligibility
+ id: sign
+ shell: bash
+ run: |
+ # Sign Windows builds only on production v* tags (not beta-v*, not workflow_dispatch).
+ # This matches the single OIDC federated credential configured in Entra ID.
+ if [ "${{ matrix.os }}" = "windows-latest" ] \
+ && [ "${{ github.event_name }}" = "push" ] \
+ && [[ "$GITHUB_REF" == refs/tags/v* ]]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ echo "Windows signing: ENABLED (v* tag on windows-latest)"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ echo "Windows signing: skipped"
+ fi
+
- name: Setup pnpm
uses: pnpm/action-setup@v5
@@ -98,7 +115,44 @@ jobs:
- name: Package & Publish
shell: bash
- run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish ${{ inputs.publish || 'always' }} -c.extraMetadata.version=${{ steps.version.outputs.VERSION }}
+ run: |
+ CMD=(pnpm exec electron-builder ${{ matrix.platform }} \
+ --config electron-builder.yml \
+ --publish "${{ inputs.publish || 'always' }}" \
+ -c.extraMetadata.version="${{ steps.version.outputs.VERSION }}")
+
+ if [ "${{ steps.sign.outputs.enabled }}" = "true" ]; then
+ CMD+=(-c.win.azureSignOptions.publisherName="$WINDOWS_PUBLISHER_NAME")
+ CMD+=(-c.win.azureSignOptions.endpoint="$AZURE_CODESIGN_ENDPOINT")
+ CMD+=(-c.win.azureSignOptions.codeSigningAccountName="$AZURE_CODESIGN_ACCOUNT")
+ CMD+=(-c.win.azureSignOptions.certificateProfileName="$AZURE_CODESIGN_PROFILE")
+ fi
+
+ "${CMD[@]}"
working-directory: surfsense_desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }}
+ AZURE_CODESIGN_ENDPOINT: ${{ vars.AZURE_CODESIGN_ENDPOINT }}
+ AZURE_CODESIGN_ACCOUNT: ${{ vars.AZURE_CODESIGN_ACCOUNT }}
+ AZURE_CODESIGN_PROFILE: ${{ vars.AZURE_CODESIGN_PROFILE }}
+ # macOS Developer ID signing + notarization. Only the macos-latest runner
+ # consumes these; Windows/Linux runners ignore them. CSC_LINK accepts either
+ # a file path or a base64-encoded .p12 blob — electron-builder auto-detects.
+ CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
+ CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ # TEMP DEBUG — remove once the codesign hang on macos-latest is diagnosed.
+ # Surfaces the exact codesign / notarize commands electron-builder spawns,
+ # so we can see which subprocess hangs.
+ DEBUG: electron-builder,electron-osx-sign*,@electron/notarize*
+ ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: "true"
+ # Service principal credentials for Azure.Identity EnvironmentCredential used by the
+ # TrustedSigning PowerShell module. Only populated when signing is enabled.
+ # electron-builder 26 does not yet support OIDC federated tokens for Azure signing,
+ # so we fall back to client-secret auth. Rotate AZURE_CLIENT_SECRET before expiry.
+ AZURE_TENANT_ID: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_TENANT_ID || '' }}
+ AZURE_CLIENT_ID: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_CLIENT_ID || '' }}
+ AZURE_CLIENT_SECRET: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_CLIENT_SECRET || '' }}
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 9b3b3eb510..d5c0113e93 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -38,7 +38,7 @@ jobs:
new_tag: ${{ steps.tag_version.outputs.next_version }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.inputs.branch }}
@@ -115,16 +115,18 @@ jobs:
name: surfsense-backend
context: ./surfsense_backend
file: ./surfsense_backend/Dockerfile
+ target: production
- image: web
name: surfsense-web
context: ./surfsense_web
file: ./surfsense_web/Dockerfile
+ target: runner
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set lowercase image name
id: image
@@ -132,19 +134,19 @@ jobs:
- name: Docker meta
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ steps.image.outputs.name }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Free up disk space
run: |
@@ -156,10 +158,11 @@ jobs:
- name: Build and push by digest ${{ matrix.name }} (${{ matrix.suffix }})
id: build
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
+ target: ${{ matrix.target }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.image.outputs.name }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
@@ -181,7 +184,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: digests-${{ matrix.image }}-${{ matrix.suffix }}
path: /tmp/digests/*
@@ -212,22 +215,22 @@ jobs:
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
- name: Download amd64 digest
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: digests-${{ matrix.image }}-amd64
path: /tmp/digests
- name: Download arm64 digest
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: digests-${{ matrix.image }}-arm64
path: /tmp/digests
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -246,7 +249,7 @@ jobs:
- name: Docker meta
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ steps.image.outputs.name }}
tags: |
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
new file mode 100644
index 0000000000..b87537dab3
--- /dev/null
+++ b/.github/workflows/e2e-tests.yml
@@ -0,0 +1,174 @@
+name: E2E Tests
+
+on:
+ pull_request:
+ branches: [main, dev]
+ types: [opened, synchronize, reopened, ready_for_review]
+ paths:
+ - 'surfsense_web/**'
+ - 'surfsense_backend/**'
+ - 'docker/docker-compose.e2e.yml'
+ - '.github/workflows/e2e-tests.yml'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ e2e:
+ name: Journey
+ runs-on: ubuntu-latest
+ if: github.event.pull_request.draft == false
+ timeout-minutes: 30
+
+ env:
+ # Test user that the backend creates via /auth/register before Playwright runs.
+ PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net
+ PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123!
+ # Frontend env: Playwright's webServer (surfsense_web/playwright.config.ts)
+ # spawns `pnpm build && pnpm start` in CI; these get baked into the build.
+ NEXT_PUBLIC_FASTAPI_BACKEND_URL: http://localhost:8000
+ NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: LOCAL
+ # Shared secret for the test-only POST /__e2e__/auth/token endpoint.
+ # Must match docker-compose.e2e.yml's backend env (x-backend-env).
+ E2E_MINT_SECRET: e2e-mint-secret-not-for-production
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ # ─── Backend stack ─────────────────────────────────────────────────
+ # Builds the e2e image (multi-stage, deps cached via GHA), brings up
+ # db + redis + backend + celery_worker, blocks until every healthcheck
+ # is green. No `uv` invocation on the runner; no PID files; no curl
+ # polling loops; readiness is gated by Docker healthchecks.
+ - name: Build & start backend stack
+ run: |
+ docker compose -f docker/docker-compose.e2e.yml \
+ up -d --build --wait --wait-timeout 300
+
+ - name: Show backend stack status
+ if: always()
+ run: docker compose -f docker/docker-compose.e2e.yml ps
+
+ - name: Register E2E test user
+ run: |
+ # 200/201 = created, 400 = already exists (idempotent across reruns).
+ STATUS=$(curl -s -o /tmp/register.json -w "%{http_code}" \
+ -X POST http://localhost:8000/auth/register \
+ -H "Content-Type: application/json" \
+ -d "{\"email\":\"${PLAYWRIGHT_TEST_EMAIL}\",\"password\":\"${PLAYWRIGHT_TEST_PASSWORD}\"}")
+ echo "Register status: ${STATUS}"
+ cat /tmp/register.json
+ if [ "${STATUS}" != "200" ] && [ "${STATUS}" != "201" ] && [ "${STATUS}" != "400" ]; then
+ echo "::error::Failed to register test user (status ${STATUS})"
+ exit 1
+ fi
+
+ # Flush auth rate-limit counters so Playwright starts clean.
+ docker compose -f docker/docker-compose.e2e.yml exec -T redis \
+ sh -c "redis-cli --scan --pattern 'surfsense:auth_rate_limit:*' \
+ | xargs -r redis-cli DEL" || true
+
+ # ─── Frontend (host-side) ──────────────────────────────────────────
+ # Playwright's webServer block in playwright.config.ts spawns
+ # `pnpm build && pnpm start` in CI mode and waits for :3000.
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '20'
+
+ - uses: pnpm/action-setup@v6
+
+ - name: Get pnpm store directory
+ id: pnpm-cache
+ shell: bash
+ run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
+
+ - name: Cache pnpm store
+ uses: actions/cache@v5
+ with:
+ path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
+ key: pnpm-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}
+ restore-keys: pnpm-${{ runner.os }}-
+
+ - name: Install web dependencies
+ working-directory: surfsense_web
+ run: pnpm install --frozen-lockfile
+
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ working-directory: surfsense_web
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Install Playwright system deps (cache hit)
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ working-directory: surfsense_web
+ run: pnpm exec playwright install-deps chromium
+
+ - name: Cache Next.js build
+ uses: actions/cache@v5
+ with:
+ path: surfsense_web/.next/cache
+ key: nextjs-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}-${{ github.sha }}
+ restore-keys: |
+ nextjs-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}-
+ nextjs-${{ runner.os }}-
+
+ # ─── Tests ─────────────────────────────────────────────────────────
+ - name: Run Playwright tests
+ working-directory: surfsense_web
+ run: pnpm test:e2e:prod
+
+ # ─── Failure diagnostics ───────────────────────────────────────────
+ - name: Dump backend stack logs on failure
+ if: ${{ failure() || cancelled() }}
+ run: |
+ mkdir -p ./compose-logs
+ docker compose -f docker/docker-compose.e2e.yml logs --no-color --timestamps \
+ > ./compose-logs/all-services.log 2>&1 || true
+ for svc in db redis backend celery_worker; do
+ docker compose -f docker/docker-compose.e2e.yml logs --no-color --timestamps "$svc" \
+ > "./compose-logs/${svc}.log" 2>&1 || true
+ done
+ docker compose -f docker/docker-compose.e2e.yml ps \
+ > ./compose-logs/ps.txt 2>&1 || true
+
+ # ─── Artifacts ─────────────────────────────────────────────────────
+ - name: Upload Playwright HTML report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: playwright-report
+ path: surfsense_web/playwright-report/
+ retention-days: 14
+
+ - name: Upload Playwright traces
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: playwright-traces
+ path: surfsense_web/test-results/
+ retention-days: 14
+
+ - name: Upload backend stack logs
+ if: ${{ failure() || cancelled() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: backend-stack-logs
+ path: ./compose-logs/
+ retention-days: 7
+
+ # ─── Teardown ──────────────────────────────────────────────────────
+ - name: Tear down backend stack
+ if: always()
+ run: docker compose -f docker/docker-compose.e2e.yml down -v --remove-orphans
diff --git a/.github/workflows/notary-status.yml b/.github/workflows/notary-status.yml
new file mode 100644
index 0000000000..5c7c420386
--- /dev/null
+++ b/.github/workflows/notary-status.yml
@@ -0,0 +1,60 @@
+name: Notary status check
+
+# One-off diagnostic workflow. Queries Apple's notary service to see if your
+# submissions are queued, in progress, accepted, or rejected. Useful when a
+# notarization seems "hung" — most often the queue itself, especially on a
+# brand-new Apple Developer account.
+#
+# Run via: Actions tab -> "Notary status check" -> Run workflow.
+# Inputs are optional; if you provide a submission ID, it also fetches that
+# submission's full Apple log.
+#
+# Safe to delete after diagnosis.
+
+on:
+ workflow_dispatch:
+ inputs:
+ submission_id:
+ description: 'Optional: submission UUID to fetch full Apple log for'
+ required: false
+ default: ''
+
+jobs:
+ status:
+ runs-on: macos-latest
+ steps:
+ - name: List recent notarization submissions
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ set -euo pipefail
+ echo "::group::Submission history (most recent first)"
+ xcrun notarytool history \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID"
+ echo "::endgroup::"
+
+ - name: Inspect specific submission (if id provided)
+ if: ${{ inputs.submission_id != '' }}
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ SUBMISSION_ID: ${{ inputs.submission_id }}
+ run: |
+ set -euo pipefail
+ echo "::group::Submission info"
+ xcrun notarytool info "$SUBMISSION_ID" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID"
+ echo "::endgroup::"
+ echo "::group::Apple's processing log for this submission"
+ xcrun notarytool log "$SUBMISSION_ID" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" || true
+ echo "::endgroup::"
diff --git a/.github/workflows/obsidian-plugin-lint.yml b/.github/workflows/obsidian-plugin-lint.yml
new file mode 100644
index 0000000000..42bd099b12
--- /dev/null
+++ b/.github/workflows/obsidian-plugin-lint.yml
@@ -0,0 +1,39 @@
+name: Obsidian Plugin Lint
+
+# Lints + type-checks + builds the Obsidian plugin on every push/PR that
+# touches its sources. The official obsidian-sample-plugin template ships
+# its own ESLint+esbuild setup; we run that here instead of folding the
+# plugin into the monorepo's Biome-based code-quality.yml so the tooling
+# stays aligned with what `obsidianmd/eslint-plugin-obsidianmd` checks
+# against.
+
+on:
+ push:
+ branches: ["**"]
+ paths:
+ - "surfsense_obsidian/**"
+ - ".github/workflows/obsidian-plugin-lint.yml"
+ pull_request:
+ branches: ["**"]
+ paths:
+ - "surfsense_obsidian/**"
+ - ".github/workflows/obsidian-plugin-lint.yml"
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: surfsense_obsidian
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22.x
+ cache: npm
+ cache-dependency-path: surfsense_obsidian/package-lock.json
+
+ - run: npm ci
+ - run: npm run lint
+ - run: npm run build
diff --git a/.github/workflows/release-obsidian-plugin.yml b/.github/workflows/release-obsidian-plugin.yml
new file mode 100644
index 0000000000..dfe15e7d65
--- /dev/null
+++ b/.github/workflows/release-obsidian-plugin.yml
@@ -0,0 +1,119 @@
+name: Release Obsidian Plugin
+
+# Tag format: `obsidian-v` and `` must match `surfsense_obsidian/manifest.json` exactly.
+on:
+ push:
+ tags:
+ - "obsidian-v*"
+ workflow_dispatch:
+ inputs:
+ publish:
+ description: "Publish to GitHub Releases"
+ required: true
+ type: choice
+ options:
+ - never
+ - always
+ default: "never"
+
+permissions:
+ contents: write
+
+jobs:
+ build-and-release:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: surfsense_obsidian
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ # Need write access for the manifest/versions.json mirror commit
+ # back to main further down.
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22.x
+ cache: npm
+ cache-dependency-path: surfsense_obsidian/package-lock.json
+
+ - name: Resolve plugin version
+ id: version
+ run: |
+ manifest_version=$(node -p "require('./manifest.json').version")
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ # Manual runs derive the release version from manifest.json.
+ version="$manifest_version"
+ tag="obsidian-v$version"
+ else
+ tag="${GITHUB_REF_NAME}"
+ if [ -z "$tag" ] || [[ "$tag" != obsidian-v* ]]; then
+ echo "::error::Invalid tag '$tag'. Expected format: obsidian-v"
+ exit 1
+ fi
+ version="${tag#obsidian-v}"
+ if [ "$version" != "$manifest_version" ]; then
+ echo "::error::Tag version '$version' does not match manifest version '$manifest_version'"
+ exit 1
+ fi
+ fi
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Resolve publish mode
+ id: release_mode
+ run: |
+ if [ "${{ github.event_name }}" = "push" ] || [ "${{ inputs.publish }}" = "always" ]; then
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - run: npm ci
+
+ - run: npm run lint
+
+ - run: npm run build
+
+ - name: Verify build artifacts
+ run: |
+ for f in main.js manifest.json styles.css; do
+ test -f "$f" || (echo "::error::Missing release artifact: $f" && exit 1)
+ done
+
+ - name: Mirror manifest.json + versions.json to repo root
+ if: steps.release_mode.outputs.should_publish == 'true'
+ working-directory: ${{ github.workspace }}
+ run: |
+ cp surfsense_obsidian/manifest.json manifest.json
+ cp surfsense_obsidian/versions.json versions.json
+ if git diff --quiet manifest.json versions.json; then
+ echo "Root manifest/versions already up to date."
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add manifest.json versions.json
+ git commit -m "chore(obsidian-plugin): mirror manifest+versions for ${{ steps.version.outputs.tag }}"
+ # Push to the default branch so Obsidian can fetch raw files from HEAD.
+ if ! git push origin HEAD:${{ github.event.repository.default_branch }}; then
+ echo "::warning::Failed to push mirrored manifest/versions to default branch (likely branch protection). Continuing release."
+ fi
+
+ # Publish release under bare `manifest.json` version (no `obsidian-v` prefix) for BRAT/store compatibility.
+ # `make_latest: "false"` keeps the desktop app's `v*` release headlined since Obsidian and BRAT resolve plugins via getReleaseByTag, not the latest flag.
+ - name: Create GitHub release
+ if: steps.release_mode.outputs.should_publish == 'true'
+ uses: softprops/action-gh-release@v3
+ with:
+ tag_name: ${{ steps.version.outputs.version }}
+ name: SurfSense Obsidian Plugin ${{ steps.version.outputs.version }}
+ generate_release_notes: true
+ make_latest: "false"
+ files: |
+ surfsense_obsidian/main.js
+ surfsense_obsidian/manifest.json
+ surfsense_obsidian/styles.css
diff --git a/.gitignore b/.gitignore
index 7a76816819..fd0d4da2a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,15 @@ node_modules/
deepagents/.env.local
.env.local
.idea/
+debug.log
+
+references/
+references
+
+# Playwright (E2E test artifacts)
+surfsense_web/playwright/.auth/
+surfsense_web/playwright-report/
+surfsense_web/test-results/
+surfsense_web/blob-report/
+
+content_research/
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 029e7c6475..ad8f8f2a70 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -26,7 +26,16 @@
"pythonArgs": [
"run",
"python"
- ]
+ ],
+ // Mute LangGraph/Pydantic checkpoint serializer warnings
+ // (UserWarnings emitted from pydantic/main.py when the
+ // runtime snapshots a SurfSenseContextSchema into a field
+ // typed `None`) so the debugger's "Raised Exceptions"
+ // breakpoint doesn't pause on a known-harmless event.
+ // Production logs are unaffected.
+ "env": {
+ "PYTHONWARNINGS": "ignore::UserWarning:pydantic.main"
+ }
},
{
"name": "Backend: FastAPI (No Reload)",
@@ -40,7 +49,10 @@
"pythonArgs": [
"run",
"python"
- ]
+ ],
+ "env": {
+ "PYTHONWARNINGS": "ignore::UserWarning:pydantic.main"
+ }
},
{
"name": "Backend: FastAPI (main.py)",
@@ -54,7 +66,10 @@
"pythonArgs": [
"run",
"python"
- ]
+ ],
+ "env": {
+ "PYTHONWARNINGS": "ignore::UserWarning:pydantic.main"
+ }
},
{
"name": "Frontend: Next.js",
@@ -104,7 +119,10 @@
"pythonArgs": [
"run",
"python"
- ]
+ ],
+ "env": {
+ "PYTHONWARNINGS": "ignore::UserWarning:pydantic.main"
+ }
},
{
"name": "Celery: Beat Scheduler",
@@ -124,7 +142,10 @@
"pythonArgs": [
"run",
"python"
- ]
+ ],
+ "env": {
+ "PYTHONWARNINGS": "ignore::UserWarning:pydantic.main"
+ }
}
],
"compounds": [
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 05bd307022..7da4b54f83 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,9 @@
{
"biome.configurationPath": "./surfsense_web/biome.json",
- "deepscan.ignoreConfirmWarning": true
+ "deepscan.ignoreConfirmWarning": true,
+ "python.defaultInterpreterPath": "${workspaceFolder}/surfsense_backend/.venv/bin/python",
+ "basedpyright.analysis.extraPaths": [
+ "${workspaceFolder}/surfsense_backend"
+ ],
+ "python-envs.pythonProjects": []
}
\ No newline at end of file
diff --git a/README.es.md b/README.es.md
index 299c6e95cc..dea86a7936 100644
--- a/README.es.md
+++ b/README.es.md
@@ -41,7 +41,7 @@ NotebookLM es una de las mejores y más útiles plataformas de IA que existen, p
- **Sin Dependencia de Proveedores** - Configura cualquier modelo LLM, de imagen, TTS y STT.
- **25+ Fuentes de Datos Externas** - Agrega tus fuentes desde Google Drive, OneDrive, Dropbox, Notion y muchos otros servicios externos.
- **Soporte Multijugador en Tiempo Real** - Trabaja fácilmente con los miembros de tu equipo en un notebook compartido.
-- **Aplicación de Escritorio** - Obtén asistencia de IA en cualquier aplicación con Quick Assist, General Assist, Extreme Assist y sincronización de carpetas locales.
+- **Aplicación de Escritorio** - Obtén asistencia de IA en cualquier aplicación con Quick Assist, General Assist, Screenshot Assist y sincronización de carpetas locales.
...y más por venir.
@@ -84,9 +84,9 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
- - Aplicación de Escritorio — Extreme Assist
+ - Aplicación de Escritorio — Screenshot Assist
-
+
- Aplicación de Escritorio — Watch Local Folder
@@ -150,7 +150,7 @@ La aplicación de escritorio incluye estas potentes funciones:
- **General Assist** — Lanza SurfSense al instante desde cualquier aplicación con un atajo global.
- **Quick Assist** — Selecciona texto en cualquier lugar, luego pide a la IA que lo explique, reescriba o actúe sobre él.
-- **Extreme Assist** — Obtén sugerencias de escritura en línea impulsadas por tu base de conocimiento mientras escribes en cualquier aplicación.
+- **Screenshot Assist** — Selecciona una región de tu pantalla y adjúntala al chat para que las respuestas se basen en tu base de conocimiento.
- **Watch Local Folder** — Vigila una carpeta local y sincroniza automáticamente los cambios de archivos con tu base de conocimiento. **Pro tip:** Apúntalo a tu bóveda de Obsidian para mantener tus notas buscables en SurfSense.
Todas las funciones operan contra tu espacio de búsqueda elegido, por lo que tus respuestas siempre están basadas en tus propios datos.
@@ -199,14 +199,14 @@ Todas las funciones operan contra tu espacio de búsqueda elegido, por lo que tu
| **Generación de Videos** | Resúmenes en video cinemáticos vía Veo 3 (solo Ultra) | Disponible (NotebookLM es mejor aquí, mejorando activamente) |
| **Generación de Presentaciones** | Diapositivas más atractivas pero no editables | Crea presentaciones editables basadas en diapositivas |
| **Generación de Podcasts** | Resúmenes de audio con hosts e idiomas personalizables | Disponible con múltiples proveedores TTS (NotebookLM es mejor aquí, mejorando activamente) |
-| **Aplicación de Escritorio** | No | Aplicación nativa con General Assist, Quick Assist, Extreme Assist y sincronización de carpetas locales |
+| **Aplicación de Escritorio** | No | Aplicación nativa con General Assist, Quick Assist, Screenshot Assist y sincronización de carpetas locales |
| **Extensión de Navegador** | No | Extensión multi-navegador para guardar cualquier página web, incluyendo páginas protegidas por autenticación |
Lista completa de Fuentes Externas
-Motores de Búsqueda (Tavily, LinkUp) · SearxNG · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · Videos de YouTube · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, y más por venir.
+Motores de Búsqueda (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · Videos de YouTube · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, y más por venir.
diff --git a/README.hi.md b/README.hi.md
index 11a25ee0d2..43e24c3ee6 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -41,7 +41,7 @@ NotebookLM वहाँ उपलब्ध सबसे अच्छे और
- **कोई विक्रेता लॉक-इन नहीं** - किसी भी LLM, इमेज, TTS और STT मॉडल को कॉन्फ़िगर करें।
- **25+ बाहरी डेटा स्रोत** - Google Drive, OneDrive, Dropbox, Notion और कई अन्य बाहरी सेवाओं से अपने स्रोत जोड़ें।
- **रीयल-टाइम मल्टीप्लेयर सपोर्ट** - एक साझा notebook में अपनी टीम के सदस्यों के साथ आसानी से काम करें।
-- **डेस्कटॉप ऐप** - Quick Assist, General Assist, Extreme Assist और लोकल फ़ोल्डर सिंक के साथ किसी भी एप्लिकेशन में AI सहायता प्राप्त करें।
+- **डेस्कटॉप ऐप** - Quick Assist, General Assist, Screenshot Assist और लोकल फ़ोल्डर सिंक के साथ किसी भी एप्लिकेशन में AI सहायता प्राप्त करें।
...और भी बहुत कुछ आने वाला है।
@@ -84,9 +84,9 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
- डेस्कटॉप ऐप — Watch Local Folder
@@ -150,7 +150,7 @@ SurfSense एक डेस्कटॉप ऐप भी प्रदान क
- **General Assist** — एक ग्लोबल शॉर्टकट से किसी भी एप्लिकेशन से तुरंत SurfSense लॉन्च करें।
- **Quick Assist** — कहीं भी टेक्स्ट चुनें, फिर AI से समझाने, फिर से लिखने या उस पर कार्रवाई करने को कहें।
-- **Extreme Assist** — किसी भी ऐप में टाइप करते समय अपनी नॉलेज बेस से संचालित इनलाइन लेखन सुझाव प्राप्त करें।
+- **Screenshot Assist** — स्क्रीन पर एक क्षेत्र चुनें और उसे चैट में जोड़ें, ताकि उत्तर आपकी नॉलेज बेस पर आधारित रहें।
- **Watch Local Folder** — एक लोकल फ़ोल्डर को वॉच करें और फ़ाइल परिवर्तनों को स्वचालित रूप से अपनी नॉलेज बेस में सिंक करें। **Pro tip:** इसे अपने Obsidian vault पर पॉइंट करें ताकि आपके नोट्स SurfSense में सर्च करने योग्य रहें।
सभी सुविधाएं आपके चुने हुए सर्च स्पेस पर काम करती हैं, ताकि आपके उत्तर हमेशा आपके अपने डेटा पर आधारित हों।
@@ -199,14 +199,14 @@ SurfSense एक डेस्कटॉप ऐप भी प्रदान क
| **वीडियो जनरेशन** | Veo 3 के माध्यम से सिनेमैटिक वीडियो ओवरव्यू (केवल Ultra) | उपलब्ध (NotebookLM यहाँ बेहतर है, सक्रिय रूप से सुधार हो रहा है) |
| **प्रेजेंटेशन जनरेशन** | बेहतर दिखने वाली स्लाइड्स लेकिन संपादन योग्य नहीं | संपादन योग्य, स्लाइड आधारित प्रेजेंटेशन बनाएं |
| **पॉडकास्ट जनरेशन** | कस्टमाइज़ेबल होस्ट और भाषाओं के साथ ऑडियो ओवरव्यू | कई TTS प्रदाताओं के साथ उपलब्ध (NotebookLM यहाँ बेहतर है, सक्रिय रूप से सुधार हो रहा है) |
-| **डेस्कटॉप ऐप** | नहीं | General Assist, Quick Assist, Extreme Assist और लोकल फ़ोल्डर सिंक के साथ नेटिव ऐप |
+| **डेस्कटॉप ऐप** | नहीं | General Assist, Quick Assist, Screenshot Assist और लोकल फ़ोल्डर सिंक के साथ नेटिव ऐप |
| **ब्राउज़र एक्सटेंशन** | नहीं | किसी भी वेबपेज को सहेजने के लिए क्रॉस-ब्राउज़र एक्सटेंशन, प्रमाणीकरण सुरक्षित पेज सहित |
बाहरी स्रोतों की पूरी सूची
-सर्च इंजन (Tavily, LinkUp) · SearxNG · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube वीडियो · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, और भी बहुत कुछ आने वाला है।
+सर्च इंजन (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube वीडियो · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, और भी बहुत कुछ आने वाला है।
diff --git a/README.md b/README.md
index 035c4f5158..ab9f9e2211 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,8 @@ NotebookLM is one of the best and most useful AI platforms out there, but once y
- **No Vendor Lock-in** - Configure any LLM, image, TTS, and STT models to use.
- **25+ External Data Sources** - Add your sources from Google Drive, OneDrive, Dropbox, Notion, and many other external services.
- **Real-Time Multiplayer Support** - Work easily with your team members in a shared notebook.
-- **Desktop App** - Get AI assistance in any application with Quick Assist, General Assist, Extreme Assist, and local folder sync.
+- **AI File Sorting** - Automatically organize your documents into a smart folder hierarchy using AI-powered categorization by source, date, and topic.
+- **Desktop App** - Get AI assistance in any application with Quick Assist, General Assist, Screenshot Assist, and local folder sync.
...and more to come.
@@ -84,9 +85,9 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
- Desktop App — Watch Local Folder
@@ -150,7 +151,7 @@ The desktop app includes these powerful features:
- **General Assist** — Launch SurfSense instantly from any application with a global shortcut.
- **Quick Assist** — Select text anywhere, then ask AI to explain, rewrite, or act on it.
-- **Extreme Assist** — Get inline writing suggestions powered by your knowledge base as you type in any app.
+- **Screenshot Assist** — Select a region on your screen and attach it to chat so answers stay grounded in your knowledge base.
- **Watch Local Folder** — Watch a local folder and automatically sync file changes to your knowledge base. **Pro tip:** Point it at your Obsidian vault to keep your notes searchable in SurfSense.
All features operate against your chosen search space, so your answers are always grounded in your own data.
@@ -199,14 +200,15 @@ All features operate against your chosen search space, so your answers are alway
| **Video Generation** | Cinematic Video Overviews via Veo 3 (Ultra only) | Available (NotebookLM is better here, actively improving) |
| **Presentation Generation** | Better looking slides but not editable | Create editable, slide-based presentations |
| **Podcast Generation** | Audio Overviews with customizable hosts and languages | Available with multiple TTS providers (NotebookLM is better here, actively improving) |
-| **Desktop App** | No | Native app with General Assist, Quick Assist, Extreme Assist, and local folder sync |
+| **AI File Sorting** | No | LLM-powered auto-categorization into source, date, category, and subcategory folders |
+| **Desktop App** | No | Native app with General Assist, Quick Assist, Screenshot Assist, and local folder sync |
| **Browser Extension** | No | Cross-browser extension to save any webpage, including auth-protected pages |
Full list of External Sources
-Search Engines (Tavily, LinkUp) · SearxNG · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube Videos · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, and more to come.
+Search Engines (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube Videos · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, and more to come.
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 9323b2bcec..fcb004cd66 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -41,7 +41,7 @@ O NotebookLM é uma das melhores e mais úteis plataformas de IA disponíveis, m
- **Sem Dependência de Fornecedor** - Configure qualquer modelo LLM, de imagem, TTS e STT.
- **25+ Fontes de Dados Externas** - Adicione suas fontes do Google Drive, OneDrive, Dropbox, Notion e muitos outros serviços externos.
- **Suporte Multiplayer em Tempo Real** - Trabalhe facilmente com os membros da sua equipe em um notebook compartilhado.
-- **Aplicativo Desktop** - Obtenha assistência de IA em qualquer aplicativo com Quick Assist, General Assist, Extreme Assist e sincronização de pastas locais.
+- **Aplicativo Desktop** - Obtenha assistência de IA em qualquer aplicativo com Quick Assist, General Assist, Screenshot Assist e sincronização de pastas locais.
...e mais por vir.
@@ -84,9 +84,9 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
- Aplicativo Desktop — Watch Local Folder
@@ -150,7 +150,7 @@ O aplicativo desktop inclui estes recursos poderosos:
- **General Assist** — Abra o SurfSense instantaneamente de qualquer aplicativo com um atalho global.
- **Quick Assist** — Selecione texto em qualquer lugar, depois peça à IA para explicar, reescrever ou agir sobre ele.
-- **Extreme Assist** — Receba sugestões de escrita em linha alimentadas pela sua base de conhecimento enquanto digita em qualquer aplicativo.
+- **Screenshot Assist** — Selecione uma região da tela e anexe ao chat para respostas fundamentadas na sua base de conhecimento.
- **Watch Local Folder** — Monitore uma pasta local e sincronize automaticamente as alterações de arquivos com sua base de conhecimento. **Pro tip:** Aponte para seu cofre do Obsidian para manter suas notas pesquisáveis no SurfSense.
Todos os recursos operam no espaço de busca escolhido, para que suas respostas sejam sempre baseadas nos seus próprios dados.
@@ -199,14 +199,14 @@ Todos os recursos operam no espaço de busca escolhido, para que suas respostas
| **Geração de Vídeos** | Visões gerais cinemáticas via Veo 3 (apenas Ultra) | Disponível (NotebookLM é melhor aqui, melhorando ativamente) |
| **Geração de Apresentações** | Slides mais bonitos mas não editáveis | Cria apresentações editáveis baseadas em slides |
| **Geração de Podcasts** | Visões gerais em áudio com hosts e idiomas personalizáveis | Disponível com múltiplos provedores TTS (NotebookLM é melhor aqui, melhorando ativamente) |
-| **Aplicativo Desktop** | Não | Aplicativo nativo com General Assist, Quick Assist, Extreme Assist e sincronização de pastas locais |
+| **Aplicativo Desktop** | Não | Aplicativo nativo com General Assist, Quick Assist, Screenshot Assist e sincronização de pastas locais |
| **Extensão de Navegador** | Não | Extensão multi-navegador para salvar qualquer página web, incluindo páginas protegidas por autenticação |
Lista completa de Fontes Externas
-Mecanismos de Busca (Tavily, LinkUp) · SearxNG · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · Vídeos do YouTube · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, e mais por vir.
+Mecanismos de Busca (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · Vídeos do YouTube · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, e mais por vir.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 29200243b5..a07f4afdcc 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -41,7 +41,7 @@ NotebookLM 是目前最好、最实用的 AI 平台之一,但当你开始经
- **无供应商锁定** - 配置任何 LLM、图像、TTS 和 STT 模型。
- **25+ 外部数据源** - 从 Google Drive、OneDrive、Dropbox、Notion 和许多其他外部服务添加你的来源。
- **实时多人协作支持** - 在共享笔记本中轻松与团队成员协作。
-- **桌面应用** - 通过 Quick Assist、General Assist、Extreme Assist 和本地文件夹同步在任何应用程序中获得 AI 助手。
+- **桌面应用** - 通过 Quick Assist、General Assist、Screenshot Assist 和本地文件夹同步在任何应用程序中获得 AI 助手。
...更多功能即将推出。
@@ -84,9 +84,9 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
- 桌面应用 — Watch Local Folder
@@ -150,7 +150,7 @@ SurfSense 还提供桌面应用,将 AI 助手带到您计算机上的每个应
- **General Assist** — 通过全局快捷键从任何应用程序即时启动 SurfSense。
- **Quick Assist** — 在任何位置选中文本,然后让 AI 解释、改写或对其执行操作。
-- **Extreme Assist** — 在任何应用中输入时,获得基于您知识库的内联写作建议。
+- **Screenshot Assist** — 在屏幕上框选区域并附加到聊天,让回复基于您的知识库。
- **Watch Local Folder** — 监视本地文件夹,自动将文件更改同步到您的知识库。**Pro tip:** 将其指向您的 Obsidian vault,让笔记在 SurfSense 中随时可搜索。
所有功能均基于您选择的搜索空间运行,确保回答始终以您自己的数据为依据。
@@ -199,14 +199,14 @@ SurfSense 还提供桌面应用,将 AI 助手带到您计算机上的每个应
| **视频生成** | 通过 Veo 3 的电影级视频概览(仅 Ultra) | 可用(NotebookLM 在此方面更好,正在积极改进) |
| **演示文稿生成** | 更美观的幻灯片但不可编辑 | 创建可编辑的幻灯片式演示文稿 |
| **播客生成** | 可自定义主持人和语言的音频概览 | 可用,支持多种 TTS 提供商(NotebookLM 在此方面更好,正在积极改进) |
-| **桌面应用** | 否 | 原生应用,包含 General Assist、Quick Assist、Extreme Assist 和本地文件夹同步 |
+| **桌面应用** | 否 | 原生应用,包含 General Assist、Quick Assist、Screenshot Assist 和本地文件夹同步 |
| **浏览器扩展** | 否 | 跨浏览器扩展,保存任何网页,包括需要身份验证的页面 |
外部数据源完整列表
-搜索引擎(Tavily、LinkUp)· SearxNG · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube 视频 · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian,更多即将推出。
+搜索引擎(SearXNG、Tavily、LinkUp、Baidu Search)· Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube 视频 · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian,更多即将推出。
diff --git a/VERSION b/VERSION
index ceddfb28f4..b056f4120e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.15
+0.0.24
diff --git a/docker/.env.example b/docker/.env.example
index 4394929a70..4a396d2d0f 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -4,7 +4,7 @@
# Database, Redis, and internal service wiring are handled automatically.
# ==============================================================================
-# SurfSense version (use "latest", a clean version like "0.0.14", or a specific build like "0.0.14.1")
+# SurfSense version (use "latest" or a specific version like "0.0.14")
SURFSENSE_VERSION=latest
# ------------------------------------------------------------------------------
@@ -74,6 +74,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com
+# FASTAPI_BACKEND_INTERNAL_URL=http://backend:8000
# ------------------------------------------------------------------------------
# Zero-cache (real-time sync)
@@ -161,6 +162,14 @@ STRIPE_PAGE_BUYING_ENABLED=FALSE
# STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10
# STRIPE_RECONCILIATION_BATCH_SIZE=100
+# Premium credit purchases via Stripe ($1 buys 1_000_000 micro-USD of
+# credit; premium turns debit the actual per-call provider cost
+# reported by LiteLLM, so cheap and expensive models bill proportionally)
+# STRIPE_TOKEN_BUYING_ENABLED=FALSE
+# STRIPE_PREMIUM_TOKEN_PRICE_ID=price_...
+# STRIPE_CREDIT_MICROS_PER_UNIT=1000000
+# DEPRECATED — STRIPE_TOKENS_PER_UNIT=1000000
+
# ------------------------------------------------------------------------------
# TTS & STT (Text-to-Speech / Speech-to-Text)
# ------------------------------------------------------------------------------
@@ -302,6 +311,23 @@ STT_SERVICE=local/base
# Advanced (optional)
# ------------------------------------------------------------------------------
+# New-chat agent feature flags
+SURFSENSE_ENABLE_CONTEXT_EDITING=true
+SURFSENSE_ENABLE_COMPACTION_V2=true
+SURFSENSE_ENABLE_RETRY_AFTER=true
+SURFSENSE_ENABLE_MODEL_FALLBACK=false
+SURFSENSE_ENABLE_MODEL_CALL_LIMIT=true
+SURFSENSE_ENABLE_TOOL_CALL_LIMIT=true
+SURFSENSE_ENABLE_TOOL_CALL_REPAIR=true
+SURFSENSE_ENABLE_BUSY_MUTEX=true
+SURFSENSE_ENABLE_SKILLS=true
+SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=true
+SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE=true
+SURFSENSE_ENABLE_ACTION_LOG=true
+SURFSENSE_ENABLE_REVERT_ROUTE=true
+SURFSENSE_ENABLE_PERMISSION=true
+SURFSENSE_ENABLE_DOOM_LOOP=true
+
# Periodic connector sync interval (default: 5m)
# SCHEDULE_CHECKER_INTERVAL=5m
@@ -312,6 +338,41 @@ STT_SERVICE=local/base
# Pages limit per user for ETL (default: unlimited)
# PAGES_LIMIT=500
+# Premium credit quota per registered user, in micro-USD (default: $5).
+# Premium turns are debited at the actual per-call provider cost reported
+# by LiteLLM. Only applies to models with billing_tier=premium.
+# PREMIUM_CREDIT_MICROS_LIMIT=5000000
+# DEPRECATED — PREMIUM_TOKEN_LIMIT=5000000
+
+# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
+# QUOTA_MAX_RESERVE_MICROS=1000000
+
+# Per-image reservation for POST /image-generations, in micro-USD ($0.05 default).
+# QUOTA_DEFAULT_IMAGE_RESERVE_MICROS=50000
+
+# Per-podcast reservation for the podcast Celery task ($0.20 default).
+# QUOTA_DEFAULT_PODCAST_RESERVE_MICROS=200000
+
+# Per-video-presentation reservation for the video Celery task ($1.00 default).
+# Override path bypasses QUOTA_MAX_RESERVE_MICROS clamp — raise with care.
+# QUOTA_DEFAULT_VIDEO_PRESENTATION_RESERVE_MICROS=1000000
+
+# No-login (anonymous) mode — public users can chat without an account
+# Set TRUE to enable /free pages and anonymous chat API
+NOLOGIN_MODE_ENABLED=FALSE
+# ANON_TOKEN_LIMIT=1000000
+# ANON_TOKEN_WARNING_THRESHOLD=800000
+# ANON_TOKEN_QUOTA_TTL_DAYS=30
+# ANON_MAX_UPLOAD_SIZE_MB=5
+# QUOTA_MAX_RESERVE_PER_CALL=8000
+# Abuse prevention: max concurrent anonymous streams per IP
+# ANON_MAX_CONCURRENT_STREAMS=2
+# Number of chat requests per IP before Turnstile CAPTCHA is required
+# ANON_CAPTCHA_REQUEST_THRESHOLD=5
+# Cloudflare Turnstile CAPTCHA (https://dash.cloudflare.com/ -> Turnstile)
+# TURNSTILE_ENABLED=FALSE
+# TURNSTILE_SECRET_KEY=
+
# Connector indexing lock TTL in seconds (default: 28800 = 8 hours)
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
diff --git a/docker/docker-compose.deps-only.yml b/docker/docker-compose.deps-only.yml
new file mode 100644
index 0000000000..2be0bfe6e3
--- /dev/null
+++ b/docker/docker-compose.deps-only.yml
@@ -0,0 +1,139 @@
+# =============================================================================
+# SurfSense — Dependencies only (no backend / frontend / Celery images)
+# =============================================================================
+# Postgres, Redis, SearXNG, pgAdmin, Zero — run API + Next + Celery on the host.
+# Celery is not Dockerized here: use `uv run` from surfsense_backend/ (no extra
+# backend image build just for workers).
+#
+# From repo root (SurfSense/):
+# docker compose -f docker/docker-compose.deps-only.yml up -d
+#
+# Compose variable substitution uses `docker/.env` (copy from .env.example).
+# Bind mounts use ./postgresql.conf and ./searxng in this directory.
+#
+# Local Celery (from surfsense_backend/, after Redis is up):
+# uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues=surfsense,surfsense.connectors
+# uv run celery -A celery_worker.celery_app beat --loglevel=info
+#
+# Host setup:
+# - Backend .env: DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense
+# - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888}
+# - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
+# - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848}
+#
+# IMPORTANT — schema migrations:
+# This compose file does NOT build the backend image and therefore cannot
+# run a `migrations` service. You MUST run alembic on the host before
+# bringing zero-cache up, or zero-cache will crash-loop with
+# `Unknown or invalid publications. Specified: [zero_publication]`.
+#
+# First-time / after-pull:
+# cd surfsense_backend && uv run alembic upgrade head
+#
+# The other compose files (docker-compose.yml, docker-compose.dev.yml)
+# handle this automatically via a dedicated `migrations` service.
+# =============================================================================
+
+name: surfsense-deps
+
+services:
+ db:
+ image: pgvector/pgvector:pg17
+ ports:
+ - "${POSTGRES_PORT:-5432}:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
+ environment:
+ - POSTGRES_USER=${DB_USER:-postgres}
+ - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
+ - POSTGRES_DB=${DB_NAME:-surfsense}
+ command: postgres -c config_file=/etc/postgresql/postgresql.conf
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-surfsense}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ pgadmin:
+ image: dpage/pgadmin4
+ ports:
+ - "${PGADMIN_PORT:-5050}:80"
+ environment:
+ - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL:-admin@surfsense.com}
+ - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD:-surfsense}
+ volumes:
+ - pgadmin_data:/var/lib/pgadmin
+ depends_on:
+ - db
+
+ redis:
+ image: redis:8-alpine
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ volumes:
+ - redis_data:/data
+ command: redis-server --appendonly yes
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ searxng:
+ image: searxng/searxng:2026.3.13-3c1f68c59
+ ports:
+ - "${SEARXNG_PORT:-8888}:8080"
+ volumes:
+ - ./searxng:/etc/searxng
+ environment:
+ - SEARXNG_SECRET=${SEARXNG_SECRET:-surfsense-searxng-secret}
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ # NOTE: zero-cache requires the `zero_publication` Postgres publication to
+ # exist before it starts. In this deps-only stack there is no backend
+ # container to run migrations, so you must run `uv run alembic upgrade head`
+ # from `surfsense_backend/` on the host BEFORE `docker compose up -d`.
+ zero-cache:
+ image: rocicorp/zero:1.4.0
+ ports:
+ - "${ZERO_CACHE_PORT:-4848}:4848"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ - ZERO_UPSTREAM_DB=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}
+ - ZERO_CVR_DB=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}
+ - ZERO_CHANGE_DB=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}
+ - ZERO_REPLICA_FILE=/data/zero.db
+ - ZERO_ADMIN_PASSWORD=${ZERO_ADMIN_PASSWORD:-surfsense-zero-admin}
+ - ZERO_APP_PUBLICATIONS=${ZERO_APP_PUBLICATIONS:-zero_publication}
+ - ZERO_NUM_SYNC_WORKERS=${ZERO_NUM_SYNC_WORKERS:-4}
+ - ZERO_UPSTREAM_MAX_CONNS=${ZERO_UPSTREAM_MAX_CONNS:-20}
+ - ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30}
+ - ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://host.docker.internal:3000/api/zero/query}
+ - ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://host.docker.internal:3000/api/zero/mutate}
+ volumes:
+ - zero_cache_data:/data
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ postgres_data:
+ name: surfsense-deps-postgres
+ pgadmin_data:
+ name: surfsense-deps-pgadmin
+ redis_data:
+ name: surfsense-deps-redis
+ zero_cache_data:
+ name: surfsense-deps-zero-cache
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index 666b0c642e..913e3e3ceb 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -10,6 +10,11 @@
name: surfsense-dev
+x-backend-build: &backend-build
+ context: ../surfsense_backend
+ args:
+ EMBEDDING_MODEL: ${EMBEDDING_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
+
services:
db:
image: pgvector/pgvector:pg17
@@ -29,6 +34,25 @@ services:
timeout: 5s
retries: 5
+ # Short-lived schema runner; see docker/docker-compose.yml `migrations`
+ # service for the full rationale. Builds from the same backend context as
+ # the dev backend/celery services.
+ migrations:
+ build: *backend-build
+ env_file:
+ - ../surfsense_backend/.env
+ environment:
+ - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
+ - PYTHONPATH=/app
+ - SERVICE_ROLE=migrate
+ - MIGRATION_TIMEOUT=${MIGRATION_TIMEOUT:-900}
+ volumes:
+ - zero_init:/zero-init
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: "no"
+
pgadmin:
image: dpage/pgadmin4
ports:
@@ -69,7 +93,7 @@ services:
retries: 5
backend:
- build: ../surfsense_backend
+ build: *backend-build
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
@@ -77,6 +101,8 @@ services:
- shared_temp:/shared_tmp
env_file:
- ../surfsense_backend/.env
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}
@@ -104,20 +130,24 @@ services:
condition: service_healthy
searxng:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s
timeout: 5s
retries: 30
start_period: 200s
celery_worker:
- build: ../surfsense_backend
+ build: *backend-build
volumes:
- ../surfsense_backend/app:/app/app
- shared_temp:/shared_tmp
env_file:
- ../surfsense_backend/.env
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}
@@ -132,11 +162,13 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
backend:
condition: service_healthy
celery_beat:
- build: ../surfsense_backend
+ build: *backend-build
env_file:
- ../surfsense_backend/.env
environment:
@@ -151,11 +183,13 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
celery_worker:
condition: service_started
# flower:
- # build: ../surfsense_backend
+ # build: *backend-build
# ports:
# - "${FLOWER_PORT:-5555}:5555"
# env_file:
@@ -170,14 +204,16 @@ services:
# - celery_worker
zero-cache:
- image: rocicorp/zero:0.26.2
+ image: rocicorp/zero:1.4.0
ports:
- "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- backend:
+ db:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
environment:
- ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
- ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
@@ -192,6 +228,12 @@ services:
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
volumes:
- zero_cache_data:/data
+ - zero_init:/zero-init
+ # Wrapper: see docker/docker-compose.yml `zero-cache` for rationale.
+ entrypoint: ["sh", "-c"]
+ # Pass the script as a single list element so Compose does not tokenize it.
+ command:
+ - 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache'
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
@@ -229,3 +271,5 @@ volumes:
name: surfsense-dev-shared-temp
zero_cache_data:
name: surfsense-dev-zero-cache
+ zero_init:
+ name: surfsense-dev-zero-init
diff --git a/docker/docker-compose.e2e.yml b/docker/docker-compose.e2e.yml
new file mode 100644
index 0000000000..2d55595f7d
--- /dev/null
+++ b/docker/docker-compose.e2e.yml
@@ -0,0 +1,181 @@
+# =============================================================================
+# SurfSense — E2E Docker Compose stack
+# =============================================================================
+# Hermetic backend stack for Playwright E2E tests:
+# - db / redis on an internal-only network (no internet egress)
+# - backend (FastAPI) joins the internal network AND a separate ingress
+# bridge so the host runner can reach :8000
+# - celery_worker on the internal network only — zero egress surface
+#
+# The backend image is built from surfsense_backend/Dockerfile target=e2e,
+# which adds tests/ via the `tests-source` additional context (tests/ is
+# excluded from the main context by .dockerignore so production never ships
+# test fakes). See surfsense_backend/Dockerfile for stage layout.
+#
+# Usage from repo root:
+# docker compose -f docker/docker-compose.e2e.yml up -d --build --wait
+# curl -X POST http://localhost:8000/auth/register ...
+# ( run Playwright on host, pointing at localhost:8000 + localhost:3000 )
+# docker compose -f docker/docker-compose.e2e.yml down -v
+# =============================================================================
+
+name: surfsense-e2e
+
+x-backend-env: &backend-env
+ DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/surfsense_e2e
+ CELERY_BROKER_URL: redis://redis:6379/0
+ CELERY_RESULT_BACKEND: redis://redis:6379/0
+ REDIS_APP_URL: redis://redis:6379/0
+ CELERY_TASK_DEFAULT_QUEUE: surfsense
+ SECRET_KEY: ci-test-secret-key-not-for-production
+ AUTH_TYPE: LOCAL
+ REGISTRATION_ENABLED: "TRUE"
+ ETL_SERVICE: DOCLING
+ EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
+ NEXT_FRONTEND_URL: http://host.docker.internal:3000
+ # Sentinel keys — fakes never read them; turns leaked real calls into 401s.
+ COMPOSIO_API_KEY: e2e-deny-real-call-sentinel
+ COMPOSIO_ENABLED: "TRUE"
+ OPENAI_API_KEY: e2e-deny-real-call-sentinel
+ ANTHROPIC_API_KEY: e2e-deny-real-call-sentinel
+ LITELLM_API_KEY: e2e-deny-real-call-sentinel
+ MICROSOFT_CLIENT_ID: fake-microsoft-client-id
+ MICROSOFT_CLIENT_SECRET: fake-microsoft-client-secret
+ ONEDRIVE_REDIRECT_URI: http://localhost:8000/api/v1/auth/onedrive/connector/callback
+ DROPBOX_APP_KEY: fake-dropbox-app-key
+ DROPBOX_APP_SECRET: fake-dropbox-app-secret
+ DROPBOX_REDIRECT_URI: http://localhost:8000/api/v1/auth/dropbox/connector/callback
+ # Defense-in-depth: even though L3 egress is denied for the worker via
+ # `internal: true`, the backend still has a route via `ingress`. Setting
+ # HTTPS_PROXY to an unreachable port turns any leaked Python outbound HTTP
+ # call into a fast Connection refused. UNLIKE the old runner-shell setup,
+ # this proxy is set on the container env and `uv` is never invoked here,
+ # so there is no interaction with uv's implicit-sync behaviour.
+ HTTPS_PROXY: http://127.0.0.1:1
+ HTTP_PROXY: http://127.0.0.1:1
+ NO_PROXY: localhost,127.0.0.1,0.0.0.0,db,redis,host.docker.internal
+ HF_HUB_OFFLINE: "1"
+ TRANSFORMERS_OFFLINE: "1"
+ # Test-only token-mint endpoint secret (see tests/e2e/run_backend.py).
+ E2E_MINT_SECRET: e2e-mint-secret-not-for-production
+
+services:
+ db:
+ image: pgvector/pgvector:pg17
+ command: >
+ postgres
+ -c wal_level=logical
+ -c max_wal_senders=10
+ -c max_replication_slots=10
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: surfsense_e2e
+ # Ephemeral storage — every CI run gets a clean DB, no volume cleanup needed.
+ tmpfs:
+ - /var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d surfsense_e2e"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ networks: [internal]
+
+ redis:
+ image: redis:8-alpine
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ networks: [internal]
+
+ backend:
+ build:
+ context: ../surfsense_backend
+ dockerfile: Dockerfile
+ target: e2e
+ additional_contexts:
+ # tests/ is excluded from the main context by .dockerignore;
+ # the e2e stage's `COPY --from=tests-source` pulls it in here.
+ tests-source: ../surfsense_backend/tests
+ args:
+ EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
+ cache_from:
+ - type=gha,scope=surfsense-e2e-backend
+ cache_to:
+ - type=gha,mode=max,scope=surfsense-e2e-backend
+ image: surfsense-e2e-backend:local
+ environment:
+ <<: *backend-env
+ SERVICE_ROLE: api
+ volumes:
+ - shared_temp:/shared_tmp
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ ports:
+ - "8000:8000"
+ depends_on:
+ db: { condition: service_healthy }
+ redis: { condition: service_healthy }
+ healthcheck:
+ # Use Python (already in the image) instead of curl/wget to avoid
+ # depending on either tool being installed in the runtime layers.
+ test:
+ - CMD
+ - python
+ - -c
+ - |
+ import sys, urllib.request
+ try:
+ r = urllib.request.urlopen("http://localhost:8000/openapi.json", timeout=2)
+ sys.exit(0 if r.status == 200 else 1)
+ except Exception:
+ sys.exit(1)
+ interval: 3s
+ timeout: 5s
+ retries: 60
+ start_period: 30s
+ networks:
+ - internal # to reach db/redis
+ - ingress # so host can reach :8000
+
+ celery_worker:
+ image: surfsense-e2e-backend:local
+ pull_policy: never
+ # No build: section — reuses the image built by the `backend` service.
+ # Compose v2 builds shared images exactly once across services that
+ # reference the same `image:` tag.
+ environment:
+ <<: *backend-env
+ SERVICE_ROLE: worker
+ volumes:
+ - shared_temp:/shared_tmp
+ depends_on:
+ backend: { condition: service_healthy }
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - "celery -A app.celery_app inspect ping --timeout 2 | grep -q pong"
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ start_period: 20s
+ networks: [internal]
+
+networks:
+ # Internal network: containers attached only to this network have NO route
+ # to the host or the internet. This is the L3 deny-egress mechanism that
+ # replaces the fragile HTTPS_PROXY-on-the-runner approach.
+ internal:
+ driver: bridge
+ internal: true
+
+ # Regular bridge network. Only the `backend` service joins it, solely so
+ # the host can reach :8000 via the published port. celery_worker / db /
+ # redis stay off this network entirely.
+ ingress:
+ driver: bridge
+
+volumes:
+ shared_temp:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index c14cd8b091..8683c82585 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -27,6 +27,28 @@ services:
timeout: 5s
retries: 5
+ # Short-lived schema runner. Executes `alembic upgrade head` and verifies
+ # that the `zero_publication` Postgres logical-replication publication
+ # exists, then exits 0. Downstream services (backend, celery_*, zero-cache)
+ # gate on this with `condition: service_completed_successfully` so a failed
+ # migration halts the whole stack instead of silently producing a half-built
+ # system that crash-loops zero-cache on missing publications.
+ migrations:
+ image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
+ PYTHONPATH: /app
+ SERVICE_ROLE: migrate
+ MIGRATION_TIMEOUT: ${MIGRATION_TIMEOUT:-900}
+ volumes:
+ - zero_init:/zero-init
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: "no"
+
redis:
image: redis:8-alpine
volumes:
@@ -60,6 +82,8 @@ services:
- shared_temp:/shared_tmp
env_file:
- .env
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
@@ -86,9 +110,11 @@ services:
condition: service_healthy
searxng:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
restart: unless-stopped
healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s
timeout: 5s
retries: 30
@@ -100,6 +126,8 @@ services:
- shared_temp:/shared_tmp
env_file:
- .env
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
@@ -114,6 +142,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
backend:
condition: service_healthy
labels:
@@ -136,6 +166,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
celery_worker:
condition: service_started
labels:
@@ -159,7 +191,7 @@ services:
# restart: unless-stopped
zero-cache:
- image: rocicorp/zero:0.26.2
+ image: rocicorp/zero:1.4.0
ports:
- "${ZERO_CACHE_PORT:-5929}:4848"
extra_hosts:
@@ -178,10 +210,21 @@ services:
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
volumes:
- zero_cache_data:/data
+ - zero_init:/zero-init
+ # Wrapper: if the migrations service flagged a publication change via
+ # /zero-init/needs_reset, wipe the SQLite replica before starting so
+ # zero-cache does a clean initial sync. Recovers from the half-built
+ # replica state (`_zero.tableMetadata` missing) caused by earlier crashes.
+ entrypoint: ["sh", "-c"]
+ # Pass the script as a single list element so Compose does not tokenize it.
+ command:
+ - 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache'
restart: unless-stopped
depends_on:
- backend:
+ db:
condition: service_healthy
+ migrations:
+ condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
interval: 10s
@@ -198,6 +241,7 @@ services:
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-SSO}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
+ FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000}
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
@@ -216,3 +260,5 @@ volumes:
name: surfsense-shared-temp
zero_cache_data:
name: surfsense-zero-cache
+ zero_init:
+ name: surfsense-zero-init
diff --git a/docker/scripts/install.ps1 b/docker/scripts/install.ps1
index 0eb3886a2e..60c4fd5dfc 100644
--- a/docker/scripts/install.ps1
+++ b/docker/scripts/install.ps1
@@ -97,6 +97,161 @@ function Wait-ForPostgres {
Write-Ok "PostgreSQL is ready."
}
+# ── Stack health helpers ────────────────────────────────────────────────────
+
+function Get-ComposeServices {
+ Push-Location $InstallDir
+ try {
+ $raw = Invoke-NativeSafe { docker compose ps -a --format json 2>$null }
+ } finally {
+ Pop-Location
+ }
+ if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
+
+ # Compose v2.21+ emits a JSON array; older versions emit one object per line.
+ try {
+ $parsed = $raw | ConvertFrom-Json
+ if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) {
+ return @($parsed)
+ }
+ return @($parsed)
+ } catch {
+ $services = @()
+ foreach ($line in ($raw -split "`r?`n")) {
+ $line = $line.Trim()
+ if (-not $line) { continue }
+ try { $services += ($line | ConvertFrom-Json) } catch { }
+ }
+ return $services
+ }
+}
+
+function Wait-StackHealthy {
+ param([int]$TimeoutSec = 300)
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSec)
+ $lastReport = ""
+
+ while ((Get-Date) -lt $deadline) {
+ $services = Get-ComposeServices
+ if (-not $services -or $services.Count -eq 0) {
+ Start-Sleep -Seconds 3
+ continue
+ }
+
+ $bad = @()
+ $waiting = @()
+ $good = @()
+
+ foreach ($svc in $services) {
+ $name = $svc.Service
+ $state = $svc.State
+ $health = if ($svc.PSObject.Properties.Name -contains 'Health') { $svc.Health } else { '' }
+ $exit = if ($svc.PSObject.Properties.Name -contains 'ExitCode') { $svc.ExitCode } else { $null }
+
+ if ($name -eq 'migrations') {
+ if ($state -eq 'exited' -and $exit -eq 0) { $good += $name }
+ elseif ($state -eq 'exited') { $bad += "${name} (exit=${exit})" }
+ else { $waiting += "${name} (${state})" }
+ continue
+ }
+
+ if ($state -eq 'running') {
+ if ([string]::IsNullOrEmpty($health) -or $health -eq 'healthy') {
+ $good += $name
+ } elseif ($health -eq 'starting') {
+ $waiting += "${name} (starting)"
+ } elseif ($health -eq 'unhealthy') {
+ $bad += "${name} (unhealthy)"
+ } else {
+ $waiting += "${name} (${health})"
+ }
+ } elseif ($state -eq 'restarting') {
+ $bad += "${name} (restarting)"
+ } elseif ($state -eq 'exited') {
+ $bad += "${name} (exited, code=${exit})"
+ } else {
+ $waiting += "${name} (${state})"
+ }
+ }
+
+ if ($bad.Count -gt 0) {
+ return @{ Ok = $false; Reason = 'failure'; Bad = $bad; Waiting = $waiting; Good = $good }
+ }
+ if ($waiting.Count -eq 0) {
+ return @{ Ok = $true; Reason = 'all_healthy'; Good = $good }
+ }
+
+ $report = "Waiting on: " + ($waiting -join ', ')
+ if ($report -ne $lastReport) {
+ Write-Info $report
+ $lastReport = $report
+ }
+ Start-Sleep -Seconds 5
+ }
+
+ return @{ Ok = $false; Reason = 'timeout'; Bad = $bad; Waiting = $waiting; Good = $good }
+}
+
+function Test-StaleZeroCacheVolume {
+ $raw = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
+ if ([string]::IsNullOrWhiteSpace($raw)) { return $false }
+ $names = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
+ $hasZeroCache = $names -contains 'surfsense-zero-cache'
+ $hasZeroInit = $names -contains 'surfsense-zero-init'
+ # Pre-fix installs created surfsense-zero-cache but never surfsense-zero-init.
+ # Such a volume may hold a half-initialized SQLite replica from an earlier
+ # crash-loop. Wiping it forces zero-cache to do a fresh initial sync.
+ return ($hasZeroCache -and -not $hasZeroInit)
+}
+
+function Invoke-StaleZeroCacheCleanup {
+ if (-not (Test-StaleZeroCacheVolume)) { return }
+
+ Write-Warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that"
+ Write-Warn "predates the migrations-service fix. It may contain a half-initialized"
+ Write-Warn "SQLite replica that would block zero-cache from starting."
+ Write-Warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel."
+ Start-Sleep -Seconds 5
+
+ Push-Location $InstallDir
+ Invoke-NativeSafe { docker compose down --remove-orphans 2>$null } | Out-Null
+ Pop-Location
+ Invoke-NativeSafe { docker volume rm surfsense-zero-cache 2>$null } | Out-Null
+ Write-Ok "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start."
+}
+
+function Write-Err-NoExit {
+ param([string]$Message)
+ Write-Host "[ERROR] $Message" -ForegroundColor Red
+}
+
+function Invoke-StackFailureReport {
+ param([hashtable]$Result)
+
+ Write-Host ""
+ Write-Err-NoExit "Stack did not reach a healthy state."
+ if ($Result.Bad.Count -gt 0) { Write-Host (" Failed: " + ($Result.Bad -join ', ')) }
+ if ($Result.Waiting.Count -gt 0) { Write-Host (" Stuck: " + ($Result.Waiting -join ', ')) }
+
+ Write-Host ""
+ Write-Info "Recent logs from migrations / zero-cache / backend:"
+ Push-Location $InstallDir
+ try {
+ Invoke-NativeSafe { docker compose logs --tail=60 migrations zero-cache backend 2>&1 } | Write-Host
+ } finally {
+ Pop-Location
+ }
+
+ Write-Host ""
+ Write-Host "Recovery hints:" -ForegroundColor Yellow
+ Write-Host " 1. Inspect migrations: cd $InstallDir; docker compose logs migrations"
+ Write-Host " 2. Verify publication: cd $InstallDir; docker compose exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
+ Write-Host " 3. Hard reset zero db: cd $InstallDir; docker compose down; docker volume rm surfsense-zero-cache; docker compose up -d"
+ Write-Host ""
+ exit 1
+}
+
# ── Download files ──────────────────────────────────────────────────────────
Write-Step "Downloading SurfSense files"
@@ -191,6 +346,8 @@ if (-not (Test-Path $envPath)) {
# ── Start containers ────────────────────────────────────────────────────────
+Invoke-StaleZeroCacheCleanup
+
if ($MigrationMode) {
$envContent = Get-Content $envPath
$DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
@@ -251,7 +408,13 @@ if ($MigrationMode) {
Push-Location $InstallDir
Invoke-NativeSafe { docker compose up -d }
Pop-Location
- Write-Ok "All services started."
+ Write-Ok "All containers started; waiting for stack to become healthy..."
+
+ $waitResult = Wait-StackHealthy -TimeoutSec 300
+ if (-not $waitResult.Ok) {
+ Invoke-StackFailureReport -Result $waitResult
+ }
+ Write-Ok "All services healthy."
Remove-Item $KeyFile -ErrorAction SilentlyContinue
@@ -260,7 +423,13 @@ if ($MigrationMode) {
Push-Location $InstallDir
Invoke-NativeSafe { docker compose up -d }
Pop-Location
- Write-Ok "All services started."
+ Write-Ok "All containers started; waiting for stack to become healthy..."
+
+ $waitResult = Wait-StackHealthy -TimeoutSec 300
+ if (-not $waitResult.Ok) {
+ Invoke-StackFailureReport -Result $waitResult
+ }
+ Write-Ok "All services healthy."
}
# ── Watchtower (auto-update) ────────────────────────────────────────────────
diff --git a/docker/scripts/install.sh b/docker/scripts/install.sh
index fcab4d55a3..db81f95eb3 100644
--- a/docker/scripts/install.sh
+++ b/docker/scripts/install.sh
@@ -97,6 +97,163 @@ wait_for_pg() {
success "PostgreSQL is ready."
}
+# ── Stack health helpers ─────────────────────────────────────────────────────
+
+# Enumerate compose services for project `surfsense` as `service|state|health|exitcode`
+# lines. Uses `docker inspect` so we don't depend on `jq`, `python3`, or the
+# exact ordering of fields in `docker compose ps --format json` output.
+get_compose_services() {
+ local containers
+ containers=$(docker ps -a --filter "label=com.docker.compose.project=surfsense" --format '{{.Names}}' 2>/dev/null) || true
+ [[ -z "$containers" ]] && return 0
+
+ while IFS= read -r container; do
+ [[ -z "$container" ]] && continue
+ local svc state health code
+ svc=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$container" 2>/dev/null || echo "")
+ state=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "unknown")
+ health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || echo "")
+ code=$(docker inspect -f '{{.State.ExitCode}}' "$container" 2>/dev/null || echo "")
+ [[ -z "$svc" ]] && continue
+ printf '%s|%s|%s|%s\n' "$svc" "$state" "$health" "$code"
+ done <<< "$containers"
+}
+
+# Globals populated by wait_stack_healthy / consumed by stack_failure_report.
+STACK_BAD=()
+STACK_WAITING=()
+STACK_GOOD=()
+STACK_TIMEOUT=false
+
+wait_stack_healthy() {
+ local timeout_sec=${1:-300}
+ local deadline=$(($(date +%s) + timeout_sec))
+ local last_report=""
+ local bad=()
+ local waiting=()
+ local good=()
+
+ while [[ $(date +%s) -lt $deadline ]]; do
+ local lines
+ lines=$(get_compose_services)
+ if [[ -z "$lines" ]]; then
+ sleep 3
+ continue
+ fi
+
+ bad=()
+ waiting=()
+ good=()
+
+ while IFS='|' read -r name state health code; do
+ [[ -z "$name" ]] && continue
+ if [[ "$name" == "migrations" ]]; then
+ if [[ "$state" == "exited" && "$code" == "0" ]]; then
+ good+=("$name")
+ elif [[ "$state" == "exited" ]]; then
+ bad+=("${name} (exit=${code})")
+ else
+ waiting+=("${name} (${state})")
+ fi
+ continue
+ fi
+
+ if [[ "$state" == "running" ]]; then
+ if [[ -z "$health" || "$health" == "healthy" ]]; then
+ good+=("$name")
+ elif [[ "$health" == "starting" ]]; then
+ waiting+=("${name} (starting)")
+ elif [[ "$health" == "unhealthy" ]]; then
+ bad+=("${name} (unhealthy)")
+ else
+ waiting+=("${name} (${health})")
+ fi
+ elif [[ "$state" == "restarting" ]]; then
+ bad+=("${name} (restarting)")
+ elif [[ "$state" == "exited" ]]; then
+ bad+=("${name} (exited, code=${code})")
+ else
+ waiting+=("${name} (${state})")
+ fi
+ done <<< "$lines"
+
+ if (( ${#bad[@]} > 0 )); then
+ STACK_BAD=("${bad[@]}")
+ STACK_WAITING=("${waiting[@]}")
+ STACK_GOOD=("${good[@]}")
+ return 1
+ fi
+ if (( ${#waiting[@]} == 0 )); then
+ STACK_GOOD=("${good[@]}")
+ return 0
+ fi
+
+ local report="Waiting on: ${waiting[*]}"
+ if [[ "$report" != "$last_report" ]]; then
+ info "$report"
+ last_report="$report"
+ fi
+ sleep 5
+ done
+
+ # bad/waiting/good are declared at function scope so referencing them is
+ # safe even if the polling loop never executed its body.
+ STACK_BAD=()
+ [[ ${#bad[@]} -gt 0 ]] && STACK_BAD=("${bad[@]}")
+ STACK_WAITING=()
+ [[ ${#waiting[@]} -gt 0 ]] && STACK_WAITING=("${waiting[@]}")
+ STACK_GOOD=()
+ [[ ${#good[@]} -gt 0 ]] && STACK_GOOD=("${good[@]}")
+ STACK_TIMEOUT=true
+ return 1
+}
+
+stack_failure_report() {
+ echo ""
+ echo -e "\033[31m[ERROR]\033[0m Stack did not reach a healthy state."
+ if (( ${#STACK_BAD[@]} > 0 )) && [[ -n "${STACK_BAD[0]}" ]]; then
+ echo " Failed: ${STACK_BAD[*]}"
+ fi
+ if (( ${#STACK_WAITING[@]} > 0 )) && [[ -n "${STACK_WAITING[0]}" ]]; then
+ echo " Stuck: ${STACK_WAITING[*]}"
+ fi
+ echo ""
+ info "Recent logs from migrations / zero-cache / backend:"
+ (cd "${INSTALL_DIR}" && ${DC} logs --tail=60 migrations zero-cache backend 2>&1) || true
+ echo ""
+ echo "Recovery hints:"
+ echo " 1. Inspect migrations: cd ${INSTALL_DIR} && ${DC} logs migrations"
+ echo " 2. Verify publication: cd ${INSTALL_DIR} && ${DC} exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
+ echo " 3. Hard reset zero db: cd ${INSTALL_DIR} && ${DC} down && docker volume rm surfsense-zero-cache && ${DC} up -d"
+ echo ""
+ exit 1
+}
+
+# True if `surfsense-zero-cache` exists but `surfsense-zero-init` does not.
+# That signals an install that predates the migrations-service fix; the old
+# replica may be half-initialized and would block zero-cache on next start.
+test_stale_zero_cache_volume() {
+ local has_zc has_zi
+ has_zc=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-cache' || true)
+ has_zi=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-init' || true)
+ [[ -n "$has_zc" && -z "$has_zi" ]]
+}
+
+invoke_stale_zero_cache_cleanup() {
+ if ! test_stale_zero_cache_volume; then
+ return 0
+ fi
+ warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that"
+ warn "predates the migrations-service fix. It may contain a half-initialized"
+ warn "SQLite replica that would block zero-cache from starting."
+ warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel."
+ sleep 5
+
+ (cd "${INSTALL_DIR}" && ${DC} down --remove-orphans 2>/dev/null) || true
+ docker volume rm surfsense-zero-cache 2>/dev/null || true
+ success "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start."
+}
+
# ── Download files ───────────────────────────────────────────────────────────
step "Downloading SurfSense files"
@@ -186,6 +343,8 @@ fi
# ── Start containers ─────────────────────────────────────────────────────────
+invoke_stale_zero_cache_cleanup
+
if $MIGRATION_MODE; then
# Read DB credentials from .env (fall back to defaults from docker-compose.yml)
DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
@@ -243,7 +402,12 @@ if $MIGRATION_MODE; then
step "Starting all SurfSense services"
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
- success "All services started."
+ success "All containers started; waiting for stack to become healthy..."
+
+ if ! wait_stack_healthy 300; then
+ stack_failure_report
+ fi
+ success "All services healthy."
# Key file is no longer needed — SECRET_KEY is now in .env
rm -f "${KEY_FILE}"
@@ -251,7 +415,12 @@ if $MIGRATION_MODE; then
else
step "Starting SurfSense"
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
- success "All services started."
+ success "All containers started; waiting for stack to become healthy..."
+
+ if ! wait_stack_healthy 300; then
+ stack_failure_report
+ fi
+ success "All services healthy."
fi
# ── Watchtower (auto-update) ─────────────────────────────────────────────────
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000000..d03a5b6500
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,10 @@
+{
+ "id": "surfsense-obsidian",
+ "name": "SurfSense",
+ "version": "0.1.0",
+ "minAppVersion": "1.5.4",
+ "description": "Turn your vault into a searchable second brain with SurfSense.",
+ "author": "SurfSense",
+ "authorUrl": "https://www.surfsense.com",
+ "isDesktopOnly": false
+}
diff --git a/package.json b/package.json
index 8a1a6add8e..1e45c17066 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
"name": "surfsense",
"private": true,
- "packageManager": "pnpm@10.24.0"
+ "packageManager": "pnpm@10.26.0"
}
diff --git a/skills-lock.json b/skills-lock.json
index a1947ff7f2..f722ec0d37 100644
--- a/skills-lock.json
+++ b/skills-lock.json
@@ -1,11 +1,117 @@
{
"version": 1,
"skills": {
+ "alert-manager": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "f96aa6f7d8dd982a866ac090c1770e39cd0a01c63c9bc315895d3c210f80b393"
+ },
+ "backlink-analyzer": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "25556853b9cd6434bbb1bcf6cd6794d84c05214c1f8564a85db46f73e5c438e7"
+ },
+ "competitor-analysis": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "ecee4d821c68ae0cdce088da9f688f2a1ed724ddad01854cceaac9e85109c6f1"
+ },
+ "content-gap-analysis": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "9979051baf6210013ba7c6df0229081c93882efe5791dfae9c0ede93349c2537"
+ },
+ "content-quality-auditor": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "02de7d19e7b4611bdb2f10a386ce7a6b9799a47bce3e964bd33b8f9f3e4599f4"
+ },
+ "content-refresher": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "282ee22b8cac4bc4ad369f9bbf581cef9770a5dfabadf0581f36c674973261ce"
+ },
+ "domain-authority-auditor": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "e97547ac36ea8e59a65bdfdda25b8d130cb7f5e2cf13dac56fd01f24e74718a5"
+ },
+ "entity-optimizer": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "58704efa3ce530dc504e5942e740339ec03d4f3f2419f9f6812c4eee73accb73"
+ },
+ "geo-content-optimizer": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "ddd61f32254be1303ce4b7be5d507c932de4af53489a0ebb1309bf61de99018c"
+ },
+ "improve-codebase-architecture": {
+ "source": "mattpocock/skills",
+ "sourceType": "github",
+ "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
+ "computedHash": "2da1d23b8f53cfe67f2e0b68924ab9f4ec400bb6480de097007eeaeb517d1722"
+ },
+ "internal-linking-optimizer": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "10c54a570eacd569bde0ff55d0cb9ecb89658ba92afceb264da59dd7f600801c"
+ },
+ "keyword-research": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "921a7e6e7ab45d1dfb4534d4ca445a94a95e7e01022b5f171690da2e40caf55f"
+ },
+ "memory-management": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "df7a35f2479812e4bf452e2c2bbb942c9ef163f8d7bd281a0e38765aa33ecff7"
+ },
+ "meta-tags-optimizer": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "4d88aa5adc17c1802f21c04162bbab6bd8c5965c35362a502ee6ccab76777a54"
+ },
+ "on-page-seo-auditor": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "12714ad503f3493a977f101d26d2b5f43991ef41f148332c22c9b29e9b7c9f23"
+ },
+ "performance-reporter": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "fb6454815439e5b9993d18aae71a92928ef36814f550a16f9ce8a8d8ac6901d3"
+ },
+ "rank-tracker": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "2d4c9a44056713d946481031cc00b3113b6055af6f4bea0cfd8dcb8a9d5abbf9"
+ },
+ "schema-markup-generator": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "8587b6f64de8274895fb97b94089ca48685e55694828e67f4a2cf2937d5d9e5d"
+ },
+ "seo-content-writer": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "0ac426e7f412aca94496e8dcc599ac11f15e04043605968a7c146293ecac54ca"
+ },
+ "serp-analysis": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "e3d18f4f1514bb1537e8429f7fe011c9f3e666e5a3dcbc1623d9f83e47de59a2"
+ },
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"computedHash": "b1cc1defe03f0e8f78652b7d20e4d4e6421babc2b45835d4f361afe684dc47a2"
},
+ "technical-seo-checker": {
+ "source": "aaron-he-zhu/seo-geo-claude-skills",
+ "sourceType": "github",
+ "computedHash": "a9bf73872ed4ec682f7d91d0a6528bdc91806571d3c47b3c8046eee5106dc791"
+ },
"vercel-react-best-practices": {
"source": "vercel-labs/agent-skills",
"sourceType": "github",
diff --git a/surfsense_backend/.dockerignore b/surfsense_backend/.dockerignore
index 8668ee72bf..2ecdb36889 100644
--- a/surfsense_backend/.dockerignore
+++ b/surfsense_backend/.dockerignore
@@ -24,4 +24,6 @@ wheels/
*.egg
.pytest_cache/
.coverage
-htmlcov/
\ No newline at end of file
+htmlcov/
+
+tests/
\ No newline at end of file
diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example
index 3899c99f79..4b72e91536 100644
--- a/surfsense_backend/.env.example
+++ b/surfsense_backend/.env.example
@@ -53,6 +53,17 @@ STRIPE_PRICE_ID=price_...
STRIPE_PAGES_PER_UNIT=1000
# Set FALSE to disable new checkout session creation temporarily
STRIPE_PAGE_BUYING_ENABLED=TRUE
+
+# Premium credit purchases via Stripe (for premium-tier model usage).
+# Each pack grants STRIPE_CREDIT_MICROS_PER_UNIT micro-USD of credit
+# (default 1_000_000 = $1.00). Premium turns are billed at the actual
+# per-call provider cost reported by LiteLLM.
+STRIPE_TOKEN_BUYING_ENABLED=FALSE
+STRIPE_PREMIUM_TOKEN_PRICE_ID=price_...
+STRIPE_CREDIT_MICROS_PER_UNIT=1000000
+# DEPRECATED — use STRIPE_CREDIT_MICROS_PER_UNIT (1:1 numerical mapping):
+# STRIPE_TOKENS_PER_UNIT=1000000
+
# Periodic Stripe safety net for purchases left in PENDING (minutes old)
STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10
# Max pending purchases to check per reconciliation run
@@ -183,6 +194,60 @@ VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES=300
# (Optional) Maximum pages limit per user for ETL services (default: `999999999` for unlimited in OSS version)
PAGES_LIMIT=500
+# Premium credit quota per registered user, in micro-USD
+# (default: 5,000,000 == $5.00 of credit). Premium turns are debited at the
+# actual per-call provider cost reported by LiteLLM, so cheap and expensive
+# models bill proportionally. Applies only to models with
+# billing_tier=premium in global_llm_config.yaml.
+PREMIUM_CREDIT_MICROS_LIMIT=5000000
+# DEPRECATED — use PREMIUM_CREDIT_MICROS_LIMIT (1:1 numerical mapping):
+# PREMIUM_TOKEN_LIMIT=5000000
+
+# Safety ceiling on per-call premium reservation, in micro-USD.
+# stream_new_chat estimates an upper-bound cost from the model's
+# litellm-published per-token rates × the config's quota_reserve_tokens
+# and clamps to this value so a misconfigured model can't lock the
+# user's whole balance on one call. Default $1.00.
+QUOTA_MAX_RESERVE_MICROS=1000000
+
+# Per-image reservation (in micro-USD) for the POST /image-generations
+# endpoint. Bypassed for free configs. Default $0.05.
+QUOTA_DEFAULT_IMAGE_RESERVE_MICROS=50000
+
+# Per-podcast reservation (in micro-USD) used by the podcast Celery task.
+# Single envelope covers one transcript-generation LLM call. Default $0.20.
+QUOTA_DEFAULT_PODCAST_RESERVE_MICROS=200000
+
+# Per-video-presentation reservation (in micro-USD) used by the video
+# presentation Celery task. Covers worst-case fan-out of N slide-scene
+# generations + refines. Default $1.00. NOTE: tasks using the override
+# path bypass the QUOTA_MAX_RESERVE_MICROS clamp — raise with care.
+QUOTA_DEFAULT_VIDEO_PRESENTATION_RESERVE_MICROS=1000000
+
+# No-login (anonymous) mode — allows public users to chat without an account
+# Set TRUE to enable /free pages and anonymous chat API
+NOLOGIN_MODE_ENABLED=FALSE
+# Total tokens allowed per anonymous session before requiring account creation
+ANON_TOKEN_LIMIT=500000
+# Token count at which the UI shows a soft warning
+ANON_TOKEN_WARNING_THRESHOLD=400000
+# Days before anonymous quota tracking expires in Redis
+ANON_TOKEN_QUOTA_TTL_DAYS=30
+# Max document upload size for anonymous users (MB)
+ANON_MAX_UPLOAD_SIZE_MB=5
+# Maximum tokens to reserve per LLM call for quota enforcement (safety cap)
+QUOTA_MAX_RESERVE_PER_CALL=8000
+
+# Abuse prevention: max concurrent anonymous streams per IP (default: 2)
+ANON_MAX_CONCURRENT_STREAMS=2
+# Number of chat requests per IP before Turnstile CAPTCHA is required (default: 5)
+ANON_CAPTCHA_REQUEST_THRESHOLD=5
+
+# Cloudflare Turnstile CAPTCHA (https://dash.cloudflare.com/ -> Turnstile)
+# Set TURNSTILE_ENABLED=TRUE and provide keys to activate CAPTCHA for anonymous chat
+TURNSTILE_ENABLED=FALSE
+TURNSTILE_SECRET_KEY=
+
# Residential Proxy Configuration (anonymous-proxies.net)
# Used for web crawling, link previews, and YouTube transcript fetching to avoid IP bans.
@@ -203,8 +268,87 @@ LLAMA_CLOUD_API_KEY=llx-nnn
# AZURE_DI_ENDPOINT=https://your-resource.cognitiveservices.azure.com/
# AZURE_DI_KEY=your-key
+# Daytona Sandbox (isolated code execution)
+# DAYTONA_SANDBOX_ENABLED=FALSE
+# DAYTONA_API_KEY=your-daytona-api-key
+# DAYTONA_API_URL=https://app.daytona.io/api
+# DAYTONA_TARGET=us
+# DAYTONA_SNAPSHOT_ID=
+
+# Desktop local filesystem mode (chat file tools run against a local folder root)
+# ENABLE_DESKTOP_LOCAL_FILESYSTEM=FALSE
+
# OPTIONAL: Add these for LangSmith Observability
LANGSMITH_TRACING=true
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
LANGSMITH_API_KEY=lsv2_pt_.....
LANGSMITH_PROJECT=surfsense
+
+
+# =============================================================================
+# OPTIONAL: New-chat agent feature flags
+# =============================================================================
+# Multi-agent orchestrator switch for authenticated chat streaming.
+# MULTI_AGENT_CHAT_ENABLED=false
+
+# Master kill-switch — when true, every flag below is forced OFF.
+# SURFSENSE_DISABLE_NEW_AGENT_STACK=false
+
+# Agent quality
+# SURFSENSE_ENABLE_CONTEXT_EDITING=false
+# SURFSENSE_ENABLE_COMPACTION_V2=false
+# SURFSENSE_ENABLE_RETRY_AFTER=false
+# SURFSENSE_ENABLE_MODEL_FALLBACK=false
+# SURFSENSE_ENABLE_MODEL_CALL_LIMIT=false
+# SURFSENSE_ENABLE_TOOL_CALL_LIMIT=false
+# SURFSENSE_ENABLE_TOOL_CALL_REPAIR=false
+# SURFSENSE_ENABLE_DOOM_LOOP=false # leave OFF until UI handles permission='doom_loop'
+
+# Safety
+# SURFSENSE_ENABLE_PERMISSION=false
+# SURFSENSE_ENABLE_BUSY_MUTEX=false
+# SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call
+
+# Observability — OTel (also requires OTEL_EXPORTER_OTLP_ENDPOINT)
+# SURFSENSE_ENABLE_OTEL=false
+
+# Skills + subagents
+# SURFSENSE_ENABLE_SKILLS=false
+# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
+# SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE=false
+
+# Snapshot / revert
+# SURFSENSE_ENABLE_ACTION_LOG=false
+# SURFSENSE_ENABLE_REVERT_ROUTE=false # Backend-only; flip when UI ships
+
+# Plugins
+# SURFSENSE_ENABLE_PLUGIN_LOADER=false
+# Comma-separated allowlist of plugin entry-point names
+# SURFSENSE_ALLOWED_PLUGINS=year_substituter
+
+# -----------------------------------------------------------------------------
+# Compiled-agent cache (Phase 1 + 2 perf optimization, default ON)
+# -----------------------------------------------------------------------------
+# When ON, the per-turn LangGraph + middleware compile result (~3-5s of CPU
+# on a cold turn) is reused across subsequent turns on the same thread,
+# collapsing it to a microsecond hash lookup. All connector tools acquire
+# their own short-lived DB session per call (Phase 2 refactor) so a cached
+# closure is safe to share across requests. Flip OFF only as a last-resort
+# rollback if you suspect cache-related staleness.
+# SURFSENSE_ENABLE_AGENT_CACHE=true
+
+# Cache capacity (max number of compiled-agent entries kept in memory)
+# and TTL per entry (seconds). Working set is typically one entry per
+# active thread on this replica; tune up for very large deployments.
+# SURFSENSE_AGENT_CACHE_MAXSIZE=256
+# SURFSENSE_AGENT_CACHE_TTL_SECONDS=1800
+
+# -----------------------------------------------------------------------------
+# Connector discovery TTL cache (Phase 1.4 perf optimization)
+# -----------------------------------------------------------------------------
+# Caches the per-search-space "available connectors" + "available document
+# types" lookups that ``create_surfsense_deep_agent`` hits on every turn.
+# ORM event listeners auto-invalidate on connector / document inserts,
+# updates and deletes — the TTL only bounds staleness for bulk-import
+# paths that bypass the ORM. Set to 0 to disable the cache.
+# SURFSENSE_CONNECTOR_DISCOVERY_TTL_SECONDS=30
diff --git a/surfsense_backend/.gitignore b/surfsense_backend/.gitignore
index 1cd7fd32c5..47fd53aef1 100644
--- a/surfsense_backend/.gitignore
+++ b/surfsense_backend/.gitignore
@@ -13,5 +13,5 @@ celerybeat-schedule*
celerybeat-schedule.*
celerybeat-schedule.dir
celerybeat-schedule.bak
-global_llm_config.yaml
+/app/config/global_llm_config.yaml
app/templates/_generated/
\ No newline at end of file
diff --git a/surfsense_backend/Dockerfile b/surfsense_backend/Dockerfile
index 1222b36b67..0c783f403d 100644
--- a/surfsense_backend/Dockerfile
+++ b/surfsense_backend/Dockerfile
@@ -1,8 +1,16 @@
-FROM python:3.12-slim
+# =============================================================================
+# SurfSense Backend — Multi-stage Dockerfile
+# =============================================================================
+# Graph: base → deps → models → {e2e, production}
+# e2e — tests/ via additional_contexts (docker-compose.e2e.yml)
+# production — published ghcr.io image (docker-build.yml pins target)
+# =============================================================================
+
+# ─── Stage 1: base (system deps, Pandoc, certificates) ──────────────────────
+FROM python:3.12-slim AS base
WORKDIR /app
-# Install system dependencies including SSL tools, CUDA dependencies, and Tesseract OCR
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
@@ -11,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
unzip \
gnupg2 \
+ ffmpeg \
espeak-ng \
libsndfile1 \
libgl1 \
@@ -22,71 +31,83 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& rm -rf /var/lib/apt/lists/*
-# Install Pandoc 3.x from GitHub as a fallback for Linux where pypandoc_binary
-# may not bundle pandoc (apt ships 2.17 which has broken table rendering).
-# pypandoc_binary bundles pandoc on Windows/macOS; on Linux it picks this up.
+RUN which ffmpeg && ffmpeg -version
+
+# Pandoc 3.x from GitHub Releases — apt ships 2.17 which has broken table rendering.
+# pypandoc_binary bundles pandoc on Windows/macOS; on Linux it picks up this binary.
RUN ARCH=$(dpkg --print-architecture) && \
wget -qO /tmp/pandoc.deb "https://github.com/jgm/pandoc/releases/download/3.9/pandoc-3.9-1-${ARCH}.deb" && \
dpkg -i /tmp/pandoc.deb && \
rm /tmp/pandoc.deb
-# Update certificates and install SSL tools
RUN update-ca-certificates
RUN pip install --upgrade certifi pip-system-certs
-# Copy requirements
-COPY pyproject.toml .
-COPY uv.lock .
-
-# Install PyTorch based on architecture
-RUN if [ "$(uname -m)" = "x86_64" ]; then \
- pip install --no-cache-dir torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121; \
- else \
- pip install --no-cache-dir torch torchvision torchaudio; \
- fi
-
-# Install python dependencies
-RUN pip install --no-cache-dir uv && \
- uv pip install --system --no-cache-dir -e .
-
-# Set SSL environment variables dynamically
-RUN CERTIFI_PATH=$(python -c "import certifi; print(certifi.where())") && \
- echo "Setting SSL_CERT_FILE to $CERTIFI_PATH" && \
- echo "export SSL_CERT_FILE=$CERTIFI_PATH" >> /root/.bashrc && \
- echo "export REQUESTS_CA_BUNDLE=$CERTIFI_PATH" >> /root/.bashrc
ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
+ENV SURFSENSE_ALLOW_STATIC_FFMPEG_DOWNLOAD=FALSE
+
+
+# ─── Stage 2: deps (Python deps frozen from uv.lock) ────────────────────────
+FROM base AS deps
+
+COPY pyproject.toml uv.lock ./
+
+# Install all Python dependencies from uv.lock for deterministic builds.
+#
+# `uv pip install -e .` re-resolves from pyproject.toml and ignores uv.lock,
+# which lets prod silently drift to newer upstream versions on every rebuild
+# (e.g. deepagents 0.4.x -> 0.5.x breaking the FilesystemMiddleware imports).
+# Exporting the lock to requirements.txt and feeding it to `uv pip install`
+# pins every transitive package to the exact version captured in uv.lock.
+#
+# Note on torch/CUDA: we do NOT install torch from a separate cu* index here.
+# PyPI's torch wheels for Linux x86_64 already ship CUDA-enabled and pull
+# nvidia-cudnn-cu13, nvidia-nccl-cu13, triton, etc. as install deps (all
+# captured in uv.lock). If a specific CUDA version is needed, wire it through
+# [tool.uv.sources] in pyproject.toml so the lock stays the source of truth.
+RUN pip install --no-cache-dir uv && \
+ uv export --frozen --no-dev --no-hashes --no-emit-project \
+ --format requirements-txt -o /tmp/requirements.txt && \
+ uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \
+ rm /tmp/requirements.txt
+
+
+# ─── Stage 3: models (pre-baked offline assets) ─────────────────────────────
+FROM deps AS models
# Pre-download EasyOCR models to avoid runtime SSL issues
-RUN mkdir -p /root/.EasyOCR/model
-RUN wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/v1.3/english_g2.zip -O /root/.EasyOCR/model/english_g2.zip || true
-RUN wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/pre-v1.1.6/craft_mlt_25k.zip -O /root/.EasyOCR/model/craft_mlt_25k.zip || true
-RUN cd /root/.EasyOCR/model && (unzip -o english_g2.zip || true) && (unzip -o craft_mlt_25k.zip || true)
+RUN mkdir -p /root/.EasyOCR/model && \
+ wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/v1.3/english_g2.zip -O /root/.EasyOCR/model/english_g2.zip || true && \
+ wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/pre-v1.1.6/craft_mlt_25k.zip -O /root/.EasyOCR/model/craft_mlt_25k.zip || true && \
+ cd /root/.EasyOCR/model && \
+ (unzip -o english_g2.zip || true) && \
+ (unzip -o craft_mlt_25k.zip || true)
# Pre-download Docling models
-RUN python -c "try:\n from docling.document_converter import DocumentConverter\n conv = DocumentConverter()\nexcept:\n pass" || true
+RUN printf '%s\n' \
+ 'try:' \
+ ' from docling.document_converter import DocumentConverter' \
+ ' DocumentConverter()' \
+ 'except Exception:' \
+ ' pass' \
+ | python || true
-# Install Playwright browsers for web scraping if needed
-RUN pip install playwright && \
- playwright install chromium --with-deps
+ARG EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
+RUN python -c "from chonkie import AutoEmbeddings; AutoEmbeddings.get_embeddings('${EMBEDDING_MODEL}')"
-# Copy source code
-COPY . .
-
-# Copy and set permissions for entrypoint script
-# Use dos2unix to ensure LF line endings (fixes CRLF issues from Windows checkouts)
-COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
-RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
+# Install Playwright browsers (the playwright python package itself is in deps)
+RUN playwright install chromium --with-deps
# Shared temp directory for file uploads between API and Worker containers.
# Python's tempfile module uses TMPDIR, so uploaded files land here.
# Mount the SAME volume at /shared_tmp on both API and Worker in Coolify.
RUN mkdir -p /shared_tmp
-ENV TMPDIR=/shared_tmp
-# Prevent uvloop compatibility issues
ENV PYTHONPATH=/app
ENV UVICORN_LOOP=asyncio
+ENV TMPDIR=/shared_tmp
+ENV PYTHONUNBUFFERED=1
# Tune glibc malloc to return freed memory to the OS more aggressively.
# Without these, Python's gc.collect() frees objects but the underlying
@@ -95,11 +116,65 @@ ENV MALLOC_MMAP_THRESHOLD_=65536
ENV MALLOC_TRIM_THRESHOLD_=131072
ENV MALLOC_MMAP_MAX_=65536
+
+# ─── Stage 4: e2e (production source + tests/ + e2e entrypoint) ─────────────
+# Built via `docker buildx build --target e2e`. The default build target is
+# `production` (the last stage), so this stage is opt-in for CI only.
+#
+# `tests/` is excluded from the main build context by .dockerignore (so prod
+# can never accidentally ship test fakes). The e2e stage receives tests/
+# through an "additional context" passed by docker-compose.e2e.yml — see
+# https://docs.docker.com/reference/compose-file/build/#additional_contexts
+FROM models AS e2e
+
+# Same source copy as production. .dockerignore filters out tests/.
+COPY . .
+
+# Bring tests/ in via the named additional build context. CI passes
+# --build-context tests-source=./tests
+# (or the equivalent additional_contexts entry in docker-compose.e2e.yml).
+COPY --from=tests-source . ./tests/
+
+# Install the project itself in editable mode. Dependencies were already
+# installed deterministically from uv.lock above, so --no-deps prevents any
+# re-resolution that could pull newer versions.
+RUN uv pip install --system --no-cache-dir --no-deps -e .
+
+COPY scripts/docker/entrypoint.e2e.sh /app/scripts/docker/entrypoint.e2e.sh
+RUN dos2unix /app/scripts/docker/entrypoint.e2e.sh && chmod +x /app/scripts/docker/entrypoint.e2e.sh
+
+# SERVICE_ROLE is overridden per service in docker-compose.e2e.yml (api / worker).
+ENV SERVICE_ROLE=api
+
+EXPOSE 8000-8001
+CMD ["/app/scripts/docker/entrypoint.e2e.sh"]
+
+
+# ─── Stage 5: production (published ghcr.io image) ──────────────────────────
+# CI pins `target: production`; also the default for `docker build` / dev compose.
+FROM models AS production
+
+# Copy source code (tests/ excluded by .dockerignore — production never ships tests).
+COPY . .
+
+# Install the project itself in editable mode. Dependencies were already
+# installed deterministically from uv.lock above, so --no-deps prevents any
+# re-resolution that could pull newer versions.
+RUN uv pip install --system --no-cache-dir --no-deps -e .
+
+# Use dos2unix to ensure LF line endings (fixes CRLF issues from Windows checkouts)
+COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
+RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
+
# SERVICE_ROLE controls which process this container runs:
-# api – FastAPI backend only (runs migrations on startup)
+# migrate – Run alembic upgrade head, verify zero_publication exists, exit 0.
+# Used by the dedicated `migrations` service in docker-compose.yml
+# so downstream services gate on `service_completed_successfully`.
+# api – FastAPI backend only (does NOT run migrations)
# worker – Celery worker only
# beat – Celery beat scheduler only
-# all – All three (legacy / dev default)
+# all – migrations + api + worker + beat (legacy / dev default;
+# fails fast on migration error)
ENV SERVICE_ROLE=all
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)
@@ -112,6 +187,5 @@ ENV CELERY_MAX_TASKS_PER_CHILD=50
# "" – both queues (default, for single-worker setups)
ENV CELERY_QUEUES=""
-# Run
EXPOSE 8000-8001
-CMD ["/app/scripts/docker/entrypoint.sh"]
\ No newline at end of file
+CMD ["/app/scripts/docker/entrypoint.sh"]
diff --git a/surfsense_backend/alembic/env.py b/surfsense_backend/alembic/env.py
index bd8c203560..5354211aa7 100644
--- a/surfsense_backend/alembic/env.py
+++ b/surfsense_backend/alembic/env.py
@@ -67,7 +67,11 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None:
- context.configure(connection=connection, target_metadata=target_metadata)
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ transaction_per_migration=True,
+ )
with context.begin_transaction():
context.run_migrations()
diff --git a/surfsense_backend/alembic/versions/116_create_zero_publication.py b/surfsense_backend/alembic/versions/116_create_zero_publication.py
index ff74952a96..927673c353 100644
--- a/surfsense_backend/alembic/versions/116_create_zero_publication.py
+++ b/surfsense_backend/alembic/versions/116_create_zero_publication.py
@@ -5,6 +5,17 @@
See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications
+NOTE for future migration authors: this is the ONLY migration allowed
+to use bare ``CREATE PUBLICATION``. All subsequent mutations of
+``zero_publication`` MUST use the ``COMMENT ON PUBLICATION`` bookend
+pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
+``upgrade()`` function from migration
+``143_force_zero_publication_resync.py`` as your starting template.
+Raw ``DROP``/``CREATE PUBLICATION`` in new migrations would
+re-introduce bug #1355 (zero-cache stuck on a stale replica snapshot
+because Zero >= 1.0's change-streamer never sees the schema-change
+event).
+
Revision ID: 116
Revises: 115
"""
diff --git a/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py b/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py
index 78a26a381b..c21ed2bf0a 100644
--- a/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py
+++ b/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py
@@ -17,6 +17,16 @@
3. Delete / reset the zero-cache data volume
4. Restart zero-cache (it will do a fresh initial sync)
+DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
+PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
+1.0 it does not reliably wake the zero-cache change-streamer and can
+leave the replica pinned to a stale snapshot. This file is
+grandfathered in because it has already shipped to users; new
+publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
+pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
+``upgrade()`` function from migration
+``143_force_zero_publication_resync.py`` as your starting template.
+
Revision ID: 117
Revises: 116
"""
@@ -79,40 +89,44 @@ def _terminate_blocked_pids(conn, table: str) -> None:
def upgrade() -> None:
conn = op.get_bind()
-
- conn.execute(sa.text("SET lock_timeout = '10s'"))
-
- for tbl in sorted(TABLES_WITH_FULL_IDENTITY):
- _terminate_blocked_pids(conn, tbl)
- conn.execute(sa.text(f'LOCK TABLE "{tbl}" IN ACCESS EXCLUSIVE MODE'))
-
- for tbl in TABLES_WITH_FULL_IDENTITY:
- conn.execute(sa.text(f'ALTER TABLE "{tbl}" REPLICA IDENTITY DEFAULT'))
-
- conn.execute(sa.text(f"DROP PUBLICATION IF EXISTS {PUBLICATION_NAME}"))
-
- has_zero_ver = conn.execute(
- sa.text(
- "SELECT 1 FROM information_schema.columns "
- "WHERE table_name = 'documents' AND column_name = '_0_version'"
+ # asyncpg requires LOCK TABLE inside a transaction block. Alembic already
+ # opened one via context.begin_transaction(), but the driver still errors
+ # unless we use an explicit SAVEPOINT (nested transaction) for this block.
+ tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
+ with tx:
+ conn.execute(sa.text("SET lock_timeout = '10s'"))
+
+ for tbl in sorted(TABLES_WITH_FULL_IDENTITY):
+ _terminate_blocked_pids(conn, tbl)
+ conn.execute(sa.text(f'LOCK TABLE "{tbl}" IN ACCESS EXCLUSIVE MODE'))
+
+ for tbl in TABLES_WITH_FULL_IDENTITY:
+ conn.execute(sa.text(f'ALTER TABLE "{tbl}" REPLICA IDENTITY DEFAULT'))
+
+ conn.execute(sa.text(f"DROP PUBLICATION IF EXISTS {PUBLICATION_NAME}"))
+
+ has_zero_ver = conn.execute(
+ sa.text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = 'documents' AND column_name = '_0_version'"
+ )
+ ).fetchone()
+
+ cols = DOCUMENT_COLS + (['"_0_version"'] if has_zero_ver else [])
+ col_list = ", ".join(cols)
+
+ conn.execute(
+ sa.text(
+ f"CREATE PUBLICATION {PUBLICATION_NAME} FOR TABLE "
+ f"notifications, "
+ f"documents ({col_list}), "
+ f"folders, "
+ f"search_source_connectors, "
+ f"new_chat_messages, "
+ f"chat_comments, "
+ f"chat_session_state"
+ )
)
- ).fetchone()
-
- cols = DOCUMENT_COLS + (['"_0_version"'] if has_zero_ver else [])
- col_list = ", ".join(cols)
-
- conn.execute(
- sa.text(
- f"CREATE PUBLICATION {PUBLICATION_NAME} FOR TABLE "
- f"notifications, "
- f"documents ({col_list}), "
- f"folders, "
- f"search_source_connectors, "
- f"new_chat_messages, "
- f"chat_comments, "
- f"chat_session_state"
- )
- )
def downgrade() -> None:
diff --git a/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py b/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py
index 1fef9fbcb6..1dce24e561 100644
--- a/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py
+++ b/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py
@@ -1,5 +1,16 @@
"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table
+DO NOT COPY THIS PATTERN. The bare ``ALTER PUBLICATION ... ADD/DROP
+TABLE`` calls below pre-date the ``COMMENT ON PUBLICATION`` bookend
+fix for bug #1355: on Zero >= 1.0 they do not reliably wake the
+zero-cache change-streamer and can leave the replica pinned to a
+stale snapshot. This file is grandfathered in because it has already
+shipped to users; new publication mutations MUST use the
+``COMMENT ON PUBLICATION`` bookend pattern wrapping an
+``ALTER PUBLICATION ... SET TABLE`` -- copy the ``upgrade()`` function
+from migration ``143_force_zero_publication_resync.py`` as your
+starting template.
+
Revision ID: 118
Revises: 117
"""
diff --git a/surfsense_backend/alembic/versions/121_add_memory_md_columns.py b/surfsense_backend/alembic/versions/121_add_memory_md_columns.py
new file mode 100644
index 0000000000..ac248dfcab
--- /dev/null
+++ b/surfsense_backend/alembic/versions/121_add_memory_md_columns.py
@@ -0,0 +1,50 @@
+"""Add memory_md columns to user and searchspaces tables
+
+Revision ID: 121
+Revises: 120
+
+Changes:
+1. Add memory_md TEXT column to user table (personal memory)
+2. Add shared_memory_md TEXT column to searchspaces table (team memory)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "121"
+down_revision: str | None = "120"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ # Idempotent: column(s) may already exist after a failed run or manual DDL.
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'user'
+ AND column_name = 'memory_md'
+ ) THEN
+ ALTER TABLE "user" ADD COLUMN memory_md TEXT DEFAULT '';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'searchspaces'
+ AND column_name = 'shared_memory_md'
+ ) THEN
+ ALTER TABLE searchspaces ADD COLUMN shared_memory_md TEXT DEFAULT '';
+ END IF;
+ END$$;
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE searchspaces DROP COLUMN IF EXISTS shared_memory_md")
+ op.execute('ALTER TABLE "user" DROP COLUMN IF EXISTS memory_md')
diff --git a/surfsense_backend/alembic/versions/122_migrate_and_drop_old_memory_tables.py b/surfsense_backend/alembic/versions/122_migrate_and_drop_old_memory_tables.py
new file mode 100644
index 0000000000..b8ddddb9cb
--- /dev/null
+++ b/surfsense_backend/alembic/versions/122_migrate_and_drop_old_memory_tables.py
@@ -0,0 +1,247 @@
+"""Migrate row-per-fact memories to markdown, then drop legacy tables
+
+Revision ID: 122
+Revises: 121
+
+Converts user_memories rows into per-user markdown documents stored in
+user.memory_md, and shared_memories rows into per-search-space markdown
+stored in searchspaces.shared_memory_md. Then drops the old tables and
+the memorycategory enum.
+
+The markdown format matches the new memory system:
+ ## Heading
+ - (YYYY-MM-DD) [fact|pref|instr] memory text
+"""
+
+from __future__ import annotations
+
+import logging
+from collections import defaultdict
+from collections.abc import Sequence
+from uuid import UUID
+
+import sqlalchemy as sa
+from sqlalchemy import inspect as sa_inspect
+
+from alembic import op
+from app.config import config
+
+logger = logging.getLogger(__name__)
+
+revision: str = "122"
+down_revision: str | None = "121"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+EMBEDDING_DIM = config.embedding_model_instance.dimension
+
+_CATEGORY_TO_MARKER = {
+ "fact": "fact",
+ "context": "fact",
+ "preference": "pref",
+ "instruction": "instr",
+}
+
+_CATEGORY_HEADING = {
+ "fact": "Facts",
+ "preference": "Preferences",
+ "instruction": "Instructions",
+ "context": "Context",
+}
+
+_HEADING_ORDER = ["fact", "preference", "instruction", "context"]
+
+
+def _build_markdown(rows: list[tuple]) -> str:
+ """Build a markdown document from (memory_text, category, created_at) rows."""
+ by_category: dict[str, list[str]] = defaultdict(list)
+
+ for memory_text, category, created_at in rows:
+ cat = str(category)
+ marker = _CATEGORY_TO_MARKER.get(cat, "fact")
+ date_str = created_at.strftime("%Y-%m-%d")
+ clean_text = str(memory_text).replace("\n", " ").strip()
+ bullet = f"- ({date_str}) [{marker}] {clean_text}"
+ by_category[cat].append(bullet)
+
+ sections: list[str] = []
+ for cat in _HEADING_ORDER:
+ if cat in by_category:
+ heading = _CATEGORY_HEADING[cat]
+ sections.append(f"## {heading}")
+ sections.extend(by_category[cat])
+ sections.append("")
+
+ return "\n".join(sections).strip() + "\n"
+
+
+def _migrate_user_memories(conn: sa.engine.Connection) -> None:
+ """Convert user_memories rows → user.memory_md grouped by user_id."""
+ rows = conn.execute(
+ sa.text(
+ "SELECT user_id, memory_text, category::text, created_at "
+ "FROM user_memories ORDER BY created_at"
+ )
+ ).fetchall()
+
+ if not rows:
+ logger.info("user_memories is empty, skipping data migration.")
+ return
+
+ by_user: dict[UUID, list[tuple]] = defaultdict(list)
+ for user_id, memory_text, category, created_at in rows:
+ by_user[user_id].append((memory_text, category, created_at))
+
+ migrated = 0
+ for uid, user_rows in by_user.items():
+ existing = conn.execute(
+ sa.text('SELECT memory_md FROM "user" WHERE id = :uid'),
+ {"uid": uid},
+ ).scalar()
+
+ if existing and existing.strip():
+ logger.info("User %s already has memory_md, skipping.", uid)
+ continue
+
+ markdown = _build_markdown(user_rows)
+ conn.execute(
+ sa.text('UPDATE "user" SET memory_md = :md WHERE id = :uid'),
+ {"md": markdown, "uid": uid},
+ )
+ migrated += 1
+
+ logger.info("Migrated user_memories for %d user(s).", migrated)
+
+
+def _migrate_shared_memories(conn: sa.engine.Connection) -> None:
+ """Convert shared_memories rows → searchspaces.shared_memory_md."""
+ rows = conn.execute(
+ sa.text(
+ "SELECT search_space_id, memory_text, category::text, created_at "
+ "FROM shared_memories ORDER BY created_at"
+ )
+ ).fetchall()
+
+ if not rows:
+ logger.info("shared_memories is empty, skipping data migration.")
+ return
+
+ by_space: dict[int, list[tuple]] = defaultdict(list)
+ for search_space_id, memory_text, category, created_at in rows:
+ by_space[search_space_id].append((memory_text, category, created_at))
+
+ migrated = 0
+ for space_id, space_rows in by_space.items():
+ existing = conn.execute(
+ sa.text("SELECT shared_memory_md FROM searchspaces WHERE id = :sid"),
+ {"sid": space_id},
+ ).scalar()
+
+ if existing and existing.strip():
+ logger.info(
+ "Search space %s already has shared_memory_md, skipping.", space_id
+ )
+ continue
+
+ markdown = _build_markdown(space_rows)
+ conn.execute(
+ sa.text("UPDATE searchspaces SET shared_memory_md = :md WHERE id = :sid"),
+ {"md": markdown, "sid": space_id},
+ )
+ migrated += 1
+
+ logger.info("Migrated shared_memories for %d search space(s).", migrated)
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa_inspect(conn)
+ tables = inspector.get_table_names()
+
+ if "user_memories" in tables:
+ _migrate_user_memories(conn)
+
+ if "shared_memories" in tables:
+ _migrate_shared_memories(conn)
+
+ op.execute("DROP TABLE IF EXISTS shared_memories CASCADE;")
+ op.execute("DROP TABLE IF EXISTS user_memories CASCADE;")
+ op.execute("DROP TYPE IF EXISTS memorycategory;")
+
+
+def downgrade() -> None:
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'memorycategory') THEN
+ CREATE TYPE memorycategory AS ENUM (
+ 'preference',
+ 'fact',
+ 'instruction',
+ 'context'
+ );
+ END IF;
+ END$$;
+ """
+ )
+
+ op.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS user_memories (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE,
+ memory_text TEXT NOT NULL,
+ category memorycategory NOT NULL DEFAULT 'fact',
+ embedding vector({EMBEDDING_DIM}),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+ );
+ """
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_user_memories_user_id ON user_memories(user_id);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_user_memories_search_space_id ON user_memories(search_space_id);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_user_memories_updated_at ON user_memories(updated_at);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_user_memories_category ON user_memories(category);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_user_memories_user_search_space ON user_memories(user_id, search_space_id);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS user_memories_vector_index ON user_memories USING hnsw (embedding public.vector_cosine_ops);"
+ )
+
+ op.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS shared_memories (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE,
+ created_by_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ memory_text TEXT NOT NULL,
+ category memorycategory NOT NULL DEFAULT 'fact',
+ embedding vector({EMBEDDING_DIM})
+ );
+ """
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_shared_memories_search_space_id ON shared_memories(search_space_id);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_shared_memories_updated_at ON shared_memories(updated_at);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_shared_memories_created_by_id ON shared_memories(created_by_id);"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS shared_memories_vector_index ON shared_memories USING hnsw (embedding public.vector_cosine_ops);"
+ )
diff --git a/surfsense_backend/alembic/versions/123_add_enable_vision_llm_to_connectors.py b/surfsense_backend/alembic/versions/123_add_enable_vision_llm_to_connectors.py
new file mode 100644
index 0000000000..353e0680e8
--- /dev/null
+++ b/surfsense_backend/alembic/versions/123_add_enable_vision_llm_to_connectors.py
@@ -0,0 +1,45 @@
+"""123_add_enable_vision_llm_to_connectors
+
+Revision ID: 123
+Revises: 122
+Create Date: 2026-04-09
+
+Adds enable_vision_llm boolean column to search_source_connectors.
+Defaults to False so vision LLM image processing is opt-in.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "123"
+down_revision: str | None = "122"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ existing_columns = [
+ col["name"] for col in sa.inspect(conn).get_columns("search_source_connectors")
+ ]
+
+ if "enable_vision_llm" not in existing_columns:
+ op.add_column(
+ "search_source_connectors",
+ sa.Column(
+ "enable_vision_llm",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("search_source_connectors", "enable_vision_llm")
diff --git a/surfsense_backend/alembic/versions/124_add_ai_file_sort_enabled.py b/surfsense_backend/alembic/versions/124_add_ai_file_sort_enabled.py
new file mode 100644
index 0000000000..b77eb9337f
--- /dev/null
+++ b/surfsense_backend/alembic/versions/124_add_ai_file_sort_enabled.py
@@ -0,0 +1,44 @@
+"""124_add_ai_file_sort_enabled
+
+Revision ID: 124
+Revises: 123
+Create Date: 2026-04-14
+
+Adds ai_file_sort_enabled boolean column to searchspaces.
+Defaults to False so AI file sorting is opt-in per search space.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "124"
+down_revision: str | None = "123"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ existing_columns = [
+ col["name"] for col in sa.inspect(conn).get_columns("searchspaces")
+ ]
+
+ if "ai_file_sort_enabled" not in existing_columns:
+ op.add_column(
+ "searchspaces",
+ sa.Column(
+ "ai_file_sort_enabled",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("searchspaces", "ai_file_sort_enabled")
diff --git a/surfsense_backend/alembic/versions/125_add_token_usage_table.py b/surfsense_backend/alembic/versions/125_add_token_usage_table.py
new file mode 100644
index 0000000000..915561c8c3
--- /dev/null
+++ b/surfsense_backend/alembic/versions/125_add_token_usage_table.py
@@ -0,0 +1,85 @@
+"""125_add_token_usage_table
+
+Revision ID: 125
+Revises: 124
+Create Date: 2026-04-14
+
+Adds token_usage table for tracking LLM token consumption per message.
+Supports future extension via usage_type for indexing, image gen, etc.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "125"
+down_revision: str | None = "124"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ if sa.inspect(conn).has_table("token_usage"):
+ return
+
+ op.create_table(
+ "token_usage",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("prompt_tokens", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column(
+ "completion_tokens", sa.Integer(), nullable=False, server_default="0"
+ ),
+ sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("model_breakdown", JSONB, nullable=True),
+ sa.Column("call_details", JSONB, nullable=True),
+ sa.Column("usage_type", sa.String(50), nullable=False, server_default="chat"),
+ sa.Column(
+ "thread_id",
+ sa.Integer(),
+ sa.ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
+ nullable=True,
+ ),
+ sa.Column(
+ "message_id",
+ sa.Integer(),
+ sa.ForeignKey("new_chat_messages.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column(
+ "search_space_id",
+ sa.Integer(),
+ sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "user_id",
+ UUID(as_uuid=True),
+ sa.ForeignKey("user.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ )
+
+ op.create_index("ix_token_usage_thread_id", "token_usage", ["thread_id"])
+ op.create_index("ix_token_usage_message_id", "token_usage", ["message_id"])
+ op.create_index(
+ "ix_token_usage_search_space_id", "token_usage", ["search_space_id"]
+ )
+ op.create_index("ix_token_usage_user_id", "token_usage", ["user_id"])
+ op.create_index("ix_token_usage_usage_type", "token_usage", ["usage_type"])
+
+
+def downgrade() -> None:
+ op.drop_table("token_usage")
diff --git a/surfsense_backend/alembic/versions/126_add_premium_token_quota.py b/surfsense_backend/alembic/versions/126_add_premium_token_quota.py
new file mode 100644
index 0000000000..c9ce7a5319
--- /dev/null
+++ b/surfsense_backend/alembic/versions/126_add_premium_token_quota.py
@@ -0,0 +1,166 @@
+"""add premium token quota columns and purchase table
+
+Revision ID: 126
+Revises: 125
+Create Date: 2026-04-15
+
+Adds premium_tokens_limit, premium_tokens_used, premium_tokens_reserved
+to the user table and creates the premium_token_purchases table.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+revision: str = "126"
+down_revision: str | None = "125"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+PREMIUM_TOKEN_LIMIT_DEFAULT = os.getenv("PREMIUM_TOKEN_LIMIT", "5000000")
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ # --- User table: add premium token columns if missing ---
+ inspector = sa.inspect(conn)
+ user_columns = {c["name"] for c in inspector.get_columns("user")}
+
+ if "premium_tokens_limit" not in user_columns:
+ op.add_column(
+ "user",
+ sa.Column(
+ "premium_tokens_limit",
+ sa.BigInteger(),
+ nullable=False,
+ server_default=PREMIUM_TOKEN_LIMIT_DEFAULT,
+ ),
+ )
+ if "premium_tokens_used" not in user_columns:
+ op.add_column(
+ "user",
+ sa.Column(
+ "premium_tokens_used",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+ if "premium_tokens_reserved" not in user_columns:
+ op.add_column(
+ "user",
+ sa.Column(
+ "premium_tokens_reserved",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ # --- PremiumTokenPurchase enum + table ---
+ enum_exists = conn.execute(
+ sa.text("SELECT 1 FROM pg_type WHERE typname = 'premiumtokenpurchasestatus'")
+ ).fetchone()
+ if not enum_exists:
+ purchase_status_enum = postgresql.ENUM(
+ "PENDING",
+ "COMPLETED",
+ "FAILED",
+ name="premiumtokenpurchasestatus",
+ create_type=False,
+ )
+ purchase_status_enum.create(conn, checkfirst=True)
+
+ if not inspector.has_table("premium_token_purchases"):
+ op.create_table(
+ "premium_token_purchases",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column(
+ "stripe_checkout_session_id",
+ sa.String(length=255),
+ nullable=False,
+ ),
+ sa.Column(
+ "stripe_payment_intent_id",
+ sa.String(length=255),
+ nullable=True,
+ ),
+ sa.Column("quantity", sa.Integer(), nullable=False),
+ sa.Column("tokens_granted", sa.BigInteger(), nullable=False),
+ sa.Column("amount_total", sa.Integer(), nullable=True),
+ sa.Column("currency", sa.String(length=10), nullable=True),
+ sa.Column(
+ "status",
+ postgresql.ENUM(
+ "PENDING",
+ "COMPLETED",
+ "FAILED",
+ name="premiumtokenpurchasestatus",
+ create_type=False,
+ ),
+ nullable=False,
+ server_default=sa.text("'PENDING'::premiumtokenpurchasestatus"),
+ ),
+ sa.Column("completed_at", sa.TIMESTAMP(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.TIMESTAMP(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["user.id"],
+ ondelete="CASCADE",
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint(
+ "stripe_checkout_session_id",
+ name="uq_premium_token_purchases_stripe_checkout_session_id",
+ ),
+ )
+
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_premium_token_purchases_user_id "
+ "ON premium_token_purchases (user_id)"
+ )
+ op.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_premium_token_purchases_stripe_session "
+ "ON premium_token_purchases (stripe_checkout_session_id)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_premium_token_purchases_payment_intent "
+ "ON premium_token_purchases (stripe_payment_intent_id)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_premium_token_purchases_status "
+ "ON premium_token_purchases (status)"
+ )
+
+
+def downgrade() -> None:
+ op.execute("DROP INDEX IF EXISTS ix_premium_token_purchases_status")
+ op.execute("DROP INDEX IF EXISTS ix_premium_token_purchases_payment_intent")
+ op.execute("DROP INDEX IF EXISTS ix_premium_token_purchases_stripe_session")
+ op.execute("DROP INDEX IF EXISTS ix_premium_token_purchases_user_id")
+ op.execute("DROP TABLE IF EXISTS premium_token_purchases")
+ postgresql.ENUM(name="premiumtokenpurchasestatus").drop(
+ op.get_bind(), checkfirst=True
+ )
+ op.drop_column("user", "premium_tokens_reserved")
+ op.drop_column("user", "premium_tokens_used")
+ op.drop_column("user", "premium_tokens_limit")
diff --git a/surfsense_backend/alembic/versions/127_add_report_content_type.py b/surfsense_backend/alembic/versions/127_add_report_content_type.py
new file mode 100644
index 0000000000..93bf471af9
--- /dev/null
+++ b/surfsense_backend/alembic/versions/127_add_report_content_type.py
@@ -0,0 +1,42 @@
+"""127_add_report_content_type
+
+Revision ID: 127
+Revises: 126
+Create Date: 2026-04-15
+
+Adds content_type column to reports table to distinguish between
+Markdown reports and Typst-based resumes.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "127"
+down_revision: str | None = "126"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ columns = [c["name"] for c in sa.inspect(conn).get_columns("reports")]
+ if "content_type" in columns:
+ return
+ op.add_column(
+ "reports",
+ sa.Column(
+ "content_type",
+ sa.String(20),
+ nullable=False,
+ server_default="markdown",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("reports", "content_type")
diff --git a/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py
new file mode 100644
index 0000000000..886879a7b3
--- /dev/null
+++ b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py
@@ -0,0 +1,43 @@
+"""128_seed_build_resume_prompt
+
+Revision ID: 128
+Revises: 127
+Create Date: 2026-04-15
+
+Seeds the 'Build Resume' default prompt for all existing users.
+New users get it automatically via SYSTEM_PROMPT_DEFAULTS on signup.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "128"
+down_revision: str | None = "127"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(
+ sa.text(
+ """
+ INSERT INTO prompts
+ (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
+ SELECT u.id, 'build-resume', 'Build Resume',
+ E'Build me a professional resume. Here is my information:\\n\\n{selection}',
+ 'explore'::prompt_mode, 1, false, now()
+ FROM "user" u
+ ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
+ """
+ )
+ )
+
+
+def downgrade() -> None:
+ op.execute("DELETE FROM prompts WHERE default_prompt_slug = 'build-resume'")
diff --git a/surfsense_backend/alembic/versions/129_obsidian_plugin_vault_identity.py b/surfsense_backend/alembic/versions/129_obsidian_plugin_vault_identity.py
new file mode 100644
index 0000000000..0c0e3dbe56
--- /dev/null
+++ b/surfsense_backend/alembic/versions/129_obsidian_plugin_vault_identity.py
@@ -0,0 +1,106 @@
+"""129_obsidian_plugin_vault_identity
+
+Revision ID: 129
+Revises: 128
+Create Date: 2026-04-21
+
+Locks down vault identity for the Obsidian plugin connector:
+
+- Deactivates pre-plugin OBSIDIAN_CONNECTOR rows.
+- Partial unique index on ``(user_id, (config->>'vault_id'))`` for the
+ ``/obsidian/connect`` upsert fast path.
+- Partial unique index on ``(user_id, (config->>'vault_fingerprint'))``
+ so two devices observing the same vault content can never produce
+ two connector rows. Collisions are caught by the route handler and
+ routed through the merge path.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "129"
+down_revision: str | None = "128"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ conn.execute(
+ sa.text(
+ """
+ UPDATE search_source_connectors
+ SET
+ is_indexable = false,
+ periodic_indexing_enabled = false,
+ next_scheduled_at = NULL,
+ config = COALESCE(config, '{}'::json)::jsonb
+ || jsonb_build_object(
+ 'legacy', true,
+ 'deactivated_at', to_char(
+ now() AT TIME ZONE 'UTC',
+ 'YYYY-MM-DD"T"HH24:MI:SS"Z"'
+ )
+ )
+ WHERE connector_type = 'OBSIDIAN_CONNECTOR'
+ AND COALESCE((config::jsonb)->>'source', '') <> 'plugin'
+ """
+ )
+ )
+
+ conn.execute(
+ sa.text(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS
+ search_source_connectors_obsidian_plugin_vault_uniq
+ ON search_source_connectors (user_id, ((config->>'vault_id')))
+ WHERE connector_type = 'OBSIDIAN_CONNECTOR'
+ AND config->>'source' = 'plugin'
+ AND config->>'vault_id' IS NOT NULL
+ """
+ )
+ )
+
+ conn.execute(
+ sa.text(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS
+ search_source_connectors_obsidian_plugin_fingerprint_uniq
+ ON search_source_connectors (user_id, ((config->>'vault_fingerprint')))
+ WHERE connector_type = 'OBSIDIAN_CONNECTOR'
+ AND config->>'source' = 'plugin'
+ AND config->>'vault_fingerprint' IS NOT NULL
+ """
+ )
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(
+ sa.text(
+ "DROP INDEX IF EXISTS "
+ "search_source_connectors_obsidian_plugin_fingerprint_uniq"
+ )
+ )
+ conn.execute(
+ sa.text(
+ "DROP INDEX IF EXISTS search_source_connectors_obsidian_plugin_vault_uniq"
+ )
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE search_source_connectors
+ SET config = (config::jsonb - 'legacy' - 'deactivated_at')::json
+ WHERE connector_type = 'OBSIDIAN_CONNECTOR'
+ AND (config::jsonb) ? 'legacy'
+ """
+ )
+ )
diff --git a/surfsense_backend/alembic/versions/130_add_agent_action_log.py b/surfsense_backend/alembic/versions/130_add_agent_action_log.py
new file mode 100644
index 0000000000..5978848d07
--- /dev/null
+++ b/surfsense_backend/alembic/versions/130_add_agent_action_log.py
@@ -0,0 +1,98 @@
+"""130_add_agent_action_log
+
+Revision ID: 130
+Revises: 129
+Create Date: 2026-04-28
+
+Adds the append-only ``agent_action_log`` table that
+:class:`ActionLogMiddleware` writes to after every tool call. Each row
+optionally carries a ``reverse_descriptor`` payload used by
+``POST /api/threads/{thread_id}/revert/{action_id}`` to undo the action.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+revision: str = "130"
+down_revision: str | None = "129"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if sa.inspect(bind).has_table("agent_action_log"):
+ return
+
+ op.create_table(
+ "agent_action_log",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column(
+ "thread_id",
+ sa.Integer(),
+ sa.ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column(
+ "user_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("user.id", ondelete="SET NULL"),
+ nullable=True,
+ index=True,
+ ),
+ sa.Column(
+ "search_space_id",
+ sa.Integer(),
+ sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column("turn_id", sa.String(length=64), nullable=True, index=True),
+ sa.Column("message_id", sa.String(length=128), nullable=True, index=True),
+ sa.Column("tool_name", sa.String(length=255), nullable=False, index=True),
+ sa.Column("args", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column("result_id", sa.String(length=255), nullable=True),
+ sa.Column(
+ "reversible",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ sa.Column(
+ "reverse_descriptor",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=True,
+ ),
+ sa.Column("error", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column(
+ "reverse_of",
+ sa.Integer(),
+ sa.ForeignKey("agent_action_log.id", ondelete="SET NULL"),
+ nullable=True,
+ index=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ nullable=False,
+ server_default=sa.text("(now() AT TIME ZONE 'utc')"),
+ index=True,
+ ),
+ )
+ op.create_index(
+ "ix_agent_action_log_thread_created",
+ "agent_action_log",
+ ["thread_id", "created_at"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_agent_action_log_thread_created", table_name="agent_action_log")
+ op.drop_table("agent_action_log")
diff --git a/surfsense_backend/alembic/versions/131_add_document_revisions.py b/surfsense_backend/alembic/versions/131_add_document_revisions.py
new file mode 100644
index 0000000000..c1e9b6068b
--- /dev/null
+++ b/surfsense_backend/alembic/versions/131_add_document_revisions.py
@@ -0,0 +1,136 @@
+"""131_add_document_revisions
+
+Revision ID: 131
+Revises: 130
+Create Date: 2026-04-28
+
+Adds two snapshot tables that back the per-action revert flow:
+
+* ``document_revisions``: pre-mutation snapshot of NOTE/FILE/EXTENSION docs.
+* ``folder_revisions``: pre-mutation snapshot of folder mkdir/move/delete.
+
+Both are written by :class:`KnowledgeBasePersistenceMiddleware` ahead of
+state-changing tool calls and consumed by ``revert_service.revert_action``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+revision: str = "131"
+down_revision: str | None = "130"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+
+ if inspector.has_table("document_revisions") and inspector.has_table(
+ "folder_revisions"
+ ):
+ return
+
+ if not inspector.has_table("document_revisions"):
+ _create_document_revisions()
+ if not inspector.has_table("folder_revisions"):
+ _create_folder_revisions()
+
+
+def _create_document_revisions() -> None:
+ op.create_table(
+ "document_revisions",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column(
+ "document_id",
+ sa.Integer(),
+ sa.ForeignKey("documents.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column(
+ "search_space_id",
+ sa.Integer(),
+ sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column("content_before", sa.Text(), nullable=True),
+ sa.Column("title_before", sa.String(), nullable=True),
+ sa.Column("folder_id_before", sa.Integer(), nullable=True),
+ sa.Column(
+ "chunks_before", postgresql.JSONB(astext_type=sa.Text()), nullable=True
+ ),
+ sa.Column(
+ "metadata_before", postgresql.JSONB(astext_type=sa.Text()), nullable=True
+ ),
+ sa.Column(
+ "created_by_turn_id", sa.String(length=64), nullable=True, index=True
+ ),
+ sa.Column(
+ "agent_action_id",
+ sa.Integer(),
+ sa.ForeignKey("agent_action_log.id", ondelete="SET NULL"),
+ nullable=True,
+ index=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ nullable=False,
+ server_default=sa.text("(now() AT TIME ZONE 'utc')"),
+ index=True,
+ ),
+ )
+
+
+def _create_folder_revisions() -> None:
+ op.create_table(
+ "folder_revisions",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column(
+ "folder_id",
+ sa.Integer(),
+ sa.ForeignKey("folders.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column(
+ "search_space_id",
+ sa.Integer(),
+ sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column("name_before", sa.String(length=255), nullable=True),
+ sa.Column("parent_id_before", sa.Integer(), nullable=True),
+ sa.Column("position_before", sa.String(length=50), nullable=True),
+ sa.Column(
+ "created_by_turn_id", sa.String(length=64), nullable=True, index=True
+ ),
+ sa.Column(
+ "agent_action_id",
+ sa.Integer(),
+ sa.ForeignKey("agent_action_log.id", ondelete="SET NULL"),
+ nullable=True,
+ index=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ nullable=False,
+ server_default=sa.text("(now() AT TIME ZONE 'utc')"),
+ index=True,
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("folder_revisions")
+ op.drop_table("document_revisions")
diff --git a/surfsense_backend/alembic/versions/132_add_agent_permission_rules.py b/surfsense_backend/alembic/versions/132_add_agent_permission_rules.py
new file mode 100644
index 0000000000..1ee3cd2f01
--- /dev/null
+++ b/surfsense_backend/alembic/versions/132_add_agent_permission_rules.py
@@ -0,0 +1,85 @@
+"""132_add_agent_permission_rules
+
+Revision ID: 132
+Revises: 131
+Create Date: 2026-04-28
+
+Adds the persistent ``agent_permission_rules`` table consumed by
+:class:`PermissionMiddleware` at agent build time. Rules can be scoped
+at search-space (``user_id`` / ``thread_id`` NULL), user-wide
+(``user_id`` set, ``thread_id`` NULL), or per-thread (``thread_id`` set).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+revision: str = "132"
+down_revision: str | None = "131"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if sa.inspect(bind).has_table("agent_permission_rules"):
+ return
+
+ op.create_table(
+ "agent_permission_rules",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column(
+ "search_space_id",
+ sa.Integer(),
+ sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column(
+ "user_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("user.id", ondelete="CASCADE"),
+ nullable=True,
+ index=True,
+ ),
+ sa.Column(
+ "thread_id",
+ sa.Integer(),
+ sa.ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
+ nullable=True,
+ index=True,
+ ),
+ sa.Column("permission", sa.String(length=255), nullable=False),
+ sa.Column(
+ "pattern",
+ sa.String(length=255),
+ nullable=False,
+ server_default="*",
+ ),
+ sa.Column("action", sa.String(length=16), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ nullable=False,
+ server_default=sa.text("(now() AT TIME ZONE 'utc')"),
+ index=True,
+ ),
+ sa.UniqueConstraint(
+ "search_space_id",
+ "user_id",
+ "thread_id",
+ "permission",
+ "pattern",
+ "action",
+ name="uq_agent_permission_rules_scope",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("agent_permission_rules")
diff --git a/surfsense_backend/alembic/versions/133_drop_documents_content_hash_unique.py b/surfsense_backend/alembic/versions/133_drop_documents_content_hash_unique.py
new file mode 100644
index 0000000000..eec53ecb64
--- /dev/null
+++ b/surfsense_backend/alembic/versions/133_drop_documents_content_hash_unique.py
@@ -0,0 +1,105 @@
+"""133_drop_documents_content_hash_unique
+
+Revision ID: 133
+Revises: 132
+Create Date: 2026-04-29
+
+Drop the global UNIQUE constraint on ``documents.content_hash`` so the
+new-chat agent's ``write_file`` flow can persist legitimate file copies
+(two paths, identical content) without hitting a constraint that mirrors
+no real filesystem semantic.
+
+Path uniqueness still lives on ``documents.unique_identifier_hash`` (per
+search space), which is the right invariant — exactly like an inode at a
+given path on a POSIX filesystem.
+
+The non-unique INDEX on ``content_hash`` is preserved so connector
+indexers' "have we seen this content before?" lookup
+(:func:`app.tasks.document_processors.base.check_duplicate_document`,
+which already uses ``.scalars().first()`` and is therefore tolerant of
+duplicates) stays cheap.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from sqlalchemy import inspect
+
+from alembic import op
+
+revision: str = "133"
+down_revision: str | None = "132"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _existing_constraint_names(bind, table: str) -> set[str]:
+ inspector = inspect(bind)
+ return {c["name"] for c in inspector.get_unique_constraints(table)}
+
+
+def _existing_index_names(bind, table: str) -> set[str]:
+ inspector = inspect(bind)
+ return {i["name"] for i in inspector.get_indexes(table)}
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ # Both the named UniqueConstraint (added in revision 8) and the
+ # implicit-unique-index variant SQLAlchemy may emit need draining.
+ constraints = _existing_constraint_names(bind, "documents")
+ if "uq_documents_content_hash" in constraints:
+ op.drop_constraint("uq_documents_content_hash", "documents", type_="unique")
+
+ indexes = _existing_index_names(bind, "documents")
+ # Some Postgres versions surface the unique constraint via a unique
+ # index of the same name; check for that too.
+ for idx_name in ("uq_documents_content_hash",):
+ if idx_name in indexes:
+ op.drop_index(idx_name, table_name="documents")
+
+ # Ensure the non-unique index is present for fast lookups.
+ if "ix_documents_content_hash" not in indexes:
+ op.create_index(
+ "ix_documents_content_hash",
+ "documents",
+ ["content_hash"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ # Re-applying UNIQUE is destructive: there may now be legitimate
+ # duplicates (e.g. two NOTE documents that share content because the
+ # user explicitly copied one to a new path). To avoid the migration
+ # silently deleting user data, we keep only the lowest-id row per
+ # content_hash — same strategy revision 8 used when first introducing
+ # the constraint.
+ op.execute(
+ """
+ DELETE FROM documents
+ WHERE id NOT IN (
+ SELECT MIN(id)
+ FROM documents
+ GROUP BY content_hash
+ )
+ """
+ )
+
+ indexes = _existing_index_names(bind, "documents")
+ if "ix_documents_content_hash" in indexes:
+ op.drop_index("ix_documents_content_hash", table_name="documents")
+
+ op.create_index(
+ "ix_documents_content_hash",
+ "documents",
+ ["content_hash"],
+ unique=False,
+ )
+ op.create_unique_constraint(
+ "uq_documents_content_hash", "documents", ["content_hash"]
+ )
diff --git a/surfsense_backend/alembic/versions/134_relax_revision_fks.py b/surfsense_backend/alembic/versions/134_relax_revision_fks.py
new file mode 100644
index 0000000000..99b665426a
--- /dev/null
+++ b/surfsense_backend/alembic/versions/134_relax_revision_fks.py
@@ -0,0 +1,139 @@
+"""134_relax_revision_fks
+
+Revision ID: 134
+Revises: 133
+Create Date: 2026-04-29
+
+Relax the parent FKs on ``document_revisions`` and ``folder_revisions`` so
+revisions survive the deletes they describe.
+
+Why: the snapshot/revert pipeline writes a ``DocumentRevision`` BEFORE
+hard-deleting a document via the ``rm`` tool (and likewise a
+``FolderRevision`` before ``rmdir``). If the FK is ``ON DELETE CASCADE``
+the snapshot row is wiped at the exact moment we need it most — revert
+then has nothing to read and the operation becomes irreversible.
+
+Migration:
+
+* ``document_revisions.document_id``: ``NOT NULL`` -> nullable; FK
+ ``ON DELETE CASCADE`` -> ``ON DELETE SET NULL``.
+* ``folder_revisions.folder_id``: same treatment.
+
+The ``search_space_id`` FK on both tables is left unchanged (still
+``ON DELETE CASCADE``). When a search space is deleted, all documents,
+folders, AND their revisions go together — that's the correct teardown
+story.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+from alembic import op
+
+revision: str = "134"
+down_revision: str | None = "133"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _fk_name(bind, table: str, column: str) -> str | None:
+ """Return the (single) FK constraint name on ``table.column``, if any."""
+ inspector = inspect(bind)
+ for fk in inspector.get_foreign_keys(table):
+ cols = fk.get("constrained_columns") or []
+ if cols == [column]:
+ return fk.get("name")
+ return None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ # --- document_revisions.document_id -> nullable + SET NULL ---------------
+ fk_name = _fk_name(bind, "document_revisions", "document_id")
+ if fk_name:
+ op.drop_constraint(fk_name, "document_revisions", type_="foreignkey")
+ op.alter_column(
+ "document_revisions",
+ "document_id",
+ existing_type=sa.Integer(),
+ nullable=True,
+ )
+ op.create_foreign_key(
+ "document_revisions_document_id_fkey",
+ "document_revisions",
+ "documents",
+ ["document_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+ # --- folder_revisions.folder_id -> nullable + SET NULL -------------------
+ fk_name = _fk_name(bind, "folder_revisions", "folder_id")
+ if fk_name:
+ op.drop_constraint(fk_name, "folder_revisions", type_="foreignkey")
+ op.alter_column(
+ "folder_revisions",
+ "folder_id",
+ existing_type=sa.Integer(),
+ nullable=True,
+ )
+ op.create_foreign_key(
+ "folder_revisions_folder_id_fkey",
+ "folder_revisions",
+ "folders",
+ ["folder_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ # Reinstating NOT NULL + CASCADE requires draining orphan rows first
+ # (any revision whose parent doc/folder has already been deleted).
+ op.execute("DELETE FROM document_revisions WHERE document_id IS NULL")
+ op.execute("DELETE FROM folder_revisions WHERE folder_id IS NULL")
+
+ # --- document_revisions.document_id -> NOT NULL + CASCADE ---------------
+ fk_name = _fk_name(bind, "document_revisions", "document_id")
+ if fk_name:
+ op.drop_constraint(fk_name, "document_revisions", type_="foreignkey")
+ op.alter_column(
+ "document_revisions",
+ "document_id",
+ existing_type=sa.Integer(),
+ nullable=False,
+ )
+ op.create_foreign_key(
+ "document_revisions_document_id_fkey",
+ "document_revisions",
+ "documents",
+ ["document_id"],
+ ["id"],
+ ondelete="CASCADE",
+ )
+
+ # --- folder_revisions.folder_id -> NOT NULL + CASCADE -------------------
+ fk_name = _fk_name(bind, "folder_revisions", "folder_id")
+ if fk_name:
+ op.drop_constraint(fk_name, "folder_revisions", type_="foreignkey")
+ op.alter_column(
+ "folder_revisions",
+ "folder_id",
+ existing_type=sa.Integer(),
+ nullable=False,
+ )
+ op.create_foreign_key(
+ "folder_revisions_folder_id_fkey",
+ "folder_revisions",
+ "folders",
+ ["folder_id"],
+ ["id"],
+ ondelete="CASCADE",
+ )
diff --git a/surfsense_backend/alembic/versions/135_action_log_correlation_ids.py b/surfsense_backend/alembic/versions/135_action_log_correlation_ids.py
new file mode 100644
index 0000000000..e40c4fb260
--- /dev/null
+++ b/surfsense_backend/alembic/versions/135_action_log_correlation_ids.py
@@ -0,0 +1,92 @@
+"""135_action_log_correlation_ids
+
+Revision ID: 135
+Revises: 134
+Create Date: 2026-04-29
+
+Action-log correlation-id cleanup.
+
+Background
+----------
+``agent_action_log.turn_id`` is misnamed. ``ActionLogMiddleware`` writes
+the LangChain ``tool_call.id`` into that column today (see
+``action_log.py:_resolve_turn_id``), and ``kb_persistence._find_action_ids_batch``
+joins on it as such. The real chat-turn id (``f"{chat_id}:{ms}"`` from
+``stream_new_chat.py``) lives in ``config.configurable.turn_id`` and was
+never persisted.
+
+This migration introduces two new, correctly-named columns:
+
+* ``tool_call_id`` (LangChain tool-call id, what ``turn_id`` actually held)
+* ``chat_turn_id`` (the per-turn correlation id from
+ ``configurable.turn_id`` — used by the per-turn ``revert-turn`` route).
+
+Backfill copies the current ``turn_id`` values into ``tool_call_id`` so
+existing joins keep working. The old ``turn_id`` column is left in place
+for one release as a deprecated alias to give safe rollback. ``ActionLogMiddleware``
+keeps writing it (= ``tool_call_id``) for the same reason.
+
+Indexes
+-------
+
+* ``ix_agent_action_log_tool_call_id`` — required by
+ ``_find_action_ids_batch`` (was on ``turn_id``).
+* ``ix_agent_action_log_chat_turn_id`` — required by the
+ ``revert-turn/{chat_turn_id}`` query.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "135"
+down_revision: str | None = "134"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ columns = {c["name"] for c in inspector.get_columns("agent_action_log")}
+ indexes = {i["name"] for i in inspector.get_indexes("agent_action_log")}
+
+ if "tool_call_id" not in columns:
+ op.add_column(
+ "agent_action_log",
+ sa.Column("tool_call_id", sa.String(length=64), nullable=True),
+ )
+ if "chat_turn_id" not in columns:
+ op.add_column(
+ "agent_action_log",
+ sa.Column("chat_turn_id", sa.String(length=64), nullable=True),
+ )
+
+ if "ix_agent_action_log_tool_call_id" not in indexes:
+ op.create_index(
+ "ix_agent_action_log_tool_call_id",
+ "agent_action_log",
+ ["tool_call_id"],
+ )
+ if "ix_agent_action_log_chat_turn_id" not in indexes:
+ op.create_index(
+ "ix_agent_action_log_chat_turn_id",
+ "agent_action_log",
+ ["chat_turn_id"],
+ )
+
+ if "turn_id" in columns:
+ op.execute(
+ "UPDATE agent_action_log SET tool_call_id = turn_id WHERE tool_call_id IS NULL"
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_agent_action_log_chat_turn_id", table_name="agent_action_log")
+ op.drop_index("ix_agent_action_log_tool_call_id", table_name="agent_action_log")
+ op.drop_column("agent_action_log", "chat_turn_id")
+ op.drop_column("agent_action_log", "tool_call_id")
diff --git a/surfsense_backend/alembic/versions/136_new_chat_message_turn_id.py b/surfsense_backend/alembic/versions/136_new_chat_message_turn_id.py
new file mode 100644
index 0000000000..ee02e453ca
--- /dev/null
+++ b/surfsense_backend/alembic/versions/136_new_chat_message_turn_id.py
@@ -0,0 +1,59 @@
+"""136_new_chat_message_turn_id
+
+Revision ID: 136
+Revises: 135
+Create Date: 2026-04-29
+
+Persist the per-turn correlation id on each chat message.
+
+Background
+----------
+LangGraph's checkpointer stores user-provided ``configurable.turn_id``
+in checkpoint metadata (see
+``langgraph/checkpoint/base/__init__.py:get_checkpoint_metadata``). To
+support edit-from-arbitrary-position, the regenerate route needs to map
+a ``message_id`` -> ``turn_id`` -> checkpoint at request time. Without
+this column the mapping doesn't exist anywhere, so regenerate would
+have to hardcode the "last 2 messages" rewind heuristic.
+
+This migration adds a nullable ``turn_id`` column to ``new_chat_messages``
+plus an index. Legacy rows have NULL — the regenerate route degrades
+gracefully to the reload-last-two heuristic for those.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "136"
+down_revision: str | None = "135"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ columns = {c["name"] for c in inspector.get_columns("new_chat_messages")}
+ indexes = {i["name"] for i in inspector.get_indexes("new_chat_messages")}
+
+ if "turn_id" not in columns:
+ op.add_column(
+ "new_chat_messages",
+ sa.Column("turn_id", sa.String(length=64), nullable=True),
+ )
+ if "ix_new_chat_messages_turn_id" not in indexes:
+ op.create_index(
+ "ix_new_chat_messages_turn_id",
+ "new_chat_messages",
+ ["turn_id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_new_chat_messages_turn_id", table_name="new_chat_messages")
+ op.drop_column("new_chat_messages", "turn_id")
diff --git a/surfsense_backend/alembic/versions/137_unique_reverse_of_in_action_log.py b/surfsense_backend/alembic/versions/137_unique_reverse_of_in_action_log.py
new file mode 100644
index 0000000000..47421e7125
--- /dev/null
+++ b/surfsense_backend/alembic/versions/137_unique_reverse_of_in_action_log.py
@@ -0,0 +1,81 @@
+"""137_unique_reverse_of_in_action_log
+
+Revision ID: 137
+Revises: 136
+Create Date: 2026-04-29
+
+Protect ``agent_action_log.reverse_of`` against double inserts. Two
+concurrent revert calls (single-action route + the per-turn batch
+route, or two batch routes racing) both pass the
+``_was_already_reverted`` SELECT and both insert their own
+``_revert:*`` rows. The application-level idempotency check is racy
+because there's no DB constraint backing it.
+
+This migration adds a partial unique index on ``reverse_of`` (PostgreSQL
+``WHERE reverse_of IS NOT NULL``) so the second concurrent insert raises
+``IntegrityError`` and the route can translate it to ``"already_reverted"``
+deterministically.
+
+The plain ``UniqueConstraint`` flavour can't be used because most
+existing rows have ``reverse_of = NULL`` (only revert rows fill it),
+and Postgres does treat NULL as distinct in unique indexes — but a
+partial index is the cleanest expression of intent and works even on
+older Postgres releases that distinguish NULL handling.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "137"
+down_revision: str | None = "136"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+_INDEX_NAME = "ux_agent_action_log_reverse_of"
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ indexes = {i["name"] for i in sa.inspect(bind).get_indexes("agent_action_log")}
+ if _INDEX_NAME in indexes:
+ return
+
+ # Defensively de-dup any pre-existing double-revert rows before
+ # adding the unique index. Keeps the OLDEST row (smallest id) and
+ # NULLs out the duplicates' ``reverse_of`` so they survive as audit
+ # trail but no longer claim to be the canonical revert. We do NOT
+ # delete them — operators can still inspect them via /actions.
+ op.execute(
+ """
+ WITH dups AS (
+ SELECT id,
+ reverse_of,
+ ROW_NUMBER() OVER (
+ PARTITION BY reverse_of ORDER BY id ASC
+ ) AS rn
+ FROM agent_action_log
+ WHERE reverse_of IS NOT NULL
+ )
+ UPDATE agent_action_log
+ SET reverse_of = NULL
+ WHERE id IN (SELECT id FROM dups WHERE rn > 1)
+ """
+ )
+
+ op.create_index(
+ _INDEX_NAME,
+ "agent_action_log",
+ ["reverse_of"],
+ unique=True,
+ postgresql_where="reverse_of IS NOT NULL",
+ )
+
+
+def downgrade() -> None:
+ op.drop_index(_INDEX_NAME, table_name="agent_action_log")
diff --git a/surfsense_backend/alembic/versions/138_add_thread_auto_model_pinning_fields.py b/surfsense_backend/alembic/versions/138_add_thread_auto_model_pinning_fields.py
new file mode 100644
index 0000000000..fba621a0cc
--- /dev/null
+++ b/surfsense_backend/alembic/versions/138_add_thread_auto_model_pinning_fields.py
@@ -0,0 +1,44 @@
+"""138_add_thread_auto_model_pinning_fields
+
+Revision ID: 138
+Revises: 137
+Create Date: 2026-04-30
+
+Add a single thread-level column to persist the Auto (Fastest) model pin:
+- pinned_llm_config_id: concrete resolved global LLM config id used for this
+ thread. NULL means "no pin; Auto will resolve on next turn".
+
+The column is unindexed: all reads are by new_chat_threads.id (primary key),
+so a secondary index would be dead write amplification.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "138"
+down_revision: str | None = "137"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.execute(
+ "ALTER TABLE new_chat_threads "
+ "ADD COLUMN IF NOT EXISTS pinned_llm_config_id INTEGER"
+ )
+
+
+def downgrade() -> None:
+ # Drop any shape the thread row may be carrying. The extra columns and
+ # indexes only exist on dev DBs that ran an earlier draft of 138; IF EXISTS
+ # makes each statement a safe no-op on the lean shape.
+ op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_pinned_auto_mode")
+ op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_pinned_llm_config_id")
+ op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS pinned_at")
+ op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS pinned_auto_mode")
+ op.execute(
+ "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS pinned_llm_config_id"
+ )
diff --git a/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py b/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py
new file mode 100644
index 0000000000..646049e3c2
--- /dev/null
+++ b/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py
@@ -0,0 +1,170 @@
+"""add user table to zero_publication with column list
+
+Adds the "user" table to zero_publication with a column-list publication
+so that only the 5 fields driving the live usage meters are replicated
+through WAL -> zero-cache -> browser IndexedDB:
+
+ id, pages_limit, pages_used,
+ premium_tokens_limit, premium_tokens_used
+
+Sensitive columns (hashed_password, email, oauth_account, display_name,
+avatar_url, memory_md, refresh_tokens, last_login, etc.) are NOT
+included in the publication, so they never enter WAL replication.
+
+Also re-asserts REPLICA IDENTITY DEFAULT on "user" for idempotency
+(it is already DEFAULT today since "user" was never in the
+TABLES_WITH_FULL_IDENTITY list of migration 117).
+
+IMPORTANT - before AND after running this migration:
+ 1. Stop zero-cache (it holds replication locks that will deadlock DDL)
+ 2. Run: alembic upgrade head
+ 3. Delete / reset the zero-cache data volume
+ 4. Restart zero-cache (it will do a fresh initial sync)
+
+DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
+PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
+1.0 it does not reliably wake the zero-cache change-streamer and can
+leave the replica pinned to a stale snapshot. This file is
+grandfathered in because it has already shipped to users; new
+publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
+pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
+``upgrade()`` function from migration
+``143_force_zero_publication_resync.py`` as your starting template.
+
+Revision ID: 139
+Revises: 138
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "139"
+down_revision: str | None = "138"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+PUBLICATION_NAME = "zero_publication"
+
+# Document column list as left by migration 117. Must match exactly.
+DOCUMENT_COLS = [
+ "id",
+ "title",
+ "document_type",
+ "search_space_id",
+ "folder_id",
+ "created_by_id",
+ "status",
+ "created_at",
+ "updated_at",
+]
+
+# Five fields needed by the live usage meters (sidebar Tokens/Pages,
+# Buy Tokens content). Keep this list narrow on purpose: anything added
+# here flows into WAL and IndexedDB for every connected browser.
+USER_COLS = [
+ "id",
+ "pages_limit",
+ "pages_used",
+ "premium_tokens_limit",
+ "premium_tokens_used",
+]
+
+
+def _terminate_blocked_pids(conn, table: str) -> None:
+ """Kill backends whose locks on *table* would block our AccessExclusiveLock."""
+ conn.execute(
+ sa.text(
+ "SELECT pg_terminate_backend(l.pid) "
+ "FROM pg_locks l "
+ "JOIN pg_class c ON c.oid = l.relation "
+ "WHERE c.relname = :tbl "
+ " AND l.pid != pg_backend_pid()"
+ ),
+ {"tbl": table},
+ )
+
+
+def _has_zero_version(conn, table: str) -> bool:
+ return (
+ conn.execute(
+ sa.text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = :tbl AND column_name = '_0_version'"
+ ),
+ {"tbl": table},
+ ).fetchone()
+ is not None
+ )
+
+
+def _build_publication_ddl(
+ documents_has_zero_ver: bool, user_has_zero_ver: bool
+) -> str:
+ doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
+ user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
+ doc_col_list = ", ".join(doc_cols)
+ user_col_list = ", ".join(user_cols)
+ return (
+ f"CREATE PUBLICATION {PUBLICATION_NAME} FOR TABLE "
+ f"notifications, "
+ f"documents ({doc_col_list}), "
+ f"folders, "
+ f"search_source_connectors, "
+ f"new_chat_messages, "
+ f"chat_comments, "
+ f"chat_session_state, "
+ f'"user" ({user_col_list})'
+ )
+
+
+def _build_publication_ddl_without_user(documents_has_zero_ver: bool) -> str:
+ doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
+ doc_col_list = ", ".join(doc_cols)
+ return (
+ f"CREATE PUBLICATION {PUBLICATION_NAME} FOR TABLE "
+ f"notifications, "
+ f"documents ({doc_col_list}), "
+ f"folders, "
+ f"search_source_connectors, "
+ f"new_chat_messages, "
+ f"chat_comments, "
+ f"chat_session_state"
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ # asyncpg requires LOCK TABLE inside a transaction block. Alembic already
+ # opened one via context.begin_transaction(), but the driver still errors
+ # unless we use an explicit SAVEPOINT (nested transaction) for this block.
+ tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
+ with tx:
+ conn.execute(sa.text("SET lock_timeout = '10s'"))
+
+ _terminate_blocked_pids(conn, "user")
+ conn.execute(sa.text('LOCK TABLE "user" IN ACCESS EXCLUSIVE MODE'))
+
+ # Idempotent: "user" was never in TABLES_WITH_FULL_IDENTITY of
+ # migration 117, so this is already DEFAULT. Re-assert anyway so
+ # the column-list publication stays valid (DEFAULT identity only
+ # requires the PK to be in the column list).
+ conn.execute(sa.text('ALTER TABLE "user" REPLICA IDENTITY DEFAULT'))
+
+ conn.execute(sa.text(f"DROP PUBLICATION IF EXISTS {PUBLICATION_NAME}"))
+
+ documents_has_zero_ver = _has_zero_version(conn, "documents")
+ user_has_zero_ver = _has_zero_version(conn, "user")
+
+ conn.execute(
+ sa.text(_build_publication_ddl(documents_has_zero_ver, user_has_zero_ver))
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(sa.text(f"DROP PUBLICATION IF EXISTS {PUBLICATION_NAME}"))
+ documents_has_zero_ver = _has_zero_version(conn, "documents")
+ conn.execute(sa.text(_build_publication_ddl_without_user(documents_has_zero_ver)))
diff --git a/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py b/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py
new file mode 100644
index 0000000000..ff88ac34eb
--- /dev/null
+++ b/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py
@@ -0,0 +1,301 @@
+"""rename premium token columns to credit micros and add cost_micros to token_usage
+
+Migrates the premium quota system from a flat token counter to a USD-cost
+based credit system, where 1 credit = 1 micro-USD ($0.000001).
+
+Column renames (1:1 numerical mapping — the prior $1 per 1M tokens Stripe
+price means every existing value is already correct in the new unit, no
+data transformation needed):
+
+ user.premium_tokens_limit -> premium_credit_micros_limit
+ user.premium_tokens_used -> premium_credit_micros_used
+ user.premium_tokens_reserved -> premium_credit_micros_reserved
+
+ premium_token_purchases.tokens_granted -> credit_micros_granted
+
+New column for cost auditing per turn:
+
+ token_usage.cost_micros (BigInteger NOT NULL DEFAULT 0)
+
+The "user" table is in zero_publication's column list (added in 139), so
+this migration must drop and recreate the publication with the renamed
+column names, otherwise zero-cache will replicate stale column names and
+the FE Zero schema will fail to bind.
+
+IMPORTANT - before AND after running this migration:
+ 1. Stop zero-cache (it holds replication locks that will deadlock DDL)
+ 2. Run: alembic upgrade head
+ 3. Delete / reset the zero-cache data volume
+ 4. Restart zero-cache (it will do a fresh initial sync)
+
+Skipping the zero-cache stop will deadlock at the ACCESS EXCLUSIVE LOCK on
+"user". Skipping the data-volume reset will leave IndexedDB clients seeing
+column-not-found errors from a stale catalog snapshot.
+
+DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
+PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
+1.0 it does not reliably wake the zero-cache change-streamer and can
+leave the replica pinned to a stale snapshot. This file is
+grandfathered in because it has already shipped to users; new
+publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
+pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
+``upgrade()`` function from migration
+``143_force_zero_publication_resync.py`` as your starting template.
+
+Revision ID: 140
+Revises: 139
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "140"
+down_revision: str | None = "139"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+PUBLICATION_NAME = "zero_publication"
+
+# Replicates 139's document column list verbatim — must stay in sync.
+DOCUMENT_COLS = [
+ "id",
+ "title",
+ "document_type",
+ "search_space_id",
+ "folder_id",
+ "created_by_id",
+ "status",
+ "created_at",
+ "updated_at",
+]
+
+# Same five live-meter fields as 139, with the renamed column names.
+USER_COLS = [
+ "id",
+ "pages_limit",
+ "pages_used",
+ "premium_credit_micros_limit",
+ "premium_credit_micros_used",
+]
+
+
+def _terminate_blocked_pids(conn, table: str) -> None:
+ """Kill backends whose locks on *table* would block our AccessExclusiveLock."""
+ conn.execute(
+ sa.text(
+ "SELECT pg_terminate_backend(l.pid) "
+ "FROM pg_locks l "
+ "JOIN pg_class c ON c.oid = l.relation "
+ "WHERE c.relname = :tbl "
+ " AND l.pid != pg_backend_pid()"
+ ),
+ {"tbl": table},
+ )
+
+
+def _has_zero_version(conn, table: str) -> bool:
+ return (
+ conn.execute(
+ sa.text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = :tbl AND column_name = '_0_version'"
+ ),
+ {"tbl": table},
+ ).fetchone()
+ is not None
+ )
+
+
+def _column_exists(conn, table: str, column: str) -> bool:
+ return (
+ conn.execute(
+ sa.text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = :tbl AND column_name = :col"
+ ),
+ {"tbl": table, "col": column},
+ ).fetchone()
+ is not None
+ )
+
+
+def _build_publication_ddl(
+ user_cols: list[str],
+ *,
+ documents_has_zero_ver: bool,
+ user_has_zero_ver: bool,
+) -> str:
+ doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
+ user_col_list_with_meta = user_cols + (
+ ['"_0_version"'] if user_has_zero_ver else []
+ )
+ doc_col_list = ", ".join(doc_cols)
+ user_col_list = ", ".join(user_col_list_with_meta)
+ return (
+ f"CREATE PUBLICATION {PUBLICATION_NAME} FOR TABLE "
+ f"notifications, "
+ f"documents ({doc_col_list}), "
+ f"folders, "
+ f"search_source_connectors, "
+ f"new_chat_messages, "
+ f"chat_comments, "
+ f"chat_session_state, "
+ f'"user" ({user_col_list})'
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ # ------------------------------------------------------------------
+ # 1. Add cost_micros to token_usage. Idempotent guard so re-runs in
+ # dev environments are safe.
+ # ------------------------------------------------------------------
+ if not _column_exists(conn, "token_usage", "cost_micros"):
+ op.add_column(
+ "token_usage",
+ sa.Column(
+ "cost_micros",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ # ------------------------------------------------------------------
+ # 2. Rename premium_token_purchases.tokens_granted -> credit_micros_granted.
+ # ------------------------------------------------------------------
+ if _column_exists(
+ conn, "premium_token_purchases", "tokens_granted"
+ ) and not _column_exists(conn, "premium_token_purchases", "credit_micros_granted"):
+ op.alter_column(
+ "premium_token_purchases",
+ "tokens_granted",
+ new_column_name="credit_micros_granted",
+ )
+
+ # ------------------------------------------------------------------
+ # 3. Rename user.premium_tokens_* -> premium_credit_micros_*.
+ #
+ # We must drop the publication first (it references the old column
+ # names) and re-acquire the lock for DDL. asyncpg requires LOCK TABLE
+ # in a transaction block; alembic's outer transaction already holds
+ # one, but a SAVEPOINT keeps the LOCK + DDL atomic.
+ # ------------------------------------------------------------------
+ tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
+ with tx:
+ conn.execute(sa.text("SET lock_timeout = '10s'"))
+
+ _terminate_blocked_pids(conn, "user")
+ conn.execute(sa.text('LOCK TABLE "user" IN ACCESS EXCLUSIVE MODE'))
+
+ # Re-assert REPLICA IDENTITY DEFAULT for safety; column-list
+ # publications require at least the PK to be in the column list,
+ # which is true for both the old and new shape.
+ conn.execute(sa.text('ALTER TABLE "user" REPLICA IDENTITY DEFAULT'))
+
+ # Drop the publication BEFORE renaming columns, otherwise Postgres
+ # rejects the rename: "cannot drop column ... referenced by
+ # publication".
+ conn.execute(sa.text(f"DROP PUBLICATION IF EXISTS {PUBLICATION_NAME}"))
+
+ for old, new in (
+ ("premium_tokens_limit", "premium_credit_micros_limit"),
+ ("premium_tokens_used", "premium_credit_micros_used"),
+ ("premium_tokens_reserved", "premium_credit_micros_reserved"),
+ ):
+ if _column_exists(conn, "user", old) and not _column_exists(
+ conn, "user", new
+ ):
+ op.alter_column("user", old, new_column_name=new)
+
+ # Update the server_default on the renamed limit column so newly
+ # inserted users get $5 of credit (== 5_000_000 micros) by
+ # default. Existing rows are unaffected.
+ op.alter_column(
+ "user",
+ "premium_credit_micros_limit",
+ server_default="5000000",
+ )
+
+ # Recreate the publication with the new column names.
+ documents_has_zero_ver = _has_zero_version(conn, "documents")
+ user_has_zero_ver = _has_zero_version(conn, "user")
+ conn.execute(
+ sa.text(
+ _build_publication_ddl(
+ USER_COLS,
+ documents_has_zero_ver=documents_has_zero_ver,
+ user_has_zero_ver=user_has_zero_ver,
+ )
+ )
+ )
+
+
+def downgrade() -> None:
+ """Revert the rename and drop ``cost_micros``.
+
+ Mirrors ``upgrade``: drop the publication, rename columns back, drop
+ the new column, recreate the publication with the old column list.
+ Same zero-cache stop/reset runbook applies in reverse.
+ """
+ conn = op.get_bind()
+
+ tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
+ with tx:
+ conn.execute(sa.text("SET lock_timeout = '10s'"))
+
+ _terminate_blocked_pids(conn, "user")
+ conn.execute(sa.text('LOCK TABLE "user" IN ACCESS EXCLUSIVE MODE'))
+
+ conn.execute(sa.text(f"DROP PUBLICATION IF EXISTS {PUBLICATION_NAME}"))
+
+ for new, old in (
+ ("premium_credit_micros_limit", "premium_tokens_limit"),
+ ("premium_credit_micros_used", "premium_tokens_used"),
+ ("premium_credit_micros_reserved", "premium_tokens_reserved"),
+ ):
+ if _column_exists(conn, "user", new) and not _column_exists(
+ conn, "user", old
+ ):
+ op.alter_column("user", new, new_column_name=old)
+
+ op.alter_column(
+ "user",
+ "premium_tokens_limit",
+ server_default="5000000",
+ )
+
+ legacy_user_cols = [
+ "id",
+ "pages_limit",
+ "pages_used",
+ "premium_tokens_limit",
+ "premium_tokens_used",
+ ]
+ documents_has_zero_ver = _has_zero_version(conn, "documents")
+ user_has_zero_ver = _has_zero_version(conn, "user")
+ conn.execute(
+ sa.text(
+ _build_publication_ddl(
+ legacy_user_cols,
+ documents_has_zero_ver=documents_has_zero_ver,
+ user_has_zero_ver=user_has_zero_ver,
+ )
+ )
+ )
+
+ if _column_exists(
+ conn, "premium_token_purchases", "credit_micros_granted"
+ ) and not _column_exists(conn, "premium_token_purchases", "tokens_granted"):
+ op.alter_column(
+ "premium_token_purchases",
+ "credit_micros_granted",
+ new_column_name="tokens_granted",
+ )
+
+ if _column_exists(conn, "token_usage", "cost_micros"):
+ op.drop_column("token_usage", "cost_micros")
diff --git a/surfsense_backend/alembic/versions/141_unique_chat_message_turn_role.py b/surfsense_backend/alembic/versions/141_unique_chat_message_turn_role.py
new file mode 100644
index 0000000000..1226a59b45
--- /dev/null
+++ b/surfsense_backend/alembic/versions/141_unique_chat_message_turn_role.py
@@ -0,0 +1,71 @@
+"""141_unique_chat_message_turn_role
+
+Revision ID: 141
+Revises: 140
+Create Date: 2026-05-04
+
+Add a partial unique index on ``new_chat_messages(thread_id, turn_id, role)``
+where ``turn_id IS NOT NULL``.
+
+Why
+---
+The streaming chat path (`stream_new_chat` / `stream_resume_chat`) is being
+moved to write its own ``new_chat_messages`` rows server-side instead of
+relying on the frontend's later ``POST /threads/{id}/messages`` call. This
+closes the "ghost-thread" abuse vector where authenticated callers got free
+LLM completions while ``new_chat_messages`` stayed empty.
+
+For server-side and legacy frontend writes to coexist we need an idempotency
+key. The natural triple is ``(thread_id, turn_id, role)``: the server issues
+exactly one ``turn_id`` per turn, and a turn produces at most one user
+message and one assistant message. Whichever side wins the race writes the
+row; the loser hits ``IntegrityError`` and recovers gracefully.
+
+Partial — ``WHERE turn_id IS NOT NULL`` — so:
+
+ * Legacy rows that predate the ``turn_id`` column (migration 136) keep
+ co-existing without de-dup.
+ * Clone / snapshot inserts in
+ ``app/services/public_chat_service.py`` that build ``NewChatMessage``
+ without ``turn_id`` are unaffected (multiple snapshot copies of the same
+ user/assistant pair are intentional).
+
+This index coexists with the existing single-column ``ix_new_chat_messages_turn_id``
+from migration 136 — no collision.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "141"
+down_revision: str | None = "140"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+INDEX_NAME = "uq_new_chat_messages_thread_turn_role"
+TABLE_NAME = "new_chat_messages"
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ indexes = {i["name"] for i in sa.inspect(bind).get_indexes(TABLE_NAME)}
+ if INDEX_NAME in indexes:
+ return
+
+ op.create_index(
+ INDEX_NAME,
+ TABLE_NAME,
+ ["thread_id", "turn_id", "role"],
+ unique=True,
+ postgresql_where=sa.text("turn_id IS NOT NULL"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_index(INDEX_NAME, table_name=TABLE_NAME)
diff --git a/surfsense_backend/alembic/versions/142_token_usage_message_id_unique.py b/surfsense_backend/alembic/versions/142_token_usage_message_id_unique.py
new file mode 100644
index 0000000000..43b30a7566
--- /dev/null
+++ b/surfsense_backend/alembic/versions/142_token_usage_message_id_unique.py
@@ -0,0 +1,134 @@
+"""142_token_usage_message_id_unique
+
+Revision ID: 142
+Revises: 141
+Create Date: 2026-05-04
+
+Add a partial unique index on ``token_usage(message_id)`` where
+``message_id IS NOT NULL``.
+
+Why
+---
+Two writers can race on the same assistant turn's ``token_usage`` row:
+
+ * ``finalize_assistant_turn`` (server-side, called from the streaming
+ finally block in ``stream_new_chat`` / ``stream_resume_chat``)
+ * ``append_message``'s recovery branch in
+ ``app/routes/new_chat_routes.py`` (legacy frontend round-trip)
+
+Both currently use ``SELECT ... THEN INSERT`` in separate sessions, so a
+micro-second-aligned race could observe "no row" on each side and double
+INSERT, producing duplicate ``token_usage`` rows for the same
+``message_id``.
+
+A partial unique index on ``message_id`` (``WHERE message_id IS NOT NULL``)
+turns both writes into ``INSERT ... ON CONFLICT (message_id) DO NOTHING``
+no-ops for the loser, hard-eliminating the race at the DB level. Partial
+because non-chat usage rows (indexing, image generation, podcasts) keep
+``message_id`` NULL — they're per-event, no de-dup needed.
+
+Pre-flight
+----------
+Today's schema only has a non-unique index on ``message_id`` so a
+duplicate population could already exist from any past race. We:
+
+ * Detect duplicate ``message_id`` groups (``HAVING COUNT(*) > 1``).
+ * If the group count is at or below ``DUPLICATE_ABORT_THRESHOLD`` (50)
+ we dedupe by deleting all but the smallest ``id`` per group.
+ * If the count exceeds the threshold we abort with a descriptive
+ error rather than silently mutate prod data — operator must
+ investigate before retrying.
+
+Concurrency
+-----------
+``CREATE INDEX CONCURRENTLY`` is required on this hot table to avoid
+stalling production writes during deploy (a regular ``CREATE INDEX``
+holds an ACCESS EXCLUSIVE lock for the duration of the build, which
+would block ``token_usage`` INSERTs for every active streaming chat).
+The trade-off is a slower migration (CONCURRENTLY scans the table
+twice) and the ``CREATE`` statement cannot run inside alembic's default
+transaction wrapper — ``autocommit_block()`` handles that.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "142"
+down_revision: str | None = "141"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+INDEX_NAME = "uq_token_usage_message_id"
+TABLE_NAME = "token_usage"
+
+# Refuse to silently mutate prod data if the duplicate population is
+# unexpectedly large — operator should investigate the upstream cause
+# before retrying. 50 is comfortably above any plausible duplicate
+# count from the existing race window (the race is microseconds wide).
+DUPLICATE_ABORT_THRESHOLD = 50
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ dup_groups = conn.execute(
+ sa.text(
+ "SELECT message_id, COUNT(*) AS n "
+ "FROM token_usage "
+ "WHERE message_id IS NOT NULL "
+ "GROUP BY message_id "
+ "HAVING COUNT(*) > 1"
+ )
+ ).fetchall()
+
+ if len(dup_groups) > DUPLICATE_ABORT_THRESHOLD:
+ raise RuntimeError(
+ f"token_usage has {len(dup_groups)} duplicate message_id groups "
+ f"(threshold={DUPLICATE_ABORT_THRESHOLD}). "
+ "Resolve the duplicates manually before re-running this migration."
+ )
+
+ if dup_groups:
+ # Delete all but the smallest-id row per duplicate group. The
+ # smallest id is by definition the earliest insert, so we keep
+ # the row most likely to reflect the actual stream's first
+ # successful write.
+ conn.execute(
+ sa.text(
+ """
+ DELETE FROM token_usage
+ WHERE id IN (
+ SELECT id FROM (
+ SELECT
+ id,
+ row_number() OVER (
+ PARTITION BY message_id ORDER BY id ASC
+ ) AS rn
+ FROM token_usage
+ WHERE message_id IS NOT NULL
+ ) ranked
+ WHERE rn > 1
+ )
+ """
+ )
+ )
+
+ # CREATE INDEX CONCURRENTLY cannot run inside a transaction. Drop
+ # alembic's auto-transaction for this op only.
+ with op.get_context().autocommit_block():
+ op.execute(
+ f"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME} "
+ f"ON {TABLE_NAME} (message_id) "
+ "WHERE message_id IS NOT NULL"
+ )
+
+
+def downgrade() -> None:
+ with op.get_context().autocommit_block():
+ op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}")
diff --git a/surfsense_backend/alembic/versions/143_force_zero_publication_resync.py b/surfsense_backend/alembic/versions/143_force_zero_publication_resync.py
new file mode 100644
index 0000000000..147cbde56f
--- /dev/null
+++ b/surfsense_backend/alembic/versions/143_force_zero_publication_resync.py
@@ -0,0 +1,142 @@
+"""force zero-cache to resync after upgrading to Zero >= 1.0
+
+Re-emits the current ``zero_publication`` shape using
+``ALTER PUBLICATION ... SET TABLE`` wrapped in
+``COMMENT ON PUBLICATION`` bookends. This is the publication-change
+hook documented for Zero ``>=1.0``:
+
+ https://zero.rocicorp.dev/docs/connecting-to-postgres#publication-changes
+
+Background
+----------
+Migrations 117 / 139 / 140 mutated ``zero_publication`` using
+``DROP PUBLICATION`` + ``CREATE PUBLICATION``. On Zero 0.26.2 that
+sequence did not reliably wake the zero-cache change-streamer, so
+affected installs ended up with a SQLite replica file (in the
+``surfsense-zero-cache`` volume) that was snapshotted against the
+pre-``user`` publication. The frontend Zero schema includes a
+``userTable`` query, which then failed with
+``SchemaVersionNotSupported`` and triggered the default
+``onUpdateNeeded`` -> ``location.reload()`` every WebSocket keepalive
+interval (~60s). See bug #1355.
+
+This migration emits the canonical publication shape one more time,
+this time using a pattern that fires Postgres event triggers and
+Zero's schema-change hook. With ``ZERO_AUTO_RESET=true`` (the default)
+and Zero ``>=1.0``, zero-cache responds by wiping its replica and
+doing a fresh initial sync from the corrected publication.
+
+The publication shape itself is unchanged versus migration 140 -- on
+installs whose replica is already correct, this is a no-op aside
+from the harmless event-trigger fire.
+
+Revision ID: 143
+Revises: 142
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "143"
+down_revision: str | None = "142"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+PUBLICATION_NAME = "zero_publication"
+
+# Must stay in sync with the column lists in migrations 117 / 139 / 140.
+DOCUMENT_COLS = [
+ "id",
+ "title",
+ "document_type",
+ "search_space_id",
+ "folder_id",
+ "created_by_id",
+ "status",
+ "created_at",
+ "updated_at",
+]
+
+USER_COLS = [
+ "id",
+ "pages_limit",
+ "pages_used",
+ "premium_credit_micros_limit",
+ "premium_credit_micros_used",
+]
+
+
+def _has_zero_version(conn, table: str) -> bool:
+ return (
+ conn.execute(
+ sa.text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = :tbl AND column_name = '_0_version'"
+ ),
+ {"tbl": table},
+ ).fetchone()
+ is not None
+ )
+
+
+def _build_set_table_ddl(
+ *, documents_has_zero_ver: bool, user_has_zero_ver: bool
+) -> str:
+ doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
+ user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
+ doc_col_list = ", ".join(doc_cols)
+ user_col_list = ", ".join(user_cols)
+ return (
+ f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE "
+ f"notifications, "
+ f"documents ({doc_col_list}), "
+ f"folders, "
+ f"search_source_connectors, "
+ f"new_chat_messages, "
+ f"chat_comments, "
+ f"chat_session_state, "
+ f'"user" ({user_col_list})'
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ exists = conn.execute(
+ sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
+ {"name": PUBLICATION_NAME},
+ ).fetchone()
+ if not exists:
+ return
+
+ documents_has_zero_ver = _has_zero_version(conn, "documents")
+ user_has_zero_ver = _has_zero_version(conn, "user")
+
+ # The COMMENT-ALTER-COMMENT trio MUST run in a single transaction so
+ # Zero observes them as one schema-change event. Alembic's outer
+ # transaction already covers us, but a SAVEPOINT keeps the trio
+ # atomic with asyncpg, matching the pattern used in migrations
+ # 117 / 139 / 140.
+ tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
+ with tx:
+ conn.execute(
+ sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-143-resync'")
+ )
+ conn.execute(
+ sa.text(
+ _build_set_table_ddl(
+ documents_has_zero_ver=documents_has_zero_ver,
+ user_has_zero_ver=user_has_zero_ver,
+ )
+ )
+ )
+ conn.execute(
+ sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-143-resync'")
+ )
+
+
+def downgrade() -> None:
+ """No-op. The publication shape is unchanged versus migration 140."""
diff --git a/surfsense_backend/app/agents/autocomplete/__init__.py b/surfsense_backend/app/agents/autocomplete/__init__.py
deleted file mode 100644
index 55d7a692dd..0000000000
--- a/surfsense_backend/app/agents/autocomplete/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Agent-based vision autocomplete with scoped filesystem exploration."""
-
-from app.agents.autocomplete.autocomplete_agent import (
- create_autocomplete_agent,
- stream_autocomplete_agent,
-)
-
-__all__ = [
- "create_autocomplete_agent",
- "stream_autocomplete_agent",
-]
diff --git a/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py b/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
index 2d8f05fd36..890b3e06ec 100644
--- a/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
+++ b/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
@@ -28,13 +28,76 @@
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, ToolMessage
+from app.agents.new_chat.document_xml import build_document_xml
from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware
from app.agents.new_chat.middleware.knowledge_search import (
- build_scoped_filesystem,
search_knowledge_base,
)
+from app.agents.new_chat.path_resolver import (
+ DOCUMENTS_ROOT,
+ build_path_index,
+ doc_to_virtual_path,
+)
+from app.db import shielded_async_session
from app.services.new_streaming_service import VercelStreamingService
+try:
+ from deepagents.backends.utils import create_file_data
+except Exception: # pragma: no cover - defensive
+
+ def create_file_data(content: str) -> dict[str, Any]:
+ return {"content": content.split("\n")}
+
+
+async def _build_autocomplete_filesystem(
+ *,
+ documents: Any,
+ search_space_id: int,
+) -> tuple[dict[str, Any], dict[int, str]]:
+ """Build a ``state['files']``-shaped dict from KB search results.
+
+ This is the autocomplete-specific replacement for the previous
+ ``build_scoped_filesystem`` helper. It uses the canonical path resolver
+ so paths line up with the rest of the system, including collision
+ suffixes for duplicate titles.
+ """
+ files: dict[str, Any] = {}
+ doc_id_to_path: dict[int, str] = {}
+
+ if not documents:
+ return files, doc_id_to_path
+
+ async with shielded_async_session() as session:
+ index = await build_path_index(session, search_space_id)
+
+ for document in documents:
+ if not isinstance(document, dict):
+ continue
+ meta = document.get("document") or {}
+ doc_id = meta.get("id")
+ if not isinstance(doc_id, int):
+ continue
+ title = str(meta.get("title") or "untitled")
+ folder_id = meta.get("folder_id")
+ path = doc_to_virtual_path(
+ doc_id=doc_id, title=title, folder_id=folder_id, index=index
+ )
+ chunk_ids = document.get("matched_chunk_ids") or []
+ try:
+ matched_set = {int(c) for c in chunk_ids}
+ except (TypeError, ValueError):
+ matched_set = set()
+ xml = build_document_xml(document, matched_chunk_ids=matched_set)
+ files[path] = create_file_data(xml)
+ doc_id_to_path[doc_id] = path
+
+ if not files:
+ # Ensure the synthetic /documents folder is visible even when empty.
+ files.setdefault(f"{DOCUMENTS_ROOT}/.placeholder", create_file_data(""))
+
+ return files, doc_id_to_path
+
+
logger = logging.getLogger(__name__)
KB_TOP_K = 10
@@ -174,7 +237,7 @@ async def precompute_kb_filesystem(
if not search_results:
return _KBResult()
- new_files, _ = await build_scoped_filesystem(
+ new_files, _ = await _build_autocomplete_filesystem(
documents=search_results,
search_space_id=search_space_id,
)
@@ -215,13 +278,12 @@ async def precompute_kb_filesystem(
class AutocompleteFilesystemMiddleware(SurfSenseFilesystemMiddleware):
"""Filesystem middleware for autocomplete — read-only exploration only.
- Strips ``save_document`` (permanent KB persistence) and passes
- ``search_space_id=None`` so ``write_file`` / ``edit_file`` stay ephemeral.
+ Passes ``search_space_id=None`` so the new persistence pipeline is
+ bypassed; the autocomplete flow only reads, never commits to Postgres.
"""
def __init__(self) -> None:
super().__init__(search_space_id=None, created_by_id=None)
- self.tools = [t for t in self.tools if t.name != "save_document"]
# ---------------------------------------------------------------------------
diff --git a/surfsense_backend/app/agents/multi_agent_chat/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/__init__.py
new file mode 100644
index 0000000000..6c7d79eb84
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/__init__.py
@@ -0,0 +1,7 @@
+"""Deepagents-backed routes: ``subagents/``; main-agent graph under ``main_agent/`` (SRP subpackages)."""
+
+from __future__ import annotations
+
+from .main_agent import create_multi_agent_chat_deep_agent
+
+__all__ = ["create_multi_agent_chat_deep_agent"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/constants.py b/surfsense_backend/app/agents/multi_agent_chat/constants.py
new file mode 100644
index 0000000000..7e40618131
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/constants.py
@@ -0,0 +1,44 @@
+"""Connector-type to subagent name; subagent name to availability tokens for build_subagents."""
+
+from __future__ import annotations
+
+CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
+ "GOOGLE_GMAIL_CONNECTOR": "gmail",
+ "COMPOSIO_GMAIL_CONNECTOR": "gmail",
+ "GOOGLE_CALENDAR_CONNECTOR": "calendar",
+ "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "calendar",
+ "DISCORD_CONNECTOR": "discord",
+ "TEAMS_CONNECTOR": "teams",
+ "LUMA_CONNECTOR": "luma",
+ "LINEAR_CONNECTOR": "linear",
+ "JIRA_CONNECTOR": "jira",
+ "CLICKUP_CONNECTOR": "clickup",
+ "SLACK_CONNECTOR": "slack",
+ "AIRTABLE_CONNECTOR": "airtable",
+ "NOTION_CONNECTOR": "notion",
+ "CONFLUENCE_CONNECTOR": "confluence",
+ "GOOGLE_DRIVE_CONNECTOR": "google_drive",
+ "COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "google_drive",
+ "DROPBOX_CONNECTOR": "dropbox",
+ "ONEDRIVE_CONNECTOR": "onedrive",
+}
+
+SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
+ "deliverables": frozenset(),
+ "knowledge_base": frozenset(),
+ "airtable": frozenset({"AIRTABLE_CONNECTOR"}),
+ "calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
+ "clickup": frozenset({"CLICKUP_CONNECTOR"}),
+ "confluence": frozenset({"CONFLUENCE_CONNECTOR"}),
+ "discord": frozenset({"DISCORD_CONNECTOR"}),
+ "dropbox": frozenset({"DROPBOX_FILE"}),
+ "gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR"}),
+ "google_drive": frozenset({"GOOGLE_DRIVE_FILE"}),
+ "jira": frozenset({"JIRA_CONNECTOR"}),
+ "linear": frozenset({"LINEAR_CONNECTOR"}),
+ "luma": frozenset({"LUMA_CONNECTOR"}),
+ "notion": frozenset({"NOTION_CONNECTOR"}),
+ "onedrive": frozenset({"ONEDRIVE_FILE"}),
+ "slack": frozenset({"SLACK_CONNECTOR"}),
+ "teams": frozenset({"TEAMS_CONNECTOR"}),
+}
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py
new file mode 100644
index 0000000000..f74ca0cd0d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py
@@ -0,0 +1,7 @@
+"""Main-agent deep agent: ``runtime/`` (factory), ``graph/`` (compile), ``system_prompt/``, etc."""
+
+from __future__ import annotations
+
+from .runtime import create_multi_agent_chat_deep_agent
+
+__all__ = ["create_multi_agent_chat_deep_agent"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py
new file mode 100644
index 0000000000..550ba54c53
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py
@@ -0,0 +1,7 @@
+"""Tool-name pruning for context editing (exclude lists without dropping protected tools)."""
+
+from __future__ import annotations
+
+from .prune_tool_names import PRUNE_PROTECTED_TOOL_NAMES, safe_exclude_tools
+
+__all__ = ["PRUNE_PROTECTED_TOOL_NAMES", "safe_exclude_tools"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py
new file mode 100644
index 0000000000..c8bf6d6e09
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py
@@ -0,0 +1,26 @@
+"""Tool names excluded from context-editing prune when bound."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from langchain_core.tools import BaseTool
+
+PRUNE_PROTECTED_TOOL_NAMES: frozenset[str] = frozenset(
+ {
+ "generate_report",
+ "generate_resume",
+ "generate_podcast",
+ "generate_video_presentation",
+ "generate_image",
+ "read_email",
+ "search_emails",
+ "invalid",
+ },
+)
+
+
+def safe_exclude_tools(tools: Sequence[BaseTool]) -> tuple[str, ...]:
+ """Names from ``PRUNE_PROTECTED_TOOL_NAMES`` that appear in ``tools``."""
+ enabled = {t.name for t in tools}
+ return tuple(n for n in PRUNE_PROTECTED_TOOL_NAMES if n in enabled)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py
new file mode 100644
index 0000000000..e12108484a
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py
@@ -0,0 +1,7 @@
+"""Sync compile of the main-agent LangGraph graph (middleware + ``create_agent``)."""
+
+from __future__ import annotations
+
+from .compile_graph_sync import build_compiled_agent_graph_sync
+
+__all__ = ["build_compiled_agent_graph_sync"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py
new file mode 100644
index 0000000000..b86da932a5
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py
@@ -0,0 +1,83 @@
+"""Synchronous graph compile (middleware + ``create_agent``)."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any
+
+from deepagents import __version__ as deepagents_version
+from langchain.agents import create_agent
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+from langgraph.types import Checkpointer
+
+from app.agents.multi_agent_chat.middleware.stack import (
+ build_main_agent_deepagent_middleware,
+)
+from app.agents.new_chat.context import SurfSenseContextSchema
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.db import ChatVisibility
+
+
+def build_compiled_agent_graph_sync(
+ *,
+ llm: BaseChatModel,
+ tools: Sequence[BaseTool],
+ final_system_prompt: str,
+ backend_resolver: Any,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+ user_id: str | None,
+ thread_id: int | None,
+ visibility: ChatVisibility,
+ anon_session_id: str | None,
+ available_connectors: list[str] | None,
+ available_document_types: list[str] | None,
+ mentioned_document_ids: list[int] | None,
+ max_input_tokens: int | None,
+ flags: AgentFeatureFlags,
+ checkpointer: Checkpointer,
+ subagent_dependencies: dict[str, Any],
+ mcp_tools_by_agent: dict[str, list[BaseTool]] | None = None,
+ disabled_tools: list[str] | None = None,
+):
+ """Sync compile: middleware + ``create_agent`` (run via ``asyncio.to_thread``)."""
+ main_agent_middleware = build_main_agent_deepagent_middleware(
+ llm=llm,
+ tools=tools,
+ backend_resolver=backend_resolver,
+ filesystem_mode=filesystem_mode,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ thread_id=thread_id,
+ visibility=visibility,
+ anon_session_id=anon_session_id,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ mentioned_document_ids=mentioned_document_ids,
+ max_input_tokens=max_input_tokens,
+ flags=flags,
+ subagent_dependencies=subagent_dependencies,
+ checkpointer=checkpointer,
+ mcp_tools_by_agent=mcp_tools_by_agent,
+ disabled_tools=disabled_tools,
+ )
+
+ agent = create_agent(
+ llm,
+ system_prompt=final_system_prompt,
+ tools=list(tools),
+ middleware=main_agent_middleware,
+ context_schema=SurfSenseContextSchema,
+ checkpointer=checkpointer,
+ )
+ return agent.with_config(
+ {
+ "recursion_limit": 10_000,
+ "metadata": {
+ "ls_integration": "deepagents",
+ "versions": {"deepagents": deepagents_version},
+ },
+ }
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py
new file mode 100644
index 0000000000..593e8da209
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py
@@ -0,0 +1,7 @@
+"""Async factory: wiring tools, prompts, MCP buckets, then graph compile."""
+
+from __future__ import annotations
+
+from .factory import create_multi_agent_chat_deep_agent
+
+__all__ = ["create_multi_agent_chat_deep_agent"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py
new file mode 100644
index 0000000000..03cf7acb83
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py
@@ -0,0 +1,116 @@
+"""Compiled agent graph caching for the multi-agent path."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Sequence
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+from langgraph.types import Checkpointer
+
+from app.agents.new_chat.agent_cache import (
+ flags_signature,
+ get_cache,
+ stable_hash,
+ system_prompt_hash,
+ tools_signature,
+)
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.db import ChatVisibility
+
+from ..graph.compile_graph_sync import build_compiled_agent_graph_sync
+
+
+def mcp_signature(mcp_tools_by_agent: dict[str, list[BaseTool]]) -> str:
+ """Hash the per-agent MCP tool surface so a change rotates the cache key."""
+ rows = []
+ for agent_name in sorted(mcp_tools_by_agent.keys()):
+ names = sorted(
+ getattr(t, "name", "") or "" for t in mcp_tools_by_agent[agent_name]
+ )
+ rows.append((agent_name, names))
+ return stable_hash(rows)
+
+
+async def build_agent_with_cache(
+ *,
+ llm: BaseChatModel,
+ tools: Sequence[BaseTool],
+ final_system_prompt: str,
+ backend_resolver: Any,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+ user_id: str | None,
+ thread_id: int | None,
+ visibility: ChatVisibility,
+ anon_session_id: str | None,
+ available_connectors: list[str],
+ available_document_types: list[str],
+ mentioned_document_ids: list[int] | None,
+ max_input_tokens: int | None,
+ flags: AgentFeatureFlags,
+ checkpointer: Checkpointer,
+ subagent_dependencies: dict[str, Any],
+ mcp_tools_by_agent: dict[str, list[BaseTool]],
+ disabled_tools: list[str] | None,
+ config_id: str | None,
+) -> Any:
+ """Compile the multi-agent graph, serving from cache when key components are stable."""
+
+ async def _build() -> Any:
+ return await asyncio.to_thread(
+ build_compiled_agent_graph_sync,
+ llm=llm,
+ tools=tools,
+ final_system_prompt=final_system_prompt,
+ backend_resolver=backend_resolver,
+ filesystem_mode=filesystem_mode,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ thread_id=thread_id,
+ visibility=visibility,
+ anon_session_id=anon_session_id,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ mentioned_document_ids=mentioned_document_ids,
+ max_input_tokens=max_input_tokens,
+ flags=flags,
+ checkpointer=checkpointer,
+ subagent_dependencies=subagent_dependencies,
+ mcp_tools_by_agent=mcp_tools_by_agent,
+ disabled_tools=disabled_tools,
+ )
+
+ if not (flags.enable_agent_cache and not flags.disable_new_agent_stack):
+ return await _build()
+
+ # Every per-request value any middleware closes over at __init__ must be in
+ # the key, otherwise a hit will leak state across threads. Bump the schema
+ # version when the component list changes shape.
+ cache_key = stable_hash(
+ "multi-agent-v1",
+ config_id,
+ thread_id,
+ user_id,
+ search_space_id,
+ visibility,
+ filesystem_mode,
+ anon_session_id,
+ tools_signature(
+ tools,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ ),
+ mcp_signature(mcp_tools_by_agent),
+ flags_signature(flags),
+ system_prompt_hash(final_system_prompt),
+ max_input_tokens,
+ sorted(disabled_tools) if disabled_tools else None,
+ )
+ return await get_cache().get_or_build(cache_key, builder=_build)
+
+
+__all__ = ["build_agent_with_cache", "mcp_signature"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py
new file mode 100644
index 0000000000..8451b3b7da
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py
@@ -0,0 +1,298 @@
+"""Async factory: tools, system prompt, MCP buckets for subagents, then sync graph compile."""
+
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import Sequence
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+from langgraph.types import Checkpointer
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.agents.multi_agent_chat.subagents import (
+ get_subagents_to_exclude,
+ main_prompt_registry_subagent_lines,
+)
+from app.agents.multi_agent_chat.subagents.mcp_tools.index import (
+ load_mcp_tools_by_connector,
+)
+from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types
+from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags
+from app.agents.new_chat.filesystem_backends import build_backend_resolver
+from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection
+from app.agents.new_chat.llm_config import AgentConfig
+from app.agents.new_chat.prompt_caching import apply_litellm_prompt_caching
+from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_tool
+from app.agents.new_chat.tools.registry import build_tools_async
+from app.db import ChatVisibility
+from app.services.connector_service import ConnectorService
+from app.services.user_tool_allowlist import (
+ fetch_user_allowlist_rulesets,
+ make_trusted_tool_saver,
+)
+from app.utils.perf import get_perf_logger
+
+from ..system_prompt import build_main_agent_system_prompt
+from ..tools import (
+ MAIN_AGENT_SURFSENSE_TOOL_NAMES,
+ MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED,
+)
+from .agent_cache import build_agent_with_cache
+
+_perf_log = get_perf_logger()
+
+
+async def create_multi_agent_chat_deep_agent(
+ llm: BaseChatModel,
+ search_space_id: int,
+ db_session: AsyncSession,
+ connector_service: ConnectorService,
+ checkpointer: Checkpointer,
+ user_id: str | None = None,
+ thread_id: int | None = None,
+ agent_config: AgentConfig | None = None,
+ enabled_tools: list[str] | None = None,
+ disabled_tools: list[str] | None = None,
+ additional_tools: Sequence[BaseTool] | None = None,
+ firecrawl_api_key: str | None = None,
+ thread_visibility: ChatVisibility | None = None,
+ mentioned_document_ids: list[int] | None = None,
+ anon_session_id: str | None = None,
+ filesystem_selection: FilesystemSelection | None = None,
+):
+ """Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled."""
+ _t_agent_total = time.perf_counter()
+
+ apply_litellm_prompt_caching(llm, agent_config=agent_config, thread_id=thread_id)
+
+ filesystem_selection = filesystem_selection or FilesystemSelection()
+ backend_resolver = build_backend_resolver(
+ filesystem_selection,
+ search_space_id=search_space_id
+ if filesystem_selection.mode == FilesystemMode.CLOUD
+ else None,
+ )
+
+ available_connectors: list[str] | None = None
+ available_document_types: list[str] | None = None
+
+ _t0 = time.perf_counter()
+ try:
+ connector_types = await connector_service.get_available_connectors(
+ search_space_id
+ )
+ available_connectors = _map_connectors_to_searchable_types(connector_types)
+
+ available_document_types = await connector_service.get_available_document_types(
+ search_space_id
+ )
+
+ except Exception as e:
+ logging.warning(
+ "Connector/doc-type discovery failed; excluding connector subagents this turn: %s",
+ e,
+ )
+
+ # Fail closed: a None list short-circuits ``get_subagents_to_exclude`` to "exclude
+ # nothing", which would silently advertise every connector specialist on a flaky
+ # discovery call. Empty list excludes connector-gated subagents while keeping builtins.
+ if available_connectors is None:
+ available_connectors = []
+ if available_document_types is None:
+ available_document_types = []
+ _perf_log.info(
+ "[create_agent] Connector/doc-type discovery in %.3fs",
+ time.perf_counter() - _t0,
+ )
+
+ visibility = thread_visibility or ChatVisibility.PRIVATE
+
+ _model_profile = getattr(llm, "profile", None)
+ _max_input_tokens: int | None = (
+ _model_profile.get("max_input_tokens")
+ if isinstance(_model_profile, dict)
+ else None
+ )
+
+ dependencies: dict[str, Any] = {
+ "search_space_id": search_space_id,
+ "db_session": db_session,
+ "connector_service": connector_service,
+ "firecrawl_api_key": firecrawl_api_key,
+ "user_id": user_id,
+ "thread_id": thread_id,
+ "thread_visibility": visibility,
+ "available_connectors": available_connectors,
+ "available_document_types": available_document_types,
+ "max_input_tokens": _max_input_tokens,
+ "llm": llm,
+ }
+
+ _t0 = time.perf_counter()
+ try:
+ mcp_tools_by_agent = await load_mcp_tools_by_connector(
+ db_session, search_space_id
+ )
+ except Exception as e:
+ # Degrade to builtins-only rather than aborting the turn: a transient
+ # DB or MCP-server hiccup should not deny the user a response.
+ logging.warning(
+ "MCP tool discovery failed; subagents will run without MCP tools this turn: %s",
+ e,
+ )
+ mcp_tools_by_agent = {}
+ _perf_log.info(
+ "[create_agent] load_mcp_tools_by_connector in %.3fs (%d agents)",
+ time.perf_counter() - _t0,
+ len(mcp_tools_by_agent),
+ )
+
+ # User-scoped allow-list ("Always Allow" persisted to
+ # ``SearchSourceConnector.config.trusted_tools``). Layered last in each
+ # subagent's PermissionMiddleware so user ``allow`` overrides coded
+ # ``ask`` via last-match-wins. Anonymous turns and read failures both
+ # degrade to "no user rules" rather than blocking the turn.
+ user_allowlist_by_subagent: dict[str, Any] = {}
+ trusted_tool_saver = None
+ if user_id:
+ try:
+ import uuid as _uuid
+
+ user_uuid = _uuid.UUID(user_id)
+ except (TypeError, ValueError):
+ user_uuid = None
+
+ if user_uuid is not None:
+ _t0 = time.perf_counter()
+ try:
+ user_allowlist_by_subagent = await fetch_user_allowlist_rulesets(
+ db_session,
+ user_id=user_uuid,
+ search_space_id=search_space_id,
+ )
+ except Exception as e:
+ logging.warning(
+ "User allow-list fetch failed; subagents will run without user trust rules this turn: %s",
+ e,
+ )
+ user_allowlist_by_subagent = {}
+ _perf_log.info(
+ "[create_agent] fetch_user_allowlist_rulesets in %.3fs (%d subagents have rules)",
+ time.perf_counter() - _t0,
+ len(user_allowlist_by_subagent),
+ )
+ trusted_tool_saver = make_trusted_tool_saver(user_uuid)
+ dependencies["user_allowlist_by_subagent"] = user_allowlist_by_subagent
+ dependencies["trusted_tool_saver"] = trusted_tool_saver
+
+ modified_disabled_tools = list(disabled_tools) if disabled_tools else []
+
+ if "search_knowledge_base" not in modified_disabled_tools:
+ modified_disabled_tools.append("search_knowledge_base")
+
+ if enabled_tools is not None:
+ main_agent_enabled_tools = [
+ n for n in enabled_tools if n in MAIN_AGENT_SURFSENSE_TOOL_NAMES
+ ]
+ else:
+ main_agent_enabled_tools = list(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED)
+
+ _t0 = time.perf_counter()
+ tools = await build_tools_async(
+ dependencies=dependencies,
+ enabled_tools=main_agent_enabled_tools,
+ disabled_tools=modified_disabled_tools,
+ additional_tools=list(additional_tools) if additional_tools else None,
+ include_mcp_tools=False,
+ )
+
+ _flags: AgentFeatureFlags = get_flags()
+ if _flags.enable_tool_call_repair and INVALID_TOOL_NAME not in {
+ t.name for t in tools
+ }:
+ tools = [*list(tools), invalid_tool]
+ _perf_log.info(
+ "[create_agent] build_tools_async in %.3fs (%d tools)",
+ time.perf_counter() - _t0,
+ len(tools),
+ )
+
+ _t0 = time.perf_counter()
+ _enabled_tool_names = {t.name for t in tools}
+ _user_disabled_tool_names = set(disabled_tools) if disabled_tools else set()
+
+ _model_name: str | None = None
+ prof = getattr(llm, "model_name", None) or getattr(llm, "model", None)
+ if isinstance(prof, str):
+ _model_name = prof
+
+ _connector_exclude = get_subagents_to_exclude(available_connectors)
+ _registry_subagent_prompt_lines = main_prompt_registry_subagent_lines(
+ _connector_exclude
+ )
+
+ if agent_config is not None:
+ system_prompt = build_main_agent_system_prompt(
+ today=None,
+ thread_visibility=thread_visibility,
+ enabled_tool_names=_enabled_tool_names,
+ disabled_tool_names=_user_disabled_tool_names,
+ custom_system_instructions=agent_config.system_instructions,
+ use_default_system_instructions=agent_config.use_default_system_instructions,
+ citations_enabled=agent_config.citations_enabled,
+ model_name=_model_name or getattr(agent_config, "model_name", None),
+ registry_subagent_prompt_lines=_registry_subagent_prompt_lines,
+ )
+ else:
+ system_prompt = build_main_agent_system_prompt(
+ thread_visibility=thread_visibility,
+ enabled_tool_names=_enabled_tool_names,
+ disabled_tool_names=_user_disabled_tool_names,
+ citations_enabled=True,
+ model_name=_model_name,
+ registry_subagent_prompt_lines=_registry_subagent_prompt_lines,
+ )
+ _perf_log.info(
+ "[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0
+ )
+
+ final_system_prompt = system_prompt
+
+ config_id = agent_config.config_id if agent_config is not None else None
+
+ _t0 = time.perf_counter()
+ agent = await build_agent_with_cache(
+ llm=llm,
+ tools=tools,
+ final_system_prompt=final_system_prompt,
+ backend_resolver=backend_resolver,
+ filesystem_mode=filesystem_selection.mode,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ thread_id=thread_id,
+ visibility=visibility,
+ anon_session_id=anon_session_id,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ mentioned_document_ids=mentioned_document_ids,
+ max_input_tokens=_max_input_tokens,
+ flags=_flags,
+ checkpointer=checkpointer,
+ subagent_dependencies=dependencies,
+ mcp_tools_by_agent=mcp_tools_by_agent,
+ disabled_tools=disabled_tools,
+ config_id=config_id,
+ )
+ _perf_log.info(
+ "[create_agent] Middleware stack + graph compiled in %.3fs",
+ time.perf_counter() - _t0,
+ )
+
+ _perf_log.info(
+ "[create_agent] Total agent creation in %.3fs",
+ time.perf_counter() - _t_agent_total,
+ )
+ return agent
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py
new file mode 100644
index 0000000000..d58aecdf43
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py
@@ -0,0 +1,7 @@
+"""Main-agent system prompt — not shared verbatim with single-agent ``new_chat``."""
+
+from __future__ import annotations
+
+from .builder import build_main_agent_system_prompt
+
+__all__ = ["build_main_agent_system_prompt"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py
new file mode 100644
index 0000000000..165235fc82
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py
@@ -0,0 +1,7 @@
+"""Assemble the main-agent system prompt from ``prompts/`` fragments."""
+
+from __future__ import annotations
+
+from .compose import build_main_agent_system_prompt
+
+__all__ = ["build_main_agent_system_prompt"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py
new file mode 100644
index 0000000000..fc341dce3b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py
@@ -0,0 +1,100 @@
+"""Assemble the main-agent system prompt from ``prompts/``.
+
+Section order (default flow)::
+
+
+ [user's custom_system_instructions, if any]
+ # default body
+ # default body
+ # always
+ # default body
+ # always (dynamic roster)
+ # always (vertical-slice)
+ # default body
+ # always
+ # always
+ # always
+ # always
+
+``custom_system_instructions`` is **additive**, not a replacement: it slots
+between identity and the default body so platform safety nets (KB-first,
+routing, citations, output formatting, refusal rules) always apply.
+
+``use_default_system_instructions=False`` skips the four "default body"
+sections but keeps all the always-on platform sections.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from app.db import ChatVisibility
+
+from .load_md import read_prompt_md
+from .sections.citations import build_citations_section
+from .sections.dynamic_context import build_dynamic_context_section
+from .sections.identity import build_identity_section
+from .sections.memory_protocol import build_memory_protocol_section
+from .sections.specialists import build_specialists_section
+from .sections.tools import build_tools_section
+
+
+def build_main_agent_system_prompt(
+ *,
+ registry_subagent_prompt_lines: list[tuple[str, str]],
+ today: datetime | None = None,
+ thread_visibility: ChatVisibility | None = None,
+ enabled_tool_names: set[str] | None = None,
+ disabled_tool_names: set[str] | None = None,
+ custom_system_instructions: str | None = None,
+ use_default_system_instructions: bool = True,
+ citations_enabled: bool = True,
+ model_name: str | None = None,
+) -> str:
+ resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
+ visibility = thread_visibility or ChatVisibility.PRIVATE
+
+ parts: list[str] = []
+
+ parts.append(
+ build_identity_section(visibility=visibility, resolved_today=resolved_today)
+ )
+
+ if custom_system_instructions and custom_system_instructions.strip():
+ parts.append(
+ "\n"
+ + custom_system_instructions.format(resolved_today=resolved_today)
+ + "\n"
+ )
+
+ if use_default_system_instructions:
+ parts.append(_wrap(read_prompt_md("core_behavior.md")))
+ parts.append(_wrap(read_prompt_md("kb_first.md")))
+
+ parts.append(build_dynamic_context_section(visibility=visibility))
+
+ if use_default_system_instructions:
+ parts.append(_wrap(read_prompt_md("routing.md")))
+
+ parts.append(build_specialists_section(registry_subagent_prompt_lines))
+ parts.append(
+ build_tools_section(
+ visibility=visibility,
+ enabled_tool_names=enabled_tool_names,
+ disabled_tool_names=disabled_tool_names,
+ )
+ )
+
+ if use_default_system_instructions:
+ parts.append(build_memory_protocol_section(visibility=visibility))
+
+ parts.append(build_citations_section(citations_enabled=citations_enabled))
+ parts.append(_wrap(read_prompt_md("output_format.md")))
+ parts.append(_wrap(read_prompt_md("refusal_and_limits.md")))
+ parts.append(_wrap(read_prompt_md("reminder.md")))
+
+ return "".join(p for p in parts if p)
+
+
+def _wrap(fragment: str) -> str:
+ return f"\n{fragment}\n" if fragment else ""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py
new file mode 100644
index 0000000000..61e30b1c74
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py
@@ -0,0 +1,16 @@
+"""Load main-agent prompt fragments from ``system_prompt/prompts/``."""
+
+from __future__ import annotations
+
+from importlib import resources
+
+_PROMPTS_PACKAGE = "app.agents.multi_agent_chat.main_agent.system_prompt.prompts"
+
+
+def read_prompt_md(filename: str) -> str:
+ """Load ``prompts/{filename}`` (e.g. ``core_behavior.md`` or ``tools/web_search/description.md``)."""
+ ref = resources.files(_PROMPTS_PACKAGE).joinpath(filename)
+ if not ref.is_file():
+ return ""
+ text = ref.read_text(encoding="utf-8")
+ return text[:-1] if text.endswith("\n") else text
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py
new file mode 100644
index 0000000000..78d7b08ec9
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py
@@ -0,0 +1,50 @@
+"""Provider-specific style hints from ``prompts/providers/`` (main agent only)."""
+
+from __future__ import annotations
+
+import re
+
+from .load_md import read_prompt_md
+
+ProviderVariant = str
+
+_OPENAI_CODEX_RE = re.compile(
+ r"\b(gpt-codex|codex-mini|gpt-[\d.]+-codex)\b", re.IGNORECASE
+)
+_OPENAI_REASONING_RE = re.compile(r"\b(gpt-5|o\d|o-)", re.IGNORECASE)
+_OPENAI_CLASSIC_RE = re.compile(r"\bgpt-4", re.IGNORECASE)
+_ANTHROPIC_RE = re.compile(r"\bclaude\b", re.IGNORECASE)
+_GOOGLE_RE = re.compile(r"\bgemini\b", re.IGNORECASE)
+_KIMI_RE = re.compile(r"\b(kimi[-\d.]*|moonshot)\b", re.IGNORECASE)
+_GROK_RE = re.compile(r"\bgrok\b", re.IGNORECASE)
+_DEEPSEEK_RE = re.compile(r"\bdeepseek\b", re.IGNORECASE)
+
+
+def detect_provider_variant(model_name: str | None) -> ProviderVariant:
+ if not model_name:
+ return "default"
+ name = model_name.strip()
+ if _OPENAI_CODEX_RE.search(name):
+ return "openai_codex"
+ if _OPENAI_REASONING_RE.search(name):
+ return "openai_reasoning"
+ if _OPENAI_CLASSIC_RE.search(name):
+ return "openai_classic"
+ if _ANTHROPIC_RE.search(name):
+ return "anthropic"
+ if _GOOGLE_RE.search(name):
+ return "google"
+ if _KIMI_RE.search(name):
+ return "kimi"
+ if _GROK_RE.search(name):
+ return "grok"
+ if _DEEPSEEK_RE.search(name):
+ return "deepseek"
+ return "default"
+
+
+def build_provider_hint_block(provider_variant: ProviderVariant) -> str:
+ if not provider_variant or provider_variant == "default":
+ return ""
+ text = read_prompt_md(f"providers/{provider_variant}.md")
+ return f"\n{text}\n" if text else ""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py
new file mode 100644
index 0000000000..568b52bafb
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py
@@ -0,0 +1 @@
+"""Rendered slices of the main-agent system prompt."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py
new file mode 100644
index 0000000000..8e7641f8de
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py
@@ -0,0 +1,11 @@
+"""```` section — on/off variant based on workspace configuration."""
+
+from __future__ import annotations
+
+from ..load_md import read_prompt_md
+
+
+def build_citations_section(*, citations_enabled: bool) -> str:
+ variant = "on" if citations_enabled else "off"
+ fragment = read_prompt_md(f"citations/{variant}.md")
+ return f"\n{fragment}\n" if fragment else ""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/dynamic_context.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/dynamic_context.py
new file mode 100644
index 0000000000..2a9554894d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/dynamic_context.py
@@ -0,0 +1,13 @@
+"""```` section — visibility-aware (private vs team thread)."""
+
+from __future__ import annotations
+
+from app.db import ChatVisibility
+
+from ..load_md import read_prompt_md
+
+
+def build_dynamic_context_section(*, visibility: ChatVisibility) -> str:
+ variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
+ fragment = read_prompt_md(f"dynamic_context/{variant}.md")
+ return f"\n{fragment}\n" if fragment else ""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/identity.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/identity.py
new file mode 100644
index 0000000000..82c826491d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/identity.py
@@ -0,0 +1,19 @@
+"""```` section — visibility-aware, with ``{resolved_today}`` injection."""
+
+from __future__ import annotations
+
+from app.db import ChatVisibility
+
+from ..load_md import read_prompt_md
+
+
+def build_identity_section(
+ *,
+ visibility: ChatVisibility,
+ resolved_today: str,
+) -> str:
+ variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
+ fragment = read_prompt_md(f"identity/{variant}.md")
+ if not fragment:
+ return ""
+ return "\n" + fragment.format(resolved_today=resolved_today) + "\n"
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/memory_protocol.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/memory_protocol.py
new file mode 100644
index 0000000000..9d77189129
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/memory_protocol.py
@@ -0,0 +1,13 @@
+"""```` section — visibility-aware (user vs team memory)."""
+
+from __future__ import annotations
+
+from app.db import ChatVisibility
+
+from ..load_md import read_prompt_md
+
+
+def build_memory_protocol_section(*, visibility: ChatVisibility) -> str:
+ variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
+ fragment = read_prompt_md(f"memory_protocol/{variant}.md")
+ return f"\n{fragment}\n" if fragment else ""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py
new file mode 100644
index 0000000000..7de7220804
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py
@@ -0,0 +1,9 @@
+"""Provider-specific style hints."""
+
+from __future__ import annotations
+
+from ..provider_hints import build_provider_hint_block, detect_provider_variant
+
+
+def build_provider_section(*, model_name: str | None) -> str:
+ return build_provider_hint_block(detect_provider_variant(model_name))
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/specialists.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/specialists.py
new file mode 100644
index 0000000000..a3455bd83c
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/specialists.py
@@ -0,0 +1,15 @@
+"""```` section — live ``task`` roster for this workspace.
+
+The roster is non-empty by contract: ``deliverables`` and ``knowledge_base``
+both declare ``frozenset()`` in ``SUBAGENT_TO_REQUIRED_CONNECTOR_MAP``, so
+they survive every connector-based exclusion pass.
+"""
+
+from __future__ import annotations
+
+
+def build_specialists_section(
+ specialist_lines: list[tuple[str, str]],
+) -> str:
+ bullets = "\n".join(f"- **{name}** — {desc}" for name, desc in specialist_lines)
+ return f"\n\n{bullets}\n\n"
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py
new file mode 100644
index 0000000000..caf741d45b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py
@@ -0,0 +1,20 @@
+"""Main-agent ```` block (memory + research builtins + ``task``)."""
+
+from __future__ import annotations
+
+from app.db import ChatVisibility
+
+from ..tool_instruction_block import build_tools_instruction_block
+
+
+def build_tools_section(
+ *,
+ visibility: ChatVisibility,
+ enabled_tool_names: set[str] | None,
+ disabled_tool_names: set[str] | None,
+) -> str:
+ return build_tools_instruction_block(
+ visibility=visibility,
+ enabled_tool_names=enabled_tool_names,
+ disabled_tool_names=disabled_tool_names,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py
new file mode 100644
index 0000000000..cbc8728ca2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py
@@ -0,0 +1,84 @@
+"""Compose the ```` block from per-tool vertical-slice folders.
+
+Each tool lives in ``prompts/tools//`` with ``description.md`` and an
+``example.md``. Visibility variants live in ``{private,team}/`` subfolders.
+"""
+
+from __future__ import annotations
+
+from app.db import ChatVisibility
+
+from ...tools import MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED
+from .load_md import read_prompt_md
+
+_MEMORY_VARIANT_TOOLS: frozenset[str] = frozenset({"update_memory"})
+
+
+def _tool_fragment(tool_name: str, variant: str, leaf: str) -> str:
+ if tool_name in _MEMORY_VARIANT_TOOLS:
+ return read_prompt_md(f"tools/{tool_name}/{variant}/{leaf}")
+ return read_prompt_md(f"tools/{tool_name}/{leaf}")
+
+
+def _format_tool_label(tool_name: str) -> str:
+ return tool_name.replace("_", " ").title()
+
+
+def build_tools_instruction_block(
+ *,
+ visibility: ChatVisibility,
+ enabled_tool_names: set[str] | None,
+ disabled_tool_names: set[str] | None,
+) -> str:
+ """Render ````. ``task`` is always included: at least ``deliverables``
+ and ``knowledge_base`` are always in ```` (see constants)."""
+ variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
+
+ parts: list[str] = ["\n\n"]
+
+ for tool_name in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED:
+ if enabled_tool_names is not None and tool_name not in enabled_tool_names:
+ continue
+
+ description = _tool_fragment(tool_name, variant, "description.md")
+ example = _tool_fragment(tool_name, variant, "example.md")
+
+ if not description and not example:
+ continue
+
+ if description:
+ parts.append(description + "\n")
+ if example:
+ parts.append("\n" + example + "\n")
+ parts.append("\n")
+
+ task_description = read_prompt_md("tools/task/description.md")
+ task_example = read_prompt_md("tools/task/example.md")
+ if task_description:
+ parts.append(task_description + "\n")
+ if task_example:
+ parts.append("\n" + task_example + "\n")
+ parts.append("\n")
+
+ known_disabled = (
+ set(disabled_tool_names) & set(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED)
+ if disabled_tool_names
+ else set()
+ )
+ if known_disabled:
+ disabled_list = ", ".join(
+ _format_tool_label(n)
+ for n in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED
+ if n in known_disabled
+ )
+ parts.append(
+ "\n"
+ f"Disabled for this session: {disabled_list}.\n"
+ "Don't claim you can use them. If the user needs that capability,\n"
+ "delegate with `task` when a specialist covers it; otherwise say\n"
+ "the tool is disabled.\n"
+ "\n"
+ )
+
+ parts.append("\n")
+ return "".join(parts)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/__init__.py
new file mode 100644
index 0000000000..b82bad81fd
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/__init__.py
@@ -0,0 +1 @@
+"""Main-agent prompt fragments loaded by :mod:`...system_prompt.builder.load_md`."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/__init__.py
new file mode 100644
index 0000000000..ef2bdad343
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/__init__.py
@@ -0,0 +1 @@
+"""```` block — ``on`` (cite chunk ids) and ``off`` (hard suppression)."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/off.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/off.md
new file mode 100644
index 0000000000..42cb099a67
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/off.md
@@ -0,0 +1,12 @@
+
+Citation markers are **disabled** in this configuration.
+
+Do NOT include `[citation:…]` markers anywhere, even if tool descriptions or
+examples reference them. Ignore citation-format reminders elsewhere in this
+prompt when they conflict with this block.
+
+1. Answer in plain prose. Optional markdown links to public URLs when
+ sources are URLs.
+2. Do not expose raw chunk ids, document ids, or internal ids to the user.
+3. Present KB or docs facts naturally without attribution markers.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
new file mode 100644
index 0000000000..b200f7a9a6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
@@ -0,0 +1,11 @@
+
+Apply chunk citations only when the runtime injects `` /
+`` blocks.
+
+1. For each factual statement taken from those chunks, add
+ `[citation:chunk_id]` using the exact id from ``.
+2. Multiple chunks → `[citation:id1], [citation:id2]` (comma-separated).
+3. Never invent or normalise ids; if unsure, omit.
+4. Plain brackets only — no markdown links, no footnote numbering.
+5. If no chunk-tagged documents appear this turn, do not fabricate citations.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/core_behavior.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/core_behavior.md
new file mode 100644
index 0000000000..c7be91cde8
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/core_behavior.md
@@ -0,0 +1,13 @@
+
+- Be concise and direct. No preamble ("Sure!", "Great question!", "I'll now…").
+- Don't narrate intent — just act. State the outcome, not the plan.
+- If the request is ambiguous, ask before acting. If asked *how* to do
+ something, explain first, then act.
+- Prioritise accuracy over agreement. Disagree respectfully when the user is
+ wrong; avoid unnecessary superlatives or emotional validation.
+- Persist until the task is done or you are genuinely blocked. Don't stop
+ partway and describe what you *would* do.
+- For longer work, give brief progress updates only when they add new
+ information (a discovery, a tradeoff, a blocker, the start of a non-trivial
+ step). Don't narrate routine reads.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/__init__.py
new file mode 100644
index 0000000000..2410538724
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/__init__.py
@@ -0,0 +1 @@
+"""```` block — private and team variants."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md
new file mode 100644
index 0000000000..71c86be40a
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md
@@ -0,0 +1,27 @@
+
+The runtime inserts these system messages each turn. They are authoritative
+for *this* turn only.
+
+`` carries the durable personal context the user has accumulated
+across sessions — role, interests, preferences, projects, background,
+standing instructions. It also reports current character usage versus the
+hard limit so you can manage the budget. Treat it as background colour for
+your answer, not as the task itself.
+
+`` lists the workspace documents most relevant to the
+latest user message, ranked by relevance score, with `[USER-MENTIONED]`
+flagged on anything the user explicitly referenced. When the task is about
+workspace content, read these first; matched passages inside each document
+are flagged via `` so you can jump straight to them.
+
+`` shows the full `/documents/` folder and file layout. Use
+it to resolve paths the user describes in natural language ("my Q2 roadmap",
+"last week's meeting notes") into concrete document references before
+delegating to a specialist.
+
+`` and `` blocks are chunked indexed content returned
+by KB search (from `search_surfsense_docs`, or backing ``).
+Each chunk carries a stable `id` attribute.
+
+If a block doesn't appear this turn, work from the conversation alone.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md
new file mode 100644
index 0000000000..592c2ed9c9
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md
@@ -0,0 +1,27 @@
+
+The runtime inserts these system messages each turn. They are authoritative
+for *this* turn only.
+
+`` carries the durable shared context this team has built up —
+decisions, conventions, architecture notes, processes, key facts. It also
+reports current character usage versus the hard limit so you can manage the
+budget. Treat it as background colour for your answer, not as the task itself.
+
+`` lists the workspace documents most relevant to the
+latest user message, ranked by relevance score, with `[USER-MENTIONED]`
+flagged on anything someone in the thread explicitly referenced. When the
+task is about workspace content, read these first; matched passages inside
+each document are flagged via `` so you can jump straight to
+them.
+
+`` shows the full `/documents/` folder and file layout. Use
+it to resolve paths described in natural language ("the Q2 roadmap", "last
+week's planning notes") into concrete document references before delegating
+to a specialist.
+
+`` and `` blocks are chunked indexed content returned
+by KB search (from `search_surfsense_docs`, or backing ``).
+Each chunk carries a stable `id` attribute.
+
+If a block doesn't appear this turn, work from the conversation alone.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/__init__.py
new file mode 100644
index 0000000000..1e7bf4f216
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/__init__.py
@@ -0,0 +1 @@
+"""```` block — private and team variants."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md
new file mode 100644
index 0000000000..38d33cab08
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md
@@ -0,0 +1,8 @@
+
+You are **SurfSense's main agent**. Your job is to answer the user using their
+knowledge base, lightweight web research, persistent memory, and **specialist
+subagents** invoked via the `task` tool. You are an orchestrator — most
+non-trivial work belongs on a specialist.
+
+Today (UTC): {resolved_today}
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md
new file mode 100644
index 0000000000..b2d1e169fd
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md
@@ -0,0 +1,11 @@
+
+You are **SurfSense's main agent**. Your job is to answer the user using their
+shared team knowledge base, lightweight web research, persistent memory, and
+**specialist subagents** invoked via the `task` tool. You are an orchestrator
+— most non-trivial work belongs on a specialist.
+
+Today (UTC): {resolved_today}
+
+You are in a **team thread**. Each message is prefixed with `[DisplayName]`.
+Attribute quotes and decisions to the named author when relevant.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
new file mode 100644
index 0000000000..f06a52c1da
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
@@ -0,0 +1,19 @@
+
+CRITICAL — ground factual answers in what you actually receive this turn:
+- injected workspace context (see ``),
+- results from your own tool calls (`search_surfsense_docs`, `web_search`,
+ `scrape_webpage`),
+- or substantive summaries returned by a `task` specialist you invoked.
+
+Do **not** answer factual or informational questions from general knowledge
+unless the user explicitly authorises it after you say you couldn't find
+enough in those sources. The flow when nothing is found:
+
+1. Say you couldn't find enough in their workspace, docs, or tool output.
+2. Ask: *"Would you like me to answer from my general knowledge instead?"*
+3. Only answer from general knowledge after a clear yes.
+
+This rule does NOT apply to: casual conversation · meta-questions about
+SurfSense ("what can you do?") · formatting or analysis of content already
+in chat · clear rewrite/edit instructions · lightweight web research.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/__init__.py
new file mode 100644
index 0000000000..f172bc1b45
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/__init__.py
@@ -0,0 +1 @@
+"""```` block — private and team variants."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/private.md
new file mode 100644
index 0000000000..4dd511014b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/private.md
@@ -0,0 +1,9 @@
+
+After understanding each user message, check: does it reveal durable facts
+about the user — role, interests, preferences, projects, background, or
+standing instructions?
+
+If yes, call `update_memory` **alongside** your normal response — don't
+defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
+session logistics). Stay within the budget shown in ``.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/team.md
new file mode 100644
index 0000000000..decd23c4d3
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/memory_protocol/team.md
@@ -0,0 +1,9 @@
+
+After understanding each user message, check: does it reveal durable facts
+about the team — decisions, conventions, architecture notes, processes, or
+key facts?
+
+If yes, call `update_memory` **alongside** your normal response — don't
+defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
+session logistics). Stay within the budget shown in ``.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/output_format.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/output_format.md
new file mode 100644
index 0000000000..46f492b169
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/output_format.md
@@ -0,0 +1,7 @@
+
+- Mathematical formulas: **always** LaTeX. Never backtick code spans or
+ Unicode symbols for math.
+- Never expose internal tool parameter names, backend IDs, or
+ implementation details. Use natural, user-friendly language.
+- External sources: markdown links `[label](url)`, never bare URLs.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/__init__.py
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/__init__.py
@@ -0,0 +1 @@
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md
new file mode 100644
index 0000000000..89154c443d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md
@@ -0,0 +1,16 @@
+
+You are running on an Anthropic Claude model (SurfSense **main agent**).
+
+Structured reasoning:
+- For non-trivial work, `` / short `` before tool calls is fine.
+
+Professional objectivity:
+- Accuracy over flattery; verify with **search_surfsense_docs**, **web_search**, **scrape_webpage**, or **task** when unsure — don’t invent connector access.
+
+Task management:
+- For 3+ steps, use todo tooling; update statuses promptly.
+
+Tool calls:
+- Parallelise independent calls; sequence only when outputs chain.
+- Never pretend you can run connector-specific tools directly — route through **task** when needed.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md
new file mode 100644
index 0000000000..4254e9ed57
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md
@@ -0,0 +1,18 @@
+
+You are running on a DeepSeek model (SurfSense **main agent**).
+
+Reasoning hygiene (R1-aware):
+- Keep internal scratch separate from the user-facing answer; don’t leak chain-of-thought into tool arguments.
+
+Output style:
+- Concise; lead with the answer or the next action; avoid sycophantic openers.
+
+Attribution:
+- When citations are **enabled** and facts come from chunk-tagged context, follow the citation block above.
+- When citations are **disabled**, do not use `[citation:…]`.
+
+Tool calls:
+- Parallelise independent calls.
+- Prefer **search_surfsense_docs** for SurfSense docs/product questions before **web_search** when that fits the ask.
+- Don’t invent paths, chunk ids, or URLs — only values from tools or the user.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/default.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/default.md
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/default.md
@@ -0,0 +1 @@
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md
new file mode 100644
index 0000000000..dc50735380
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md
@@ -0,0 +1,18 @@
+
+You are running on a Google Gemini model (SurfSense **main agent**).
+
+Output style:
+- Concise & direct. Fewer than ~3 lines of prose when the task allows (excluding tool output and code).
+- No filler openers/closers — move straight to the answer or the tool call.
+- GitHub-flavoured Markdown; monospace-friendly.
+
+Workflow (Understand → Plan → Act → Verify):
+1. **Understand:** parse the ask; use **search_surfsense_docs** / injected workspace context before guessing.
+2. **Plan:** for multi-step work, a short plan first.
+3. **Act:** only with tools you actually have on this agent (see `` and ``). Connector work → **task**.
+4. **Verify:** re-read or re-search only when it materially reduces risk.
+
+Discipline:
+- Do not imply access to connectors, MCP tools, or deliverable generators except via **task**.
+- Pass paths to **task(knowledge_base, …)** only when you saw them in `` or ``. Otherwise describe the document in natural language and let the subagent resolve it.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/grok.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/grok.md
new file mode 100644
index 0000000000..3219e10d38
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/grok.md
@@ -0,0 +1,16 @@
+
+You are running on an xAI Grok model (SurfSense **main agent**).
+
+Maximum terseness:
+- Fewer than 4 lines unless detail is requested; skip preamble/postamble.
+
+Tool discipline:
+- Typically one investigative tool per turn unless several independent read-only queries are clearly needed; don’t repeat identical calls.
+
+Attribution:
+- When citations are **enabled** (see citation block above) and you answer from chunk-tagged documents, use `[citation:chunk_id]` exactly as specified there.
+- When citations are **disabled**, never emit `[citation:…]` — plain prose and links per tool guidance.
+
+Style:
+- No emojis unless asked; flat lists for short answers.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/kimi.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/kimi.md
new file mode 100644
index 0000000000..3fe07d1808
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/kimi.md
@@ -0,0 +1,21 @@
+
+You are running on a Moonshot Kimi model (Kimi-K1.5 / Kimi-K2 / Kimi-K2.5+), SurfSense **main agent**.
+
+Action bias:
+- Default to taking action with tools rather than describing solutions in prose. If a tool can answer the question, call the tool.
+- Don't narrate routine reads, searches, or obvious next steps. Combine related progress into one short status line.
+- Be thorough in actions (test what you build, verify what you change). Be brief in explanations.
+
+Tool calls:
+- Output multiple non-interfering tool calls in a SINGLE response — parallelism is a major efficiency win on this model.
+- When the `task` tool is available, delegate focused subtasks to a subagent with full context (subagents don't inherit yours).
+- Don't apologise or pre-announce tool calls. The tool call itself is self-explanatory.
+
+Language:
+- Respond in the SAME language as the user's most recent turn unless explicitly instructed otherwise.
+
+Discipline:
+- Stay on track. Never give the user more than what they asked for.
+- Fact-check with tools; don’t fabricate chunk ids or connector outcomes.
+- Keep it stupidly simple. Don't overcomplicate.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md
new file mode 100644
index 0000000000..7ff3ec912d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md
@@ -0,0 +1,20 @@
+
+You are running on a classic OpenAI chat model (GPT-4 family), SurfSense **main agent**.
+
+Persistence:
+- Finish the user’s request in the same turn when tools allow — don’t stop at intent only.
+- If a tool errors, fix arguments and retry once before giving up.
+
+Planning:
+- For 3+ steps, use the todo / planning tool; mark `in_progress` / `completed` promptly.
+- One short sentence before non-trivial tool use is fine.
+
+Output style:
+- Conversational but professional; bullets for findings; fenced code with language tags when needed.
+- Summarize tool output — don’t paste walls of text.
+
+Tool calls:
+- Parallelise independent calls in one turn.
+- Prefer **search_surfsense_docs** for SurfSense-product questions, **web_search** / **scrape_webpage**
+ for fresh public facts; integrations and heavy workflows → **task**.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_codex.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_codex.md
new file mode 100644
index 0000000000..aad52f9952
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_codex.md
@@ -0,0 +1,13 @@
+
+You are running on an OpenAI Codex-class model (SurfSense **main agent**).
+
+Output style:
+- Concise; don’t paste huge fetch blobs — summarize.
+- When citations are **enabled** and you rely on chunk-tagged docs, references may use `[citation:chunk_id]` per the citation block above; when **disabled**, use prose and URLs only.
+- Numbered lists work well when the user should reply with a single option index.
+- No emojis; single-level bullets.
+
+Tool calls:
+- Parallelise independent calls; chain only when required.
+- Don’t ask permission for obvious safe defaults — state what you did.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_reasoning.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_reasoning.md
new file mode 100644
index 0000000000..6c8a34087b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_reasoning.md
@@ -0,0 +1,22 @@
+
+You are running on an OpenAI reasoning model (GPT-5+ / o-series), SurfSense **main agent**.
+
+Output style:
+- Be terse and direct. Don't restate the user's request before answering.
+- Don't begin with conversational openers ("Done!", "Got it", "Great question", "Sure thing"). Get to the answer or the action.
+- Match response complexity to the task: simple questions → one-line answer; substantial work → lead with the outcome, then context, then any next steps.
+- No nested bullets — keep lists flat (single level). For options the user can pick by replying with a number, use `1.` `2.` `3.`.
+- Use inline backticks for paths/commands/identifiers; fenced code blocks (with language tags) for multi-line snippets.
+
+Channels (for clients that support them):
+- `commentary` — short progress updates only when they add genuinely new information (a discovery, a tradeoff, a blocker, the start of a non-trivial step). Don't narrate routine reads or obvious next steps.
+- `final` — the completed response. Keep it self-contained; no "see above" / "see below" cross-references.
+
+Tool calls:
+- Parallelise independent tool calls in a single response (`multi_tool_use.parallel` where supported). Only sequence when a later call needs an earlier one's output.
+- Connector or integration execution belongs in **task**, not invented main-agent tools.
+- Don't ask permission ("Should I proceed?", "Do you want me to…?"). Pick the most reasonable default, do it, and state what you did.
+
+Autonomy:
+- Persist until the task is fully resolved within the current turn whenever feasible — within tools you actually have; delegate the rest via **task**.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/refusal_and_limits.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/refusal_and_limits.md
new file mode 100644
index 0000000000..7a88d34783
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/refusal_and_limits.md
@@ -0,0 +1,12 @@
+
+- If a capability is not in `` and no entry in `` covers
+ it, say so plainly and ask whether the user wants to proceed differently.
+ Don't pretend you can do it.
+- If a `task` call errors or the specialist is unavailable, surface that to
+ the user with a clear next step. Don't silently retry forever.
+- Disabled tools announced by the runtime are off-limits even if documented
+ elsewhere — say so and offer a `task` alternative if one exists.
+- Never claim filesystem access, connector access, or persistent storage you
+ don't have. The four direct tools and the `` list are your
+ entire surface area.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/reminder.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/reminder.md
new file mode 100644
index 0000000000..b7ff348a65
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/reminder.md
@@ -0,0 +1,4 @@
+
+Concise · KB-grounded · delegation-first · one `task` per turn · no direct
+filesystem · persist memory when durable facts appear.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
new file mode 100644
index 0000000000..4e27381d32
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
@@ -0,0 +1,96 @@
+
+You have two execution channels. Pick the one that owns the work — never
+simulate one with the other.
+
+### 1. Direct tools (you call them yourself)
+- `search_surfsense_docs` — SurfSense product docs (setup, configuration,
+ connector docs, feature behavior).
+- `web_search` — search the public web (anything outside SurfSense docs and
+ the workspace KB).
+- `scrape_webpage` — fetch the body of a specific public URL.
+- `update_memory` — curate persistent memory (see ``).
+- `write_todos` — maintain a structured plan when the turn series spans
+ multiple specialists or steps. Mark each item
+ `in_progress` **before** the `task` call that handles it, `completed`
+ once the call returns. Skip for single-step requests.
+
+**You have NO filesystem tools.** Any read, write, edit, move, rename, or
+search inside the user's workspace goes through `task(knowledge_base, …)` —
+never via `write_file`, `ls`, or any direct file operation.
+
+### 2. `task(, …)` — specialist subagents
+Use `task` for anything beyond the direct tools above. See
+`` for the live roster.
+
+Rules for `task`:
+- **One specialist per `task` call.** A single `task` invocation targets
+ exactly one specialist; that specialist only has tools for its own
+ domain, so any work outside that domain in the same prompt won't run.
+- **Parallelise independent specialist work.** When a turn needs multiple
+ `task` calls whose work doesn't depend on each other's results (e.g.
+ "create a ClickUp ticket AND a Linear ticket"), emit them as parallel
+ `task` calls. Two `task` calls are independent when:
+ - Neither's prompt references the other's output, and
+ - They target different specialists, OR the same specialist with
+ non-overlapping scopes (e.g. reading two unrelated paths).
+- **Serialise dependent work across turns.** If one specialist's output
+ must inform another's input (e.g. "find the roadmap in my KB, then
+ email it to Maya"), invoke them on consecutive turns — first finishes,
+ then you call the second with the first's result baked into its prompt.
+ Use `write_todos` to keep the plan alive across those turns.
+- Within a single specialist, bundle every related step into the same task
+ prompt (read + write + summary go together).
+- Put the **full instructions inside the task prompt** — the specialist
+ cannot see this thread.
+- Don't claim to already know what a specialist's source contains; invoke
+ the specialist and use what it returns.
+
+
+user: "Save these meeting notes to my KB: …"
+→ task(knowledge_base, "Save the meeting notes below to a new document
+ under /documents/notes/. Pick a sensible title and folder; tell me the
+ path you used.\n\n…")
+
+
+
+user: "What did Maya say about the Q2 roadmap in Slack last week?"
+→ task(slack, "Find messages from Maya about the Q2 roadmap from the past
+ week. Return the most relevant quotes with channel and timestamp.")
+
+
+
+user: "What's the current USD/INR rate?"
+→ web_search(query="current USD to INR exchange rate")
+
+
+
+user: "Find my Q2 roadmap and summarise the milestones."
+→ task(knowledge_base, "Locate the Q2 roadmap document under /documents
+ and summarise its milestones. Use glob or grep if the path isn't
+ obvious from the workspace tree.")
+
+
+
+user: "Create a ClickUp ticket and a Linear ticket for the new feature flag."
+→ Independent work — call both specialists in parallel:
+ write_todos([
+ {content: "Create ClickUp ticket for feature flag rollout", status: "in_progress"},
+ {content: "Create Linear ticket for feature flag rollout", status: "in_progress"},
+ ])
+ task(clickup, "Create a ClickUp ticket titled 'Feature flag rollout'
+ in the default list. Description: <…>. Tell me the ticket URL.")
+ task(linear, "Create a Linear ticket titled 'Feature flag rollout'
+ in the default team. Description: <…>. Tell me the ticket URL.")
+
+
+
+user: "Find my Q2 roadmap doc in the KB and email a summary to Maya."
+→ The email body depends on the doc's contents — serialise across turns.
+ This turn:
+ task(knowledge_base, "Find the Q2 roadmap document under /documents
+ and return its full text plus a 3-bullet summary.")
+ Next turn (with the returned summary in hand):
+ task(gmail, "Send an email to Maya with subject 'Q2 roadmap summary'
+ and the following body: .")
+
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/__init__.py
new file mode 100644
index 0000000000..100dccf66f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/__init__.py
@@ -0,0 +1 @@
+"""```` block — one vertical-slice subfolder per direct main-agent tool."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/__init__.py
new file mode 100644
index 0000000000..a101e7e1c1
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/__init__.py
@@ -0,0 +1 @@
+"""``scrape_webpage`` — description + few-shot examples."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/description.md
new file mode 100644
index 0000000000..d8f7313597
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/description.md
@@ -0,0 +1,11 @@
+- `scrape_webpage` — Fetch and extract readable content from a single URL.
+ - Use when the user wants the actual page body (article, table, dashboard
+ snapshot), not just search snippets.
+ - Try the tool when a URL is given or referenced; don't refuse without
+ attempting unless the URL is clearly unsafe or invalid.
+ - Public web only. For URLs behind a connector (Notion pages, Linear
+ issues, Confluence, anything that needs auth), use `task` with the
+ matching specialist instead.
+ - Args: `url`, `max_length` (default 50000).
+ - Returns title, metadata, and markdown-ish body. Summarise clearly and
+ link back with `[label](url)`.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/example.md
new file mode 100644
index 0000000000..977d40b6d1
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/scrape_webpage/example.md
@@ -0,0 +1,24 @@
+
+user: "Check out https://dev.to/some-article"
+→ scrape_webpage(url="https://dev.to/some-article")
+(Respond with a structured analysis — key points, takeaways.)
+
+
+
+user: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
+→ scrape_webpage(url="https://example.com/blog/ai-trends")
+(Thorough summary using headings and bullets.)
+
+
+
+user: (after discussing https://example.com/stats) "Can you get the live data from that page?"
+→ scrape_webpage(url="https://example.com/stats")
+(Always attempt scraping first. Never refuse before trying.)
+
+
+
+user: "https://example.com/blog/weekend-recipes"
+→ scrape_webpage(url="https://example.com/blog/weekend-recipes")
+(When a user sends just a URL with no instructions, scrape it and provide
+a concise summary.)
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py
new file mode 100644
index 0000000000..c2cda318e2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py
@@ -0,0 +1 @@
+"""``search_surfsense_docs`` — description + few-shot examples."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md
new file mode 100644
index 0000000000..256d3f3a46
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md
@@ -0,0 +1,10 @@
+- `search_surfsense_docs` — Search official SurfSense documentation (product
+ help).
+ - Use when the user asks how SurfSense itself works — setup, configuration,
+ connector documentation, feature behavior, anything covered in the
+ product docs.
+ - Not a substitute for `task` when the user wants actions inside a
+ connected service (Gmail, Slack, Jira, Notion, etc.).
+ - Args: `query`, `top_k` (default 10).
+ - Returns doc excerpts; chunk ids may appear for attribution — see
+ `` for the contract.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md
new file mode 100644
index 0000000000..d53ad8c916
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md
@@ -0,0 +1,15 @@
+
+user: "How do I install SurfSense?"
+→ search_surfsense_docs(query="installation setup")
+
+
+
+user: "What connectors does SurfSense support?"
+→ search_surfsense_docs(query="available connectors integrations")
+
+
+
+user: "How do I set up the Notion connector?"
+→ search_surfsense_docs(query="Notion connector setup configuration")
+(Changing data inside Notion itself → `task(notion, …)`, not this tool.)
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/__init__.py
new file mode 100644
index 0000000000..5eb371b75f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/__init__.py
@@ -0,0 +1 @@
+"""``task`` — description + few-shot examples for the specialist-delegation tool."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/description.md
new file mode 100644
index 0000000000..2f47d4df10
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/description.md
@@ -0,0 +1,15 @@
+- `task` — Invoke a specialist subagent.
+ - Specialists own workspace knowledge-base operations and connected
+ third-party services (Slack, Notion, Jira, Gmail, etc.). See
+ `` for the live roster.
+ - Each subagent runs in isolation with its own tool stack and context,
+ and returns a single synthesized result.
+ - Args:
+ - `subagent_type` — name of the specialist to invoke (must match an
+ entry in ``).
+ - `description` — the FULL task prompt. The specialist cannot see this
+ thread, so include all context and constraints, plus what you need
+ back. The specialist will respond in its own format — don't dictate
+ one.
+ - Routing rules (when to call, how often, how to scope) live in
+ ``.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/example.md
new file mode 100644
index 0000000000..87e5e1b6d7
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/task/example.md
@@ -0,0 +1,20 @@
+
+user: "Save these meeting notes to my KB: …"
+→ task(subagent_type="knowledge_base", description="Save the notes below to
+ a new document under /documents/notes/. Pick a sensible title and folder;
+ tell me the path you used.\n\n…")
+
+
+
+user: "What did Maya say about the Q2 roadmap in Slack last week?"
+→ task(subagent_type="slack", description="Find messages from Maya about
+ the Q2 roadmap from the past week. Return the most relevant quotes with
+ channel and timestamp.")
+
+
+
+user: "Find my Q2 roadmap and summarise the milestones."
+→ task(subagent_type="knowledge_base", description="Locate the Q2 roadmap
+ document under /documents and summarise its milestones. Use glob or grep
+ if the path isn't obvious from the workspace tree.")
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/__init__.py
new file mode 100644
index 0000000000..21f06de5be
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/__init__.py
@@ -0,0 +1 @@
+"""``update_memory`` — private and team visibility variants."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/__init__.py
new file mode 100644
index 0000000000..c25dc6336a
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/__init__.py
@@ -0,0 +1 @@
+"""``update_memory`` (private variant) — description + few-shot examples."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/description.md
new file mode 100644
index 0000000000..e7fa842b1f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/description.md
@@ -0,0 +1,15 @@
+- `update_memory` — Curate the **personal** long-term memory document for
+ this user.
+ - The current memory (if any) appears in `` with usage vs limit.
+ - Call when the user asks to remember or forget something, or shares
+ durable facts, preferences, or instructions.
+ - Use the first name from `` when writing entries — write
+ "Alex prefers…" not "The user prefers…". Don't store the name alone as a
+ memory entry.
+ - Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
+ - Args: `updated_memory` — FULL replacement markdown (merge and curate,
+ don't only append).
+ - Formatting: bullets `- (YYYY-MM-DD) [marker] text` with markers `[fact]`,
+ `[pref]`, `[instr]` (priority when trimming: `instr > pref > fact`).
+ Group bullets under short `##` headings; stay under the limit shown in
+ ``.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/example.md
new file mode 100644
index 0000000000..2505bdf87d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/private/example.md
@@ -0,0 +1,28 @@
+
+Alex, is empty.
+user: "I'm a space enthusiast, explain astrophage to me"
+→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
+(Casual durable fact; use first name, neutral heading.)
+
+
+
+user: "Remember that I prefer concise answers over detailed explanations"
+→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
+(Durable preference; merge with existing memory.)
+
+
+
+user: "I actually moved to Tokyo last month"
+→ update_memory(updated_memory="...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
+(Updated fact; date reflects when recorded.)
+
+
+
+user: "I'm a freelance photographer working on a nature documentary"
+→ update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
+
+
+
+user: "Always respond in bullet points"
+→ update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/__init__.py
new file mode 100644
index 0000000000..85fda97dec
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/__init__.py
@@ -0,0 +1 @@
+"""``update_memory`` (team variant) — description + few-shot examples."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md
new file mode 100644
index 0000000000..13341a9100
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md
@@ -0,0 +1,16 @@
+- `update_memory` — Curate the team's **shared** long-term memory document
+ for this search space.
+ - The current memory (if any) appears in `` with usage vs limit.
+ - Call when a team member asks to remember or forget something, or when
+ the conversation surfaces durable team decisions, conventions,
+ architecture notes, processes, or key facts.
+ - NEVER store personal memory in team memory (individual bios, personal
+ preferences, user-only standing instructions).
+ - Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
+ - Args: `updated_memory` — FULL replacement markdown (merge and curate,
+ don't only append).
+ - Formatting: bullets `- (YYYY-MM-DD) [fact] text`. Team memory uses ONLY
+ the `[fact]` marker (never `[pref]` or `[instr]`). Group bullets under
+ short `##` headings (2-3 words each); stay under the limit shown in
+ ``. When trimming, prioritise: decisions/conventions > key
+ facts > current priorities.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/example.md
new file mode 100644
index 0000000000..8bd8fcfe41
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/example.md
@@ -0,0 +1,9 @@
+
+user: "Let's remember that we decided to do weekly standup meetings on Mondays"
+→ update_memory(updated_memory="...\n\n## Team rituals\n- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
+
+
+
+user: "Our office is in downtown Seattle, 5th floor"
+→ update_memory(updated_memory="...\n\n## Workspace\n- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/__init__.py
new file mode 100644
index 0000000000..95e4549b9b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/__init__.py
@@ -0,0 +1 @@
+"""``web_search`` — description + few-shot examples."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/description.md
new file mode 100644
index 0000000000..df15a62848
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/description.md
@@ -0,0 +1,10 @@
+- `web_search` — Search the public web.
+ - Use whenever an answer benefits from external sources — current events,
+ prices, weather, news, technical references, definitions, background
+ facts, anything outside SurfSense docs and the workspace KB. Reach for
+ it whenever freshness matters or you'd otherwise guess from memory.
+ - Don't refuse with "I lack network access" — call the tool.
+ - If results are thin, say so and offer to refine the query.
+ - Args: `query`, `top_k` (default 10, max 50).
+ - Follow up with `scrape_webpage` on the best URL when snippets are too
+ shallow. Present sources with `[label](url)` markdown links.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/example.md
new file mode 100644
index 0000000000..04f9e899c1
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/web_search/example.md
@@ -0,0 +1,15 @@
+
+user: "What's the current USD to INR exchange rate?"
+→ web_search(query="current USD to INR exchange rate")
+(Answer from snippets; scrape a top URL if needed.)
+
+
+
+user: "What's the latest news about AI?"
+→ web_search(query="latest AI news today")
+
+
+
+user: "What's the weather in New York?"
+→ web_search(query="weather New York today")
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py
new file mode 100644
index 0000000000..80e86e5c8f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py
@@ -0,0 +1,10 @@
+"""Main-agent SurfSense tool allowlist."""
+
+from __future__ import annotations
+
+from .index import (
+ MAIN_AGENT_SURFSENSE_TOOL_NAMES,
+ MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED,
+)
+
+__all__ = ["MAIN_AGENT_SURFSENSE_TOOL_NAMES", "MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py
new file mode 100644
index 0000000000..5d309261cf
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py
@@ -0,0 +1,17 @@
+"""Main-agent SurfSense builtin tool names (not full ``new_chat``).
+
+Connector integrations, MCP, deliverables, etc. are delegated via ``task`` subagents.
+"""
+
+from __future__ import annotations
+
+MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: tuple[str, ...] = (
+ "search_surfsense_docs",
+ "web_search",
+ "scrape_webpage",
+ "update_memory",
+)
+
+MAIN_AGENT_SURFSENSE_TOOL_NAMES: frozenset[str] = frozenset(
+ MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED,
+)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py
new file mode 100644
index 0000000000..c9f893d974
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py
@@ -0,0 +1,36 @@
+"""Audit row per tool call (reversibility metadata)."""
+
+from __future__ import annotations
+
+import logging
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import ActionLogMiddleware
+from app.agents.new_chat.tools.registry import BUILTIN_TOOLS
+
+from ..shared.flags import enabled
+
+
+def build_action_log_mw(
+ *,
+ flags: AgentFeatureFlags,
+ thread_id: int | None,
+ search_space_id: int,
+ user_id: str | None,
+) -> ActionLogMiddleware | None:
+ if not enabled(flags, "enable_action_log") or thread_id is None:
+ return None
+ try:
+ tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS}
+ return ActionLogMiddleware(
+ thread_id=thread_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ tool_definitions=tool_defs_by_name,
+ )
+ except Exception: # pragma: no cover - defensive
+ logging.warning(
+ "ActionLogMiddleware init failed; running without it.",
+ exc_info=True,
+ )
+ return None
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py
new file mode 100644
index 0000000000..afd54a2d38
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py
@@ -0,0 +1,16 @@
+"""Anonymous document hydration from Redis (cloud only)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.middleware import AnonymousDocumentMiddleware
+
+
+def build_anonymous_doc_mw(
+ *,
+ filesystem_mode: FilesystemMode,
+ anon_session_id: str | None,
+) -> AnonymousDocumentMiddleware | None:
+ if filesystem_mode != FilesystemMode.CLOUD:
+ return None
+ return AnonymousDocumentMiddleware(anon_session_id=anon_session_id)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py
new file mode 100644
index 0000000000..0ea53bf169
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py
@@ -0,0 +1,12 @@
+"""Per-thread cooperative lock around the whole turn."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import BusyMutexMiddleware
+
+from ..shared.flags import enabled
+
+
+def build_busy_mutex_mw(flags: AgentFeatureFlags) -> BusyMutexMiddleware | None:
+ return BusyMutexMiddleware() if enabled(flags, "enable_busy_mutex") else None
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py
new file mode 100644
index 0000000000..f370a71c76
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py
@@ -0,0 +1,32 @@
+"""SubAgent ``task`` tool wiring required for HITL inside subagents.
+
+Replaces upstream ``SubAgentMiddleware`` to:
+
+- share the parent's checkpointer with each subagent,
+- forward ``runtime.config`` (thread_id, recursion_limit, …) into nested invokes,
+- isolate each parallel ``task`` call in its own checkpoint slot via
+ per-call ``thread_id`` namespacing,
+- bridge ``Command(resume=...)`` from the parent into the subagent via the
+ ``config["configurable"]["surfsense_resume_value"]`` side-channel, keyed by
+ ``tool_call_id`` so parallel siblings never race on a shared scalar,
+- target the resume at the captured interrupt id so a follow-up
+ ``HumanInTheLoopMiddleware.after_model`` does not consume the same payload,
+- stamp each subagent's pending interrupt with the parent's ``tool_call_id``
+ so ``stream_resume_chat`` can route a flat ``decisions`` list back to the
+ right paused subagent.
+
+Module layout
+-------------
+
+- ``constants`` — shared keys / limits.
+- ``config`` — RunnableConfig + side-channel resume read + per-call ``thread_id``.
+- ``resume`` — pending-interrupt detection, fan-out, ``Command(resume=...)`` builder.
+- ``propagation`` — ``wrap_with_tool_call_id`` helper for stamping interrupt values.
+- ``resume_routing``— slice a flat decisions list to per-``tool_call_id`` payloads.
+- ``task_tool`` — the ``task`` tool factory (sync + async), and the catch-and-stamp chokepoint.
+- ``middleware`` — :class:`SurfSenseCheckpointedSubAgentMiddleware` itself.
+"""
+
+from .middleware import SurfSenseCheckpointedSubAgentMiddleware
+
+__all__ = ["SurfSenseCheckpointedSubAgentMiddleware"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py
new file mode 100644
index 0000000000..ad5b586074
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py
@@ -0,0 +1,125 @@
+"""RunnableConfig wiring for nested subagent invocations.
+
+Forwards the parent's ``runtime.config`` (thread_id, …) into the subagent and
+exposes the side-channel ``stream_resume_chat`` uses to ferry resume payloads.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from langchain.tools import ToolRuntime
+
+from .constants import DEFAULT_SUBAGENT_RECURSION_LIMIT
+
+logger = logging.getLogger(__name__)
+
+# langgraph stores the parent task's scratchpad under this configurable key;
+# subagents inherit the chain via ``parent_scratchpad`` fallback.
+_LANGGRAPH_SCRATCHPAD_KEY = "__pregel_scratchpad"
+
+
+def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]:
+ """RunnableConfig for the nested invoke; raises ``recursion_limit`` and isolates ``thread_id``.
+
+ Each parallel subagent invocation lands in its own checkpoint slot keyed
+ by an extended ``thread_id`` of the form ``{parent_thread}::task:{tool_call_id}``.
+ The same call across the resume cycle keeps reading from the same snapshot
+ (``tool_call_id`` is stable per LLM-emitted call).
+
+ We namespace via ``thread_id`` rather than ``checkpoint_ns`` because
+ langgraph's ``aget_state`` interprets a non-empty ``checkpoint_ns`` as a
+ subgraph path and raises ``ValueError("Subgraph X not found")``.
+ """
+ merged: dict[str, Any] = dict(runtime.config) if runtime.config else {}
+ current_limit = merged.get("recursion_limit")
+ try:
+ current_int = int(current_limit) if current_limit is not None else 0
+ except (TypeError, ValueError):
+ current_int = 0
+ if current_int < DEFAULT_SUBAGENT_RECURSION_LIMIT:
+ merged["recursion_limit"] = DEFAULT_SUBAGENT_RECURSION_LIMIT
+
+ configurable: dict[str, Any] = dict(merged.get("configurable") or {})
+ parent_thread_id = configurable.get("thread_id")
+ per_call_suffix = f"task:{runtime.tool_call_id}"
+ configurable["thread_id"] = (
+ f"{parent_thread_id}::{per_call_suffix}"
+ if parent_thread_id
+ else per_call_suffix
+ )
+ merged["configurable"] = configurable
+ return merged
+
+
+def consume_surfsense_resume(runtime: ToolRuntime) -> Any:
+ """Pop the resume payload for *this* call's ``tool_call_id``.
+
+ The configurable holds ``surfsense_resume_value: dict[tool_call_id, payload]``
+ so parallel sibling subagents (each with their own ``tool_call_id``) read
+ only their own decision and never race on a shared scalar.
+ """
+ cfg = runtime.config or {}
+ configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
+ if not isinstance(configurable, dict):
+ return None
+ by_tcid = configurable.get("surfsense_resume_value")
+ if not isinstance(by_tcid, dict):
+ return None
+ payload = by_tcid.pop(runtime.tool_call_id, None)
+ if not by_tcid:
+ configurable.pop("surfsense_resume_value", None)
+ return payload
+
+
+def has_surfsense_resume(runtime: ToolRuntime) -> bool:
+ """True iff a resume payload for this call's ``tool_call_id`` is queued (non-destructive)."""
+ cfg = runtime.config or {}
+ configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
+ if not isinstance(configurable, dict):
+ return False
+ by_tcid = configurable.get("surfsense_resume_value")
+ if not isinstance(by_tcid, dict):
+ return False
+ return runtime.tool_call_id in by_tcid
+
+
+def drain_parent_null_resume(runtime: ToolRuntime) -> None:
+ """Consume the parent's lingering ``NULL_TASK_ID/RESUME`` write before delegating.
+
+ ``stream_resume_chat`` wakes the main agent with
+ ``Command(resume={tool_call_id: {"decisions": [...]}})`` so the previously
+ propagated parent-level interrupt can return. langgraph stores that
+ payload as the parent task's ``null_resume`` pending write. The ``task``
+ tool then forwards this turn's slice into the subagent via its own
+ ``Command(resume=...)``. While the subagent is mid-execution, any *new*
+ ``interrupt()`` inside it (e.g. a follow-up tool call after a mixed
+ approve/reject) walks ``subagent_scratchpad → parent_scratchpad.get_null_resume``
+ and picks up the parent's still-live decisions — mismatching against a
+ different number of hanging tool calls and crashing
+ ``HumanInTheLoopMiddleware``.
+
+ Draining the write here closes that cross-graph leak so subagent
+ interrupts pause cleanly and bubble back up as a fresh approval card.
+ """
+ cfg = runtime.config or {}
+ configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
+ if not isinstance(configurable, dict):
+ return
+ scratchpad = configurable.get(_LANGGRAPH_SCRATCHPAD_KEY)
+ if scratchpad is None:
+ return
+ consume = getattr(scratchpad, "get_null_resume", None)
+ if not callable(consume):
+ return
+ try:
+ consume(True)
+ except Exception:
+ # Defensive: if langgraph's internal scratchpad shape changes we don't
+ # want to break the resume path. Worst case the original ValueError
+ # still surfaces — same behavior as before this fix.
+ logger.debug(
+ "drain_parent_null_resume: scratchpad.get_null_resume raised",
+ exc_info=True,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py
new file mode 100644
index 0000000000..6c4519f3aa
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py
@@ -0,0 +1,18 @@
+"""Constants shared by the checkpointed subagent middleware."""
+
+from __future__ import annotations
+
+# Mirror of deepagents.middleware.subagents._EXCLUDED_STATE_KEYS.
+EXCLUDED_STATE_KEYS = frozenset(
+ {
+ "messages",
+ "todos",
+ "structured_response",
+ "skills_metadata",
+ "memory_contents",
+ }
+)
+
+# Match the parent graph's budget; the LangGraph default of 25 trips on
+# multi-step subagent runs.
+DEFAULT_SUBAGENT_RECURSION_LIMIT = 10_000
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py
new file mode 100644
index 0000000000..8f51ffed7e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py
@@ -0,0 +1,98 @@
+"""SubAgent middleware that compiles each subagent against the parent checkpointer."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+from deepagents.backends.protocol import BackendFactory, BackendProtocol
+from deepagents.middleware.subagents import (
+ TASK_SYSTEM_PROMPT,
+ CompiledSubAgent,
+ SubAgent,
+ SubAgentMiddleware,
+)
+from langchain.agents import create_agent
+from langchain.chat_models import init_chat_model
+from langgraph.types import Checkpointer
+
+from .task_tool import build_task_tool_with_parent_config
+
+
+class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
+ """``SubAgentMiddleware`` variant that compiles each subagent against the parent checkpointer."""
+
+ def __init__(
+ self,
+ *,
+ checkpointer: Checkpointer,
+ backend: BackendProtocol | BackendFactory,
+ subagents: list[SubAgent | CompiledSubAgent],
+ system_prompt: str | None = TASK_SYSTEM_PROMPT,
+ task_description: str | None = None,
+ ) -> None:
+ self._surf_checkpointer = checkpointer
+ super(SubAgentMiddleware, self).__init__()
+ if not subagents:
+ raise ValueError(
+ "At least one subagent must be specified when using the new API"
+ )
+ self._backend = backend
+ self._subagents = subagents
+ subagent_specs = self._surf_compile_subagent_graphs()
+ task_tool = build_task_tool_with_parent_config(subagent_specs, task_description)
+ if system_prompt and subagent_specs:
+ agents_desc = "\n".join(
+ f"- {s['name']}: {s['description']}" for s in subagent_specs
+ )
+ self.system_prompt = (
+ system_prompt + "\n\nAvailable subagent types:\n" + agents_desc
+ )
+ else:
+ self.system_prompt = system_prompt
+ self.tools = [task_tool]
+
+ def _surf_compile_subagent_graphs(self) -> list[dict[str, Any]]:
+ """Mirror of ``SubAgentMiddleware._get_subagents`` that threads the parent checkpointer."""
+ specs: list[dict[str, Any]] = []
+
+ for spec in self._subagents:
+ if "runnable" in spec:
+ compiled = cast(CompiledSubAgent, spec)
+ specs.append(
+ {
+ "name": compiled["name"],
+ "description": compiled["description"],
+ "runnable": compiled["runnable"],
+ }
+ )
+ continue
+
+ if "model" not in spec:
+ msg = f"SubAgent '{spec['name']}' must specify 'model'"
+ raise ValueError(msg)
+ if "tools" not in spec:
+ msg = f"SubAgent '{spec['name']}' must specify 'tools'"
+ raise ValueError(msg)
+
+ model = spec["model"]
+ if isinstance(model, str):
+ model = init_chat_model(model)
+
+ middleware: list[Any] = list(spec.get("middleware", []))
+
+ specs.append(
+ {
+ "name": spec["name"],
+ "description": spec["description"],
+ "runnable": create_agent(
+ model,
+ system_prompt=spec["system_prompt"],
+ tools=spec["tools"],
+ middleware=middleware,
+ name=spec["name"],
+ checkpointer=self._surf_checkpointer,
+ ),
+ }
+ )
+
+ return specs
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py
new file mode 100644
index 0000000000..cfebe1fd94
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py
@@ -0,0 +1,38 @@
+"""Stamp the parent's ``tool_call_id`` onto a subagent's pending interrupt value.
+
+When a subagent (compiled as a langgraph subgraph and invoked from a parent
+tool node) hits an ``interrupt(...)`` from its HITL middleware, langgraph
+raises ``GraphInterrupt`` out of ``subagent.[a]invoke(...)``. The parent's
+``task`` tool catches that exception, stamps ``tool_call_id`` onto each
+``Interrupt.value`` using :func:`wrap_with_tool_call_id`, and re-raises a
+fresh ``GraphInterrupt`` whose values carry that stamp.
+
+``stream_resume_chat`` then reads ``parent.state.interrupts[*].value["tool_call_id"]``
+to route a flat ``decisions`` list back to the right paused subagent — without
+the stamp, parallel HITL across siblings would collapse into an ambiguous
+bucket and resume would fail.
+
+This module hosts only the stamping helper; the catch/re-raise lives in
+``task_tool.py`` since that's the single chokepoint where the raw exception
+is in our hands.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def wrap_with_tool_call_id(value: Any, tool_call_id: str) -> dict[str, Any]:
+ """Return a value dict that always carries the parent's ``tool_call_id``.
+
+ Dict values are shallow-copied with ``tool_call_id`` stamped on top, so
+ any value the subagent may already carry under that key (from a deeper
+ HITL level) is overwritten — the parent's call id is the only one
+ ``stream_resume_chat`` correlates against.
+
+ Non-dict values are wrapped as ``{"value": , "tool_call_id": ...}``
+ so simple ``interrupt("approve?")`` patterns still propagate cleanly.
+ """
+ if isinstance(value, dict):
+ return {**value, "tool_call_id": tool_call_id}
+ return {"value": value, "tool_call_id": tool_call_id}
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py
new file mode 100644
index 0000000000..0bb477b6b6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py
@@ -0,0 +1,76 @@
+"""Resume-payload shaping and pending-interrupt detection for subagents.
+
+Splits the work of "given a state snapshot and a parent-stashed resume value,
+produce the right ``Command(resume=...)`` for the subagent" into pure helpers.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langgraph.types import Command
+
+
+def hitlrequest_action_count(pending_value: Any) -> int:
+ """Bundle size for a LangChain ``HITLRequest`` payload; ``0`` for non-bundle interrupts."""
+ if not isinstance(pending_value, dict):
+ return 0
+ actions = pending_value.get("action_requests")
+ if isinstance(actions, list):
+ return len(actions)
+ return 0
+
+
+def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any:
+ """Legacy fallback: pad a 1-decision resume to N for an ``action_requests=N`` bundle.
+
+ Modern frontend submits N decisions per bundle (one per action_request) so
+ this is a no-op; kept for backwards compatibility with old in-flight
+ threads or non-bundle clients that send a single decision.
+ """
+ if expected_count <= 1:
+ return resume_value
+ if not isinstance(resume_value, dict):
+ return resume_value
+ decisions = resume_value.get("decisions")
+ if not isinstance(decisions, list) or len(decisions) >= expected_count:
+ return resume_value
+ if not decisions:
+ return resume_value
+ padded = list(decisions) + [decisions[-1]] * (expected_count - len(decisions))
+ return {**resume_value, "decisions": padded}
+
+
+def get_first_pending_subagent_interrupt(state: Any) -> tuple[str | None, Any]:
+ """First pending ``(interrupt_id, value)``; ``(None, None)`` if no interrupt.
+
+ Assumes at most one pending interrupt per snapshot (sequential tool nodes).
+ Parallel tool nodes would need an id-aware lookup instead of first-wins.
+ """
+ if state is None:
+ return None, None
+ for it in getattr(state, "interrupts", None) or ():
+ value = getattr(it, "value", None)
+ interrupt_id = getattr(it, "id", None)
+ if value is not None:
+ return (
+ interrupt_id if isinstance(interrupt_id, str) else None,
+ value,
+ )
+ for sub_task in getattr(state, "tasks", None) or ():
+ for it in getattr(sub_task, "interrupts", None) or ():
+ value = getattr(it, "value", None)
+ interrupt_id = getattr(it, "id", None)
+ if value is not None:
+ return (
+ interrupt_id if isinstance(interrupt_id, str) else None,
+ value,
+ )
+ return None, None
+
+
+def build_resume_command(resume_value: Any, pending_id: str | None) -> Command:
+ """``Command(resume={id: value})`` when ``id`` is known, else fall back to scalar."""
+ if pending_id is None:
+ return Command(resume=resume_value)
+ return Command(resume={pending_id: resume_value})
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume_routing.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume_routing.py
new file mode 100644
index 0000000000..37f45e42f1
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume_routing.py
@@ -0,0 +1,183 @@
+"""Route a flat ``decisions`` list to per-``tool_call_id`` resume payloads.
+
+The frontend submits decisions in the same order the SSE stream emitted
+approval cards. When multiple parallel subagents are paused, the backend uses
+this module to:
+
+1. Read ``state.interrupts`` from the parent's paused snapshot, extracting
+ ``[(tool_call_id, action_count), ...]`` from each interrupt's value.
+ The ``tool_call_id`` is stamped on by ``propagation.wrap_with_tool_call_id``
+ inside ``task_tool``'s catch-and-stamp block when a subagent's
+ ``GraphInterrupt`` bubbles up through ``[a]task``.
+2. Slice the flat ``decisions`` list against that ordered pending list to
+ produce the dict shape expected by ``consume_surfsense_resume``.
+3. Re-key those slices by ``Interrupt.id`` (langgraph's primitive) for use as
+ the parent-level ``Command(resume={interrupt_id: payload})`` input — the
+ only shape langgraph accepts when multiple interrupts are pending.
+
+All helpers are pure: callers own the state and the input decisions; we
+return new structures and never mutate.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Iterable
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def slice_decisions_by_tool_call(
+ decisions: list[dict[str, Any]],
+ pending: Iterable[tuple[str, int]],
+) -> dict[str, dict[str, Any]]:
+ """Slice ``decisions`` into ``{tool_call_id: {"decisions": }}``.
+
+ Args:
+ decisions: Flat list of decisions in the order the SSE stream rendered
+ them.
+ pending: Ordered ``(tool_call_id, action_count)`` pairs in the same
+ order. The slicer consumes ``decisions`` left-to-right.
+
+ Returns:
+ Per-``tool_call_id`` payload dict ready to be written to
+ ``configurable["surfsense_resume_value"]``.
+
+ Raises:
+ ValueError: When the total expected action count differs from the
+ number of decisions provided. We fail loud rather than silently
+ dropping or padding so a frontend/backend contract drift surfaces
+ immediately.
+ """
+ pending_list = list(pending)
+ expected = sum(count for _, count in pending_list)
+ if expected != len(decisions):
+ raise ValueError(
+ f"Decision count mismatch: pending tool calls expect "
+ f"{expected} actions but received {len(decisions)} decisions."
+ )
+
+ routed: dict[str, dict[str, Any]] = {}
+ cursor = 0
+ for tool_call_id, action_count in pending_list:
+ routed[tool_call_id] = {"decisions": decisions[cursor : cursor + action_count]}
+ cursor += action_count
+ return routed
+
+
+def collect_pending_tool_calls(state: Any) -> list[tuple[str, int]]:
+ """Extract ``[(tool_call_id, action_count), ...]`` from a paused parent state.
+
+ Reads ``state.interrupts`` (the bundle langgraph aggregated from each
+ paused subagent's propagated interrupt). Each interrupt value carries the
+ ``tool_call_id`` that the parent's ``task`` tool was processing — see
+ ``propagation.wrap_with_tool_call_id`` and ``task_tool``'s
+ ``except GraphInterrupt`` chokepoint.
+
+ Order is preserved from ``state.interrupts``, which is the order the SSE
+ stream emitted approval cards. The frontend submits decisions in that
+ same order, so the slicer can consume them left-to-right.
+
+ Interrupts without a ``tool_call_id`` are skipped — they were not
+ produced by our task-routing layer (e.g. parent-side HITL middleware on
+ a different tool); ``stream_resume_chat`` is not responsible for routing
+ those.
+
+ Args:
+ state: A langgraph ``StateSnapshot`` (or any object with an
+ ``interrupts`` attribute).
+
+ Returns:
+ Ordered list of ``(tool_call_id, action_count)``. ``action_count`` is
+ ``len(value["action_requests"])`` for HITL-bundle values, or ``1`` for
+ scalar-style ``interrupt("...")`` values that were wrapped as
+ ``{"value": ..., "tool_call_id": ...}``.
+
+ Raises:
+ ValueError: When an interrupt value carries a ``tool_call_id`` but
+ the action count cannot be determined (contract bug — every
+ propagated value should be either a HITL bundle or a wrapped
+ scalar).
+ """
+ pending: list[tuple[str, int]] = []
+ for idx, interrupt_obj in enumerate(getattr(state, "interrupts", ()) or ()):
+ value = getattr(interrupt_obj, "value", None)
+ if not isinstance(value, dict):
+ logger.warning(
+ "[hitl_route] interrupt[%d] skipped: value not a dict (type=%s)",
+ idx,
+ type(value).__name__,
+ )
+ continue
+ tool_call_id = value.get("tool_call_id")
+ if not isinstance(tool_call_id, str):
+ # Should not happen post-stamping; flag loudly if a regression
+ # ever lets an unstamped value reach the parent state.
+ logger.warning(
+ "[hitl_route] interrupt[%d] skipped: no tool_call_id stamp (keys=%s)",
+ idx,
+ sorted(value.keys()),
+ )
+ continue
+
+ action_requests = value.get("action_requests")
+ if isinstance(action_requests, list):
+ pending.append((tool_call_id, len(action_requests)))
+ continue
+ if "value" in value:
+ pending.append((tool_call_id, 1))
+ continue
+
+ raise ValueError(
+ f"Interrupt for tool_call_id={tool_call_id!r} has no "
+ "``action_requests`` list and is not a wrapped scalar value; "
+ "cannot determine action count for resume routing."
+ )
+
+ return pending
+
+
+def build_lg_resume_map(
+ state: Any, by_tool_call_id: dict[str, dict[str, Any]]
+) -> dict[str, dict[str, Any]]:
+ """Map ``Interrupt.id → resume_payload`` for langgraph's multi-interrupt resume.
+
+ ``stream_resume_chat`` builds ``by_tool_call_id`` via
+ :func:`slice_decisions_by_tool_call`. Langgraph's ``Command(resume=...)``
+ requires ``Interrupt.id`` keys (not our ``tool_call_id`` stamps) when the
+ parent state has multiple pending interrupts. This pure helper re-keys the
+ slice without mutating it, and skips entries that can't be paired (no
+ stamp, no slice) so contract drift surfaces as a count mismatch at the
+ call site instead of a silent mis-route.
+
+ The two key spaces serve two different consumers:
+ - ``surfsense_resume_value`` (keyed by ``tool_call_id``): read by the
+ subagent bridge inside ``task_tool``.
+ - ``Command(resume=...)`` (keyed by ``Interrupt.id``): read by langgraph's
+ pregel to wake each pending interrupt site.
+
+ Args:
+ state: A langgraph ``StateSnapshot`` (or any object with an
+ ``interrupts`` iterable).
+ by_tool_call_id: Output of :func:`slice_decisions_by_tool_call`.
+
+ Returns:
+ Dict ready to be passed as ``Command(resume=)``.
+ """
+ out: dict[str, dict[str, Any]] = {}
+ for interrupt_obj in getattr(state, "interrupts", ()) or ():
+ value = getattr(interrupt_obj, "value", None)
+ if not isinstance(value, dict):
+ continue
+ tool_call_id = value.get("tool_call_id")
+ if not isinstance(tool_call_id, str):
+ continue
+ interrupt_id = getattr(interrupt_obj, "id", None)
+ if not isinstance(interrupt_id, str):
+ continue
+ payload = by_tool_call_id.get(tool_call_id)
+ if payload is None:
+ continue
+ out[interrupt_id] = payload
+ return out
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_description.py
new file mode 100644
index 0000000000..73afa68238
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_description.py
@@ -0,0 +1,15 @@
+"""Schema-level description for the ``task`` tool.
+
+Loaded from ``prompts/tools/task/description.md`` so the tool-schema text
+and the ```` block render from the same source.
+"""
+
+from __future__ import annotations
+
+from app.agents.multi_agent_chat.main_agent.system_prompt.builder.load_md import (
+ read_prompt_md,
+)
+
+TASK_TOOL_DESCRIPTION: str = read_prompt_md("tools/task/description.md")
+
+__all__ = ["TASK_TOOL_DESCRIPTION"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py
new file mode 100644
index 0000000000..f9b316e237
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py
@@ -0,0 +1,283 @@
+"""Build the ``task`` tool that invokes subagents with HITL bridging.
+
+The tool's body is the only place where the parent and the subagent meet at
+runtime: it reads the parent's stashed resume value, decides whether to send
+fresh state or a targeted ``Command(resume=...)`` to the subagent, then
+re-raises any new pending interrupt back to the parent.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Annotated, Any, NoReturn
+
+from deepagents.middleware.subagents import TASK_TOOL_DESCRIPTION
+from langchain.tools import BaseTool, ToolRuntime
+from langchain_core.messages import HumanMessage, ToolMessage
+from langchain_core.runnables import Runnable
+from langchain_core.tools import StructuredTool
+from langgraph.errors import GraphInterrupt
+from langgraph.types import Command, Interrupt
+
+from .config import (
+ consume_surfsense_resume,
+ drain_parent_null_resume,
+ has_surfsense_resume,
+ subagent_invoke_config,
+)
+from .constants import EXCLUDED_STATE_KEYS
+from .propagation import wrap_with_tool_call_id
+from .resume import (
+ build_resume_command,
+ fan_out_decisions_to_match,
+ get_first_pending_subagent_interrupt,
+ hitlrequest_action_count,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _reraise_stamped_subagent_interrupt(
+ gi: GraphInterrupt, tool_call_id: str
+) -> NoReturn:
+ """Stamp ``tool_call_id`` onto each pending interrupt value and re-raise.
+
+ See :mod:`...propagation` for why this stamp is required for resume routing.
+ Chained via ``from gi`` so tracebacks point at the subagent's original
+ ``interrupt(...)`` site.
+ """
+ interrupts = gi.args[0] if gi.args else ()
+ stamped = tuple(
+ Interrupt(
+ value=wrap_with_tool_call_id(i.value, tool_call_id),
+ id=i.id,
+ )
+ for i in interrupts
+ )
+ logger.info(
+ "[hitl_route] stamped %d subagent interrupt(s) with tool_call_id=%s",
+ len(stamped),
+ tool_call_id,
+ )
+ raise GraphInterrupt(stamped) from gi
+
+
+def build_task_tool_with_parent_config(
+ subagents: list[dict[str, Any]],
+ task_description: str | None = None,
+) -> BaseTool:
+ """Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging."""
+ subagent_graphs: dict[str, Runnable] = {
+ spec["name"]: spec["runnable"] for spec in subagents
+ }
+ subagent_description_str = "\n".join(
+ f"- {s['name']}: {s['description']}" for s in subagents
+ )
+
+ if task_description is None:
+ description = TASK_TOOL_DESCRIPTION.format(
+ available_agents=subagent_description_str
+ )
+ elif "{available_agents}" in task_description:
+ description = task_description.format(available_agents=subagent_description_str)
+ else:
+ description = task_description
+
+ def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command:
+ if "messages" not in result:
+ msg = (
+ "CompiledSubAgent must return a state containing a 'messages' key. "
+ "Custom StateGraphs used with CompiledSubAgent should include 'messages' "
+ "in their state schema to communicate results back to the main agent."
+ )
+ raise ValueError(msg)
+
+ state_update = {k: v for k, v in result.items() if k not in EXCLUDED_STATE_KEYS}
+ messages = result["messages"]
+ if not messages:
+ msg = (
+ "CompiledSubAgent returned an empty 'messages' list. "
+ "Subagents must produce at least one message so the parent has "
+ "output to forward back to the user."
+ )
+ raise ValueError(msg)
+ last_text = getattr(messages[-1], "text", None) or ""
+ message_text = last_text.rstrip()
+ return Command(
+ update={
+ **state_update,
+ "messages": [ToolMessage(message_text, tool_call_id=tool_call_id)],
+ }
+ )
+
+ def _validate_and_prepare_state(
+ subagent_type: str, description: str, runtime: ToolRuntime
+ ) -> tuple[Runnable, dict]:
+ subagent = subagent_graphs[subagent_type]
+ subagent_state = {
+ k: v for k, v in runtime.state.items() if k not in EXCLUDED_STATE_KEYS
+ }
+ subagent_state["messages"] = [HumanMessage(content=description)]
+ return subagent, subagent_state
+
+ def task(
+ description: Annotated[
+ str,
+ "A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.",
+ ],
+ subagent_type: Annotated[
+ str,
+ "The type of subagent to use. Must be one of the available agent types listed in the tool description.",
+ ],
+ runtime: ToolRuntime,
+ ) -> str | Command:
+ if subagent_type not in subagent_graphs:
+ allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs])
+ return (
+ f"We cannot invoke subagent {subagent_type} because it does not exist, "
+ f"the only allowed types are {allowed_types}"
+ )
+ if not runtime.tool_call_id:
+ raise ValueError("Tool call ID is required for subagent invocation")
+ subagent, subagent_state = _validate_and_prepare_state(
+ subagent_type, description, runtime
+ )
+ sub_config = subagent_invoke_config(runtime)
+
+ # Resume bridge: forward the parent's stashed decision into the
+ # subagent's pending ``interrupt()``, targeted by id.
+ pending_id: str | None = None
+ pending_value: Any = None
+ get_state = getattr(subagent, "get_state", None)
+ if callable(get_state):
+ try:
+ snapshot = get_state(sub_config)
+ pending_id, pending_value = get_first_pending_subagent_interrupt(
+ snapshot
+ )
+ except Exception:
+ # Fail loud if a resume is queued: silent fallback would
+ # replay the original interrupt to the user.
+ if has_surfsense_resume(runtime):
+ logger.exception(
+ "Subagent %r get_state raised with resume queued; re-raising.",
+ subagent_type,
+ )
+ raise
+ logger.debug(
+ "Subagent get_state failed; falling back to fresh invoke",
+ exc_info=True,
+ )
+
+ if pending_value is not None:
+ resume_value = consume_surfsense_resume(runtime)
+ if resume_value is None:
+ # Bridge invariant: a queued resume must accompany any pending
+ # subagent interrupt. Fall-through replay would silently re-prompt
+ # the user; raise so the streaming layer surfaces a clear error.
+ raise RuntimeError(
+ f"Subagent {subagent_type!r} has a pending interrupt but no "
+ "surfsense_resume_value on config; resume bridge is broken."
+ )
+ expected = hitlrequest_action_count(pending_value)
+ resume_value = fan_out_decisions_to_match(resume_value, expected)
+ # Prevent the parent's resume payload from leaking into subagent
+ # interrupts via langgraph's parent_scratchpad fallback.
+ drain_parent_null_resume(runtime)
+ try:
+ result = subagent.invoke(
+ build_resume_command(resume_value, pending_id),
+ config=sub_config,
+ )
+ except GraphInterrupt as gi:
+ _reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
+ else:
+ try:
+ result = subagent.invoke(subagent_state, config=sub_config)
+ except GraphInterrupt as gi:
+ _reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
+ return _return_command_with_state_update(result, runtime.tool_call_id)
+
+ async def atask(
+ description: Annotated[
+ str,
+ "A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.",
+ ],
+ subagent_type: Annotated[
+ str,
+ "The type of subagent to use. Must be one of the available agent types listed in the tool description.",
+ ],
+ runtime: ToolRuntime,
+ ) -> str | Command:
+ logger.info(
+ "[hitl_route] atask ENTRY: subagent_type=%r tool_call_id=%s",
+ subagent_type,
+ runtime.tool_call_id,
+ )
+ if subagent_type not in subagent_graphs:
+ allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs])
+ return (
+ f"We cannot invoke subagent {subagent_type} because it does not exist, "
+ f"the only allowed types are {allowed_types}"
+ )
+ if not runtime.tool_call_id:
+ raise ValueError("Tool call ID is required for subagent invocation")
+ subagent, subagent_state = _validate_and_prepare_state(
+ subagent_type, description, runtime
+ )
+ sub_config = subagent_invoke_config(runtime)
+
+ # Resume bridge — see ``task`` above.
+ pending_id: str | None = None
+ pending_value: Any = None
+ aget_state = getattr(subagent, "aget_state", None)
+ if callable(aget_state):
+ try:
+ snapshot = await aget_state(sub_config)
+ pending_id, pending_value = get_first_pending_subagent_interrupt(
+ snapshot
+ )
+ except Exception:
+ if has_surfsense_resume(runtime):
+ logger.exception(
+ "Subagent %r aget_state raised with resume queued; re-raising.",
+ subagent_type,
+ )
+ raise
+ logger.debug(
+ "Subagent aget_state failed; falling back to fresh ainvoke",
+ exc_info=True,
+ )
+
+ if pending_value is not None:
+ resume_value = consume_surfsense_resume(runtime)
+ if resume_value is None:
+ raise RuntimeError(
+ f"Subagent {subagent_type!r} has a pending interrupt but no "
+ "surfsense_resume_value on config; resume bridge is broken."
+ )
+ expected = hitlrequest_action_count(pending_value)
+ resume_value = fan_out_decisions_to_match(resume_value, expected)
+ # Prevent the parent's resume payload from leaking into subagent
+ # interrupts via langgraph's parent_scratchpad fallback.
+ drain_parent_null_resume(runtime)
+ try:
+ result = await subagent.ainvoke(
+ build_resume_command(resume_value, pending_id),
+ config=sub_config,
+ )
+ except GraphInterrupt as gi:
+ _reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
+ else:
+ try:
+ result = await subagent.ainvoke(subagent_state, config=sub_config)
+ except GraphInterrupt as gi:
+ _reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
+ return _return_command_with_state_update(result, runtime.tool_call_id)
+
+ return StructuredTool.from_function(
+ name="task",
+ func=task,
+ coroutine=atask,
+ description=description,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py
new file mode 100644
index 0000000000..e8f99933e6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py
@@ -0,0 +1,50 @@
+"""Spill + clear-tool-uses passes to keep payloads under budget."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.main_agent.context_prune.prune_tool_names import (
+ safe_exclude_tools,
+)
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import (
+ ClearToolUsesEdit,
+ SpillingContextEditingMiddleware,
+ SpillToBackendEdit,
+)
+
+from ..shared.flags import enabled
+
+
+def build_context_editing_mw(
+ *,
+ flags: AgentFeatureFlags,
+ max_input_tokens: int | None,
+ tools: Sequence[BaseTool],
+ backend_resolver: Any,
+) -> SpillingContextEditingMiddleware | None:
+ if not enabled(flags, "enable_context_editing") or not max_input_tokens:
+ return None
+ spill_edit = SpillToBackendEdit(
+ trigger=int(max_input_tokens * 0.55),
+ clear_at_least=int(max_input_tokens * 0.15),
+ keep=5,
+ exclude_tools=safe_exclude_tools(tools),
+ clear_tool_inputs=True,
+ )
+ clear_edit = ClearToolUsesEdit(
+ trigger=int(max_input_tokens * 0.55),
+ clear_at_least=int(max_input_tokens * 0.15),
+ keep=5,
+ exclude_tools=safe_exclude_tools(tools),
+ clear_tool_inputs=True,
+ placeholder="[cleared - older tool output trimmed for context]",
+ )
+ return SpillingContextEditingMiddleware(
+ edits=[spill_edit, clear_edit],
+ backend_resolver=backend_resolver,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py
new file mode 100644
index 0000000000..66cae300b1
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py
@@ -0,0 +1,13 @@
+"""Drop duplicate HITL tool calls before execution."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware
+
+
+def build_dedup_hitl_mw(tools: Sequence[BaseTool]) -> DedupHITLToolCallsMiddleware:
+ return DedupHITLToolCallsMiddleware(agent_tools=list(tools))
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py
new file mode 100644
index 0000000000..d67b8d518b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py
@@ -0,0 +1,14 @@
+"""Stop N identical tool calls in a row via interrupt."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import DoomLoopMiddleware
+
+from ..shared.flags import enabled
+
+
+def build_doom_loop_mw(flags: AgentFeatureFlags) -> DoomLoopMiddleware | None:
+ return (
+ DoomLoopMiddleware(threshold=3) if enabled(flags, "enable_doom_loop") else None
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py
new file mode 100644
index 0000000000..4b27581e7e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py
@@ -0,0 +1,23 @@
+"""Commit staged cloud filesystem mutations to Postgres at end of turn."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.middleware import KnowledgeBasePersistenceMiddleware
+
+
+def build_kb_persistence_mw(
+ *,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+ user_id: str | None,
+ thread_id: int | None,
+) -> KnowledgeBasePersistenceMiddleware | None:
+ if filesystem_mode != FilesystemMode.CLOUD:
+ return None
+ return KnowledgeBasePersistenceMiddleware(
+ search_space_id=search_space_id,
+ created_by_id=user_id,
+ filesystem_mode=filesystem_mode,
+ thread_id=thread_id,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py
new file mode 100644
index 0000000000..fcdb1c61e8
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py
@@ -0,0 +1,28 @@
+"""KB priority planner: injection."""
+
+from __future__ import annotations
+
+from langchain_core.language_models import BaseChatModel
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.middleware import KnowledgePriorityMiddleware
+
+
+def build_knowledge_priority_mw(
+ *,
+ llm: BaseChatModel,
+ search_space_id: int,
+ filesystem_mode: FilesystemMode,
+ available_connectors: list[str] | None,
+ available_document_types: list[str] | None,
+ mentioned_document_ids: list[int] | None,
+) -> KnowledgePriorityMiddleware:
+ return KnowledgePriorityMiddleware(
+ llm=llm,
+ search_space_id=search_space_id,
+ filesystem_mode=filesystem_mode,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ mentioned_document_ids=mentioned_document_ids,
+ inject_system_message=False,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py
new file mode 100644
index 0000000000..fb45110679
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py
@@ -0,0 +1,24 @@
+""" injection (cloud only)."""
+
+from __future__ import annotations
+
+from langchain_core.language_models import BaseChatModel
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.middleware import KnowledgeTreeMiddleware
+
+
+def build_knowledge_tree_mw(
+ *,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+ llm: BaseChatModel,
+) -> KnowledgeTreeMiddleware | None:
+ if filesystem_mode != FilesystemMode.CLOUD:
+ return None
+ return KnowledgeTreeMiddleware(
+ search_space_id=search_space_id,
+ filesystem_mode=filesystem_mode,
+ llm=llm,
+ inject_system_message=False,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py
new file mode 100644
index 0000000000..6e6467ad09
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py
@@ -0,0 +1,12 @@
+"""Provider-compat: append a `_noop` tool when tools=[] but history has tool calls."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import NoopInjectionMiddleware
+
+from ..shared.flags import enabled
+
+
+def build_noop_injection_mw(flags: AgentFeatureFlags) -> NoopInjectionMiddleware | None:
+ return NoopInjectionMiddleware() if enabled(flags, "enable_compaction_v2") else None
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py
new file mode 100644
index 0000000000..bd7516e655
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py
@@ -0,0 +1,12 @@
+"""OTel spans on model and tool calls."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import OtelSpanMiddleware
+
+from ..shared.flags import enabled
+
+
+def build_otel_mw(flags: AgentFeatureFlags) -> OtelSpanMiddleware | None:
+ return OtelSpanMiddleware() if enabled(flags, "enable_otel") else None
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py
new file mode 100644
index 0000000000..4418e38068
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py
@@ -0,0 +1,49 @@
+"""Tail-of-stack plugin slot driven by env allowlist."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.plugin_loader import (
+ PluginContext,
+ load_allowed_plugin_names_from_env,
+ load_plugin_middlewares,
+)
+from app.db import ChatVisibility
+
+from ..shared.flags import enabled
+
+
+def build_plugin_middlewares(
+ *,
+ flags: AgentFeatureFlags,
+ search_space_id: int,
+ user_id: str | None,
+ visibility: ChatVisibility,
+ llm: BaseChatModel,
+) -> list[Any]:
+ if not enabled(flags, "enable_plugin_loader"):
+ return []
+ try:
+ allowed_names = load_allowed_plugin_names_from_env()
+ if not allowed_names:
+ return []
+ return load_plugin_middlewares(
+ PluginContext.build(
+ search_space_id=search_space_id,
+ user_id=user_id,
+ thread_visibility=visibility,
+ llm=llm,
+ ),
+ allowed_plugin_names=allowed_names,
+ )
+ except Exception: # pragma: no cover - defensive
+ logging.warning(
+ "Plugin loader failed; continuing without plugins.",
+ exc_info=True,
+ )
+ return []
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py
new file mode 100644
index 0000000000..378b61be19
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py
@@ -0,0 +1,50 @@
+"""Repair miscased / unknown tool names to the registered set or invalid_tool."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import ToolCallNameRepairMiddleware
+
+from ..shared.flags import enabled
+
+# deepagents-built-in tool names the repair pass treats as known.
+_DEEPAGENT_BUILTIN_TOOL_NAMES: frozenset[str] = frozenset(
+ {
+ "write_todos",
+ "ls",
+ "read_file",
+ "write_file",
+ "edit_file",
+ "glob",
+ "grep",
+ "execute",
+ "task",
+ "mkdir",
+ "cd",
+ "pwd",
+ "move_file",
+ "rm",
+ "rmdir",
+ "list_tree",
+ "execute_code",
+ }
+)
+
+
+def build_repair_mw(
+ *,
+ flags: AgentFeatureFlags,
+ tools: Sequence[BaseTool],
+) -> ToolCallNameRepairMiddleware | None:
+ if not enabled(flags, "enable_tool_call_repair"):
+ return None
+ registered_names: set[str] = {t.name for t in tools}
+ registered_names |= _DEEPAGENT_BUILTIN_TOOL_NAMES
+ return ToolCallNameRepairMiddleware(
+ registered_tool_names=registered_names,
+ fuzzy_match_threshold=None,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py
new file mode 100644
index 0000000000..63a57c5a04
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py
@@ -0,0 +1,39 @@
+"""Skill discovery + injection."""
+
+from __future__ import annotations
+
+import logging
+
+from deepagents.middleware.skills import SkillsMiddleware
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.middleware import (
+ build_skills_backend_factory,
+ default_skills_sources,
+)
+
+from ..shared.flags import enabled
+
+
+def build_skills_mw(
+ *,
+ flags: AgentFeatureFlags,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+) -> SkillsMiddleware | None:
+ if not enabled(flags, "enable_skills"):
+ return None
+ try:
+ skills_factory = build_skills_backend_factory(
+ search_space_id=search_space_id
+ if filesystem_mode == FilesystemMode.CLOUD
+ else None,
+ )
+ return SkillsMiddleware(
+ backend=skills_factory,
+ sources=default_skills_sources(),
+ )
+ except Exception as exc: # pragma: no cover - defensive
+ logging.warning("SkillsMiddleware init failed; skipping: %s", exc)
+ return None
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py
new file mode 100644
index 0000000000..f99fb9c7f2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py
@@ -0,0 +1,9 @@
+"""Anthropic prompt caching annotations on system/tool/message blocks."""
+
+from __future__ import annotations
+
+from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
+
+
+def build_anthropic_cache_mw() -> AnthropicPromptCachingMiddleware:
+ return AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore")
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py
new file mode 100644
index 0000000000..b59e7d2c4b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py
@@ -0,0 +1,14 @@
+"""Context-window summarization with SurfSense protected sections."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from deepagents.backends import StateBackend
+from langchain_core.language_models import BaseChatModel
+
+from app.agents.new_chat.middleware import create_surfsense_compaction_middleware
+
+
+def build_compaction_mw(llm: BaseChatModel) -> Any:
+ return create_surfsense_compaction_middleware(llm, StateBackend)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/__init__.py
new file mode 100644
index 0000000000..0dcc567e0f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/__init__.py
@@ -0,0 +1,11 @@
+"""SurfSense filesystem middleware (multi-agent flavour)."""
+
+from __future__ import annotations
+
+from .index import build_filesystem_mw
+from .middleware import SurfSenseFilesystemMiddleware
+
+__all__ = [
+ "SurfSenseFilesystemMiddleware",
+ "build_filesystem_mw",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/index.py
new file mode 100644
index 0000000000..fb8dbe2092
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/index.py
@@ -0,0 +1,28 @@
+"""Public composition factory for the filesystem middleware."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+from .middleware import SurfSenseFilesystemMiddleware
+
+
+def build_filesystem_mw(
+ *,
+ backend_resolver: Any,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+ user_id: str | None,
+ thread_id: int | None,
+ read_only: bool = False,
+) -> SurfSenseFilesystemMiddleware:
+ return SurfSenseFilesystemMiddleware(
+ backend=backend_resolver,
+ filesystem_mode=filesystem_mode,
+ search_space_id=search_space_id,
+ created_by_id=user_id,
+ thread_id=thread_id,
+ read_only=read_only,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/__init__.py
new file mode 100644
index 0000000000..466672aa33
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/__init__.py
@@ -0,0 +1,33 @@
+"""SurfSense filesystem middleware: class + focused-responsibility helpers."""
+
+from __future__ import annotations
+
+from .index import (
+ SurfSenseFilesystemMiddleware,
+ check_cloud_write_namespace,
+ current_cwd,
+ default_cwd,
+ get_contract_suggested_path,
+ is_cloud,
+ normalize_local_mount_path,
+ resolve_list_target_path,
+ resolve_move_target_path,
+ resolve_relative,
+ resolve_write_target_path,
+ run_async_blocking,
+)
+
+__all__ = [
+ "SurfSenseFilesystemMiddleware",
+ "check_cloud_write_namespace",
+ "current_cwd",
+ "default_cwd",
+ "get_contract_suggested_path",
+ "is_cloud",
+ "normalize_local_mount_path",
+ "resolve_list_target_path",
+ "resolve_move_target_path",
+ "resolve_relative",
+ "resolve_write_target_path",
+ "run_async_blocking",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/async_dispatch.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/async_dispatch.py
new file mode 100644
index 0000000000..58a2d9168b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/async_dispatch.py
@@ -0,0 +1,22 @@
+"""Sync/async dispatcher: drive an async tool body from a sync entry-point."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+
+def run_async_blocking(coro: Any) -> Any:
+ """Run ``coro`` to completion, blocking the current thread.
+
+ Returns an error string instead of raising if the current thread is
+ already inside a running event loop — keeps sync tool entry-points
+ safe to call from any context.
+ """
+ try:
+ loop = asyncio.get_running_loop()
+ if loop.is_running():
+ return "Error: sync filesystem operation not supported inside an active event loop."
+ except RuntimeError:
+ pass
+ return asyncio.run(coro)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/index.py
new file mode 100644
index 0000000000..b5ceb31023
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/index.py
@@ -0,0 +1,32 @@
+"""Public surface of the middleware package: class + helpers used by tool factories."""
+
+from __future__ import annotations
+
+from .async_dispatch import run_async_blocking
+from .middleware import SurfSenseFilesystemMiddleware
+from .mode import default_cwd, is_cloud
+from .namespace_policy import check_cloud_write_namespace
+from .path_resolution import (
+ current_cwd,
+ get_contract_suggested_path,
+ normalize_local_mount_path,
+ resolve_list_target_path,
+ resolve_move_target_path,
+ resolve_relative,
+ resolve_write_target_path,
+)
+
+__all__ = [
+ "SurfSenseFilesystemMiddleware",
+ "check_cloud_write_namespace",
+ "current_cwd",
+ "default_cwd",
+ "get_contract_suggested_path",
+ "is_cloud",
+ "normalize_local_mount_path",
+ "resolve_list_target_path",
+ "resolve_move_target_path",
+ "resolve_relative",
+ "resolve_write_target_path",
+ "run_async_blocking",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/middleware.py
new file mode 100644
index 0000000000..c32e144388
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/middleware.py
@@ -0,0 +1,105 @@
+"""``SurfSenseFilesystemMiddleware``: per-session state + tool registration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from deepagents import FilesystemMiddleware
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.sandbox import is_sandbox_enabled
+
+from ..system_prompt import build_system_prompt
+from ..tools import (
+ create_cd_tool,
+ create_edit_file_tool,
+ create_execute_code_tool,
+ create_list_tree_tool,
+ create_ls_tool,
+ create_mkdir_tool,
+ create_move_file_tool,
+ create_pwd_tool,
+ create_read_file_tool,
+ create_rm_tool,
+ create_rmdir_tool,
+ create_write_file_tool,
+)
+from ..tools.glob.description import select_description as glob_description
+from ..tools.grep.description import select_description as grep_description
+from .read_only_policy import READ_ONLY_TOOL_NAMES
+
+
+class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
+ """SurfSense-specific filesystem middleware (cloud + desktop)."""
+
+ state_schema = SurfSenseFilesystemState
+
+ def __init__(
+ self,
+ *,
+ backend: Any = None,
+ filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
+ search_space_id: int | None = None,
+ created_by_id: str | None = None,
+ thread_id: int | str | None = None,
+ tool_token_limit_before_evict: int | None = 20000,
+ read_only: bool = False,
+ ) -> None:
+ self._filesystem_mode = filesystem_mode
+ self._search_space_id = search_space_id
+ self._created_by_id = created_by_id
+ self._thread_id = thread_id
+ self._read_only = read_only
+ self._sandbox_available = (
+ is_sandbox_enabled() and thread_id is not None and not read_only
+ )
+
+ system_prompt = build_system_prompt(
+ filesystem_mode,
+ sandbox_available=self._sandbox_available,
+ )
+
+ super().__init__(
+ backend=backend,
+ system_prompt=system_prompt,
+ tool_token_limit_before_evict=tool_token_limit_before_evict,
+ )
+ self.tools = [t for t in self.tools if t.name != "execute"]
+ self.tools.append(create_mkdir_tool(self))
+ self.tools.append(create_cd_tool(self))
+ self.tools.append(create_pwd_tool(self))
+ self.tools.append(create_move_file_tool(self))
+ self.tools.append(create_rm_tool(self))
+ self.tools.append(create_rmdir_tool(self))
+ self.tools.append(create_list_tree_tool(self))
+ if self._sandbox_available:
+ self.tools.append(create_execute_code_tool(self))
+
+ if read_only:
+ self.tools = [t for t in self.tools if t.name in READ_ONLY_TOOL_NAMES]
+
+ # ----------------------------------------- base-class tool overrides
+
+ def _create_ls_tool(self) -> BaseTool:
+ return create_ls_tool(self)
+
+ def _create_read_file_tool(self) -> BaseTool:
+ return create_read_file_tool(self)
+
+ def _create_write_file_tool(self) -> BaseTool:
+ return create_write_file_tool(self)
+
+ def _create_edit_file_tool(self) -> BaseTool:
+ return create_edit_file_tool(self)
+
+ def _create_glob_tool(self) -> BaseTool:
+ tool = super()._create_glob_tool()
+ tool.description = glob_description(self._filesystem_mode).rstrip()
+ return tool
+
+ def _create_grep_tool(self) -> BaseTool:
+ tool = super()._create_grep_tool()
+ tool.description = grep_description(self._filesystem_mode).rstrip()
+ return tool
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/mode.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/mode.py
new file mode 100644
index 0000000000..a23d775354
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/mode.py
@@ -0,0 +1,15 @@
+"""Mode-derived facts: ``is_cloud`` and ``default_cwd``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+
+
+def is_cloud(mode: FilesystemMode) -> bool:
+ return mode == FilesystemMode.CLOUD
+
+
+def default_cwd(mode: FilesystemMode) -> str:
+ """``/documents`` on cloud; ``/`` on desktop (mounts are children of ``/``)."""
+ return DOCUMENTS_ROOT if is_cloud(mode) else "/"
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/namespace_policy.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/namespace_policy.py
new file mode 100644
index 0000000000..5390504141
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/namespace_policy.py
@@ -0,0 +1,51 @@
+"""Cloud-only write namespace policy.
+
+A write is allowed iff it lands under ``/documents/`` OR its basename uses
+the ``temp_`` scratch prefix. The anonymous uploaded document is read-only
+even when its path is under ``/documents/``.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from langchain.tools import ToolRuntime
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+
+from ..shared.paths import TEMP_PREFIX, basename
+from .mode import is_cloud
+
+if TYPE_CHECKING:
+ from .middleware import SurfSenseFilesystemMiddleware
+
+
+def check_cloud_write_namespace(
+ mw: SurfSenseFilesystemMiddleware,
+ path: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str | None:
+ """Return an error string if cloud writes to ``path`` are not allowed.
+
+ Order matters:
+ 1. Reject writes to the anonymous read-only doc.
+ 2. Allow ``/documents/*``.
+ 3. Allow ``temp_*`` basename anywhere.
+ 4. Reject everything else.
+ """
+ if not is_cloud(mw._filesystem_mode):
+ return None
+ anon = runtime.state.get("kb_anon_doc") or {}
+ if isinstance(anon, dict):
+ anon_path = str(anon.get("path") or "")
+ if anon_path and anon_path == path:
+ return "Error: the anonymous uploaded document is read-only."
+ if path.startswith(DOCUMENTS_ROOT + "/") or path == DOCUMENTS_ROOT:
+ return None
+ if basename(path).startswith(TEMP_PREFIX):
+ return None
+ return (
+ "Error: cloud writes must target /documents/<...> or use a 'temp_' "
+ f"basename for scratch (got '{path}')."
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/path_resolution.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/path_resolution.py
new file mode 100644
index 0000000000..2c8ec6b4d4
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/path_resolution.py
@@ -0,0 +1,174 @@
+"""Resolve user-supplied paths to absolute paths the backends accept."""
+
+from __future__ import annotations
+
+import posixpath
+from typing import TYPE_CHECKING
+
+from langchain.tools import ToolRuntime
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
+ MultiRootLocalFolderBackend,
+)
+
+from ..shared.paths import (
+ extract_mount_from_path,
+ local_parent_path,
+ normalize_absolute_path,
+)
+from .mode import default_cwd
+
+if TYPE_CHECKING:
+ from .middleware import SurfSenseFilesystemMiddleware
+
+
+def current_cwd(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ cwd = runtime.state.get("cwd") if hasattr(runtime, "state") else None
+ if isinstance(cwd, str) and cwd.startswith("/"):
+ return cwd
+ return default_cwd(mw._filesystem_mode)
+
+
+def get_contract_suggested_path(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ """Read the planner's suggested write path; otherwise default to ``notes.md``."""
+ contract = runtime.state.get("file_operation_contract") or {}
+ suggested = contract.get("suggested_path")
+ if isinstance(suggested, str) and suggested.strip():
+ return normalize_absolute_path(suggested)
+ return default_cwd(mw._filesystem_mode).rstrip("/") + "/notes.md"
+
+
+def resolve_relative(
+ mw: SurfSenseFilesystemMiddleware,
+ path: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ """Resolve ``path`` against cwd (no-op if already absolute)."""
+ candidate = path.strip()
+ if not candidate:
+ return current_cwd(mw, runtime)
+ if candidate.startswith("/"):
+ return normalize_absolute_path(candidate)
+ cwd = current_cwd(mw, runtime)
+ joined = posixpath.normpath(posixpath.join(cwd, candidate))
+ return normalize_absolute_path(joined)
+
+
+def resolve_write_target_path(
+ mw: SurfSenseFilesystemMiddleware,
+ file_path: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ """Empty → contract suggestion; desktop → mount-prefix; cloud → cwd-relative."""
+ candidate = file_path.strip()
+ if not candidate:
+ return get_contract_suggested_path(mw, runtime)
+ if mw._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
+ return normalize_local_mount_path(mw, candidate, runtime)
+ return resolve_relative(mw, candidate, runtime)
+
+
+def resolve_move_target_path(
+ mw: SurfSenseFilesystemMiddleware,
+ file_path: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ """Empty → empty (caller validates); desktop → mount-prefix; cloud → cwd-relative."""
+ candidate = file_path.strip()
+ if not candidate:
+ return ""
+ if mw._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
+ return normalize_local_mount_path(mw, candidate, runtime)
+ return resolve_relative(mw, candidate, runtime)
+
+
+def resolve_list_target_path(
+ mw: SurfSenseFilesystemMiddleware,
+ path: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ """Root stays root; desktop → mount-prefix; cloud → cwd-relative."""
+ candidate = path.strip() or current_cwd(mw, runtime)
+ if candidate == "/":
+ return "/"
+ if mw._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
+ return normalize_local_mount_path(mw, candidate, runtime)
+ return resolve_relative(mw, candidate, runtime)
+
+
+def normalize_local_mount_path(
+ mw: SurfSenseFilesystemMiddleware,
+ candidate: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+) -> str:
+ """Desktop only: prepend a mount prefix when the path doesn't already have one.
+
+ Resolution order: explicit mount prefix → single available mount →
+ contract-suggested mount → mount where the path exists → mount where the
+ parent exists → backend default mount.
+ """
+ normalized = normalize_absolute_path(candidate)
+ backend = mw._get_backend(runtime)
+ if not isinstance(backend, MultiRootLocalFolderBackend):
+ return normalized
+
+ mounts = backend.list_mounts()
+ explicit_mount = extract_mount_from_path(normalized, mounts)
+ if explicit_mount:
+ return normalized
+
+ if len(mounts) == 1:
+ return f"/{mounts[0]}{normalized}"
+
+ suggested_mount: str | None = None
+ contract = runtime.state.get("file_operation_contract") or {}
+ suggested_path = contract.get("suggested_path")
+ if isinstance(suggested_path, str) and suggested_path.strip():
+ normalized_suggested = normalize_absolute_path(suggested_path)
+ suggested_mount = extract_mount_from_path(normalized_suggested, mounts)
+
+ matching_mounts = [
+ mount
+ for mount in mounts
+ if _path_exists_under_mount(backend, mount, normalized)
+ ]
+ if len(matching_mounts) == 1:
+ return f"/{matching_mounts[0]}{normalized}"
+
+ parent_path = local_parent_path(normalized)
+ if parent_path != "/":
+ parent_matching_mounts = [
+ mount
+ for mount in mounts
+ if _path_exists_under_mount(backend, mount, parent_path)
+ ]
+ if len(parent_matching_mounts) == 1:
+ return f"/{parent_matching_mounts[0]}{normalized}"
+
+ if suggested_mount:
+ return f"/{suggested_mount}{normalized}"
+
+ return f"/{backend.default_mount()}{normalized}"
+
+
+def _path_exists_under_mount(
+ backend: MultiRootLocalFolderBackend,
+ mount: str,
+ local_path: str,
+) -> bool:
+ result = backend.list_tree(
+ f"/{mount}{local_path}",
+ max_depth=0,
+ page_size=1,
+ include_files=True,
+ include_dirs=True,
+ )
+ return not bool(result.get("error"))
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/read_only_policy.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/read_only_policy.py
new file mode 100644
index 0000000000..c1d8578734
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/middleware/read_only_policy.py
@@ -0,0 +1,7 @@
+"""Allowlist consulted by ``SurfSenseFilesystemMiddleware`` when ``read_only=True``."""
+
+from __future__ import annotations
+
+READ_ONLY_TOOL_NAMES = frozenset(
+ {"ls", "read_file", "glob", "grep", "list_tree", "pwd", "cd"}
+)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/shared/__init__.py
new file mode 100644
index 0000000000..3c86805c16
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/shared/__init__.py
@@ -0,0 +1,21 @@
+"""Stateless utilities shared by the middleware and tool factories."""
+
+from __future__ import annotations
+
+from .paths import (
+ TEMP_PREFIX,
+ basename,
+ extract_mount_from_path,
+ is_ancestor_of,
+ local_parent_path,
+ normalize_absolute_path,
+)
+
+__all__ = [
+ "TEMP_PREFIX",
+ "basename",
+ "extract_mount_from_path",
+ "is_ancestor_of",
+ "local_parent_path",
+ "normalize_absolute_path",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/shared/paths.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/shared/paths.py
new file mode 100644
index 0000000000..39853c519b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/shared/paths.py
@@ -0,0 +1,51 @@
+"""Stateless path utilities shared by the middleware class and tool factories."""
+
+from __future__ import annotations
+
+import re
+
+TEMP_PREFIX = "temp_"
+
+
+def normalize_absolute_path(candidate: str) -> str:
+ """Collapse slashes / backslashes and force an absolute path."""
+ normalized = re.sub(r"/+", "/", candidate.strip().replace("\\", "/"))
+ if not normalized:
+ return "/"
+ if normalized.startswith("/"):
+ return normalized
+ return f"/{normalized.lstrip('/')}"
+
+
+def extract_mount_from_path(path: str, mounts: tuple[str, ...]) -> str | None:
+ """Return the leading mount segment if it's in ``mounts``, else None."""
+ rel = path.lstrip("/")
+ if not rel:
+ return None
+ mount, _, _ = rel.partition("/")
+ if mount in mounts:
+ return mount
+ return None
+
+
+def local_parent_path(path: str) -> str:
+ """Posix-style parent path (root = ``/``)."""
+ rel = path.lstrip("/")
+ if "/" not in rel:
+ return "/"
+ parent = rel.rsplit("/", 1)[0].strip("/")
+ if not parent:
+ return "/"
+ return f"/{parent}"
+
+
+def basename(path: str) -> str:
+ return path.rsplit("/", 1)[-1]
+
+
+def is_ancestor_of(candidate: str, target: str) -> bool:
+ """True iff ``candidate`` is a strict-or-equal ancestor of ``target``."""
+ if candidate == "/":
+ return target != "/"
+ cand = candidate.rstrip("/")
+ return target == cand or target.startswith(cand + "/")
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/__init__.py
new file mode 100644
index 0000000000..e191d70662
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/__init__.py
@@ -0,0 +1,7 @@
+"""Filesystem-middleware system prompt (cloud + desktop modes)."""
+
+from __future__ import annotations
+
+from .index import build_system_prompt
+
+__all__ = ["build_system_prompt"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/cloud.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/cloud.py
new file mode 100644
index 0000000000..98dbbaaabd
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/cloud.py
@@ -0,0 +1,71 @@
+"""Cloud-mode filesystem system prompt body."""
+
+from __future__ import annotations
+
+BODY = """
+## Filesystem Tools
+
+All file paths must start with `/`. Relative paths resolve against the
+current working directory (`cwd`, default `/documents`).
+
+- ls(path, offset=0, limit=200): list files and directories at the given path.
+- read_file(path, offset, limit): read a file (paginated) from the filesystem.
+- write_file(path, content): create a new text file in the workspace.
+- edit_file(path, old, new): exact string-replacement edit (lazy-loads KB
+ documents on first edit).
+- glob(pattern, path): find files matching a glob pattern.
+- grep(pattern, path, glob): substring search across files.
+- mkdir(path): create a folder under `/documents/` (committed at end of turn).
+- cd(path): change the current working directory.
+- pwd(): print the current working directory.
+- move_file(source, dest): move/rename a file under `/documents/`.
+- rm(path): delete a single file under `/documents/` (no `-r`).
+- rmdir(path): delete an empty directory under `/documents/`.
+- list_tree(path, max_depth, page_size): recursively list files/folders.
+
+## Persistence Rules
+
+- Files written under `/documents/<...>` are **persisted** at end of turn as
+ Documents in the user's knowledge base.
+- Files whose **basename** starts with `temp_` (e.g. `temp_plan.md` or
+ `/documents/temp_scratch.md`) are **discarded** at end of turn — use this
+ prefix for any scratch/working content you do NOT want saved.
+- All other paths (outside `/documents/` and not `temp_*`) are rejected.
+- mkdir/move_file/rm/rmdir are staged this turn and committed at end of
+ turn alongside any new/edited documents. Snapshot/revert is enabled
+ for every destructive operation when action logging is on.
+
+## Reading Documents Efficiently
+
+Documents are formatted as XML. Each document contains:
+- `` — title, type, URL, etc.
+- `` — a table of every chunk with its **line range** and a
+ `matched="true"` flag for chunks that matched the search query.
+- `` — the actual chunks in original document order.
+
+**Workflow**: when reading a large document, read the first ~20 lines to see
+the ``, identify chunks marked `matched="true"`, then use
+`read_file(path, offset=, limit=)` to jump directly to
+those sections instead of reading the entire file sequentially.
+
+Use `` values as citation IDs in your answers.
+
+## Priority List
+
+You receive a `` system message each turn listing the
+top-K paths most relevant to the user's query (by hybrid search). Read those
+first — matched sections are flagged inside each document's ``.
+
+## Workspace Tree
+
+You receive a `` system message each turn with the current
+folder/document layout. The tree may be truncated past a hard cap; in that
+case, drill into specific folders with `ls(...)` or `list_tree(...)`.
+
+## grep Line Numbers
+
+`grep` searches across both your in-memory edits and the indexed chunks in
+Postgres. State-cached files return real line numbers; database hits return
+`line=0` because their position depends on per-document XML layout — call
+`read_file(path)` to find the exact line.
+"""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/common.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/common.py
new file mode 100644
index 0000000000..81c2641182
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/common.py
@@ -0,0 +1,22 @@
+"""Mode-agnostic prompt fragments: header conventions + sandbox addendum."""
+
+from __future__ import annotations
+
+HEADER = """## Following Conventions
+
+- Read files before editing — understand existing content before making changes.
+- Mimic existing style, naming conventions, and patterns.
+- Never claim a file was created/updated unless filesystem tool output confirms success.
+- If a file write/edit fails, explicitly report the failure.
+"""
+
+SANDBOX_ADDENDUM = (
+ "\n- execute_code: run Python code in an isolated sandbox."
+ "\n\n## Code Execution"
+ "\n\nUse execute_code whenever a task benefits from running code."
+ " Never perform arithmetic manually."
+ "\n\nDocuments here are XML-wrapped markdown, not raw data files."
+ " To work with them programmatically, read the document first,"
+ " extract the data, write it as a clean file (CSV, JSON, etc.),"
+ " and then run your code against it."
+)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/desktop.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/desktop.py
new file mode 100644
index 0000000000..712b51c26d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/desktop.py
@@ -0,0 +1,49 @@
+"""Desktop-mode filesystem system prompt body."""
+
+from __future__ import annotations
+
+BODY = """
+## Local Folder Mode
+
+This chat operates directly on the user's local folders. Writes and edits
+hit disk immediately — there is no end-of-turn staging, no `/documents/`
+namespace, and no `temp_` semantics.
+
+## Filesystem Tools
+
+All file paths must start with `/` and use mount-prefixed absolute paths
+like `//file.ext`. Relative paths resolve against the current working
+directory (`cwd`).
+
+- ls(path, offset=0, limit=200): list files and directories at the given path.
+- read_file(path, offset, limit): read a file (paginated) from disk.
+- write_file(path, content): write a file to disk.
+- edit_file(path, old, new): exact string-replacement edit on disk.
+- glob(pattern, path): find files matching a glob pattern.
+- grep(pattern, path, glob): substring search across files.
+- mkdir(path): create a directory on disk.
+- cd(path): change the current working directory.
+- pwd(): print the current working directory.
+- move_file(source, dest): move/rename a file.
+- rm(path): delete a single file from disk (no `-r`). NOT reversible.
+- rmdir(path): delete an empty directory from disk. NOT reversible.
+- list_tree(path, max_depth, page_size): recursively list files/folders.
+
+## Workflow Tips
+
+- If you are unsure which mounts are available, call `ls('/')` first.
+- For large trees, prefer `list_tree` then `grep` then `read_file` over
+ brute-force directory traversal.
+- Cross-mount moves are not supported.
+- Desktop deletes hit disk immediately and cannot be undone via the
+ agent's revert flow — confirm before calling `rm`/`rmdir`.
+
+## Priority List
+
+You may receive a `` system message listing the top-K
+documents from the user's SurfSense knowledge base — these are cloud-ingested
+via connectors (Notion, Slack, etc.), not local files. Treat it as a hint:
+consult it when the task spans both local and cloud sources (e.g. drafting a
+local note from a Notion summary); skip when the task is purely about local
+files.
+"""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/index.py
new file mode 100644
index 0000000000..9d3cdbae39
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/system_prompt/index.py
@@ -0,0 +1,18 @@
+"""Public assembly of the FS system prompt for a given session."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+from .cloud import BODY as CLOUD_BODY
+from .common import HEADER, SANDBOX_ADDENDUM
+from .desktop import BODY as DESKTOP_BODY
+
+
+def build_system_prompt(mode: FilesystemMode, *, sandbox_available: bool) -> str:
+ """Assemble the FS prompt: common header + mode body + optional sandbox section."""
+ body = CLOUD_BODY if mode == FilesystemMode.CLOUD else DESKTOP_BODY
+ base = HEADER + body
+ if sandbox_available:
+ base += SANDBOX_ADDENDUM
+ return base
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/__init__.py
new file mode 100644
index 0000000000..6e490bb826
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/__init__.py
@@ -0,0 +1,31 @@
+"""Filesystem tool factories — one vertical slice per tool."""
+
+from __future__ import annotations
+
+from .cd import create_cd_tool
+from .edit_file import create_edit_file_tool
+from .execute_code import create_execute_code_tool
+from .list_tree import create_list_tree_tool
+from .ls import create_ls_tool
+from .mkdir import create_mkdir_tool
+from .move_file import create_move_file_tool
+from .pwd import create_pwd_tool
+from .read_file import create_read_file_tool
+from .rm import create_rm_tool
+from .rmdir import create_rmdir_tool
+from .write_file import create_write_file_tool
+
+__all__ = [
+ "create_cd_tool",
+ "create_edit_file_tool",
+ "create_execute_code_tool",
+ "create_list_tree_tool",
+ "create_ls_tool",
+ "create_mkdir_tool",
+ "create_move_file_tool",
+ "create_pwd_tool",
+ "create_read_file_tool",
+ "create_rm_tool",
+ "create_rmdir_tool",
+ "create_write_file_tool",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/__init__.py
new file mode 100644
index 0000000000..4f7d3660a9
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``cd`` — change the current working directory (cwd)."""
+
+from __future__ import annotations
+
+from .index import create_cd_tool
+
+__all__ = ["create_cd_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/description.py
new file mode 100644
index 0000000000..6d7b987c8c
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/description.py
@@ -0,0 +1,19 @@
+"""Description string for ``cd`` (mode-agnostic)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_DESCRIPTION = """Changes the current working directory (cwd).
+
+Args:
+- path: absolute or relative directory path. Relative paths resolve against
+ the current cwd.
+
+The new cwd is used by other filesystem tools whenever a relative path is
+given. Returns the resolved cwd.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ return _DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/index.py
new file mode 100644
index 0000000000..8df6b9edb2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/cd/index.py
@@ -0,0 +1,80 @@
+"""``cd`` factory: resolve target, verify existence (staged + on-disk), update cwd."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.path_resolution import resolve_relative
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_cd_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_cd(
+ path: Annotated[str, "Absolute or relative directory path to switch into."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ target = resolve_relative(mw, path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ backend = mw._get_backend(runtime)
+ try:
+ infos = await backend.als_info(validated)
+ except Exception as exc: # pragma: no cover - defensive
+ return f"Error: {exc}"
+ staged_dirs = list(runtime.state.get("staged_dirs") or [])
+ files = runtime.state.get("files") or {}
+ cwd_exists = (
+ bool(infos)
+ or validated in staged_dirs
+ or any(p == validated for p in files)
+ or any(
+ isinstance(p, str) and p.startswith(validated.rstrip("/") + "/")
+ for p in files
+ )
+ or validated == "/"
+ or validated == DOCUMENTS_ROOT
+ )
+ if not cwd_exists:
+ return f"Error: directory '{validated}' not found."
+ return Command(
+ update={
+ "cwd": validated,
+ "messages": [
+ ToolMessage(
+ content=f"cwd changed to {validated}",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ )
+
+ def sync_cd(
+ path: Annotated[str, "Absolute or relative directory path to switch into."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ return run_async_blocking(async_cd(path, runtime))
+
+ return StructuredTool.from_function(
+ name="cd",
+ description=description,
+ func=sync_cd,
+ coroutine=async_cd,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/__init__.py
new file mode 100644
index 0000000000..7413a69b9c
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``edit_file`` — exact string replacement on a file."""
+
+from __future__ import annotations
+
+from .index import create_edit_file_tool
+
+__all__ = ["create_edit_file_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/description.py
new file mode 100644
index 0000000000..de2a476485
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/description.py
@@ -0,0 +1,28 @@
+"""Mode-specific description strings for ``edit_file``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Performs exact string replacements in files.
+
+IMPORTANT:
+- Read the file before editing.
+- Preserve exact indentation and formatting.
+- Edits to documents under `/documents/` are persisted at end of turn.
+- Edits to `temp_*` files are discarded at end of turn.
+"""
+
+_DESKTOP_DESCRIPTION = """Performs exact string replacements in files on disk.
+
+IMPORTANT:
+- Read the file before editing.
+- Preserve exact indentation and formatting.
+- Edits hit disk immediately.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/index.py
new file mode 100644
index 0000000000..324ef09b02
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/edit_file/index.py
@@ -0,0 +1,132 @@
+"""``edit_file`` factory: lazy-load KB doc, enforce cloud namespace, dispatch to backend."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated, Any
+
+from deepagents.backends.protocol import EditResult
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.mode import is_cloud
+from ...middleware.namespace_policy import check_cloud_write_namespace
+from ...middleware.path_resolution import resolve_relative
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_edit_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_edit_file(
+ file_path: Annotated[
+ str,
+ "Absolute path to the file to edit. Relative paths resolve against the current cwd.",
+ ],
+ old_string: Annotated[
+ str,
+ "Exact text to replace. Must be unique unless replace_all is True.",
+ ],
+ new_string: Annotated[
+ str,
+ "Replacement text. Must differ from old_string.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ *,
+ replace_all: Annotated[
+ bool,
+ "If True, replace all occurrences of old_string. Defaults to False.",
+ ] = False,
+ ) -> Command | str:
+ target = resolve_relative(mw, file_path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ namespace_error = check_cloud_write_namespace(mw, validated, runtime)
+ if namespace_error:
+ return namespace_error
+
+ backend = mw._get_backend(runtime)
+ files_state = runtime.state.get("files") or {}
+ doc_id_to_attach: int | None = None
+
+ if (
+ is_cloud(mw._filesystem_mode)
+ and validated not in files_state
+ and isinstance(backend, KBPostgresBackend)
+ ):
+ loaded = await backend._load_file_data(validated)
+ if loaded is None:
+ return f"Error: File '{validated}' not found"
+ _, doc_id_to_attach = loaded
+
+ res: EditResult = await backend.aedit(
+ validated, old_string, new_string, replace_all=replace_all
+ )
+ if res.error:
+ return res.error
+
+ path = res.path or validated
+ files_update = res.files_update or {}
+ update: dict[str, Any] = {
+ "files": files_update,
+ "messages": [
+ ToolMessage(
+ content=(
+ f"Successfully replaced {res.occurrences} instance(s) "
+ f"of the string in '{path}'"
+ ),
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ if is_cloud(mw._filesystem_mode):
+ update["dirty_paths"] = [path]
+ update["dirty_path_tool_calls"] = {path: runtime.tool_call_id}
+ if doc_id_to_attach is not None:
+ update["doc_id_by_path"] = {path: doc_id_to_attach}
+ return Command(update=update)
+
+ def sync_edit_file(
+ file_path: Annotated[
+ str,
+ "Absolute path to the file to edit. Relative paths resolve against the current cwd.",
+ ],
+ old_string: Annotated[
+ str,
+ "Exact text to replace. Must be unique unless replace_all is True.",
+ ],
+ new_string: Annotated[
+ str,
+ "Replacement text. Must differ from old_string.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ *,
+ replace_all: Annotated[
+ bool,
+ "If True, replace all occurrences of old_string. Defaults to False.",
+ ] = False,
+ ) -> Command | str:
+ return run_async_blocking(
+ async_edit_file(
+ file_path, old_string, new_string, runtime, replace_all=replace_all
+ )
+ )
+
+ return StructuredTool.from_function(
+ name="edit_file",
+ description=description,
+ func=sync_edit_file,
+ coroutine=async_edit_file,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/__init__.py
new file mode 100644
index 0000000000..8ce0af270e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``execute_code`` — run Python code in an isolated sandbox."""
+
+from __future__ import annotations
+
+from .index import create_execute_code_tool
+
+__all__ = ["create_execute_code_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/description.py
new file mode 100644
index 0000000000..89415c2f34
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/description.py
@@ -0,0 +1,21 @@
+"""Description string for ``execute_code`` (mode-agnostic)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_DESCRIPTION = """Executes Python code in an isolated sandbox environment.
+
+Common data-science packages are pre-installed (pandas, numpy, matplotlib,
+scipy, scikit-learn).
+
+Usage notes:
+- No outbound network access.
+- Returns combined stdout/stderr with exit code.
+- Use print() to produce output.
+- Use the optional timeout parameter to override the default timeout.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ return _DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/helpers.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/helpers.py
new file mode 100644
index 0000000000..cda9f535d6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/helpers.py
@@ -0,0 +1,87 @@
+"""Sandbox-execution helpers for ``execute_code``.
+
+Wraps user-supplied code in a heredoc and dispatches it to the Daytona
+sandbox associated with the current chat thread, with a single retry on
+sandbox failure.
+"""
+
+from __future__ import annotations
+
+import logging
+import secrets
+from typing import TYPE_CHECKING
+
+from daytona.common.errors import DaytonaError
+from langchain.tools import ToolRuntime
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.sandbox import (
+ _evict_sandbox_cache,
+ delete_sandbox,
+ get_or_create_sandbox,
+)
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+logger = logging.getLogger(__name__)
+
+MAX_EXECUTE_TIMEOUT = 300
+
+
+def wrap_as_python(code: str) -> str:
+ """Wrap ``code`` in a unique-sentinel heredoc for shell execution."""
+ sentinel = f"_PYEOF_{secrets.token_hex(8)}"
+ return f"python3 << '{sentinel}'\n{code}\n{sentinel}"
+
+
+async def execute_in_sandbox(
+ mw: SurfSenseFilesystemMiddleware,
+ command: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ timeout: int | None,
+) -> str:
+ """Top-level entry: wraps + retries once on sandbox failure."""
+ assert mw._thread_id is not None
+ command = wrap_as_python(command)
+ try:
+ return await _try_sandbox_execute(mw, command, runtime, timeout)
+ except (DaytonaError, Exception) as first_err:
+ logger.warning(
+ "Sandbox execute failed for thread %s, retrying: %s",
+ mw._thread_id,
+ first_err,
+ )
+ try:
+ await delete_sandbox(mw._thread_id)
+ except Exception:
+ _evict_sandbox_cache(mw._thread_id)
+ try:
+ return await _try_sandbox_execute(mw, command, runtime, timeout)
+ except Exception:
+ logger.exception("Sandbox retry also failed for thread %s", mw._thread_id)
+ return "Error: Code execution is temporarily unavailable. Please try again."
+
+
+async def _try_sandbox_execute(
+ mw: SurfSenseFilesystemMiddleware,
+ command: str,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ timeout: int | None,
+) -> str:
+ """One sandbox-execute attempt: get/create sandbox, run, format output."""
+ sandbox, _is_new = await get_or_create_sandbox(mw._thread_id)
+ result = await sandbox.aexecute(command, timeout=timeout)
+ output = (result.output or "").strip()
+ if not output and result.exit_code == 0:
+ return (
+ "[Code executed successfully but produced no output. "
+ "Use print() to display results, then try again.]"
+ )
+ parts = [result.output]
+ if result.exit_code is not None:
+ status = "succeeded" if result.exit_code == 0 else "failed"
+ parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
+ if result.truncated:
+ parts.append("\n[Output was truncated due to size limits]")
+ return "".join(parts)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/index.py
new file mode 100644
index 0000000000..2711636e4e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/execute_code/index.py
@@ -0,0 +1,58 @@
+"""``execute_code`` factory: bounds-check timeout, dispatch to the sandbox."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from langchain.tools import ToolRuntime
+from langchain_core.tools import BaseTool, StructuredTool
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+
+from ...middleware.async_dispatch import run_async_blocking
+from .description import select_description
+from .helpers import MAX_EXECUTE_TIMEOUT, execute_in_sandbox
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_execute_code_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ def sync_execute_code(
+ command: Annotated[str, "Python code to execute. Use print() to see output."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ timeout: Annotated[
+ int | None,
+ "Optional timeout in seconds.",
+ ] = None,
+ ) -> str:
+ if timeout is not None:
+ if timeout < 0:
+ return f"Error: timeout must be non-negative, got {timeout}."
+ if timeout > MAX_EXECUTE_TIMEOUT:
+ return f"Error: timeout {timeout}s exceeds maximum ({MAX_EXECUTE_TIMEOUT}s)."
+ return run_async_blocking(execute_in_sandbox(mw, command, runtime, timeout))
+
+ async def async_execute_code(
+ command: Annotated[str, "Python code to execute. Use print() to see output."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ timeout: Annotated[
+ int | None,
+ "Optional timeout in seconds.",
+ ] = None,
+ ) -> str:
+ if timeout is not None:
+ if timeout < 0:
+ return f"Error: timeout must be non-negative, got {timeout}."
+ if timeout > MAX_EXECUTE_TIMEOUT:
+ return f"Error: timeout {timeout}s exceeds maximum ({MAX_EXECUTE_TIMEOUT}s)."
+ return await execute_in_sandbox(mw, command, runtime, timeout)
+
+ return StructuredTool.from_function(
+ name="execute_code",
+ description=description,
+ func=sync_execute_code,
+ coroutine=async_execute_code,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/glob/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/glob/__init__.py
new file mode 100644
index 0000000000..7c8c387e15
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/glob/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``glob`` — description override (the tool comes from the base middleware)."""
+
+from __future__ import annotations
+
+from .description import select_description
+
+__all__ = ["select_description"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/glob/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/glob/description.py
new file mode 100644
index 0000000000..d022f9a7a7
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/glob/description.py
@@ -0,0 +1,15 @@
+"""Description string for ``glob`` (mode-agnostic)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_DESCRIPTION = """Find files matching a glob pattern.
+
+Supports standard glob patterns: `*`, `**`, `?`.
+Returns absolute file paths.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ return _DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/grep/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/grep/__init__.py
new file mode 100644
index 0000000000..8bdb129fbe
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/grep/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``grep`` — description override (the tool comes from the base middleware)."""
+
+from __future__ import annotations
+
+from .description import select_description
+
+__all__ = ["select_description"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/grep/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/grep/description.py
new file mode 100644
index 0000000000..5d7c393a9e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/grep/description.py
@@ -0,0 +1,24 @@
+"""Mode-specific description strings for ``grep``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Search for a literal text pattern across files.
+
+Searches both your in-memory edits and the indexed chunks in Postgres.
+State-cached file matches include real line numbers; database hits return
+`line=0` because their position depends on per-document XML layout — call
+`read_file(path)` afterwards to find the exact line.
+"""
+
+_DESKTOP_DESCRIPTION = """Search for a literal text pattern across files.
+
+Searches files on disk and any in-memory edits. Returns real line numbers.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/__init__.py
new file mode 100644
index 0000000000..33b2a2b23d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``list_tree`` — recursively list files / folders in one bounded call."""
+
+from __future__ import annotations
+
+from .index import create_list_tree_tool
+
+__all__ = ["create_list_tree_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/description.py
new file mode 100644
index 0000000000..a24230fb01
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/description.py
@@ -0,0 +1,37 @@
+"""Mode-specific description strings for ``list_tree``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Lists files/folders recursively in a single bounded call.
+
+Args:
+- path: absolute path to start from. Defaults to `/documents`.
+- max_depth: recursion depth limit (default 8).
+- page_size: maximum number of entries returned (max 1000).
+- include_files / include_dirs: filter returned entry types.
+
+Returns JSON with:
+- entries: [{path, is_dir, size, modified_at, depth}]
+- truncated: true when additional entries were omitted due to page_size.
+"""
+
+_DESKTOP_DESCRIPTION = """Lists files/folders recursively in a single bounded call.
+
+Args:
+- path: absolute path to start from. Defaults to `/`.
+- max_depth: recursion depth limit (default 8).
+- page_size: maximum number of entries returned (max 1000).
+- include_files / include_dirs: filter returned entry types.
+
+Returns JSON with:
+- entries: [{path, is_dir, size, modified_at, depth}]
+- truncated: true when additional entries were omitted due to page_size.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/index.py
new file mode 100644
index 0000000000..8bad88a748
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/list_tree/index.py
@@ -0,0 +1,101 @@
+"""``list_tree`` factory: bounded recursive listing across cloud / desktop backends."""
+
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING, Annotated
+
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.tools import BaseTool, StructuredTool
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.path_resolution import resolve_list_target_path
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_list_tree_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_list_tree(
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ path: Annotated[
+ str,
+ "Absolute path to start from. Defaults to /documents in cloud mode.",
+ ] = "",
+ max_depth: Annotated[int, "Recursion depth limit. Default 8."] = 8,
+ page_size: Annotated[int, "Maximum entries returned. Max 1000."] = 500,
+ include_files: Annotated[bool, "Include file entries."] = True,
+ include_dirs: Annotated[bool, "Include directory entries."] = True,
+ ) -> str:
+ if max_depth < 0:
+ return "Error: max_depth must be >= 0."
+ if page_size < 1:
+ return "Error: page_size must be >= 1."
+ if not include_files and not include_dirs:
+ return "Error: include_files and include_dirs cannot both be false."
+
+ target = resolve_list_target_path(mw, path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ backend = mw._get_backend(runtime)
+ if isinstance(backend, KBPostgresBackend):
+ result = await backend.alist_tree_listing(
+ validated,
+ max_depth=max_depth,
+ page_size=page_size,
+ include_files=include_files,
+ include_dirs=include_dirs,
+ )
+ elif hasattr(backend, "alist_tree"):
+ result = await backend.alist_tree(
+ validated,
+ max_depth=max_depth,
+ page_size=page_size,
+ include_files=include_files,
+ include_dirs=include_dirs,
+ )
+ else:
+ return "Error: list_tree is not supported by the active backend."
+
+ if isinstance(result, dict) and isinstance(result.get("error"), str):
+ return result["error"]
+ return json.dumps(result, ensure_ascii=True)
+
+ def sync_list_tree(
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ path: Annotated[
+ str,
+ "Absolute path to start from. Defaults to /documents in cloud mode.",
+ ] = "",
+ max_depth: Annotated[int, "Recursion depth limit. Default 8."] = 8,
+ page_size: Annotated[int, "Maximum entries returned. Max 1000."] = 500,
+ include_files: Annotated[bool, "Include file entries."] = True,
+ include_dirs: Annotated[bool, "Include directory entries."] = True,
+ ) -> str:
+ return run_async_blocking(
+ async_list_tree(
+ runtime,
+ path=path,
+ max_depth=max_depth,
+ page_size=page_size,
+ include_files=include_files,
+ include_dirs=include_dirs,
+ )
+ )
+
+ return StructuredTool.from_function(
+ name="list_tree",
+ description=description,
+ func=sync_list_tree,
+ coroutine=async_list_tree,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/__init__.py
new file mode 100644
index 0000000000..b409d34699
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``ls`` — list files and directories at a path."""
+
+from __future__ import annotations
+
+from .index import create_ls_tool
+
+__all__ = ["create_ls_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/description.py
new file mode 100644
index 0000000000..8c7e301dc0
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/description.py
@@ -0,0 +1,29 @@
+"""Mode-specific description strings for ``ls``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Lists files and directories at the given path.
+
+Usage:
+- Provide an absolute path under `/documents` (relative paths resolve under
+ the current cwd, which defaults to `/documents`).
+- For very large folders, use `offset` and `limit` to paginate the listing.
+- Returns one entry per line; directories end with a trailing `/`.
+"""
+
+_DESKTOP_DESCRIPTION = """Lists files and directories at the given path.
+
+Usage:
+- Provide an absolute path using a mount prefix (e.g. `//sub/dir`).
+ Use `ls('/')` to discover available mounts.
+- For very large folders, use `offset` and `limit` to paginate the listing.
+- Returns one entry per line; directories end with a trailing `/`.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/index.py
new file mode 100644
index 0000000000..70f31dd044
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/ls/index.py
@@ -0,0 +1,96 @@
+"""``ls`` factory: resolve target, page through backend listing."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.tools import BaseTool, StructuredTool
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import paginate_listing
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.path_resolution import resolve_list_target_path
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_ls_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_ls(
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ path: Annotated[
+ str,
+ "Absolute path to the directory to list. Relative paths resolve against the current cwd.",
+ ] = "",
+ offset: Annotated[
+ int,
+ "Number of entries to skip. Use for paginating large folders. Defaults to 0.",
+ ] = 0,
+ limit: Annotated[
+ int,
+ "Maximum number of entries to return. Defaults to 200.",
+ ] = 200,
+ ) -> str:
+ target = resolve_list_target_path(mw, path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+ if offset < 0:
+ offset = 0
+ if limit < 1:
+ limit = 1
+ backend = mw._get_backend(runtime)
+ infos = await backend.als_info(validated)
+ page = paginate_listing(infos, offset=offset, limit=limit)
+ paths = [
+ f"{fi.get('path', '')}/" if fi.get("is_dir") else fi.get("path", "")
+ for fi in page
+ ]
+ total = len(infos)
+ shown = len(page)
+ header = (
+ f"{validated} ({shown} of {total} entries"
+ f"{f', offset={offset}' if offset else ''})"
+ )
+ if not paths:
+ return f"{header}\n(empty)"
+ body = "\n".join(paths)
+ if total > offset + shown:
+ body += (
+ f"\n... {total - offset - shown} more — call ls("
+ f"'{validated}', offset={offset + shown}, limit={limit})"
+ )
+ return f"{header}\n{body}"
+
+ def sync_ls(
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ path: Annotated[
+ str,
+ "Absolute path to the directory to list. Relative paths resolve against the current cwd.",
+ ] = "",
+ offset: Annotated[
+ int,
+ "Number of entries to skip. Use for paginating large folders. Defaults to 0.",
+ ] = 0,
+ limit: Annotated[
+ int,
+ "Maximum number of entries to return. Defaults to 200.",
+ ] = 200,
+ ) -> str:
+ return run_async_blocking(
+ async_ls(runtime, path=path, offset=offset, limit=limit)
+ )
+
+ return StructuredTool.from_function(
+ name="ls",
+ description=description,
+ func=sync_ls,
+ coroutine=async_ls,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/__init__.py
new file mode 100644
index 0000000000..42149b7fd3
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``mkdir`` — create a directory."""
+
+from __future__ import annotations
+
+from .index import create_mkdir_tool
+
+__all__ = ["create_mkdir_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/description.py
new file mode 100644
index 0000000000..1c86e72f72
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/description.py
@@ -0,0 +1,33 @@
+"""Mode-specific description strings for ``mkdir``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Creates a directory under `/documents/`.
+
+Stages the folder for end-of-turn commit; the Folder row is inserted only
+after the agent's turn finishes successfully.
+
+Args:
+- path: absolute path of the new directory (must start with
+ `/documents/`).
+
+Notes:
+- Parent folders are created as needed.
+"""
+
+_DESKTOP_DESCRIPTION = """Creates a directory on disk.
+
+Args:
+- path: absolute mount-prefixed path of the new directory.
+
+Notes:
+- Parent folders are created as needed.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/index.py
new file mode 100644
index 0000000000..788381faaf
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/mkdir/index.py
@@ -0,0 +1,94 @@
+"""``mkdir`` factory: cloud stages for end-of-turn; desktop hits disk immediately."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Annotated, Any
+
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.mode import is_cloud
+from ...middleware.path_resolution import resolve_relative
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_mkdir_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_mkdir(
+ path: Annotated[str, "Absolute or relative directory path to create."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ target = resolve_relative(mw, path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ if is_cloud(mw._filesystem_mode):
+ if not (
+ validated.startswith(DOCUMENTS_ROOT + "/")
+ or validated == DOCUMENTS_ROOT
+ ):
+ return (
+ "Error: cloud mkdir must target a path under /documents/ "
+ f"(got '{validated}')."
+ )
+ return Command(
+ update={
+ "staged_dirs": [validated],
+ "staged_dir_tool_calls": {
+ validated: runtime.tool_call_id,
+ },
+ "messages": [
+ ToolMessage(
+ content=(
+ f"Staged directory '{validated}' (will be created "
+ "at end of turn)."
+ ),
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ )
+
+ backend = mw._get_backend(runtime)
+ local_method = getattr(backend, "amkdir", None) or getattr(
+ backend, "mkdir", None
+ )
+ if callable(local_method):
+ try:
+ res: Any = local_method(validated, parents=True, exist_ok=True)
+ if asyncio.iscoroutine(res):
+ await res
+ except TypeError:
+ res = local_method(validated)
+ if asyncio.iscoroutine(res):
+ await res
+ except Exception as exc: # pragma: no cover
+ return f"Error: {exc}"
+ return f"Created directory {validated}"
+
+ def sync_mkdir(
+ path: Annotated[str, "Absolute or relative directory path to create."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ return run_async_blocking(async_mkdir(path, runtime))
+
+ return StructuredTool.from_function(
+ name="mkdir",
+ description=description,
+ func=sync_mkdir,
+ coroutine=async_mkdir,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/__init__.py
new file mode 100644
index 0000000000..307d863439
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``move_file`` — move or rename a file."""
+
+from __future__ import annotations
+
+from .index import create_move_file_tool
+
+__all__ = ["create_move_file_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/description.py
new file mode 100644
index 0000000000..fdba40b297
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/description.py
@@ -0,0 +1,33 @@
+"""Mode-specific description strings for ``move_file``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Moves or renames a file or folder.
+
+Use absolute paths for both source and destination.
+
+Notes:
+- `move_file` is staged this turn and committed at end of turn.
+- The agent cannot overwrite an existing destination — pass a fresh dest
+ path or move the existing destination away first.
+- The anonymous uploaded document is read-only and cannot be moved.
+- Rename is a special case of move (same folder, different filename).
+"""
+
+_DESKTOP_DESCRIPTION = """Moves or renames a file or folder on disk.
+
+Use mount-prefixed absolute paths for both source and destination
+(e.g. `//old.txt` -> `//new.txt`).
+
+Notes:
+- Cross-mount moves are not supported.
+- Rename is a special case of move (same folder, different filename).
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/helpers.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/helpers.py
new file mode 100644
index 0000000000..7613f62f10
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/helpers.py
@@ -0,0 +1,108 @@
+"""Cloud-mode move helper: stages source/dest into pending_moves + files."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+from app.agents.new_chat.state_reducers import _CLEAR
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+async def cloud_move_file(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ source: str,
+ dest: str,
+ *,
+ overwrite: bool,
+) -> Command | str:
+ """Stage a source/dest move in cloud mode (commit at end of turn)."""
+ backend = mw._get_backend(runtime)
+ if not isinstance(backend, KBPostgresBackend):
+ return "Error: cloud move requires KBPostgresBackend."
+
+ if source == dest:
+ return f"Moved '{source}' to '{dest}' (no-op)"
+ if overwrite:
+ return (
+ "Error: overwrite=True is not supported in cloud mode. Move/edit "
+ "the destination doc explicitly first."
+ )
+ if not source.startswith(DOCUMENTS_ROOT + "/"):
+ return (
+ f"Error: cloud move_file source must be under /documents/ (got '{source}')."
+ )
+ if not dest.startswith(DOCUMENTS_ROOT + "/"):
+ return (
+ "Error: cloud move_file destination must be under /documents/ (got "
+ f"'{dest}')."
+ )
+ anon = runtime.state.get("kb_anon_doc") or {}
+ if isinstance(anon, dict):
+ anon_path = str(anon.get("path") or "")
+ if anon_path and (anon_path in (source, dest)):
+ return "Error: the anonymous uploaded document is read-only."
+
+ files = runtime.state.get("files") or {}
+ doc_id_by_path = runtime.state.get("doc_id_by_path") or {}
+ pending_moves = list(runtime.state.get("pending_moves") or [])
+
+ if dest in files:
+ return f"Error: destination '{dest}' already exists."
+ if any(move.get("dest") == dest for move in pending_moves):
+ return f"Error: destination '{dest}' already exists."
+ if dest != source:
+ existing_dest = await backend._load_file_data(dest)
+ if existing_dest is not None:
+ return f"Error: destination '{dest}' already exists."
+
+ source_file_data = files.get(source)
+ source_doc_id = doc_id_by_path.get(source)
+ if source_file_data is None:
+ loaded = await backend._load_file_data(source)
+ if loaded is None:
+ return f"Error: source '{source}' not found."
+ source_file_data, loaded_doc_id = loaded
+ if source_doc_id is None:
+ source_doc_id = loaded_doc_id
+
+ files_update: dict[str, Any] = {source: None, dest: source_file_data}
+ update: dict[str, Any] = {
+ "files": files_update,
+ "pending_moves": [
+ {
+ "source": source,
+ "dest": dest,
+ "overwrite": False,
+ "tool_call_id": runtime.tool_call_id,
+ }
+ ],
+ "messages": [
+ ToolMessage(
+ content=(f"Moved '{source}' to '{dest}' (will commit at end of turn)."),
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+
+ doc_id_update: dict[str, int | None] = {source: None}
+ if source_doc_id is not None:
+ doc_id_update[dest] = source_doc_id
+ update["doc_id_by_path"] = doc_id_update
+
+ dirty_paths = list(runtime.state.get("dirty_paths") or [])
+ if source in dirty_paths:
+ new_dirty: list[Any] = [_CLEAR]
+ for entry in dirty_paths:
+ new_dirty.append(dest if entry == source else entry)
+ update["dirty_paths"] = new_dirty
+ return Command(update=update)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/index.py
new file mode 100644
index 0000000000..d905359901
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/move_file/index.py
@@ -0,0 +1,96 @@
+"""``move_file`` factory: dispatches cloud (staged) vs desktop (direct disk) moves."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated, Any
+
+from deepagents.backends.protocol import WriteResult
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.mode import is_cloud
+from ...middleware.path_resolution import resolve_move_target_path
+from .description import select_description
+from .helpers import cloud_move_file
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_move_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_move_file(
+ source_path: Annotated[str, "Absolute or relative source path."],
+ destination_path: Annotated[str, "Absolute or relative destination path."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ *,
+ overwrite: Annotated[
+ bool,
+ "If True, replace existing destination. Cloud mode rejects True. Defaults to False.",
+ ] = False,
+ ) -> Command | str:
+ if not source_path.strip() or not destination_path.strip():
+ return "Error: source_path and destination_path are required."
+
+ source = resolve_move_target_path(mw, source_path, runtime)
+ dest = resolve_move_target_path(mw, destination_path, runtime)
+ try:
+ validated_source = validate_path(source)
+ validated_dest = validate_path(dest)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ if is_cloud(mw._filesystem_mode):
+ return await cloud_move_file(
+ mw,
+ runtime,
+ validated_source,
+ validated_dest,
+ overwrite=overwrite,
+ )
+
+ backend = mw._get_backend(runtime)
+ res: WriteResult = await backend.amove(
+ validated_source, validated_dest, overwrite=overwrite
+ )
+ if res.error:
+ return res.error
+ update: dict[str, Any] = {
+ "messages": [
+ ToolMessage(
+ content=f"Moved '{validated_source}' to '{res.path or validated_dest}'",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ if res.files_update is not None:
+ update["files"] = res.files_update
+ return Command(update=update)
+
+ def sync_move_file(
+ source_path: Annotated[str, "Absolute or relative source path."],
+ destination_path: Annotated[str, "Absolute or relative destination path."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ *,
+ overwrite: Annotated[
+ bool,
+ "If True, replace existing destination. Cloud mode rejects True. Defaults to False.",
+ ] = False,
+ ) -> Command | str:
+ return run_async_blocking(
+ async_move_file(source_path, destination_path, runtime, overwrite=overwrite)
+ )
+
+ return StructuredTool.from_function(
+ name="move_file",
+ description=description,
+ func=sync_move_file,
+ coroutine=async_move_file,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/__init__.py
new file mode 100644
index 0000000000..8be37c6683
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``pwd`` — print the current working directory."""
+
+from __future__ import annotations
+
+from .index import create_pwd_tool
+
+__all__ = ["create_pwd_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/description.py
new file mode 100644
index 0000000000..594a38843e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/description.py
@@ -0,0 +1,11 @@
+"""Description string for ``pwd`` (mode-agnostic)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_DESCRIPTION = """Prints the current working directory."""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ return _DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/index.py
new file mode 100644
index 0000000000..c15b67114b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/pwd/index.py
@@ -0,0 +1,37 @@
+"""``pwd`` factory: read the cwd from state."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from langchain.tools import ToolRuntime
+from langchain_core.tools import BaseTool, StructuredTool
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+
+from ...middleware.path_resolution import current_cwd
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_pwd_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ def sync_pwd(
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> str:
+ return current_cwd(mw, runtime)
+
+ async def async_pwd(
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> str:
+ return current_cwd(mw, runtime)
+
+ return StructuredTool.from_function(
+ name="pwd",
+ description=description,
+ func=sync_pwd,
+ coroutine=async_pwd,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/__init__.py
new file mode 100644
index 0000000000..681ac6f161
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``read_file`` — read a file (paginated) from the filesystem."""
+
+from __future__ import annotations
+
+from .index import create_read_file_tool
+
+__all__ = ["create_read_file_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/description.py
new file mode 100644
index 0000000000..9b5d7623f8
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/description.py
@@ -0,0 +1,22 @@
+"""Description string for ``read_file`` (mode-agnostic)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_DESCRIPTION = """Reads a file from the filesystem.
+
+Usage:
+- By default, reads up to 100 lines from the beginning.
+- Use `offset` and `limit` for pagination when files are large.
+- Results include line numbers.
+- Documents contain a `` near the top listing every chunk with
+ its line range and a `matched="true"` flag for search-relevant chunks.
+ Read the index first, then jump to matched chunks with
+ `read_file(path, offset=, limit=)`.
+- Use chunk IDs (``) as citations in answers.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ return _DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/index.py
new file mode 100644
index 0000000000..8b0a1a1c8d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/read_file/index.py
@@ -0,0 +1,100 @@
+"""``read_file`` factory: state-cache lookup, then lazy KB load, then disk read."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated, Any
+
+from deepagents.backends.utils import format_read_response, validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.path_resolution import resolve_relative
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_read_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_read_file(
+ file_path: Annotated[
+ str,
+ "Absolute path to the file to read. Relative paths resolve against the current cwd.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ offset: Annotated[
+ int,
+ "Line number to start reading from (0-indexed).",
+ ] = 0,
+ limit: Annotated[
+ int,
+ "Maximum number of lines to read.",
+ ] = 100,
+ ) -> Command | str:
+ target = resolve_relative(mw, file_path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ files = runtime.state.get("files") or {}
+ if validated in files:
+ return format_read_response(files[validated], offset, limit)
+
+ backend = mw._get_backend(runtime)
+ if isinstance(backend, KBPostgresBackend):
+ loaded = await backend._load_file_data(validated)
+ if loaded is None:
+ return f"Error: File '{validated}' not found"
+ file_data, doc_id = loaded
+ rendered = format_read_response(file_data, offset, limit)
+ update: dict[str, Any] = {
+ "files": {validated: file_data},
+ "messages": [
+ ToolMessage(
+ content=rendered,
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ if doc_id is not None:
+ update["doc_id_by_path"] = {validated: doc_id}
+ return Command(update=update)
+
+ try:
+ rendered = await backend.aread(validated, offset=offset, limit=limit)
+ except Exception as exc: # pragma: no cover - defensive
+ return f"Error: {exc}"
+ return rendered
+
+ def sync_read_file(
+ file_path: Annotated[
+ str,
+ "Absolute path to the file to read. Relative paths resolve against the current cwd.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ offset: Annotated[
+ int,
+ "Line number to start reading from (0-indexed).",
+ ] = 0,
+ limit: Annotated[
+ int,
+ "Maximum number of lines to read.",
+ ] = 100,
+ ) -> Command | str:
+ return run_async_blocking(async_read_file(file_path, runtime, offset, limit))
+
+ return StructuredTool.from_function(
+ name="read_file",
+ description=description,
+ func=sync_read_file,
+ coroutine=async_read_file,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/__init__.py
new file mode 100644
index 0000000000..866a74ad8f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``rm`` — delete a single file."""
+
+from __future__ import annotations
+
+from .index import create_rm_tool
+
+__all__ = ["create_rm_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/description.py
new file mode 100644
index 0000000000..a9e120e7cf
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/description.py
@@ -0,0 +1,38 @@
+"""Mode-specific description strings for ``rm``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Deletes a single file under `/documents/`.
+
+Mirrors POSIX `rm path` (no `-r`, no glob expansion). Stages the deletion
+for end-of-turn commit; the row is removed only after the agent's turn
+finishes successfully.
+
+Args:
+- path: absolute or relative file path. Cannot point at a directory — use
+ `rmdir` for empty folders. Cannot target the root or `/documents`.
+
+Notes:
+- The action is reversible via the per-action revert flow when action
+ logging is enabled.
+- The anonymous uploaded document is read-only and cannot be deleted.
+"""
+
+_DESKTOP_DESCRIPTION = """Deletes a single file from disk.
+
+Mirrors POSIX `rm path` (no `-r`, no glob expansion). The deletion hits
+disk immediately. Desktop deletes are NOT reversible via the agent's
+revert flow.
+
+Args:
+- path: absolute mount-prefixed file path. Cannot point at a directory —
+ use `rmdir` for empty folders.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/helpers.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/helpers.py
new file mode 100644
index 0000000000..8a02544d83
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/helpers.py
@@ -0,0 +1,130 @@
+"""Cloud and desktop ``rm`` branches.
+
+Both branches receive an already-resolved + validated absolute path.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from deepagents.backends.protocol import WriteResult
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+from app.agents.new_chat.state_reducers import _CLEAR
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+async def cloud_rm(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ validated: str,
+) -> Command | str:
+ """Stage a deletion in cloud mode (commit at end of turn)."""
+ if validated in ("/", DOCUMENTS_ROOT):
+ return f"Error: refusing to rm '{validated}'."
+ if not validated.startswith(DOCUMENTS_ROOT + "/"):
+ return (
+ f"Error: cloud rm must target a path under /documents/ (got '{validated}')."
+ )
+
+ anon = runtime.state.get("kb_anon_doc") or {}
+ if isinstance(anon, dict) and str(anon.get("path") or "") == validated:
+ return "Error: the anonymous uploaded document is read-only."
+
+ staged_dirs = list(runtime.state.get("staged_dirs") or [])
+ if validated in staged_dirs:
+ return f"Error: '{validated}' is a directory. Use rmdir for empty directories."
+ pending_dir_deletes = list(runtime.state.get("pending_dir_deletes") or [])
+ if any(
+ isinstance(d, dict) and d.get("path") == validated for d in pending_dir_deletes
+ ):
+ return f"Error: '{validated}' is already queued for rmdir."
+
+ backend = mw._get_backend(runtime)
+ if isinstance(backend, KBPostgresBackend):
+ children = await backend.als_info(validated)
+ if children:
+ return (
+ f"Error: '{validated}' is a directory. Use rmdir for empty directories."
+ )
+
+ pending_deletes = list(runtime.state.get("pending_deletes") or [])
+ if any(isinstance(d, dict) and d.get("path") == validated for d in pending_deletes):
+ return f"'{validated}' is already queued for deletion."
+
+ files_state = runtime.state.get("files") or {}
+ doc_id_by_path = runtime.state.get("doc_id_by_path") or {}
+ resolved_doc_id: int | None = doc_id_by_path.get(validated)
+ if (
+ validated not in files_state
+ and resolved_doc_id is None
+ and isinstance(backend, KBPostgresBackend)
+ ):
+ loaded = await backend._load_file_data(validated)
+ if loaded is None:
+ return f"Error: file '{validated}' not found."
+ _, resolved_doc_id = loaded
+
+ files_update: dict[str, Any] = {validated: None}
+ update: dict[str, Any] = {
+ "pending_deletes": [
+ {
+ "path": validated,
+ "tool_call_id": runtime.tool_call_id,
+ }
+ ],
+ "files": files_update,
+ "doc_id_by_path": {validated: None},
+ "messages": [
+ ToolMessage(
+ content=(
+ f"Staged delete of '{validated}' (will commit at end of turn)."
+ ),
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+
+ dirty_paths = list(runtime.state.get("dirty_paths") or [])
+ if validated in dirty_paths:
+ new_dirty: list[Any] = [_CLEAR]
+ for entry in dirty_paths:
+ if entry != validated:
+ new_dirty.append(entry)
+ update["dirty_paths"] = new_dirty
+ update["dirty_path_tool_calls"] = {validated: None}
+
+ return Command(update=update)
+
+
+async def desktop_rm(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ validated: str,
+) -> Command | str:
+ """Hit disk immediately in desktop mode."""
+ backend = mw._get_backend(runtime)
+ adelete = getattr(backend, "adelete_file", None)
+ if not callable(adelete):
+ return "Error: rm is not supported by the active backend."
+ res: WriteResult = await adelete(validated)
+ if res.error:
+ return res.error
+ return Command(
+ update={
+ "files": {validated: None},
+ "messages": [
+ ToolMessage(
+ content=f"Deleted file '{res.path or validated}'",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/index.py
new file mode 100644
index 0000000000..0c4e2fc719
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rm/index.py
@@ -0,0 +1,61 @@
+"""``rm`` factory: resolve + validate the path, then dispatch to cloud / desktop."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.mode import is_cloud
+from ...middleware.path_resolution import resolve_relative
+from .description import select_description
+from .helpers import cloud_rm, desktop_rm
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_rm_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_rm(
+ path: Annotated[
+ str,
+ "Absolute or relative path to the file to delete.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ if not path or not path.strip():
+ return "Error: path is required."
+
+ target = resolve_relative(mw, path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ if is_cloud(mw._filesystem_mode):
+ return await cloud_rm(mw, runtime, validated)
+ return await desktop_rm(mw, runtime, validated)
+
+ def sync_rm(
+ path: Annotated[
+ str,
+ "Absolute or relative path to the file to delete.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ return run_async_blocking(async_rm(path, runtime))
+
+ return StructuredTool.from_function(
+ name="rm",
+ description=description,
+ func=sync_rm,
+ coroutine=async_rm,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/__init__.py
new file mode 100644
index 0000000000..24e5e1b21b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``rmdir`` — delete an empty directory."""
+
+from __future__ import annotations
+
+from .index import create_rmdir_tool
+
+__all__ = ["create_rmdir_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/description.py
new file mode 100644
index 0000000000..2b72f815b3
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/description.py
@@ -0,0 +1,42 @@
+"""Mode-specific description strings for ``rmdir``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Deletes an empty directory under `/documents/`.
+
+Mirrors POSIX `rmdir path`: refuses non-empty directories. Recursive
+deletion (`rm -r`) is intentionally NOT supported — clear contents with
+`rm` first.
+
+Args:
+- path: absolute or relative directory path. Cannot target the root,
+ `/documents`, the current cwd, or any ancestor of cwd (use `cd` to
+ move out first).
+
+Notes:
+- Emptiness is evaluated against the post-staged view, so a same-turn
+ `rm /a/x.md` followed by `rmdir /a` is fine.
+- If the directory was added in this same turn via `mkdir` and never
+ committed, the staged mkdir is dropped instead of issuing a delete.
+- The action is reversible via the per-action revert flow when action
+ logging is enabled.
+"""
+
+_DESKTOP_DESCRIPTION = """Deletes an empty directory from disk.
+
+Mirrors POSIX `rmdir path`: refuses non-empty directories. Recursive
+deletion is NOT supported. The deletion hits disk immediately and is
+NOT reversible via the agent's revert flow.
+
+Args:
+- path: absolute mount-prefixed directory path. Cannot target the mount
+ root or any directory containing files/subfolders.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/helpers.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/helpers.py
new file mode 100644
index 0000000000..de5afe722b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/helpers.py
@@ -0,0 +1,136 @@
+"""Cloud and desktop ``rmdir`` branches.
+
+Both branches receive an already-resolved + validated absolute path.
+"""
+
+from __future__ import annotations
+
+import posixpath
+from typing import TYPE_CHECKING
+
+from deepagents.backends.protocol import WriteResult
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
+from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
+from app.agents.new_chat.state_reducers import _CLEAR
+
+from ...middleware.path_resolution import current_cwd
+from ...shared.paths import is_ancestor_of
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+async def cloud_rmdir(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ validated: str,
+) -> Command | str:
+ """Stage an empty-folder delete in cloud mode (commit at end of turn)."""
+ if validated in ("/", DOCUMENTS_ROOT):
+ return f"Error: refusing to rmdir '{validated}'."
+ if not validated.startswith(DOCUMENTS_ROOT + "/"):
+ return (
+ "Error: cloud rmdir must target a path under /documents/ "
+ f"(got '{validated}')."
+ )
+
+ cwd = current_cwd(mw, runtime)
+ if validated == cwd or is_ancestor_of(validated, cwd):
+ return (
+ f"Error: cannot rmdir '{validated}' because the current "
+ "cwd is at or under it. cd out first."
+ )
+
+ staged_dirs = list(runtime.state.get("staged_dirs") or [])
+ pending_dir_deletes = list(runtime.state.get("pending_dir_deletes") or [])
+ if any(
+ isinstance(d, dict) and d.get("path") == validated for d in pending_dir_deletes
+ ):
+ return f"'{validated}' is already queued for deletion."
+
+ backend = mw._get_backend(runtime)
+
+ exists_in_staged = validated in staged_dirs
+ children: list = []
+ if isinstance(backend, KBPostgresBackend):
+ children = list(await backend.als_info(validated))
+
+ if isinstance(backend, KBPostgresBackend) and not children and not exists_in_staged:
+ loaded = await backend._load_file_data(validated)
+ if loaded is not None:
+ return f"Error: '{validated}' is a file. Use rm to delete files."
+ parent = posixpath.dirname(validated) or "/"
+ parent_listing = await backend.als_info(parent)
+ parent_has_dir = any(
+ info.get("path") == validated and info.get("is_dir")
+ for info in parent_listing
+ )
+ if not parent_has_dir:
+ return f"Error: directory '{validated}' not found."
+
+ if children:
+ return f"Error: directory '{validated}' is not empty. Remove contents first."
+
+ if exists_in_staged:
+ rest = [d for d in staged_dirs if d != validated]
+ return Command(
+ update={
+ "staged_dirs": [_CLEAR, *rest],
+ "staged_dir_tool_calls": {validated: None},
+ "messages": [
+ ToolMessage(
+ content=(f"Un-staged directory '{validated}'."),
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ )
+
+ return Command(
+ update={
+ "pending_dir_deletes": [
+ {
+ "path": validated,
+ "tool_call_id": runtime.tool_call_id,
+ }
+ ],
+ "messages": [
+ ToolMessage(
+ content=(
+ f"Staged rmdir of '{validated}' (will commit at end of turn)."
+ ),
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ )
+
+
+async def desktop_rmdir(
+ mw: SurfSenseFilesystemMiddleware,
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ validated: str,
+) -> Command | str:
+ """Hit disk immediately in desktop mode."""
+ backend = mw._get_backend(runtime)
+ armdir = getattr(backend, "armdir", None)
+ if not callable(armdir):
+ return "Error: rmdir is not supported by the active backend."
+ res: WriteResult = await armdir(validated)
+ if res.error:
+ return res.error
+ return Command(
+ update={
+ "messages": [
+ ToolMessage(
+ content=f"Deleted directory '{res.path or validated}'",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/index.py
new file mode 100644
index 0000000000..cdf0573533
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/rmdir/index.py
@@ -0,0 +1,61 @@
+"""``rmdir`` factory: resolve + validate the path, then dispatch to cloud / desktop."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from deepagents.backends.utils import validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.mode import is_cloud
+from ...middleware.path_resolution import resolve_relative
+from .description import select_description
+from .helpers import cloud_rmdir, desktop_rmdir
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_rmdir_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_rmdir(
+ path: Annotated[
+ str,
+ "Absolute or relative path of the empty directory to delete.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ if not path or not path.strip():
+ return "Error: path is required."
+
+ target = resolve_relative(mw, path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ if is_cloud(mw._filesystem_mode):
+ return await cloud_rmdir(mw, runtime, validated)
+ return await desktop_rmdir(mw, runtime, validated)
+
+ def sync_rmdir(
+ path: Annotated[
+ str,
+ "Absolute or relative path of the empty directory to delete.",
+ ],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ return run_async_blocking(async_rmdir(path, runtime))
+
+ return StructuredTool.from_function(
+ name="rmdir",
+ description=description,
+ func=sync_rmdir,
+ coroutine=async_rmdir,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/__init__.py
new file mode 100644
index 0000000000..cf54fffe4c
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/__init__.py
@@ -0,0 +1,7 @@
+"""Tool: ``write_file`` — create or overwrite a text file."""
+
+from __future__ import annotations
+
+from .index import create_write_file_tool
+
+__all__ = ["create_write_file_tool"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/description.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/description.py
new file mode 100644
index 0000000000..223cc3f265
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/description.py
@@ -0,0 +1,35 @@
+"""Mode-specific description strings for ``write_file``."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+_CLOUD_DESCRIPTION = """Writes a new text file to the workspace.
+
+Usage:
+- Files written under `/documents/<...>` are persisted as Documents at end
+ of turn.
+- Use a `temp_` filename prefix (e.g. `temp_plan.md` or `/documents/temp_x.md`)
+ for scratch/working files; they are automatically discarded at end of turn.
+- Writes outside `/documents/` are rejected unless the basename starts with
+ `temp_`.
+- Supported outputs include common LLM-friendly text formats like markdown,
+ json, yaml, csv, xml, html, css, sql, and code files.
+- Avoid placeholders; produce concrete and useful text.
+"""
+
+_DESKTOP_DESCRIPTION = """Writes a text file to disk.
+
+Usage:
+- Use mount-prefixed absolute paths like `//sub/file.ext`.
+- Writes hit disk immediately. There is no end-of-turn staging.
+- Supported outputs include common LLM-friendly text formats like markdown,
+ json, yaml, csv, xml, html, css, sql, and code files.
+- Avoid placeholders; produce concrete and useful text.
+"""
+
+
+def select_description(mode: FilesystemMode) -> str:
+ if mode == FilesystemMode.CLOUD:
+ return _CLOUD_DESCRIPTION
+ return _DESKTOP_DESCRIPTION
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/index.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/index.py
new file mode 100644
index 0000000000..a42f7ed62f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem/tools/write_file/index.py
@@ -0,0 +1,83 @@
+"""``write_file`` factory: resolve target, enforce cloud namespace, dispatch to backend."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated, Any
+
+from deepagents.backends.protocol import WriteResult
+from deepagents.backends.utils import create_file_data, validate_path
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+
+from ...middleware.async_dispatch import run_async_blocking
+from ...middleware.mode import is_cloud
+from ...middleware.namespace_policy import check_cloud_write_namespace
+from ...middleware.path_resolution import resolve_write_target_path
+from .description import select_description
+
+if TYPE_CHECKING:
+ from ...middleware import SurfSenseFilesystemMiddleware
+
+
+def create_write_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
+ description = select_description(mw._filesystem_mode)
+
+ async def async_write_file(
+ file_path: Annotated[
+ str,
+ "Absolute path where the file should be created. Relative paths resolve against the current cwd.",
+ ],
+ content: Annotated[str, "Text content to write to the file."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ target = resolve_write_target_path(mw, file_path, runtime)
+ try:
+ validated = validate_path(target)
+ except ValueError as exc:
+ return f"Error: {exc}"
+
+ namespace_error = check_cloud_write_namespace(mw, validated, runtime)
+ if namespace_error:
+ return namespace_error
+
+ backend = mw._get_backend(runtime)
+ res: WriteResult = await backend.awrite(validated, content)
+ if res.error:
+ return res.error
+
+ path = res.path or validated
+ files_update = res.files_update or {path: create_file_data(content)}
+ update: dict[str, Any] = {
+ "files": files_update,
+ "messages": [
+ ToolMessage(
+ content=f"Updated file {path}",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ }
+ if is_cloud(mw._filesystem_mode):
+ update["dirty_paths"] = [path]
+ update["dirty_path_tool_calls"] = {path: runtime.tool_call_id}
+ return Command(update=update)
+
+ def sync_write_file(
+ file_path: Annotated[
+ str,
+ "Absolute path where the file should be created. Relative paths resolve against the current cwd.",
+ ],
+ content: Annotated[str, "Text content to write to the file."],
+ runtime: ToolRuntime[None, SurfSenseFilesystemState],
+ ) -> Command | str:
+ return run_async_blocking(async_write_file(file_path, content, runtime))
+
+ return StructuredTool.from_function(
+ name="write_file",
+ description=description,
+ func=sync_write_file,
+ coroutine=async_write_file,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py
new file mode 100644
index 0000000000..69994ae008
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py
@@ -0,0 +1,10 @@
+"""Single source of truth for the feature-flag predicate."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+
+
+def enabled(flags: AgentFeatureFlags, attr: str) -> bool:
+ """``flags.`` is on AND the new-agent-stack kill switch is off."""
+ return getattr(flags, attr) and not flags.disable_new_agent_stack
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/kb_context_projection.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/kb_context_projection.py
new file mode 100644
index 0000000000..01c77bb844
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/kb_context_projection.py
@@ -0,0 +1,54 @@
+"""Project ``workspace_tree_text`` + ``kb_priority`` from state into SystemMessages."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain.agents.middleware import AgentMiddleware, AgentState
+from langchain_core.messages import SystemMessage
+from langgraph.runtime import Runtime
+
+from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
+from app.agents.new_chat.middleware.knowledge_search import _render_priority_message
+
+
+class KbContextProjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
+ """Emit ```` + ```` from shared state.
+
+ Read-only consumer: no DB, no LLM, no state writes. The orchestrator's
+ renderer middlewares populate the source fields; this projection lets any
+ agent (orchestrator or subagent) put the same content in front of its
+ own LLM call.
+ """
+
+ tools = ()
+ state_schema = SurfSenseFilesystemState
+
+ def before_agent( # type: ignore[override]
+ self,
+ state: AgentState,
+ runtime: Runtime[Any],
+ ) -> dict[str, Any] | None:
+ del runtime
+ tree_text = state.get("workspace_tree_text")
+ priority = state.get("kb_priority")
+ if not tree_text and not priority:
+ return None
+
+ messages = list(state.get("messages") or [])
+ insert_at = max(len(messages) - 1, 0)
+ if tree_text:
+ messages.insert(insert_at, SystemMessage(content=tree_text))
+ if priority:
+ messages.insert(insert_at, _render_priority_message(priority))
+ return {"messages": messages}
+
+
+def build_kb_context_projection_mw() -> KbContextProjectionMiddleware:
+ return KbContextProjectionMiddleware()
+
+
+__all__ = [
+ "KbContextProjectionMiddleware",
+ "build_kb_context_projection_mw",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py
new file mode 100644
index 0000000000..9316b3e21c
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py
@@ -0,0 +1,19 @@
+"""User/team memory injection prepended to the conversation."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.middleware import MemoryInjectionMiddleware
+from app.db import ChatVisibility
+
+
+def build_memory_mw(
+ *,
+ user_id: str | None,
+ search_space_id: int,
+ visibility: ChatVisibility,
+) -> MemoryInjectionMiddleware:
+ return MemoryInjectionMiddleware(
+ user_id=user_id,
+ search_space_id=search_space_id,
+ thread_visibility=visibility,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py
new file mode 100644
index 0000000000..50036dbbed
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py
@@ -0,0 +1,9 @@
+"""Repair dangling tool-call sequences before each agent turn."""
+
+from __future__ import annotations
+
+from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
+
+
+def build_patch_tool_calls_mw() -> PatchToolCallsMiddleware:
+ return PatchToolCallsMiddleware()
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py
new file mode 100644
index 0000000000..c25c2b2816
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py
@@ -0,0 +1,11 @@
+"""Pattern-based allow/deny/ask middleware with HITL fallback (vertical slice).
+
+Public surface (one entry point only — every other symbol is an internal of
+the rule engine and stays inside ``middleware/``, ``ask/``, or ``deny.py``):
+
+- :func:`build_permission_mw` — construction recipe shared by every stack.
+"""
+
+from .middleware.factory import build_permission_mw
+
+__all__ = ["build_permission_mw"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py
new file mode 100644
index 0000000000..f507e85ff5
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py
@@ -0,0 +1,74 @@
+"""Translate the unified langchain HITL envelope into permission-domain semantics.
+
+``PermissionMiddleware`` works with the canonical shape
+``{decision_type: "once" | "approve_always" | "reject", feedback?: str, edited_args?: dict}``.
+The wire envelope arriving from langgraph already lives in the LC HITL shape
+(parsed once in :mod:`hitl_wire.decision`); this module performs the small
+domain mapping (``approve|edit`` → ``once``, ``approve_always`` →
+``approve_always``, anything else → ``reject``) without re-implementing the
+envelope walk.
+
+Failing closed: any unrecognised decision becomes ``reject`` (with a warning)
+so the middleware never proceeds on ambiguous input.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
+ LC_DECISION_APPROVE,
+ LC_DECISION_EDIT,
+ LC_DECISION_REJECT,
+ SURFSENSE_DECISION_APPROVE_ALWAYS,
+ parse_lc_envelope,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ``approve`` and ``edit`` both mean "let this call go through this once". The
+# legacy SurfSense bare-scalar values (``once`` / ``approve_always`` / ``reject``)
+# pass through unchanged so historical resume payloads still work.
+_LC_TO_PERMISSION: dict[str, str] = {
+ LC_DECISION_APPROVE: "once",
+ LC_DECISION_EDIT: "once",
+ SURFSENSE_DECISION_APPROVE_ALWAYS: "approve_always",
+ LC_DECISION_REJECT: "reject",
+ "once": "once",
+ "approve_always": "approve_always",
+ "reject": "reject",
+}
+
+
+def normalize_permission_decision(envelope: Any) -> dict[str, Any]:
+ """Project the user's reply into the canonical permission decision shape.
+
+ Args:
+ envelope: The raw resume value from langgraph (LC HITL envelope, a
+ bare scalar string, or a pre-canonical dict).
+
+ Returns:
+ ``{"decision_type": "once"|"approve_always"|"reject"}`` plus optional
+ ``feedback`` (``reject`` with a user message) and ``edited_args``
+ (``edit`` reply with non-empty arg overrides).
+ """
+ parsed = parse_lc_envelope(envelope)
+ mapped = _LC_TO_PERMISSION.get(parsed.decision_type)
+ if mapped is None:
+ logger.warning(
+ "Unknown permission decision %r; treating as reject",
+ parsed.decision_type,
+ )
+ mapped = "reject"
+
+ out: dict[str, Any] = {"decision_type": mapped}
+ if parsed.message:
+ out["feedback"] = parsed.message
+ if parsed.edited_args:
+ out["edited_args"] = parsed.edited_args
+ return out
+
+
+__all__ = ["normalize_permission_decision"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py
new file mode 100644
index 0000000000..2921cbe700
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py
@@ -0,0 +1,10 @@
+"""Apply ``edit`` permission decisions to tool calls.
+
+Edited-arg extraction now lives in :mod:`hitl_wire.decision` (single parser
+for all approval paths); this module owns the merge step that produces a
+fresh tool-call dict for the orchestrator.
+"""
+
+from .merge import merge_edited_args
+
+__all__ = ["merge_edited_args"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py
new file mode 100644
index 0000000000..21474ad529
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py
@@ -0,0 +1,22 @@
+"""Apply edited args to a tool call (shallow merge, no mutation).
+
+Edited values override originals; keys absent from ``edited_args`` keep
+their original values, so partial edits are safe. Returns a NEW tool-call
+dict so the caller can swap it into ``AIMessage.tool_calls`` without
+aliasing the live message object.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def merge_edited_args(
+ tool_call: dict[str, Any], edited_args: dict[str, Any]
+) -> dict[str, Any]:
+ original_args = tool_call.get("args") or {}
+ merged_args = {**original_args, **edited_args}
+ return {**tool_call, "args": merged_args}
+
+
+__all__ = ["merge_edited_args"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py
new file mode 100644
index 0000000000..6c5d011dfe
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py
@@ -0,0 +1,89 @@
+"""Build the permission-ask interrupt payload (LC HITL wire + SurfSense context)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
+ LC_DECISION_APPROVE,
+ LC_DECISION_EDIT,
+ LC_DECISION_REJECT,
+ SURFSENSE_DECISION_APPROVE_ALWAYS,
+ build_lc_hitl_payload,
+)
+from app.agents.new_chat.permissions import Rule
+
+PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask"
+
+_BASE_PERMISSION_ASK_DECISIONS: list[str] = [
+ LC_DECISION_APPROVE,
+ LC_DECISION_REJECT,
+ LC_DECISION_EDIT,
+]
+
+
+def _is_mcp_tool(tool: BaseTool | None) -> bool:
+ """An MCP tool advertises a connector id in its langchain metadata."""
+ if tool is None:
+ return False
+ metadata = getattr(tool, "metadata", None) or {}
+ return metadata.get("mcp_connector_id") is not None
+
+
+def _card_fields_from_tool(tool: BaseTool | None) -> dict[str, Any]:
+ """Project the FE card's tool-scoped fields out of a BaseTool."""
+ if tool is None:
+ return {}
+ metadata = getattr(tool, "metadata", None) or {}
+ fields: dict[str, Any] = {}
+ connector_id = metadata.get("mcp_connector_id")
+ if connector_id is not None:
+ fields["mcp_connector_id"] = connector_id
+ connector_name = metadata.get("mcp_connector_name")
+ if connector_name:
+ fields["mcp_server"] = connector_name
+ if tool.description:
+ fields["tool_description"] = tool.description
+ return fields
+
+
+def build_permission_ask_payload(
+ *,
+ tool_name: str,
+ args: dict[str, Any],
+ patterns: list[str],
+ rules: list[Rule],
+ tool: BaseTool | None = None,
+) -> dict[str, Any]:
+ """Build the permission-ask interrupt payload.
+
+ ``approve_always`` is added to the palette only for MCP tools, since that
+ is the only case where the user's choice can persist beyond the current
+ agent instance (saved to the connector's trusted-tools list). Native
+ tools fall back to the once/reject/edit triad.
+ """
+ allowed_decisions = list(_BASE_PERMISSION_ASK_DECISIONS)
+ if _is_mcp_tool(tool):
+ allowed_decisions.append(SURFSENSE_DECISION_APPROVE_ALWAYS)
+
+ context: dict[str, Any] = {
+ "patterns": patterns,
+ "rules": [
+ {"permission": r.permission, "pattern": r.pattern, "action": r.action}
+ for r in rules
+ ],
+ "always": patterns,
+ **_card_fields_from_tool(tool),
+ }
+ return build_lc_hitl_payload(
+ tool_name=tool_name,
+ args=args,
+ allowed_decisions=allowed_decisions,
+ interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE,
+ context=context,
+ )
+
+
+__all__ = ["PERMISSION_ASK_INTERRUPT_TYPE", "build_permission_ask_payload"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py
new file mode 100644
index 0000000000..d61d38f345
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py
@@ -0,0 +1,59 @@
+"""Side-effectful entry point: pause the graph and return the permission decision.
+
+Wraps :func:`langgraph.types.interrupt` with the OTel spans the SurfSense
+dashboard expects, then projects the resume value through
+:func:`normalize_permission_decision` so the middleware downstream only
+sees the canonical permission-domain shape.
+
+When ``emit_interrupt`` is ``False`` the call short-circuits to ``reject``;
+this is used by non-interactive deployments where ``ask`` must not block.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+from langgraph.types import interrupt
+
+from app.agents.new_chat.permissions import Rule
+from app.observability import otel as ot
+
+from .decision import normalize_permission_decision
+from .payload import PERMISSION_ASK_INTERRUPT_TYPE, build_permission_ask_payload
+
+
+def request_permission_decision(
+ *,
+ tool_name: str,
+ args: dict[str, Any],
+ patterns: list[str],
+ rules: list[Rule],
+ emit_interrupt: bool,
+ tool: BaseTool | None = None,
+) -> dict[str, Any]:
+ """Pause for an ``ask`` decision; return the canonical permission decision dict."""
+ if not emit_interrupt:
+ return {"decision_type": "reject"}
+
+ payload = build_permission_ask_payload(
+ tool_name=tool_name,
+ args=args,
+ patterns=patterns,
+ rules=rules,
+ tool=tool,
+ )
+
+ with (
+ ot.permission_asked_span(
+ permission=tool_name,
+ pattern=patterns[0] if patterns else None,
+ extra={"permission.patterns": list(patterns)},
+ ),
+ ot.interrupt_span(interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE),
+ ):
+ decision = interrupt(payload)
+ return normalize_permission_decision(decision)
+
+
+__all__ = ["request_permission_decision"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py
new file mode 100644
index 0000000000..196c4040e2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py
@@ -0,0 +1,39 @@
+"""Synthesise a ``ToolMessage`` for a denied tool call.
+
+The denied call is replaced with this message so the model sees a typed
+``permission_denied`` error in ``ToolMessage.additional_kwargs["error"]``
+and can adjust its plan without retrying the same forbidden call.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.messages import ToolMessage
+
+from app.agents.new_chat.errors import StreamingError
+from app.agents.new_chat.permissions import Rule
+
+
+def build_deny_message(tool_call: dict[str, Any], rule: Rule) -> ToolMessage:
+ err = StreamingError(
+ code="permission_denied",
+ retryable=False,
+ suggestion=(
+ f"rule permission={rule.permission!r} pattern={rule.pattern!r} "
+ f"blocked this call"
+ ),
+ )
+ return ToolMessage(
+ content=(
+ f"Permission denied: rule {rule.permission}/{rule.pattern} "
+ f"blocked tool {tool_call.get('name')!r}."
+ ),
+ tool_call_id=tool_call.get("id") or "",
+ name=tool_call.get("name"),
+ status="error",
+ additional_kwargs={"error": err.model_dump()},
+ )
+
+
+__all__ = ["build_deny_message"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/__init__.py
new file mode 100644
index 0000000000..81cf9d7ca6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/__init__.py
@@ -0,0 +1,13 @@
+"""The orchestrator class plus its evaluation and ruleset-view helpers."""
+
+from .core import PermissionMiddleware
+from .evaluation import evaluate_tool_call, resolve_patterns
+from .ruleset_view import all_rulesets, globally_denied
+
+__all__ = [
+ "PermissionMiddleware",
+ "all_rulesets",
+ "evaluate_tool_call",
+ "globally_denied",
+ "resolve_patterns",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py
new file mode 100644
index 0000000000..d2950c5b4b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py
@@ -0,0 +1,225 @@
+"""``PermissionMiddleware`` — pattern-based allow/deny/ask with HITL fallback.
+
+LangChain's :class:`HumanInTheLoopMiddleware` only supports a static
+"this tool always asks" decision per tool. There's no rule-based
+allow/deny/ask, no glob patterns, no per-space/per-thread overrides, and
+no auto-deny synthesis.
+
+This middleware layers OpenCode's wildcard-ruleset model on top of the
+unified langchain HITL wire format (see :mod:`hitl_wire`), so it sits
+beside ``HumanInTheLoopMiddleware`` and self-gated approvals on a single
+parallel-HITL routing layer in ``task_tool`` + ``resume_routing``.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+from langchain.agents.middleware.types import (
+ AgentMiddleware,
+ AgentState,
+ ContextT,
+)
+from langchain_core.messages import AIMessage, ToolMessage
+from langchain_core.tools import BaseTool
+from langgraph.runtime import Runtime
+
+from app.agents.new_chat.errors import CorrectedError, RejectedError
+from app.agents.new_chat.permissions import Ruleset
+from app.services.user_tool_allowlist import TrustedToolSaver
+
+from ..ask.edit import merge_edited_args
+from ..ask.request import request_permission_decision
+from ..deny import build_deny_message
+from .evaluation import evaluate_tool_call
+from .pattern_resolver import PatternResolver
+from .ruleset_view import all_rulesets
+from .runtime_promote import persist_always
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class _AlwaysPromotion:
+ """A pending request to save an ``approve_always`` decision to the user's trust list."""
+
+ connector_id: int
+ tool_name: str
+
+
+class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
+ """Allow/deny/ask layer over the agent's tool calls.
+
+ Args:
+ rulesets: Layered rulesets to evaluate (earliest-to-latest wins).
+ Typical layering: ``defaults < global < space < thread < runtime_approved``.
+ pattern_resolvers: Optional per-tool callables that map ``args``
+ to wildcard patterns. Tools without an entry use the bare
+ tool name as the only pattern.
+ runtime_ruleset: Mutable :class:`Ruleset` extended in-place when
+ the user replies ``"approve_always"``. Reused across calls in
+ the same agent instance so newly-allowed rules apply downstream.
+ always_emit_interrupt_payload: Set ``False`` to make ``ask``
+ collapse to ``deny`` (for non-interactive deployments).
+ tools_by_name: Map from tool name to :class:`BaseTool`, used to
+ decorate ``ask`` interrupts with the tool's description and
+ MCP metadata for the FE card.
+ trusted_tool_saver: Async callback invoked on ``approve_always``
+ decisions for MCP tools (those whose ``metadata`` carries an
+ ``mcp_connector_id``). Without it the promotion only lives
+ in-memory for the current agent instance.
+ """
+
+ tools = ()
+
+ def __init__(
+ self,
+ *,
+ rulesets: list[Ruleset] | None = None,
+ pattern_resolvers: dict[str, PatternResolver] | None = None,
+ runtime_ruleset: Ruleset | None = None,
+ always_emit_interrupt_payload: bool = True,
+ tools_by_name: dict[str, BaseTool] | None = None,
+ trusted_tool_saver: TrustedToolSaver | None = None,
+ ) -> None:
+ super().__init__()
+ self._static_rulesets: list[Ruleset] = list(rulesets or [])
+ self._pattern_resolvers: dict[str, PatternResolver] = dict(
+ pattern_resolvers or {}
+ )
+ self._runtime_ruleset: Ruleset = runtime_ruleset or Ruleset(
+ origin="runtime_approved"
+ )
+ self._emit_interrupt = always_emit_interrupt_payload
+ self._tools_by_name: dict[str, BaseTool] = dict(tools_by_name or {})
+ self._trusted_tool_saver: TrustedToolSaver | None = trusted_tool_saver
+
+ def _process(
+ self,
+ state: AgentState,
+ runtime: Runtime[Any],
+ ) -> tuple[dict[str, Any] | None, list[_AlwaysPromotion]]:
+ """Pure decision pass: returns ``(state_update, pending_promotions)``.
+
+ Side effects performed here are in-memory only (rule promotion
+ into ``runtime_ruleset``). DB writes for ``approve_always``
+ decisions are queued as ``_AlwaysPromotion`` and flushed by the
+ async hook.
+ """
+ del runtime
+ messages = state.get("messages") or []
+ if not messages:
+ return None, []
+ last = messages[-1]
+ if not isinstance(last, AIMessage) or not last.tool_calls:
+ return None, []
+
+ rulesets = all_rulesets(self._static_rulesets, self._runtime_ruleset)
+ deny_messages: list[ToolMessage] = []
+ kept_calls: list[dict[str, Any]] = []
+ promotions: list[_AlwaysPromotion] = []
+ any_change = False
+
+ for raw in last.tool_calls:
+ call = (
+ dict(raw)
+ if isinstance(raw, dict)
+ else {
+ "name": getattr(raw, "name", None),
+ "args": getattr(raw, "args", {}),
+ "id": getattr(raw, "id", None),
+ "type": "tool_call",
+ }
+ )
+ name = call.get("name") or ""
+ args = call.get("args") or {}
+ action, patterns, rules = evaluate_tool_call(
+ name, args, self._pattern_resolvers, rulesets
+ )
+
+ if action == "deny":
+ deny_rule = next((r for r in rules if r.action == "deny"), rules[0])
+ deny_messages.append(build_deny_message(call, deny_rule))
+ any_change = True
+ continue
+
+ if action == "ask":
+ decision = request_permission_decision(
+ tool_name=name,
+ args=args,
+ patterns=patterns,
+ rules=rules,
+ emit_interrupt=self._emit_interrupt,
+ tool=self._tools_by_name.get(name),
+ )
+ kind = str(decision.get("decision_type") or "reject").lower()
+ edited_args = decision.get("edited_args")
+ if kind in ("once", "approve_always"):
+ final_call = (
+ merge_edited_args(call, edited_args)
+ if isinstance(edited_args, dict) and edited_args
+ else call
+ )
+ if final_call is not call:
+ any_change = True
+ if kind == "approve_always":
+ persist_always(self._runtime_ruleset, name, patterns)
+ promotion = self._build_always_promotion(name)
+ if promotion is not None:
+ promotions.append(promotion)
+ kept_calls.append(final_call)
+ elif kind == "reject":
+ feedback = decision.get("feedback")
+ if isinstance(feedback, str) and feedback.strip():
+ raise CorrectedError(feedback, tool=name)
+ raise RejectedError(
+ tool=name, pattern=patterns[0] if patterns else None
+ )
+ else:
+ logger.warning(
+ "Unknown permission decision %r; treating as reject", kind
+ )
+ raise RejectedError(tool=name)
+ continue
+
+ kept_calls.append(call)
+
+ if not any_change and len(kept_calls) == len(last.tool_calls):
+ return None, promotions
+
+ updated = last.model_copy(update={"tool_calls": kept_calls})
+ result_messages: list[Any] = [updated]
+ if deny_messages:
+ result_messages.extend(deny_messages)
+ return {"messages": result_messages}, promotions
+
+ def _build_always_promotion(self, tool_name: str) -> _AlwaysPromotion | None:
+ """Return a save request iff the tool exposes an ``mcp_connector_id``."""
+ tool = self._tools_by_name.get(tool_name)
+ metadata = getattr(tool, "metadata", None) or {}
+ connector_id = metadata.get("mcp_connector_id")
+ if not isinstance(connector_id, int):
+ return None
+ return _AlwaysPromotion(connector_id=connector_id, tool_name=tool_name)
+
+ def after_model( # type: ignore[override]
+ self, state: AgentState, runtime: Runtime[ContextT]
+ ) -> dict[str, Any] | None:
+ update, _ = self._process(state, runtime)
+ return update
+
+ async def aafter_model( # type: ignore[override]
+ self, state: AgentState, runtime: Runtime[ContextT]
+ ) -> dict[str, Any] | None:
+ update, promotions = self._process(state, runtime)
+ if self._trusted_tool_saver is not None:
+ for promotion in promotions:
+ await self._trusted_tool_saver(
+ promotion.connector_id, promotion.tool_name
+ )
+ return update
+
+
+__all__ = ["PermissionMiddleware"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py
new file mode 100644
index 0000000000..51531c4eb8
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py
@@ -0,0 +1,60 @@
+"""Resolve patterns for a tool call and aggregate the resulting rules.
+
+Two stages run on every tool call:
+
+1. :func:`resolve_patterns` asks the tool's resolver (or the default) for
+ the wildcard patterns the rule engine should evaluate. Resolver
+ failures fall back to the bare tool name so a buggy resolver can't
+ cascade into permission decisions.
+2. :func:`evaluate_tool_call` runs the rule engine against those patterns
+ and collapses the per-pattern rules into a single action
+ (``deny`` > ``ask`` > ``allow``).
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from app.agents.new_chat.permissions import (
+ Rule,
+ RuleAction,
+ Ruleset,
+ aggregate_action,
+ evaluate_many,
+)
+
+from .pattern_resolver import PatternResolver, default_pattern_resolver
+
+logger = logging.getLogger(__name__)
+
+
+def resolve_patterns(
+ tool_name: str,
+ args: dict[str, Any],
+ pattern_resolvers: dict[str, PatternResolver],
+) -> list[str]:
+ resolver = pattern_resolvers.get(tool_name, default_pattern_resolver(tool_name))
+ try:
+ patterns = resolver(args or {})
+ except Exception:
+ logger.exception("Pattern resolver for %s raised; using bare name", tool_name)
+ patterns = [tool_name]
+ if not patterns:
+ patterns = [tool_name]
+ return patterns
+
+
+def evaluate_tool_call(
+ tool_name: str,
+ args: dict[str, Any],
+ pattern_resolvers: dict[str, PatternResolver],
+ rulesets: list[Ruleset],
+) -> tuple[RuleAction, list[str], list[Rule]]:
+ patterns = resolve_patterns(tool_name, args, pattern_resolvers)
+ rules = evaluate_many(tool_name, patterns, *rulesets)
+ action = aggregate_action(rules)
+ return action, patterns, rules
+
+
+__all__ = ["evaluate_tool_call", "resolve_patterns"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py
new file mode 100644
index 0000000000..3c061ded62
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py
@@ -0,0 +1,88 @@
+"""Construction recipe for :class:`PermissionMiddleware` shared across stacks.
+
+Single source of truth used by both the main-agent stack and every subagent
+stack. Rule layers are evaluated earliest-to-latest (last match wins,
+matching OpenCode's ``permission/index.ts`` evaluation order):
+
+1. ``surfsense_defaults`` — single ``allow */*`` rule. Connector tools
+ already self-gate via :func:`request_approval`, so the rule engine only
+ needs to *deny* what the user has explicitly forbidden; the default
+ ``ask`` fallback would otherwise double-prompt every safe read-only
+ call.
+2. ``subagent_rulesets`` — caller-supplied rulesets contributed by the
+ consuming subagent. Each subagent passes its coded rules (KB:
+ destructive-FS ``ask`` rules; connectors: per-tool ``allow``/``ask``)
+ plus, when present, the user's persisted allow-list for that subagent.
+
+Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules``
+is intentionally NOT replicated: the multi-agent orchestrator already
+excludes entire subagents whose required connectors are missing
+(``SUBAGENT_TO_REQUIRED_CONNECTOR_MAP``), so the per-tool deny pass is
+redundant here.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.permissions import Rule, Ruleset
+from app.services.user_tool_allowlist import TrustedToolSaver
+
+from .core import PermissionMiddleware
+
+_SURFSENSE_DEFAULTS = Ruleset(
+ rules=[Rule(permission="*", pattern="*", action="allow")],
+ origin="surfsense_defaults",
+)
+
+
+def build_permission_mw(
+ *,
+ flags: AgentFeatureFlags,
+ subagent_rulesets: list[Ruleset] | None = None,
+ tools: Sequence[BaseTool] | None = None,
+ trusted_tool_saver: TrustedToolSaver | None = None,
+) -> PermissionMiddleware | None:
+ """Return a configured :class:`PermissionMiddleware` or ``None`` when no work is needed.
+
+ Args:
+ flags: Feature toggles. ``enable_permission`` switches the engine on;
+ ``disable_new_agent_stack`` overrides everything for safety.
+ subagent_rulesets: Caller-supplied rulesets layered after the
+ defaults. Subagents pass their own coded ruleset here (and,
+ when present, the user's persisted allow-list for that
+ subagent) so each subagent owns its own rule surface without
+ aliasing a shared engine. Presence of any subagent ruleset
+ forces the middleware on regardless of ``enable_permission`` —
+ an explicit ``ask`` rule always asks.
+ tools: Subagent tools used to decorate ``ask`` interrupts with
+ FE-card metadata (description, MCP connector). Optional.
+ trusted_tool_saver: Async callback invoked when an MCP tool's
+ ``always`` decision lands; persists the user's preference to
+ ``connector.config['trusted_tools']``. Optional.
+
+ Returns:
+ ``None`` when the engine has no rules to enforce
+ (``enable_permission=False`` and no subagent rulesets); a
+ configured middleware otherwise.
+ """
+ permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack
+ has_subagent_rulesets = bool(subagent_rulesets)
+ if not (permission_enabled or has_subagent_rulesets):
+ return None
+
+ rulesets: list[Ruleset] = [_SURFSENSE_DEFAULTS]
+ if subagent_rulesets:
+ rulesets.extend(subagent_rulesets)
+ tools_by_name = {t.name: t for t in (tools or [])}
+ return PermissionMiddleware(
+ rulesets=rulesets,
+ tools_by_name=tools_by_name,
+ trusted_tool_saver=trusted_tool_saver,
+ )
+
+
+__all__ = ["build_permission_mw"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/pattern_resolver.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/pattern_resolver.py
new file mode 100644
index 0000000000..32da90302e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/pattern_resolver.py
@@ -0,0 +1,28 @@
+"""Per-tool pattern resolution.
+
+A :data:`PatternResolver` turns a tool's ``args`` dict into a list of
+wildcard patterns evaluated against the layered rulesets. The first
+pattern is conventionally the bare tool name (catch-all); later entries
+narrow down to specific resources (file paths, ids, etc.).
+
+Tools without a custom resolver fall back to :func:`default_pattern_resolver`,
+which yields only the bare tool name.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+PatternResolver = Callable[[dict[str, Any]], list[str]]
+
+
+def default_pattern_resolver(name: str) -> PatternResolver:
+ def _resolve(args: dict[str, Any]) -> list[str]:
+ del args
+ return [name]
+
+ return _resolve
+
+
+__all__ = ["PatternResolver", "default_pattern_resolver"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py
new file mode 100644
index 0000000000..fbb66d4555
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py
@@ -0,0 +1,27 @@
+"""Combined view over static + runtime rulesets.
+
+Static rulesets come from the agent factory (defaults, space-scoped,
+thread-scoped, etc.). The runtime ruleset is the in-memory one that
+:func:`runtime_promote.persist_always` extends when the user replies
+``"approve_always"``. Evaluators always see them merged in this order so
+newly-promoted rules apply to subsequent calls.
+"""
+
+from __future__ import annotations
+
+from app.agents.new_chat.permissions import Ruleset, aggregate_action, evaluate_many
+
+
+def all_rulesets(
+ static_rulesets: list[Ruleset], runtime_ruleset: Ruleset
+) -> list[Ruleset]:
+ return [*static_rulesets, runtime_ruleset]
+
+
+def globally_denied(tool_name: str, rulesets: list[Ruleset]) -> bool:
+ """True if an unconditional deny rule blocks every invocation of ``tool_name``."""
+ rules = evaluate_many(tool_name, ["*"], *rulesets)
+ return aggregate_action(rules) == "deny"
+
+
+__all__ = ["all_rulesets", "globally_denied"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py
new file mode 100644
index 0000000000..afc65fdc04
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py
@@ -0,0 +1,22 @@
+"""Promote an ``"approve_always"`` reply into in-memory allow rules.
+
+Subsequent calls within the same agent instance match these new rules and
+proceed without prompting. Durable persistence (to ``agent_permission_rules``)
+is the streaming layer's job — this module keeps the in-memory copy only.
+"""
+
+from __future__ import annotations
+
+from app.agents.new_chat.permissions import Rule, Ruleset
+
+
+def persist_always(
+ runtime_ruleset: Ruleset, tool_name: str, patterns: list[str]
+) -> None:
+ for pattern in patterns:
+ runtime_ruleset.rules.append(
+ Rule(permission=tool_name, pattern=pattern, action="allow")
+ )
+
+
+__all__ = ["persist_always"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py
new file mode 100644
index 0000000000..9f26ffe498
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py
@@ -0,0 +1,7 @@
+"""Resilience middleware shared as the same instances across parent / registry."""
+
+from __future__ import annotations
+
+from .bundle import ResilienceMiddlewares, build_resilience_middlewares
+
+__all__ = ["ResilienceMiddlewares", "build_resilience_middlewares"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py
new file mode 100644
index 0000000000..1112447846
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py
@@ -0,0 +1,53 @@
+"""Construct each resilience middleware once; same instances flow into every consumer."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from langchain.agents.middleware import (
+ ModelCallLimitMiddleware,
+ ToolCallLimitMiddleware,
+)
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import RetryAfterMiddleware
+from app.agents.new_chat.middleware.scoped_model_fallback import (
+ ScopedModelFallbackMiddleware,
+)
+
+from .fallback import build_fallback_mw
+from .model_call_limit import build_model_call_limit_mw
+from .retry import build_retry_mw
+from .tool_call_limit import build_tool_call_limit_mw
+
+
+@dataclass(frozen=True)
+class ResilienceMiddlewares:
+ """The four resilience middleware instances, any of which may be ``None`` when disabled by flags."""
+
+ retry: RetryAfterMiddleware | None
+ fallback: ScopedModelFallbackMiddleware | None
+ model_call_limit: ModelCallLimitMiddleware | None
+ tool_call_limit: ToolCallLimitMiddleware | None
+
+ def as_list(self) -> list[Any]:
+ return [
+ m
+ for m in (
+ self.retry,
+ self.fallback,
+ self.model_call_limit,
+ self.tool_call_limit,
+ )
+ if m is not None
+ ]
+
+
+def build_resilience_middlewares(flags: AgentFeatureFlags) -> ResilienceMiddlewares:
+ return ResilienceMiddlewares(
+ retry=build_retry_mw(flags),
+ fallback=build_fallback_mw(flags),
+ model_call_limit=build_model_call_limit_mw(flags),
+ tool_call_limit=build_tool_call_limit_mw(flags),
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py
new file mode 100644
index 0000000000..ea68a764eb
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py
@@ -0,0 +1,27 @@
+"""Switch to a fallback model on provider/network errors only."""
+
+from __future__ import annotations
+
+import logging
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware.scoped_model_fallback import (
+ ScopedModelFallbackMiddleware,
+)
+
+from ..flags import enabled
+
+
+def build_fallback_mw(
+ flags: AgentFeatureFlags,
+) -> ScopedModelFallbackMiddleware | None:
+ if not enabled(flags, "enable_model_fallback"):
+ return None
+ try:
+ return ScopedModelFallbackMiddleware(
+ "openai:gpt-4o-mini",
+ "anthropic:claude-3-5-haiku-20241022",
+ )
+ except Exception:
+ logging.warning("ScopedModelFallbackMiddleware init failed; skipping.")
+ return None
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py
new file mode 100644
index 0000000000..85707a385e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py
@@ -0,0 +1,21 @@
+"""Cap model calls per thread / per run to prevent runaway cost."""
+
+from __future__ import annotations
+
+from langchain.agents.middleware import ModelCallLimitMiddleware
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+
+from ..flags import enabled
+
+
+def build_model_call_limit_mw(
+ flags: AgentFeatureFlags,
+) -> ModelCallLimitMiddleware | None:
+ if not enabled(flags, "enable_model_call_limit"):
+ return None
+ return ModelCallLimitMiddleware(
+ thread_limit=120,
+ run_limit=80,
+ exit_behavior="end",
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py
new file mode 100644
index 0000000000..c98fc40839
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py
@@ -0,0 +1,16 @@
+"""Retry on transient model errors (e.g. Retry-After-bearing 429s)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.middleware import RetryAfterMiddleware
+
+from ..flags import enabled
+
+
+def build_retry_mw(flags: AgentFeatureFlags) -> RetryAfterMiddleware | None:
+ return (
+ RetryAfterMiddleware(max_retries=3)
+ if enabled(flags, "enable_retry_after")
+ else None
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py
new file mode 100644
index 0000000000..dcde81f373
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py
@@ -0,0 +1,21 @@
+"""Cap tool calls per thread / per run to bound infinite-loop blast radius."""
+
+from __future__ import annotations
+
+from langchain.agents.middleware import ToolCallLimitMiddleware
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+
+from ..flags import enabled
+
+
+def build_tool_call_limit_mw(
+ flags: AgentFeatureFlags,
+) -> ToolCallLimitMiddleware | None:
+ if not enabled(flags, "enable_tool_call_limit"):
+ return None
+ return ToolCallLimitMiddleware(
+ thread_limit=300,
+ run_limit=80,
+ exit_behavior="continue",
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py
new file mode 100644
index 0000000000..dac1496275
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py
@@ -0,0 +1,12 @@
+"""Todo-list middleware (each consumer needs its own instance)."""
+
+from __future__ import annotations
+
+from langchain.agents.middleware import TodoListMiddleware
+
+
+def build_todos_mw(*, system_prompt: str | None = None) -> TodoListMiddleware:
+ """Pass ``system_prompt=""`` to suppress the upstream prompt append. We use a custom system prompt in the main agent."""
+ if system_prompt is None:
+ return TodoListMiddleware()
+ return TodoListMiddleware(system_prompt=system_prompt)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py
new file mode 100644
index 0000000000..c1ebe31caf
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py
@@ -0,0 +1,209 @@
+"""Main-agent middleware list assembly: one line per slot.
+
+The main agent is a pure router — filesystem reads/writes are owned by the
+``knowledge_base`` subagent and delegated via the ``task`` tool. The stack
+here only renders KB context (workspace tree + priority docs), projects it
+into system messages, and commits any subagent-side staged writes at end of
+turn (cloud mode).
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Sequence
+from typing import Any
+
+from deepagents import SubAgent
+from deepagents.backends import StateBackend
+from langchain.agents import create_agent
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+from langgraph.types import Checkpointer
+
+from app.agents.multi_agent_chat.subagents import (
+ build_subagents,
+ get_subagents_to_exclude,
+)
+from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.agent import (
+ READONLY_NAME as KB_READONLY_NAME,
+ build_readonly_subagent as build_kb_readonly_subagent,
+)
+from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.ask_knowledge_base_tool import (
+ build_ask_knowledge_base_tool,
+)
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.db import ChatVisibility
+
+from .main_agent.action_log import build_action_log_mw
+from .main_agent.anonymous_doc import build_anonymous_doc_mw
+from .main_agent.busy_mutex import build_busy_mutex_mw
+from .main_agent.checkpointed_subagent_middleware import (
+ SurfSenseCheckpointedSubAgentMiddleware,
+)
+from .main_agent.checkpointed_subagent_middleware.task_description import (
+ TASK_TOOL_DESCRIPTION,
+)
+from .main_agent.context_editing import build_context_editing_mw
+from .main_agent.dedup_hitl import build_dedup_hitl_mw
+from .main_agent.doom_loop import build_doom_loop_mw
+from .main_agent.kb_persistence import build_kb_persistence_mw
+from .main_agent.knowledge_priority import build_knowledge_priority_mw
+from .main_agent.knowledge_tree import build_knowledge_tree_mw
+from .main_agent.noop_injection import build_noop_injection_mw
+from .main_agent.otel import build_otel_mw
+from .main_agent.plugins import build_plugin_middlewares
+from .main_agent.repair import build_repair_mw
+from .main_agent.skills import build_skills_mw
+from .shared.anthropic_cache import build_anthropic_cache_mw
+from .shared.compaction import build_compaction_mw
+from .shared.kb_context_projection import build_kb_context_projection_mw
+from .shared.memory import build_memory_mw
+from .shared.patch_tool_calls import build_patch_tool_calls_mw
+from .shared.permissions import build_permission_mw
+from .shared.resilience import build_resilience_middlewares
+from .shared.todos import build_todos_mw
+from .subagent.middleware_stack import build_subagent_middleware_stack
+
+
+def build_main_agent_deepagent_middleware(
+ *,
+ llm: BaseChatModel,
+ tools: Sequence[BaseTool],
+ backend_resolver: Any,
+ filesystem_mode: FilesystemMode,
+ search_space_id: int,
+ user_id: str | None,
+ thread_id: int | None,
+ visibility: ChatVisibility,
+ anon_session_id: str | None,
+ available_connectors: list[str] | None,
+ available_document_types: list[str] | None,
+ mentioned_document_ids: list[int] | None,
+ max_input_tokens: int | None,
+ flags: AgentFeatureFlags,
+ subagent_dependencies: dict[str, Any],
+ checkpointer: Checkpointer,
+ mcp_tools_by_agent: dict[str, list[BaseTool]] | None = None,
+ disabled_tools: list[str] | None = None,
+) -> list[Any]:
+ """Ordered middleware for ``create_agent`` (None entries already stripped)."""
+ resilience = build_resilience_middlewares(flags)
+
+ memory_mw = build_memory_mw(
+ user_id=user_id,
+ search_space_id=search_space_id,
+ visibility=visibility,
+ )
+
+ subagent_dependencies = {
+ **subagent_dependencies,
+ "backend_resolver": backend_resolver,
+ "filesystem_mode": filesystem_mode,
+ "flags": flags,
+ }
+ shared_subagent_middleware = build_subagent_middleware_stack(
+ resilience=resilience,
+ flags=flags,
+ )
+
+ kb_readonly = build_kb_readonly_subagent(
+ dependencies=subagent_dependencies,
+ model=llm,
+ middleware_stack=shared_subagent_middleware,
+ )
+ kb_readonly_spec = kb_readonly.spec
+ kb_readonly_runnable = create_agent(
+ llm,
+ system_prompt=kb_readonly_spec["system_prompt"],
+ tools=kb_readonly_spec["tools"],
+ middleware=kb_readonly_spec["middleware"],
+ name=KB_READONLY_NAME,
+ checkpointer=checkpointer,
+ )
+ ask_kb_tool = build_ask_knowledge_base_tool(kb_readonly_runnable)
+
+ subagents: list[SubAgent] = build_subagents(
+ dependencies=subagent_dependencies,
+ model=llm,
+ middleware_stack=shared_subagent_middleware,
+ mcp_tools_by_agent=mcp_tools_by_agent or {},
+ exclude=get_subagents_to_exclude(available_connectors),
+ disabled_tools=disabled_tools,
+ ask_kb_tool=ask_kb_tool,
+ )
+ logging.debug("Subagents registry: %s", [s["name"] for s in subagents])
+
+ stack: list[Any] = [
+ build_busy_mutex_mw(flags),
+ build_otel_mw(flags),
+ build_todos_mw(system_prompt=""),
+ memory_mw,
+ build_anonymous_doc_mw(
+ filesystem_mode=filesystem_mode, anon_session_id=anon_session_id
+ ),
+ build_knowledge_tree_mw(
+ filesystem_mode=filesystem_mode,
+ search_space_id=search_space_id,
+ llm=llm,
+ ),
+ build_knowledge_priority_mw(
+ llm=llm,
+ search_space_id=search_space_id,
+ filesystem_mode=filesystem_mode,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ mentioned_document_ids=mentioned_document_ids,
+ ),
+ build_kb_context_projection_mw(),
+ build_kb_persistence_mw(
+ filesystem_mode=filesystem_mode,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ thread_id=thread_id,
+ ),
+ build_skills_mw(
+ flags=flags,
+ filesystem_mode=filesystem_mode,
+ search_space_id=search_space_id,
+ ),
+ SurfSenseCheckpointedSubAgentMiddleware(
+ checkpointer=checkpointer,
+ backend=StateBackend,
+ subagents=subagents,
+ system_prompt=None,
+ task_description=TASK_TOOL_DESCRIPTION,
+ ),
+ resilience.model_call_limit,
+ resilience.tool_call_limit,
+ build_context_editing_mw(
+ flags=flags,
+ max_input_tokens=max_input_tokens,
+ tools=tools,
+ backend_resolver=backend_resolver,
+ ),
+ build_compaction_mw(llm),
+ build_noop_injection_mw(flags),
+ resilience.retry,
+ resilience.fallback,
+ build_repair_mw(flags=flags, tools=tools),
+ build_permission_mw(flags=flags),
+ build_doom_loop_mw(flags),
+ build_action_log_mw(
+ flags=flags,
+ thread_id=thread_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ ),
+ build_patch_tool_calls_mw(),
+ build_dedup_hitl_mw(tools),
+ *build_plugin_middlewares(
+ flags=flags,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ visibility=visibility,
+ llm=llm,
+ ),
+ build_anthropic_cache_mw(),
+ ]
+ return [m for m in stack if m is not None]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py
new file mode 100644
index 0000000000..aa6211fcc2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py
@@ -0,0 +1,49 @@
+"""Shared middleware stack threaded into every subagent.
+
+Mirrors ``middleware/stack.py`` (the orchestrator's middleware stack) but
+exposes its contents as a dict keyed by purpose so specialists can pick
+the entries they need and decide ordering. The default consumer
+(:func:`pack_subagent`) prepends every non-``None`` value in insertion
+order, so ``None`` slots are silently skipped.
+
+Registry subagents never touch the SurfSense filesystem — that capability
+belongs to ``knowledge_base`` — so no FS middleware is exposed here.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+
+from ..shared.permissions import build_permission_mw
+from ..shared.resilience import ResilienceMiddlewares
+from ..shared.todos import build_todos_mw
+
+
+def build_subagent_middleware_stack(
+ *,
+ resilience: ResilienceMiddlewares,
+ flags: AgentFeatureFlags | None = None,
+) -> dict[str, Any]:
+ """Assemble the dict of middlewares prepended to every subagent's stack.
+
+ Args:
+ resilience: Pre-built retry / fallback / call-limit middlewares
+ (shared with the orchestrator stack to keep behaviour symmetric).
+ flags: Feature flags driving optional layers. ``None`` disables the
+ permission layer (used in tests that only need todos+resilience).
+
+ Returns:
+ Insertion-ordered dict; ``None`` values are tolerated and dropped by
+ the consumer so callers can flip slots on/off without reshaping.
+ """
+ permission = build_permission_mw(flags=flags) if flags is not None else None
+ return {
+ "todos": build_todos_mw(),
+ "permission": permission,
+ "retry": resilience.retry,
+ "fallback": resilience.fallback,
+ "model_call_limit": resilience.model_call_limit,
+ "tool_call_limit": resilience.tool_call_limit,
+ }
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py
new file mode 100644
index 0000000000..ca9e4aa3e5
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py
@@ -0,0 +1,19 @@
+"""Registry-backed subagent builders and helpers."""
+
+from __future__ import annotations
+
+from .registry import (
+ SUBAGENT_BUILDERS_BY_NAME,
+ SubagentBuilder,
+ build_subagents,
+ get_subagents_to_exclude,
+ main_prompt_registry_subagent_lines,
+)
+
+__all__ = [
+ "SUBAGENT_BUILDERS_BY_NAME",
+ "SubagentBuilder",
+ "build_subagents",
+ "get_subagents_to_exclude",
+ "main_prompt_registry_subagent_lines",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py
new file mode 100644
index 0000000000..396e0ec797
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py
@@ -0,0 +1,44 @@
+"""``deliverables`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
+
+Tools self-gate inside their bodies via :func:`request_approval`; the
+empty :data:`tools.index.RULESET` is layered into a per-subagent
+:class:`PermissionMiddleware` for uniformity.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles deliverables tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md
new file mode 100644
index 0000000000..fcebdcaf02
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md
@@ -0,0 +1,2 @@
+Specialist for producing long-form deliverables: reports, podcasts, video presentations, resumes, and generated images.
+Use proactively when the user wants one of these artifacts produced.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md
new file mode 100644
index 0000000000..c44f131bbc
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md
@@ -0,0 +1,55 @@
+You are the SurfSense deliverables operations sub-agent.
+You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
+
+
+Produce **deliverables**: shareable **artifacts** the user keeps (reports, slide-style video presentations, podcasts, resumes, images). Use explicit constraints and reliable proof of what was generated.
+
+
+
+- `generate_report`
+- `generate_podcast`
+- `generate_video_presentation`
+- `generate_resume`
+- `generate_image`
+
+
+
+- Use only tools in ``.
+- Require essential generation constraints (audience, format, tone, core content).
+- If critical constraints are missing, return `status=blocked` with `missing_fields`.
+- Never claim artifact generation success without tool confirmation.
+
+
+
+- Do not perform connector data mutations unrelated to artifact generation.
+
+
+
+- Avoid generating artifacts with missing critical constraints.
+- Prefer one complete artifact over partial multi-artifact output.
+
+
+
+- On generation failure, return `status=error` with best retry guidance.
+- On missing constraints, return `status=blocked` with required fields.
+
+
+
+Return **only** one JSON object (no markdown/prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "artifact_type": "report" | "podcast" | "video_presentation" | "resume" | "image" | null,
+ "artifact_id": string | null,
+ "artifact_location": string | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+Rules:
+- `status=success` -> `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` -> `next_step` must be non-null.
+- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py
new file mode 100644
index 0000000000..d0fe942177
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py
@@ -0,0 +1,15 @@
+"""Deliverable generators: reports, podcasts, video decks, resumes, images."""
+
+from .generate_image import create_generate_image_tool
+from .podcast import create_generate_podcast_tool
+from .report import create_generate_report_tool
+from .resume import create_generate_resume_tool
+from .video_presentation import create_generate_video_presentation_tool
+
+__all__ = [
+ "create_generate_image_tool",
+ "create_generate_podcast_tool",
+ "create_generate_report_tool",
+ "create_generate_resume_tool",
+ "create_generate_video_presentation_tool",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py
new file mode 100644
index 0000000000..ab9dbc0ea9
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py
@@ -0,0 +1,247 @@
+"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads."""
+
+import hashlib
+import logging
+from typing import Any
+
+from langchain_core.tools import tool
+from litellm import aimage_generation
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import config
+from app.db import (
+ ImageGeneration,
+ ImageGenerationConfig,
+ SearchSpace,
+ shielded_async_session,
+)
+from app.services.image_gen_router_service import (
+ IMAGE_GEN_AUTO_MODE_ID,
+ ImageGenRouterService,
+ is_image_gen_auto_mode,
+)
+from app.utils.signed_image_urls import generate_image_token
+
+logger = logging.getLogger(__name__)
+
+# Provider mapping (same as routes)
+_PROVIDER_MAP = {
+ "OPENAI": "openai",
+ "AZURE_OPENAI": "azure",
+ "GOOGLE": "gemini",
+ "VERTEX_AI": "vertex_ai",
+ "BEDROCK": "bedrock",
+ "RECRAFT": "recraft",
+ "OPENROUTER": "openrouter",
+ "XINFERENCE": "xinference",
+ "NSCALE": "nscale",
+}
+
+
+def _build_model_string(
+ provider: str, model_name: str, custom_provider: str | None
+) -> str:
+ if custom_provider:
+ return f"{custom_provider}/{model_name}"
+ prefix = _PROVIDER_MAP.get(provider.upper(), provider.lower())
+ return f"{prefix}/{model_name}"
+
+
+def _get_global_image_gen_config(config_id: int) -> dict | None:
+ """Get a global image gen config by negative ID."""
+ for cfg in config.GLOBAL_IMAGE_GEN_CONFIGS:
+ if cfg.get("id") == config_id:
+ return cfg
+ return None
+
+
+def create_generate_image_tool(
+ search_space_id: int,
+ db_session: AsyncSession,
+):
+ """Create ``generate_image`` with bound search space; DB work uses a per-call session."""
+ del db_session # use a fresh per-call session, see below
+
+ @tool
+ async def generate_image(
+ prompt: str,
+ n: int = 1,
+ ) -> dict[str, Any]:
+ """
+ Generate an image from a text description using AI image models.
+
+ Use this tool when the user asks you to create, generate, draw, or make an image.
+ The generated image will be displayed directly in the chat.
+
+ Args:
+ prompt: A detailed text description of the image to generate.
+ Be specific about subject, style, colors, composition, and mood.
+ n: Number of images to generate (1-4). Default: 1
+
+ Returns:
+ A dictionary containing the generated image(s) for display in the chat.
+ """
+ try:
+ # Use a per-call session so concurrent tool calls don't share an
+ # AsyncSession (which is not concurrency-safe). The streaming
+ # task's session is shared across every tool; without isolation,
+ # autoflushes from a concurrent writer poison this tool too.
+ async with shielded_async_session() as session:
+ result = await session.execute(
+ select(SearchSpace).filter(SearchSpace.id == search_space_id)
+ )
+ search_space = result.scalars().first()
+ if not search_space:
+ return {"error": "Search space not found"}
+
+ config_id = (
+ search_space.image_generation_config_id or IMAGE_GEN_AUTO_MODE_ID
+ )
+
+ # Build generation kwargs
+ # NOTE: size, quality, and style are intentionally NOT passed.
+ # Different models support different values for these params
+ # (e.g. DALL-E 3 wants "hd"/"standard" for quality while
+ # gpt-image-1 wants "high"/"medium"/"low"; size options also
+ # differ). Letting the model use its own defaults avoids errors.
+ gen_kwargs: dict[str, Any] = {}
+ if n is not None and n > 1:
+ gen_kwargs["n"] = n
+
+ # Call litellm based on config type
+ if is_image_gen_auto_mode(config_id):
+ if not ImageGenRouterService.is_initialized():
+ return {
+ "error": "No image generation models configured. "
+ "Please add an image model in Settings > Image Models."
+ }
+ response = await ImageGenRouterService.aimage_generation(
+ prompt=prompt, model="auto", **gen_kwargs
+ )
+ elif config_id < 0:
+ cfg = _get_global_image_gen_config(config_id)
+ if not cfg:
+ return {
+ "error": f"Image generation config {config_id} not found"
+ }
+
+ model_string = _build_model_string(
+ cfg.get("provider", ""),
+ cfg["model_name"],
+ cfg.get("custom_provider"),
+ )
+ gen_kwargs["api_key"] = cfg.get("api_key")
+ if cfg.get("api_base"):
+ gen_kwargs["api_base"] = cfg["api_base"]
+ if cfg.get("api_version"):
+ gen_kwargs["api_version"] = cfg["api_version"]
+ if cfg.get("litellm_params"):
+ gen_kwargs.update(cfg["litellm_params"])
+
+ response = await aimage_generation(
+ prompt=prompt, model=model_string, **gen_kwargs
+ )
+ else:
+ # Positive ID = user-created ImageGenerationConfig
+ cfg_result = await session.execute(
+ select(ImageGenerationConfig).filter(
+ ImageGenerationConfig.id == config_id
+ )
+ )
+ db_cfg = cfg_result.scalars().first()
+ if not db_cfg:
+ return {
+ "error": f"Image generation config {config_id} not found"
+ }
+
+ model_string = _build_model_string(
+ db_cfg.provider.value,
+ db_cfg.model_name,
+ db_cfg.custom_provider,
+ )
+ gen_kwargs["api_key"] = db_cfg.api_key
+ if db_cfg.api_base:
+ gen_kwargs["api_base"] = db_cfg.api_base
+ if db_cfg.api_version:
+ gen_kwargs["api_version"] = db_cfg.api_version
+ if db_cfg.litellm_params:
+ gen_kwargs.update(db_cfg.litellm_params)
+
+ response = await aimage_generation(
+ prompt=prompt, model=model_string, **gen_kwargs
+ )
+
+ # Parse the response and store in DB
+ response_dict = (
+ response.model_dump()
+ if hasattr(response, "model_dump")
+ else dict(response)
+ )
+
+ # Generate a random access token for this image
+ access_token = generate_image_token()
+
+ # Save to image_generations table for history
+ db_image_gen = ImageGeneration(
+ prompt=prompt,
+ model=getattr(response, "_hidden_params", {}).get("model"),
+ n=n,
+ image_generation_config_id=config_id,
+ response_data=response_dict,
+ search_space_id=search_space_id,
+ access_token=access_token,
+ )
+ session.add(db_image_gen)
+ await session.commit()
+ await session.refresh(db_image_gen)
+ db_image_gen_id = db_image_gen.id
+
+ # Extract image URLs from response
+ images = response_dict.get("data", [])
+ if not images:
+ return {"error": "No images were generated"}
+
+ first_image = images[0]
+ revised_prompt = first_image.get("revised_prompt", prompt)
+
+ # Resolve image URL:
+ # - If the API returned a URL, use it directly.
+ # - If the API returned b64_json (e.g. gpt-image-1), serve the
+ # image through our backend endpoint to avoid bloating the
+ # LLM context with megabytes of base64 data.
+ if first_image.get("url"):
+ image_url = first_image["url"]
+ elif first_image.get("b64_json"):
+ backend_url = config.BACKEND_URL or "http://localhost:8000"
+ image_url = (
+ f"{backend_url}/api/v1/image-generations/"
+ f"{db_image_gen_id}/image?token={access_token}"
+ )
+ else:
+ return {"error": "No displayable image data in the response"}
+
+ image_id = f"image-{hashlib.md5(image_url.encode()).hexdigest()[:12]}"
+
+ return {
+ "id": image_id,
+ "assetId": image_url,
+ "src": image_url,
+ "alt": revised_prompt or prompt,
+ "title": "Generated Image",
+ "description": revised_prompt if revised_prompt != prompt else None,
+ "domain": "ai-generated",
+ "ratio": "auto",
+ "generated": True,
+ "prompt": prompt,
+ "image_count": len(images),
+ }
+
+ except Exception as e:
+ logger.exception("Image generation failed in tool")
+ return {
+ "error": f"Image generation failed: {e!s}",
+ "prompt": prompt,
+ }
+
+ return generate_image
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py
new file mode 100644
index 0000000000..5f76f1d52d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py
@@ -0,0 +1,55 @@
+"""``deliverables`` native tools and (empty) permission ruleset.
+
+Tools self-gate via :func:`request_approval` in their bodies.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.permissions import Ruleset
+
+from .generate_image import create_generate_image_tool
+from .podcast import create_generate_podcast_tool
+from .report import create_generate_report_tool
+from .resume import create_generate_resume_tool
+from .video_presentation import create_generate_video_presentation_tool
+
+NAME = "deliverables"
+
+RULESET = Ruleset(origin=NAME, rules=[])
+
+
+def load_tools(
+ *, dependencies: dict[str, Any] | None = None, **kwargs: Any
+) -> list[BaseTool]:
+ d = {**(dependencies or {}), **kwargs}
+ return [
+ create_generate_podcast_tool(
+ search_space_id=d["search_space_id"],
+ db_session=d["db_session"],
+ thread_id=d["thread_id"],
+ ),
+ create_generate_video_presentation_tool(
+ search_space_id=d["search_space_id"],
+ db_session=d["db_session"],
+ thread_id=d["thread_id"],
+ ),
+ create_generate_report_tool(
+ search_space_id=d["search_space_id"],
+ thread_id=d["thread_id"],
+ connector_service=d.get("connector_service"),
+ available_connectors=d.get("available_connectors"),
+ available_document_types=d.get("available_document_types"),
+ ),
+ create_generate_resume_tool(
+ search_space_id=d["search_space_id"],
+ thread_id=d["thread_id"],
+ ),
+ create_generate_image_tool(
+ search_space_id=d["search_space_id"],
+ db_session=d["db_session"],
+ ),
+ ]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py
new file mode 100644
index 0000000000..55d9b35654
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py
@@ -0,0 +1,92 @@
+"""Factory for a podcast-generation tool that queues background work and returns an ID for polling."""
+
+from typing import Any
+
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import Podcast, PodcastStatus, shielded_async_session
+
+
+def create_generate_podcast_tool(
+ search_space_id: int,
+ db_session: AsyncSession,
+ thread_id: int | None = None,
+):
+ """Create ``generate_podcast`` with bound search space and thread; DB writes use a tool-local session."""
+ del db_session # writes use a fresh tool-local session, see below
+
+ @tool
+ async def generate_podcast(
+ source_content: str,
+ podcast_title: str = "SurfSense Podcast",
+ user_prompt: str | None = None,
+ ) -> dict[str, Any]:
+ """
+ Generate a podcast from the provided content.
+
+ Use this tool when the user asks to create, generate, or make a podcast.
+ Common triggers include phrases like:
+ - "Give me a podcast about this"
+ - "Create a podcast from this conversation"
+ - "Generate a podcast summary"
+ - "Make a podcast about..."
+ - "Turn this into a podcast"
+
+ Args:
+ source_content: The text content to convert into a podcast.
+ podcast_title: Title for the podcast (default: "SurfSense Podcast")
+ user_prompt: Optional instructions for podcast style, tone, or format.
+
+ Returns:
+ A dictionary containing:
+ - status: PodcastStatus value (pending, generating, or failed)
+ - podcast_id: The podcast ID for polling (when status is pending or generating)
+ - title: The podcast title
+ - message: Status message (or "error" field if status is failed)
+ """
+ try:
+ # One DB session per tool call so parallel invocations never share an AsyncSession.
+ async with shielded_async_session() as session:
+ podcast = Podcast(
+ title=podcast_title,
+ status=PodcastStatus.PENDING,
+ search_space_id=search_space_id,
+ thread_id=thread_id,
+ )
+ session.add(podcast)
+ await session.commit()
+ await session.refresh(podcast)
+ podcast_id = podcast.id
+
+ from app.tasks.celery_tasks.podcast_tasks import (
+ generate_content_podcast_task,
+ )
+
+ task = generate_content_podcast_task.delay(
+ podcast_id=podcast_id,
+ source_content=source_content,
+ search_space_id=search_space_id,
+ user_prompt=user_prompt,
+ )
+
+ print(f"[generate_podcast] Created podcast {podcast_id}, task: {task.id}")
+
+ return {
+ "status": PodcastStatus.PENDING.value,
+ "podcast_id": podcast_id,
+ "title": podcast_title,
+ "message": "Podcast generation started. This may take a few minutes.",
+ }
+
+ except Exception as e:
+ error_message = str(e)
+ print(f"[generate_podcast] Error: {error_message}")
+ return {
+ "status": PodcastStatus.FAILED.value,
+ "error": error_message,
+ "title": podcast_title,
+ "podcast_id": None,
+ }
+
+ return generate_podcast
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py
new file mode 100644
index 0000000000..385100c62b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py
@@ -0,0 +1,1061 @@
+"""Factory for inline Markdown reports: optional KB sourcing, section-aware revision, short-lived DB sessions."""
+
+import asyncio
+import json
+import logging
+import re
+from typing import Any
+
+from langchain_core.callbacks import dispatch_custom_event
+from langchain_core.messages import HumanMessage
+from langchain_core.tools import tool
+
+from app.db import Report, shielded_async_session
+from app.services.connector_service import ConnectorService
+from app.services.llm_service import get_document_summary_llm
+
+logger = logging.getLogger(__name__)
+
+# ─── Shared Formatting Rules ────────────────────────────────────────────────
+# Reusable formatting instructions appended to section-level and review prompts.
+
+_FORMATTING_RULES = """\
+- IMPORTANT: Output raw Markdown directly. Do NOT wrap the entire output in a \
+code fence (e.g. ```markdown, ````markdown, or any backtick fence). Individual \
+code examples and diagrams inside the report should still use fenced code blocks, \
+but the report itself must NOT be enclosed in one.
+- Maintain proper Markdown formatting throughout.
+- When including code examples, ALWAYS format them as proper fenced code blocks \
+with the correct language identifier (e.g. ```java, ```python). Code inside code \
+blocks MUST have proper line breaks and indentation — NEVER put multiple statements \
+on a single line. Each statement, brace, and logical block must be on its own line \
+with correct indentation.
+- When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid \
+statement MUST be on its own line — NEVER use semicolons to join multiple statements \
+on one line. For line breaks inside node labels, use (NOT ).
+- When including mathematical formulas or equations, ALWAYS use LaTeX notation. \
+NEVER use backtick code spans or Unicode symbols for math."""
+
+# ─── Standard Report Footer ─────────────────────────────────────────────────
+# Appended to every generated report after content generation.
+
+_REPORT_FOOTER = "Powered by SurfSense AI."
+
+# ─── Prompt: Single-Shot Report Generation ───────────────────────────────────
+
+_REPORT_PROMPT = """You are an expert report writer. Generate a comprehensive Markdown report.
+
+**Topic:** {topic}
+**Report Style:** {report_style}
+{user_instructions_section}
+{previous_version_section}
+
+**Source Content:**
+{source_content}
+
+---
+
+{length_instruction}
+
+Write a well-structured Markdown report with a # title, executive summary, organized sections, and conclusion. Cite facts from the source content. Be thorough and professional.
+
+{formatting_rules}
+"""
+
+# ─── Prompt: Full-Document Revision (fallback when section-level fails) ──────
+
+_REVISION_PROMPT = """You are an expert report editor. Apply ONLY the requested changes — do NOT rewrite from scratch.
+
+**Topic:** {topic}
+**Report Style:** {report_style}
+**Modification Instructions:** {user_instructions_section}
+
+**Source Content (use if relevant):**
+{source_content}
+
+---
+
+**EXISTING REPORT:**
+
+{previous_report_content}
+
+---
+
+{length_instruction}
+
+Preserve all structure and content not affected by the modification.
+
+{formatting_rules}
+"""
+
+# ─── Prompt: Section-Level Revision — Identify Affected Sections ─────────────
+
+_IDENTIFY_SECTIONS_PROMPT = """You are analyzing a Markdown report to determine which sections need modification based on the user's request.
+
+**User's Modification Request:** {user_instructions}
+
+**Report Sections (indexed starting at 0):**
+{sections_listing}
+
+---
+
+Determine which sections need to be modified, added, or removed to fulfill the user's request.
+
+Return ONLY a JSON object with these fields:
+- "modify": Array of section indices (0-based) that need content changes
+- "add": Array of objects like {{"after_index": 2, "heading": "## New Section Title", "description": "What this section should cover"}} for new sections to insert
+- "remove": Array of section indices to remove entirely (use sparingly)
+- "reasoning": A brief explanation of your decisions
+
+Guidelines:
+- If the change is GLOBAL (e.g., "change the tone", "make the whole report shorter", "translate to Spanish"), include ALL section indices in "modify".
+- If the change is TARGETED (e.g., "expand the budget section", "fix the conclusion"), include ONLY the affected section indices.
+- For "add a section about X", use the "add" field with the appropriate insertion point.
+- Prefer modifying over removing+adding when possible.
+
+Return ONLY valid JSON, no markdown fences:
+"""
+
+# ─── Prompt: Section-Level Revision — Revise a Single Section ────────────────
+
+_REVISE_SECTION_PROMPT = """Revise ONLY this section based on the instructions. If the instructions don't apply, return it UNCHANGED.
+
+**Modification Instructions:** {user_instructions}
+
+**Current Section:**
+{section_content}
+
+**Context (surrounding sections — for coherence only, do NOT output them):**
+{context_sections}
+
+**Source Content:**
+{source_content}
+
+---
+
+Keep the same heading and heading level. Preserve content not affected by the modification.
+{formatting_rules}
+"""
+
+# ─── Prompt: New Section Generation (for section-level add) ─────────────────
+
+_NEW_SECTION_PROMPT = """You are an expert report writer. Write a new section to be inserted into an existing report.
+
+**Report Topic:** {topic}
+**Report Style:** {report_style}
+**Section Heading:** {heading}
+**Section Goal:** {description}
+**User Instructions:** {user_instructions}
+
+**Surrounding Context:**
+{context_sections}
+
+**Source Content:**
+{source_content}
+
+---
+
+**Rules:**
+1. Write ONLY this section, starting with the heading "{heading}".
+2. Ensure the section flows naturally with the surrounding context.
+3. Be comprehensive — cover the topic described above.
+{formatting_rules}
+
+Write the new section now:
+"""
+
+
+# ─── Utility Functions ──────────────────────────────────────────────────────
+
+
+def _strip_wrapping_code_fences(text: str) -> str:
+ """Remove wrapping code fences that LLMs often add around Markdown output.
+
+ Handles patterns like:
+ ```markdown\\n...content...\\n```
+ ````markdown\\n...content...\\n````
+ ```md\\n...content...\\n```
+ ```\\n...content...\\n```
+ ```json\\n...content...\\n```
+ Supports 3 or more backticks (LLMs escalate when content has triple-backtick blocks).
+ """
+ stripped = text.strip()
+ # Match opening fence with 3+ backticks and optional language tag
+ m = re.match(r"^(`{3,})(?:markdown|md|json)?\s*\n", stripped)
+ if m:
+ fence = m.group(1) # e.g. "```" or "````"
+ if stripped.endswith(fence):
+ stripped = stripped[m.end() :] # remove opening fence
+ stripped = stripped[: -len(fence)].rstrip() # remove closing fence
+ return stripped
+
+
+def _extract_metadata(content: str) -> dict[str, Any]:
+ """Extract metadata from generated Markdown content."""
+ # Count section headings
+ headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE)
+
+ # Word count
+ word_count = len(content.split())
+
+ # Character count
+ char_count = len(content)
+
+ return {
+ "status": "ready",
+ "word_count": word_count,
+ "char_count": char_count,
+ "section_count": len(headings),
+ }
+
+
+def _parse_sections(content: str) -> list[dict[str, str]]:
+ """Parse Markdown content into sections split by # and ## headings.
+
+ Returns a list of dicts: [{"heading": "## Title", "body": "content..."}, ...]
+ Content before the first heading is captured with heading="".
+ ### and deeper headings are kept inside their parent ## section's body.
+ """
+ lines = content.split("\n")
+ sections: list[dict[str, str]] = []
+ current_heading = ""
+ current_body_lines: list[str] = []
+ in_code_block = False
+
+ for line in lines:
+ # Track code blocks to avoid matching headings inside them
+ stripped = line.strip()
+ if stripped.startswith("```"):
+ in_code_block = not in_code_block
+
+ # Only split on # or ## headings (not ### or deeper) and only outside code blocks
+ is_section_heading = (
+ not in_code_block
+ and re.match(r"^#{1,2}\s+", line)
+ and not re.match(r"^#{3,}\s+", line)
+ )
+
+ if is_section_heading:
+ # Save previous section
+ if current_heading or current_body_lines:
+ sections.append(
+ {
+ "heading": current_heading,
+ "body": "\n".join(current_body_lines).strip(),
+ }
+ )
+ current_heading = line.strip()
+ current_body_lines = []
+ else:
+ current_body_lines.append(line)
+
+ # Save last section
+ if current_heading or current_body_lines:
+ sections.append(
+ {
+ "heading": current_heading,
+ "body": "\n".join(current_body_lines).strip(),
+ }
+ )
+
+ return sections
+
+
+def _stitch_sections(sections: list[dict[str, str]]) -> str:
+ """Stitch parsed sections back into a single Markdown string."""
+ parts = []
+ for section in sections:
+ if section["heading"]:
+ parts.append(section["heading"])
+ if section["body"]:
+ parts.append(section["body"])
+ return "\n\n".join(parts)
+
+
+# ─── Async Generation Helpers ───────────────────────────────────────────────
+
+
+async def _revise_with_sections(
+ llm: Any,
+ parent_content: str,
+ user_instructions: str,
+ source_content: str,
+ topic: str,
+ report_style: str,
+) -> str | None:
+ """Section-level revision: identify affected sections and revise only those.
+
+ Unchanged sections are kept byte-for-byte identical.
+ Returns the revised content, or None to trigger full-document revision fallback.
+ """
+ # Parse report into sections
+ sections = _parse_sections(parent_content)
+ if len(sections) < 2:
+ logger.info(
+ "[generate_report] Too few sections for section-level revision, using full revision"
+ )
+ return None
+
+ # Build a sections listing for the LLM
+ sections_listing = ""
+ for i, sec in enumerate(sections):
+ heading = sec["heading"] or "(preamble — content before first heading)"
+ body_preview = (
+ sec["body"][:200] + "..." if len(sec["body"]) > 200 else sec["body"]
+ )
+ sections_listing += f"\n[{i}] {heading}\n Preview: {body_preview}\n"
+
+ # Step 1: Ask LLM which sections need modification
+ identify_prompt = _IDENTIFY_SECTIONS_PROMPT.format(
+ user_instructions=user_instructions,
+ sections_listing=sections_listing,
+ )
+
+ try:
+ response = await llm.ainvoke([HumanMessage(content=identify_prompt)])
+ raw = response.content
+ if not raw or not isinstance(raw, str):
+ return None
+
+ raw = _strip_wrapping_code_fences(raw).strip()
+ json_match = re.search(r"\{[\s\S]*\}", raw)
+ if json_match:
+ raw = json_match.group(0)
+
+ plan = json.loads(raw)
+ modify_indices: list[int] = plan.get("modify", [])
+ add_sections: list[dict[str, Any]] = plan.get("add", [])
+ remove_indices: list[int] = plan.get("remove", [])
+ reasoning = plan.get("reasoning", "")
+
+ logger.info(
+ f"[generate_report] Section-level revision plan: "
+ f"modify={modify_indices}, add={len(add_sections)}, "
+ f"remove={remove_indices}, reasoning={reasoning}"
+ )
+ except Exception:
+ logger.warning(
+ "[generate_report] Failed to identify sections for revision, "
+ "falling back to full revision",
+ exc_info=True,
+ )
+ return None
+
+ # If ALL sections need modification, full revision is more efficient and coherent
+ if len(modify_indices) >= len(sections):
+ logger.info(
+ "[generate_report] All sections need modification, deferring to full revision"
+ )
+ return None
+
+ # Compute total operations for progress tracking
+ total_ops = len(modify_indices) + len(add_sections)
+ current_op = 0
+
+ # Emit plan summary
+ parts = []
+ if modify_indices:
+ parts.append(
+ f"modifying {len(modify_indices)} section{'s' if len(modify_indices) > 1 else ''}"
+ )
+ if add_sections:
+ parts.append(
+ f"adding {len(add_sections)} new section{'s' if len(add_sections) > 1 else ''}"
+ )
+ if remove_indices:
+ parts.append(
+ f"removing {len(remove_indices)} section{'s' if len(remove_indices) > 1 else ''}"
+ )
+ plan_summary = ", ".join(parts) if parts else "no changes needed"
+
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "revision_plan",
+ "message": plan_summary.capitalize(),
+ "modify_count": len(modify_indices),
+ "add_count": len(add_sections),
+ "remove_count": len(remove_indices),
+ "total_ops": total_ops,
+ },
+ )
+
+ # Step 2: Revise only the affected sections
+ revised_sections = list(sections) # shallow copy — unmodified sections stay as-is
+
+ for idx in modify_indices:
+ if idx < 0 or idx >= len(sections):
+ continue
+
+ current_op += 1
+ sec = sections[idx]
+
+ # Extract plain section name (strip markdown heading markers)
+ section_name = (
+ re.sub(r"^#+\s*", "", sec["heading"]).strip()
+ if sec["heading"]
+ else "Preamble"
+ )
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "revising_section",
+ "message": f"Revising: {section_name} ({current_op}/{total_ops})...",
+ },
+ )
+
+ section_content = (
+ f"{sec['heading']}\n\n{sec['body']}" if sec["heading"] else sec["body"]
+ )
+
+ # Build context from surrounding sections
+ context_parts = []
+ if idx > 0:
+ prev = sections[idx - 1]
+ prev_preview = prev["body"][:300] + (
+ "..." if len(prev["body"]) > 300 else ""
+ )
+ context_parts.append(
+ f"**Previous section:** {prev['heading']}\n{prev_preview}"
+ )
+ if idx < len(sections) - 1:
+ nxt = sections[idx + 1]
+ nxt_preview = nxt["body"][:300] + ("..." if len(nxt["body"]) > 300 else "")
+ context_parts.append(f"**Next section:** {nxt['heading']}\n{nxt_preview}")
+ context = (
+ "\n\n".join(context_parts) if context_parts else "(No surrounding sections)"
+ )
+
+ revise_prompt = _REVISE_SECTION_PROMPT.format(
+ user_instructions=user_instructions,
+ section_content=section_content,
+ context_sections=context,
+ source_content=source_content[:40000],
+ formatting_rules=_FORMATTING_RULES,
+ )
+
+ resp = await llm.ainvoke([HumanMessage(content=revise_prompt)])
+ revised_text = resp.content
+ if revised_text and isinstance(revised_text, str):
+ revised_text = _strip_wrapping_code_fences(revised_text).strip()
+ # Parse the LLM output back into heading + body
+ revised_parsed = _parse_sections(revised_text)
+ if revised_parsed:
+ revised_sections[idx] = revised_parsed[0]
+ else:
+ revised_sections[idx] = {
+ "heading": sec["heading"],
+ "body": revised_text,
+ }
+
+ logger.info(f"[generate_report] Revised section [{idx}]: {sec['heading']}")
+
+ # Step 3: Handle new section additions (insert in reverse order to preserve indices)
+ for add_info in sorted(
+ add_sections,
+ key=lambda x: x.get("after_index", len(revised_sections) - 1),
+ reverse=True,
+ ):
+ current_op += 1
+ after_idx = add_info.get("after_index", len(revised_sections) - 1)
+ heading = add_info.get("heading", "## New Section")
+ description = add_info.get("description", "")
+
+ # Extract plain section name for progress display
+ plain_heading = re.sub(r"^#+\s*", "", heading).strip()
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "adding_section",
+ "message": f"Adding: {plain_heading} ({current_op}/{total_ops})...",
+ },
+ )
+
+ # Build context from the surrounding sections at the insertion point
+ ctx_parts = []
+ if 0 <= after_idx < len(revised_sections):
+ before_sec = revised_sections[after_idx]
+ ctx_parts.append(
+ f"**Section before:** {before_sec['heading']}\n{before_sec['body'][:300]}"
+ )
+ insert_idx = min(after_idx + 1, len(revised_sections))
+ if insert_idx < len(revised_sections):
+ after_sec = revised_sections[insert_idx]
+ ctx_parts.append(
+ f"**Section after:** {after_sec['heading']}\n{after_sec['body'][:300]}"
+ )
+
+ new_prompt = _NEW_SECTION_PROMPT.format(
+ topic=topic,
+ report_style=report_style,
+ heading=heading,
+ description=description,
+ user_instructions=user_instructions,
+ context_sections="\n\n".join(ctx_parts) if ctx_parts else "(None)",
+ source_content=source_content[:30000],
+ formatting_rules=_FORMATTING_RULES,
+ )
+
+ resp = await llm.ainvoke([HumanMessage(content=new_prompt)])
+ new_content = resp.content
+ if new_content and isinstance(new_content, str):
+ new_content = _strip_wrapping_code_fences(new_content).strip()
+ new_parsed = _parse_sections(new_content)
+ if new_parsed:
+ revised_sections.insert(insert_idx, new_parsed[0])
+ else:
+ revised_sections.insert(
+ insert_idx,
+ {
+ "heading": heading,
+ "body": new_content,
+ },
+ )
+
+ logger.info(
+ f"[generate_report] Added new section after [{after_idx}]: {heading}"
+ )
+
+ # Step 4: Handle removals (reverse order to preserve indices)
+ for idx in sorted(remove_indices, reverse=True):
+ if 0 <= idx < len(revised_sections):
+ logger.info(
+ f"[generate_report] Removed section [{idx}]: "
+ f"{revised_sections[idx]['heading']}"
+ )
+ revised_sections.pop(idx)
+
+ return _stitch_sections(revised_sections)
+
+
+# ─── Tool Factory ───────────────────────────────────────────────────────────
+
+
+def create_generate_report_tool(
+ search_space_id: int,
+ thread_id: int | None = None,
+ connector_service: ConnectorService | None = None,
+ available_connectors: list[str] | None = None,
+ available_document_types: list[str] | None = None,
+):
+ """
+ Factory function to create the generate_report tool with injected dependencies.
+
+ The tool generates a Markdown report inline using the search space's
+ document summary LLM, saves it to the database, and returns immediately.
+
+ Uses short-lived database sessions for each DB operation so no connection
+ is held during the long LLM API call.
+
+ Generation strategies:
+ - New reports: single-shot generation (1 LLM call)
+ - Revisions (targeted edits): section-level (unchanged sections preserved)
+ - Revisions (global changes): full-document revision fallback
+
+ Source strategies:
+ - "provided"/"conversation": use only the supplied source_content
+ - "kb_search": search the knowledge base internally using targeted queries
+ - "auto": use source_content if sufficient, otherwise fall back to KB search
+
+ Args:
+ search_space_id: The user's search space ID
+ thread_id: The chat thread ID for associating the report
+ connector_service: Optional connector service for internal KB search.
+ When provided, the tool can search the knowledge base internally
+ (used by the "kb_search" and "auto" source strategies).
+ available_connectors: Optional list of connector types available in the
+ search space (used to scope internal KB searches).
+
+ Returns:
+ A configured tool function for generating reports
+ """
+
+ @tool
+ async def generate_report(
+ topic: str,
+ source_content: str = "",
+ source_strategy: str = "provided",
+ search_queries: list[str] | None = None,
+ report_style: str = "detailed",
+ user_instructions: str | None = None,
+ parent_report_id: int | None = None,
+ ) -> dict[str, Any]:
+ """
+ Generate a structured Markdown report artifact from provided content.
+
+ Use this tool when the user asks to create, generate, write, produce,
+ draft, or summarize into a report-style deliverable.
+
+ Trigger classes include:
+ - Direct trigger words WITH creation/modification verb: report,
+ document, memo, letter, template, article, guide, blog post,
+ one-pager, briefing, comprehensive guide.
+ - Creation-intent phrases: "write a report", "generate a document",
+ "draft a summary", "create an executive summary".
+ - Modification-intent phrases: "revise the report", "update the
+ report", "make it shorter", "add a section about X", "expand the
+ budget section", "rewrite in formal tone".
+
+ IMPORTANT — what does NOT count as "asking for a report":
+ - Questions or discussion about a report or its topic are NOT report
+ requests. Respond to these conversationally in chat.
+ Examples: "What other examples to put there?", "What else could be
+ added?", "Can you explain section 2?", "Is the data accurate?",
+ "What's missing?", "How could this be improved?", "What other
+ topics are related?"
+ - Quick summary requests, explanations, or follow-up questions.
+ - The test: Does the message contain a creation/modification VERB
+ (write, create, generate, draft, add, revise, update, expand,
+ rewrite, make) directed at producing a deliverable? If no verb
+ → answer in chat.
+
+ FORMAT/EXPORT RULE:
+ - Always generate the report content in Markdown.
+ - If the user requests DOCX/Word/PDF or another file format, export
+ from the generated Markdown report.
+
+ SOURCE STRATEGY (how to collect source material):
+ - source_strategy="conversation" — The conversation already has
+ enough context (prior Q&A, filesystem exploration, pasted text,
+ uploaded files, scraped webpages). Pass a thorough summary as
+ source_content.
+ - source_strategy="kb_search" — Search the knowledge base
+ internally. Provide 1-5 targeted search_queries. The tool
+ handles searching internally — do NOT manually read and dump
+ /documents/ files into source_content.
+ - source_strategy="provided" — Use only what is in source_content
+ (default, backward-compatible).
+ - source_strategy="auto" — Use source_content if it has enough
+ material; otherwise fall back to internal KB search using
+ search_queries.
+
+ CONVERSATION REUSE (HIGH PRIORITY):
+ - If the user has been asking questions in this chat and the
+ conversation contains substantive answers/discussion on the
+ topic, prefer source_strategy="conversation" with a thorough
+ summary of the full chat history as source_content.
+ - The user's prior questions and your answers ARE the source
+ material. Do NOT redundantly search the knowledge base for
+ information that is already in the chat.
+
+ VERSIONING — parent_report_id:
+ - Set parent_report_id when the user wants to MODIFY, REVISE,
+ IMPROVE, UPDATE, EXPAND, or ADD CONTENT TO an existing report
+ that was already generated in this conversation.
+ - This includes both explicit AND implicit modification requests.
+ If the user references the existing report using words like "it",
+ "this", "here", "the report", or clearly refers to a previously
+ generated report, treat it as a revision request.
+ - The value must be the report_id from a previous generate_report
+ result in this same conversation.
+ - Do NOT set parent_report_id when:
+ * The user asks for a report on a completely NEW/DIFFERENT topic
+ * The user says "generate another report" (new report, not revision)
+ * There is no prior report to reference
+
+ Examples of when to SET parent_report_id:
+ User: "Make that report shorter" → parent_report_id =
+ User: "Add a cost analysis section to the report" → parent_report_id =
+ User: "Rewrite the report in a more formal tone" → parent_report_id =
+ User: "I want more details about pricing in here" → parent_report_id =
+ User: "Include more examples" → parent_report_id =
+ User: "Can you also cover nutrition in this?" → parent_report_id =
+ User: "Make it more detailed" → parent_report_id =
+ User: "Not bad, but expand on the budget section" → parent_report_id =
+ User: "Also mention the competitor landscape" → parent_report_id =
+
+ Examples of when to LEAVE parent_report_id as None:
+ User: "Generate a report on climate change" → None (new topic)
+ User: "Write me a report about the budget" → None (new topic)
+ User: "Create another report, this time about marketing" → None
+ User: "Now write one about travel trends in Europe" → None (new topic)
+
+ Args:
+ topic: Short title for the report (max ~8 words).
+ source_content: Text to base the report on. Can be empty when
+ using source_strategy="kb_search".
+ source_strategy: How to collect source material. One of
+ "provided", "conversation", "kb_search", or "auto".
+ search_queries: When source_strategy is "kb_search" or "auto",
+ provide 1-5 targeted search queries for the knowledge base.
+ These should be specific, not just the topic repeated.
+ report_style: "detailed", "deep_research", or "brief".
+ user_instructions: Optional focus or modification instructions.
+ When revising (parent_report_id set), describe WHAT TO CHANGE.
+ parent_report_id: ID of a previous report to revise (creates new
+ version in the same version group).
+
+ Returns:
+ Dict with status, report_id, title, word_count, and message.
+ """
+ # Initialize version tracking variables (used by _save_failed_report closure)
+ parent_report_content: str | None = None
+ report_group_id: int | None = None
+
+ async def _save_failed_report(error_msg: str) -> int | None:
+ """Persist a failed report row using a short-lived session."""
+ try:
+ async with shielded_async_session() as session:
+ failed_report = Report(
+ title=topic,
+ content=None,
+ report_metadata={
+ "status": "failed",
+ "error_message": error_msg,
+ },
+ report_style=report_style,
+ search_space_id=search_space_id,
+ thread_id=thread_id,
+ report_group_id=report_group_id,
+ )
+ session.add(failed_report)
+ await session.commit()
+ await session.refresh(failed_report)
+ # If this is a new group (v1 failed), set group to self
+ if not failed_report.report_group_id:
+ failed_report.report_group_id = failed_report.id
+ await session.commit()
+ logger.info(
+ f"[generate_report] Saved failed report {failed_report.id}: {error_msg}"
+ )
+ return failed_report.id
+ except Exception:
+ logger.exception(
+ "[generate_report] Could not persist failed report row"
+ )
+ return None
+
+ try:
+ # ── Phase 1: READ (short-lived session) ──────────────────────
+ # Fetch parent report and LLM config, then close the session
+ # so no DB connection is held during the long LLM call.
+ async with shielded_async_session() as read_session:
+ if parent_report_id:
+ parent_report = await read_session.get(Report, parent_report_id)
+ if parent_report:
+ report_group_id = parent_report.report_group_id
+ parent_report_content = parent_report.content
+ logger.info(
+ f"[generate_report] Creating new version from parent {parent_report_id} "
+ f"(group {report_group_id})"
+ )
+ else:
+ logger.warning(
+ f"[generate_report] parent_report_id={parent_report_id} not found, "
+ "creating standalone report"
+ )
+
+ llm = await get_document_summary_llm(read_session, search_space_id)
+ # read_session closed — connection returned to pool
+
+ if not llm:
+ error_msg = (
+ "No LLM configured. Please configure a language model in Settings."
+ )
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": topic,
+ }
+
+ # Build the user instructions string
+ user_instructions_section = ""
+ if user_instructions:
+ user_instructions_section = (
+ f"**Additional Instructions:** {user_instructions}"
+ )
+
+ # ── Phase 1b: SOURCE COLLECTION (smart KB search) ────────────
+ # Decide whether to augment source_content with KB search results.
+ effective_source = source_content or ""
+
+ strategy = (source_strategy or "provided").lower().strip()
+
+ needs_kb_search = False
+ if strategy == "kb_search":
+ needs_kb_search = True
+ elif strategy == "auto":
+ # Heuristic: if source_content has fewer than 200 words,
+ # it's likely insufficient — augment with KB search.
+ word_count_estimate = len(effective_source.split())
+ if word_count_estimate < 200:
+ needs_kb_search = True
+ logger.info(
+ f"[generate_report] auto strategy: source has ~{word_count_estimate} words, "
+ "triggering KB search"
+ )
+ # "provided" and "conversation" → use source_content as-is
+
+ if needs_kb_search and connector_service and search_queries:
+ query_count = min(len(search_queries), 5)
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "kb_search",
+ "message": f"Searching knowledge base ({query_count} queries)...",
+ },
+ )
+ logger.info(
+ f"[generate_report] Running internal KB search with "
+ f"{query_count} queries: {search_queries[:5]}"
+ )
+ try:
+ from .knowledge_base import search_knowledge_base_async
+
+ # Run all queries in parallel, each with its own session
+ async def _run_single_query(q: str) -> str:
+ async with shielded_async_session() as kb_session:
+ kb_connector_svc = ConnectorService(
+ kb_session, search_space_id
+ )
+ return await search_knowledge_base_async(
+ query=q,
+ search_space_id=search_space_id,
+ db_session=kb_session,
+ connector_service=kb_connector_svc,
+ top_k=10,
+ available_connectors=available_connectors,
+ available_document_types=available_document_types,
+ )
+
+ kb_results = await asyncio.gather(
+ *[_run_single_query(q) for q in search_queries[:5]]
+ )
+
+ # Merge non-empty results into source_content
+ kb_text_parts = [r for r in kb_results if r and r.strip()]
+ if kb_text_parts:
+ kb_combined = "\n\n---\n\n".join(kb_text_parts)
+ if effective_source.strip():
+ effective_source = (
+ effective_source
+ + "\n\n--- Knowledge Base Search Results ---\n\n"
+ + kb_combined
+ )
+ else:
+ effective_source = kb_combined
+
+ # Count docs found (rough: count tags)
+ doc_count = kb_combined.count("")
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "kb_search_done",
+ "message": f"Found {doc_count} relevant documents"
+ if doc_count
+ else f"Found results from {len(kb_text_parts)} queries",
+ },
+ )
+ logger.info(
+ f"[generate_report] KB search added ~{len(kb_combined)} chars "
+ f"from {len(kb_text_parts)} queries"
+ )
+ else:
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "kb_search_done",
+ "message": "No results found in knowledge base",
+ },
+ )
+ logger.info("[generate_report] KB search returned no results")
+
+ except Exception as e:
+ logger.warning(
+ f"[generate_report] Internal KB search failed: {e}. "
+ "Proceeding with existing source_content."
+ )
+ elif needs_kb_search and not connector_service:
+ logger.warning(
+ "[generate_report] KB search requested but connector_service "
+ "not available. Using source_content as-is."
+ )
+ elif needs_kb_search and not search_queries:
+ logger.warning(
+ "[generate_report] KB search requested but no search_queries "
+ "provided. Using source_content as-is."
+ )
+
+ capped_source = effective_source[:100000] # Cap source content
+
+ # Length constraint — only when user explicitly asks for brevity
+ length_instruction = ""
+ if report_style == "brief":
+ length_instruction = (
+ "**LENGTH CONSTRAINT (MANDATORY):** The user wants a SHORT report. "
+ "Keep it concise — aim for ~400 words (~1 page) unless a different "
+ "length is specified in the Additional Instructions above. "
+ "Prioritize brevity over thoroughness. Do NOT write a long report."
+ )
+
+ # ── Phase 2: LLM GENERATION (no DB connection held) ──────────
+
+ report_content: str | None = None
+
+ if parent_report_content:
+ # ─── REVISION MODE ───────────────────────────────────────
+ # Strategy: Try section-level revision first (preserves
+ # unchanged sections byte-for-byte). Falls back to full-
+ # document revision if section identification fails or if
+ # all sections need changes.
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "revision_start",
+ "message": "Analyzing sections to modify...",
+ },
+ )
+ logger.info(
+ "[generate_report] Revision mode — attempting section-level revision"
+ )
+ report_content = await _revise_with_sections(
+ llm=llm,
+ parent_content=parent_report_content,
+ user_instructions=user_instructions
+ or "Improve and refine the report.",
+ source_content=capped_source,
+ topic=topic,
+ report_style=report_style,
+ )
+
+ if report_content is None:
+ # Fallback: full-document revision
+ dispatch_custom_event(
+ "report_progress",
+ {"phase": "writing", "message": "Rewriting your full report"},
+ )
+ logger.info(
+ "[generate_report] Section-level revision deferred, "
+ "using full-document revision"
+ )
+ prompt = _REVISION_PROMPT.format(
+ topic=topic,
+ report_style=report_style,
+ user_instructions_section=user_instructions_section
+ or "Improve and refine the report.",
+ source_content=capped_source,
+ previous_report_content=parent_report_content,
+ length_instruction=length_instruction,
+ formatting_rules=_FORMATTING_RULES,
+ )
+ response = await llm.ainvoke([HumanMessage(content=prompt)])
+ report_content = response.content
+
+ else:
+ # ─── NEW REPORT MODE ─────────────────────────────────────
+ # Single-shot generation: one LLM call produces the full
+ # report. Fast, globally coherent, and cost-efficient.
+ dispatch_custom_event(
+ "report_progress",
+ {"phase": "writing", "message": "Writing your report"},
+ )
+ logger.info(
+ "[generate_report] New report — using single-shot generation"
+ )
+ prompt = _REPORT_PROMPT.format(
+ topic=topic,
+ report_style=report_style,
+ user_instructions_section=user_instructions_section,
+ previous_version_section="",
+ source_content=capped_source,
+ length_instruction=length_instruction,
+ formatting_rules=_FORMATTING_RULES,
+ )
+ response = await llm.ainvoke([HumanMessage(content=prompt)])
+ report_content = response.content
+
+ # ── Validate LLM output ──────────────────────────────────────
+
+ if not report_content or not isinstance(report_content, str):
+ error_msg = "LLM returned empty or invalid content"
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": topic,
+ }
+
+ # LLMs often wrap output in ```markdown ... ``` fences — strip them
+ report_content = _strip_wrapping_code_fences(report_content)
+
+ if not report_content:
+ error_msg = "LLM returned empty or invalid content"
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": topic,
+ }
+
+ # Strip any existing footer(s) carried over from parent version(s)
+ while report_content.rstrip().endswith(_REPORT_FOOTER):
+ idx = report_content.rstrip().rfind(_REPORT_FOOTER)
+ report_content = report_content[:idx].rstrip()
+ if report_content.rstrip().endswith("---"):
+ report_content = report_content.rstrip()[:-3].rstrip()
+
+ # Append exactly one standard disclaimer
+ report_content += "\n\n---\n\n" + _REPORT_FOOTER
+
+ # Extract metadata (includes "status": "ready")
+ metadata = _extract_metadata(report_content)
+
+ # ── Phase 3: WRITE (short-lived session) ─────────────────────
+ # Save the report to the database, then close the session.
+ async with shielded_async_session() as write_session:
+ report = Report(
+ title=topic,
+ content=report_content,
+ report_metadata=metadata,
+ report_style=report_style,
+ search_space_id=search_space_id,
+ thread_id=thread_id,
+ report_group_id=report_group_id,
+ )
+ write_session.add(report)
+ await write_session.commit()
+ await write_session.refresh(report)
+
+ # If this is a brand-new report (v1), set report_group_id = own id
+ if not report.report_group_id:
+ report.report_group_id = report.id
+ await write_session.commit()
+
+ saved_report_id = report.id
+ saved_group_id = report.report_group_id
+ # write_session closed — connection returned to pool
+
+ logger.info(
+ f"[generate_report] Created report {saved_report_id} "
+ f"(group={saved_group_id}): "
+ f"{metadata.get('word_count', 0)} words, "
+ f"{metadata.get('section_count', 0)} sections"
+ )
+
+ return {
+ "status": "ready",
+ "report_id": saved_report_id,
+ "title": topic,
+ "word_count": metadata.get("word_count", 0),
+ "is_revision": bool(parent_report_content),
+ "report_markdown": report_content,
+ "message": f"Report generated successfully: {topic}",
+ }
+
+ except Exception as e:
+ error_message = str(e)
+ logger.exception(f"[generate_report] Error: {error_message}")
+ report_id = await _save_failed_report(error_message)
+
+ return {
+ "status": "failed",
+ "error": error_message,
+ "report_id": report_id,
+ "title": topic,
+ }
+
+ return generate_report
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py
new file mode 100644
index 0000000000..ece3ce241b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py
@@ -0,0 +1,799 @@
+"""Resume as Typst: LLM fills the body; backend prepends a template from ``_TEMPLATES`` and compiles."""
+
+import io
+import logging
+import re
+from datetime import UTC, datetime
+from typing import Any
+
+import pypdf
+import typst
+from langchain_core.callbacks import dispatch_custom_event
+from langchain_core.messages import HumanMessage
+from langchain_core.tools import tool
+
+from app.db import Report, shielded_async_session
+from app.services.llm_service import get_document_summary_llm
+
+logger = logging.getLogger(__name__)
+
+
+# ─── Template Registry ───────────────────────────────────────────────────────
+# Each template defines:
+# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders
+# component_reference - component docs shown to the LLM
+# rules - generation rules for the LLM
+
+_TEMPLATES: dict[str, dict[str, str]] = {
+ "classic": {
+ "header": """\
+#import "@preview/rendercv:0.3.0": *
+
+#show: rendercv.with(
+ name: "{name}",
+ title: "{name} - Resume",
+ footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }},
+ top-note: [ #emph[Last updated in {month_name} {year}] ],
+ locale-catalog-language: "en",
+ text-direction: ltr,
+ page-size: "us-letter",
+ page-top-margin: 0.7in,
+ page-bottom-margin: 0.7in,
+ page-left-margin: 0.7in,
+ page-right-margin: 0.7in,
+ page-show-footer: false,
+ page-show-top-note: true,
+ colors-body: rgb(0, 0, 0),
+ colors-name: rgb(0, 0, 0),
+ colors-headline: rgb(0, 0, 0),
+ colors-connections: rgb(0, 0, 0),
+ colors-section-titles: rgb(0, 0, 0),
+ colors-links: rgb(0, 0, 0),
+ colors-footer: rgb(128, 128, 128),
+ colors-top-note: rgb(128, 128, 128),
+ typography-line-spacing: 0.6em,
+ typography-alignment: "justified",
+ typography-date-and-location-column-alignment: right,
+ typography-font-family-body: "XCharter",
+ typography-font-family-name: "XCharter",
+ typography-font-family-headline: "XCharter",
+ typography-font-family-connections: "XCharter",
+ typography-font-family-section-titles: "XCharter",
+ typography-font-size-body: 10pt,
+ typography-font-size-name: 25pt,
+ typography-font-size-headline: 10pt,
+ typography-font-size-connections: 10pt,
+ typography-font-size-section-titles: 1.2em,
+ typography-small-caps-name: false,
+ typography-small-caps-headline: false,
+ typography-small-caps-connections: false,
+ typography-small-caps-section-titles: false,
+ typography-bold-name: false,
+ typography-bold-headline: false,
+ typography-bold-connections: false,
+ typography-bold-section-titles: true,
+ links-underline: true,
+ links-show-external-link-icon: false,
+ header-alignment: center,
+ header-photo-width: 3.5cm,
+ header-space-below-name: 0.7cm,
+ header-space-below-headline: 0.7cm,
+ header-space-below-connections: 0.7cm,
+ header-connections-hyperlink: true,
+ header-connections-show-icons: false,
+ header-connections-display-urls-instead-of-usernames: true,
+ header-connections-separator: "|",
+ header-connections-space-between-connections: 0.5cm,
+ section-titles-type: "with_full_line",
+ section-titles-line-thickness: 0.5pt,
+ section-titles-space-above: 0.5cm,
+ section-titles-space-below: 0.3cm,
+ sections-allow-page-break: true,
+ sections-space-between-text-based-entries: 0.15cm,
+ sections-space-between-regular-entries: 0.42cm,
+ entries-date-and-location-width: 4.15cm,
+ entries-side-space: 0cm,
+ entries-space-between-columns: 0.1cm,
+ entries-allow-page-break: false,
+ entries-short-second-row: false,
+ entries-degree-width: 1cm,
+ entries-summary-space-left: 0cm,
+ entries-summary-space-above: 0.08cm,
+ entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
+ entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
+ entries-highlights-space-left: 0cm,
+ entries-highlights-space-above: 0.08cm,
+ entries-highlights-space-between-items: 0.02cm,
+ entries-highlights-space-between-bullet-and-text: 0.3em,
+ date: datetime(
+ year: {year},
+ month: {month},
+ day: {day},
+ ),
+)
+
+""",
+ "component_reference": """\
+Available components (use ONLY these):
+
+= Full Name // Top-level heading — person's full name
+
+#connections( // Contact info row (pipe-separated)
+ [City, Country],
+ [#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]],
+ [#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]],
+ [#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]],
+)
+
+== Section Title // Section heading (arbitrary name)
+
+#regular-entry( // Work experience, projects, publications, etc.
+ [
+ #strong[Role/Title], Company Name -- Location
+ ],
+ [
+ Start -- End
+ ],
+ main-column-second-row: [
+ - Achievement or responsibility
+ - Another bullet point
+ ],
+)
+
+#education-entry( // Education entries
+ [
+ #strong[Institution], Degree in Field -- Location
+ ],
+ [
+ Start -- End
+ ],
+ main-column-second-row: [
+ - GPA, honours, relevant coursework
+ ],
+)
+
+#summary([Short paragraph summary]) // Optional summary inside an entry
+#content-area([Free-form content]) // Freeform text block
+
+For skills sections, use one bullet per category label:
+- #strong[Category:] item1, item2, item3
+
+For simple list sections (e.g. Honors), use plain bullet points:
+- Item one
+- Item two
+""",
+ "rules": """\
+RULES:
+- Do NOT include any #import or #show lines. Start directly with = Full Name.
+- Output ONLY valid Typst content. No explanatory text before or after.
+- Do NOT wrap output in ```typst code fences.
+- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate.
+- Escape @ symbols inside link labels with a backslash: email\\@example.com
+- Escape forward slashes in link display text: linkedin.com\\/in\\/user
+- Every section MUST use == heading.
+- Use #regular-entry() for experience, projects, publications, certifications, and similar entries.
+- Use #education-entry() for education.
+- For skills sections, use one bullet line per category with a bold label.
+- Keep content professional, concise, and achievement-oriented.
+- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.).
+- This template works for ALL professions — adapt sections to the user's field.
+- Default behavior should prioritize concise one-page content.
+""",
+ },
+}
+
+DEFAULT_TEMPLATE = "classic"
+MIN_RESUME_PAGES = 1
+MAX_RESUME_PAGES = 5
+MAX_COMPRESSION_ATTEMPTS = 2
+
+
+# ─── Template Helpers ─────────────────────────────────────────────────────────
+
+
+def _get_template(template_id: str | None = None) -> dict[str, str]:
+ """Get a template by ID, falling back to default."""
+ return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE])
+
+
+_MONTH_NAMES = [
+ "",
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+]
+
+
+def _build_header(template: dict[str, str], name: str) -> str:
+ """Build the template header with the person's name and current date."""
+ now = datetime.now(tz=UTC)
+ return (
+ template["header"]
+ .replace("{name}", name)
+ .replace("{year}", str(now.year))
+ .replace("{month}", str(now.month))
+ .replace("{day}", str(now.day))
+ .replace("{month_name}", _MONTH_NAMES[now.month])
+ )
+
+
+def _strip_header(full_source: str) -> str:
+ """Strip the import + show rule from stored source to get the body only.
+
+ Finds the closing parenthesis of the rendercv.with(...) block by tracking
+ nesting depth, then returns everything after it.
+ """
+ show_match = re.search(r"#show:\s*rendercv\.with\(", full_source)
+ if not show_match:
+ return full_source
+
+ start = show_match.end()
+ depth = 1
+ i = start
+ while i < len(full_source) and depth > 0:
+ if full_source[i] == "(":
+ depth += 1
+ elif full_source[i] == ")":
+ depth -= 1
+ i += 1
+
+ return full_source[i:].lstrip("\n")
+
+
+def _extract_name(body: str) -> str | None:
+ """Extract the person's full name from the = heading in the body."""
+ match = re.search(r"^=\s+(.+)$", body, re.MULTILINE)
+ return match.group(1).strip() if match else None
+
+
+def _strip_imports(body: str) -> str:
+ """Remove any #import or #show lines the LLM might accidentally include."""
+ lines = body.split("\n")
+ cleaned: list[str] = []
+ skip_show = False
+ depth = 0
+
+ for line in lines:
+ stripped = line.strip()
+
+ if stripped.startswith("#import"):
+ continue
+
+ if skip_show:
+ depth += stripped.count("(") - stripped.count(")")
+ if depth <= 0:
+ skip_show = False
+ continue
+
+ if stripped.startswith("#show:") and "rendercv" in stripped:
+ depth = stripped.count("(") - stripped.count(")")
+ if depth > 0:
+ skip_show = True
+ continue
+
+ cleaned.append(line)
+
+ result = "\n".join(cleaned).strip()
+ return result
+
+
+def _build_llm_reference(template: dict[str, str]) -> str:
+ """Build the LLM prompt reference from a template."""
+ return f"""\
+You MUST output valid Typst content for a resume.
+Do NOT include any #import or #show lines — those are handled automatically.
+Start directly with the = Full Name heading.
+
+{template["component_reference"]}
+
+{template["rules"]}"""
+
+
+# ─── Prompts ─────────────────────────────────────────────────────────────────
+
+_RESUME_PROMPT = """\
+You are an expert resume writer. Generate professional resume content as Typst markup.
+
+{llm_reference}
+
+**User Information:**
+{user_info}
+
+**Target Maximum Pages:** {max_pages}
+
+{user_instructions_section}
+
+Generate the resume content now (starting with = Full Name):
+"""
+
+_REVISION_PROMPT = """\
+You are an expert resume editor. Modify the existing resume according to the instructions.
+Apply ONLY the requested changes — do NOT rewrite sections that are not affected.
+
+{llm_reference}
+
+**Target Maximum Pages:** {max_pages}
+
+**Modification Instructions:** {user_instructions}
+
+**EXISTING RESUME CONTENT:**
+
+{previous_content}
+
+---
+
+Output the complete, updated resume content with the changes applied (starting with = Full Name):
+"""
+
+_FIX_COMPILE_PROMPT = """\
+The resume content you generated failed to compile. Fix the error while preserving all content.
+
+{llm_reference}
+
+**Compilation Error:**
+{error}
+
+**Full Typst Source (for context — error line numbers refer to this):**
+{full_source}
+
+**Your content starts after the template header. Output ONLY the content portion \
+(starting with = Full Name), NOT the #import or #show rule:**
+"""
+
+_COMPRESS_TO_PAGE_LIMIT_PROMPT = """\
+The resume compiles, but it exceeds the maximum allowed page count.
+Compress the resume while preserving high-impact accomplishments and role relevance.
+
+{llm_reference}
+
+**Target Maximum Pages:** {max_pages}
+**Current Page Count:** {actual_pages}
+**Compression Attempt:** {attempt_number}
+
+Compression priorities (in this order):
+1) Keep recent, high-impact, role-relevant bullets.
+2) Remove low-impact or redundant bullets.
+3) Shorten verbose wording while preserving meaning.
+4) Trim older or less relevant details before recent ones.
+
+Return the complete updated Typst content (starting with = Full Name), and keep it at or below the target pages.
+
+**EXISTING RESUME CONTENT:**
+{previous_content}
+"""
+
+
+# ─── Helpers ─────────────────────────────────────────────────────────────────
+
+
+def _strip_typst_fences(text: str) -> str:
+ """Remove wrapping ```typst ... ``` fences that LLMs sometimes add."""
+ stripped = text.strip()
+ m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped)
+ if m:
+ fence = m.group(1)
+ if stripped.endswith(fence):
+ stripped = stripped[m.end() :]
+ stripped = stripped[: -len(fence)].rstrip()
+ return stripped
+
+
+def _compile_typst(source: str) -> bytes:
+ """Compile Typst source to PDF bytes. Raises on failure."""
+ return typst.compile(source.encode("utf-8"))
+
+
+def _count_pdf_pages(pdf_bytes: bytes) -> int:
+ """Count the number of pages in compiled PDF bytes."""
+ with io.BytesIO(pdf_bytes) as pdf_stream:
+ reader = pypdf.PdfReader(pdf_stream)
+ return len(reader.pages)
+
+
+def _validate_max_pages(max_pages: int) -> int:
+ """Validate and normalize max_pages input."""
+ if MIN_RESUME_PAGES <= max_pages <= MAX_RESUME_PAGES:
+ return max_pages
+ msg = (
+ f"max_pages must be between {MIN_RESUME_PAGES} and "
+ f"{MAX_RESUME_PAGES}. Received: {max_pages}"
+ )
+ raise ValueError(msg)
+
+
+# ─── Tool Factory ───────────────────────────────────────────────────────────
+
+
+def create_generate_resume_tool(
+ search_space_id: int,
+ thread_id: int | None = None,
+):
+ """
+ Factory function to create the generate_resume tool.
+
+ Generates a Typst-based resume, validates it via compilation,
+ and stores the source in the Report table with content_type='typst'.
+ The LLM generates only the content body; the template header is
+ prepended by the backend.
+ """
+
+ @tool
+ async def generate_resume(
+ user_info: str,
+ user_instructions: str | None = None,
+ parent_report_id: int | None = None,
+ max_pages: int = 1,
+ ) -> dict[str, Any]:
+ """
+ Generate a professional resume as a Typst document.
+
+ Use this tool when the user asks to create, build, generate, write,
+ or draft a resume or CV. Also use it when the user wants to modify,
+ update, or revise an existing resume generated in this conversation.
+
+ Trigger phrases include:
+ - "build me a resume", "create my resume", "generate a CV"
+ - "update my resume", "change my title", "add my new job"
+ - "make my resume more concise", "reformat my resume"
+
+ Do NOT use this tool for:
+ - General questions about resumes or career advice
+ - Reviewing or critiquing a resume without changes
+ - Cover letters (use generate_report instead)
+
+ VERSIONING — parent_report_id:
+ - Set parent_report_id when the user wants to MODIFY an existing
+ resume that was already generated in this conversation.
+ - Leave as None for new resumes.
+
+ Args:
+ user_info: The user's resume content — work experience,
+ education, skills, contact info, etc. Can be structured
+ or unstructured text.
+ user_instructions: Optional style or content preferences
+ (e.g. "emphasize leadership", "keep it to one page",
+ "use a modern style"). For revisions, describe what to change.
+ parent_report_id: ID of a previous resume to revise (creates
+ new version in the same version group).
+ max_pages: Maximum number of pages for the generated resume.
+ Defaults to 1. Allowed range: 1-5.
+
+ Returns:
+ Dict with status, report_id, title, and content_type.
+ """
+ report_group_id: int | None = None
+ parent_content: str | None = None
+
+ template = _get_template()
+ llm_reference = _build_llm_reference(template)
+
+ async def _save_failed_report(error_msg: str) -> int | None:
+ try:
+ async with shielded_async_session() as session:
+ failed = Report(
+ title="Resume",
+ content=None,
+ content_type="typst",
+ report_metadata={
+ "status": "failed",
+ "error_message": error_msg,
+ },
+ report_style="resume",
+ search_space_id=search_space_id,
+ thread_id=thread_id,
+ report_group_id=report_group_id,
+ )
+ session.add(failed)
+ await session.commit()
+ await session.refresh(failed)
+ if not failed.report_group_id:
+ failed.report_group_id = failed.id
+ await session.commit()
+ logger.info(
+ f"[generate_resume] Saved failed report {failed.id}: {error_msg}"
+ )
+ return failed.id
+ except Exception:
+ logger.exception(
+ "[generate_resume] Could not persist failed report row"
+ )
+ return None
+
+ try:
+ try:
+ validated_max_pages = _validate_max_pages(max_pages)
+ except ValueError as e:
+ error_msg = str(e)
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ # ── Phase 1: READ ─────────────────────────────────────────────
+ async with shielded_async_session() as read_session:
+ if parent_report_id:
+ parent_report = await read_session.get(Report, parent_report_id)
+ if parent_report:
+ report_group_id = parent_report.report_group_id
+ parent_content = parent_report.content
+ logger.info(
+ f"[generate_resume] Revising from parent {parent_report_id} "
+ f"(group {report_group_id})"
+ )
+
+ llm = await get_document_summary_llm(read_session, search_space_id)
+
+ if not llm:
+ error_msg = (
+ "No LLM configured. Please configure a language model in Settings."
+ )
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ # ── Phase 2: LLM GENERATION ───────────────────────────────────
+
+ user_instructions_section = ""
+ if user_instructions:
+ user_instructions_section = (
+ f"**Additional Instructions:** {user_instructions}"
+ )
+
+ if parent_content:
+ dispatch_custom_event(
+ "report_progress",
+ {"phase": "writing", "message": "Updating your resume"},
+ )
+ parent_body = _strip_header(parent_content)
+ prompt = _REVISION_PROMPT.format(
+ llm_reference=llm_reference,
+ max_pages=validated_max_pages,
+ user_instructions=user_instructions
+ or "Improve and refine the resume.",
+ previous_content=parent_body,
+ )
+ else:
+ dispatch_custom_event(
+ "report_progress",
+ {"phase": "writing", "message": "Building your resume"},
+ )
+ prompt = _RESUME_PROMPT.format(
+ llm_reference=llm_reference,
+ user_info=user_info,
+ max_pages=validated_max_pages,
+ user_instructions_section=user_instructions_section,
+ )
+
+ response = await llm.ainvoke([HumanMessage(content=prompt)])
+ body = response.content
+
+ if not body or not isinstance(body, str):
+ error_msg = "LLM returned empty or invalid content"
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ body = _strip_typst_fences(body)
+ body = _strip_imports(body)
+
+ # ── Phase 3: ASSEMBLE + COMPILE ───────────────────────────────
+ dispatch_custom_event(
+ "report_progress",
+ {"phase": "compiling", "message": "Compiling resume..."},
+ )
+
+ name = _extract_name(body) or "Resume"
+ typst_source = ""
+ actual_pages = 0
+ compression_attempts = 0
+ target_page_met = False
+
+ for compression_round in range(MAX_COMPRESSION_ATTEMPTS + 1):
+ header = _build_header(template, name)
+ typst_source = header + body
+ compile_error: str | None = None
+ pdf_bytes: bytes | None = None
+
+ for compile_attempt in range(2):
+ try:
+ pdf_bytes = _compile_typst(typst_source)
+ compile_error = None
+ break
+ except Exception as e:
+ compile_error = str(e)
+ logger.warning(
+ "[generate_resume] Compile attempt %s failed: %s",
+ compile_attempt + 1,
+ compile_error,
+ )
+
+ if compile_attempt == 0:
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "fixing",
+ "message": "Fixing compilation issue...",
+ },
+ )
+ fix_prompt = _FIX_COMPILE_PROMPT.format(
+ llm_reference=llm_reference,
+ error=compile_error,
+ full_source=typst_source,
+ )
+ fix_response = await llm.ainvoke(
+ [HumanMessage(content=fix_prompt)]
+ )
+ if fix_response.content and isinstance(
+ fix_response.content, str
+ ):
+ body = _strip_typst_fences(fix_response.content)
+ body = _strip_imports(body)
+ name = _extract_name(body) or name
+ header = _build_header(template, name)
+ typst_source = header + body
+
+ if compile_error or not pdf_bytes:
+ error_msg = (
+ "Typst compilation failed after 2 attempts: "
+ f"{compile_error or 'Unknown compile error'}"
+ )
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ actual_pages = _count_pdf_pages(pdf_bytes)
+ if actual_pages <= validated_max_pages:
+ target_page_met = True
+ break
+
+ if compression_round >= MAX_COMPRESSION_ATTEMPTS:
+ break
+
+ compression_attempts += 1
+ dispatch_custom_event(
+ "report_progress",
+ {
+ "phase": "compressing",
+ "message": f"Condensing resume to {validated_max_pages} page(s)...",
+ },
+ )
+ compress_prompt = _COMPRESS_TO_PAGE_LIMIT_PROMPT.format(
+ llm_reference=llm_reference,
+ max_pages=validated_max_pages,
+ actual_pages=actual_pages,
+ attempt_number=compression_attempts,
+ previous_content=body,
+ )
+ compress_response = await llm.ainvoke(
+ [HumanMessage(content=compress_prompt)]
+ )
+ if not compress_response.content or not isinstance(
+ compress_response.content, str
+ ):
+ error_msg = "LLM returned empty content while compressing resume"
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ body = _strip_typst_fences(compress_response.content)
+ body = _strip_imports(body)
+ name = _extract_name(body) or name
+
+ if actual_pages > MAX_RESUME_PAGES:
+ error_msg = (
+ "Resume exceeds hard page limit after compression retries. "
+ f"Hard limit: <= {MAX_RESUME_PAGES} page(s), actual: {actual_pages}."
+ )
+ report_id = await _save_failed_report(error_msg)
+ return {
+ "status": "failed",
+ "error": error_msg,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ # ── Phase 4: SAVE ─────────────────────────────────────────────
+ dispatch_custom_event(
+ "report_progress",
+ {"phase": "saving", "message": "Saving your resume"},
+ )
+
+ resume_title = f"{name} - Resume" if name != "Resume" else "Resume"
+
+ metadata: dict[str, Any] = {
+ "status": "ready",
+ "word_count": len(typst_source.split()),
+ "char_count": len(typst_source),
+ "target_max_pages": validated_max_pages,
+ "actual_page_count": actual_pages,
+ "page_limit_enforced": True,
+ "compression_attempts": compression_attempts,
+ "target_page_met": target_page_met,
+ }
+
+ async with shielded_async_session() as write_session:
+ report = Report(
+ title=resume_title,
+ content=typst_source,
+ content_type="typst",
+ report_metadata=metadata,
+ report_style="resume",
+ search_space_id=search_space_id,
+ thread_id=thread_id,
+ report_group_id=report_group_id,
+ )
+ write_session.add(report)
+ await write_session.commit()
+ await write_session.refresh(report)
+
+ if not report.report_group_id:
+ report.report_group_id = report.id
+ await write_session.commit()
+
+ saved_id = report.id
+
+ logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}")
+
+ return {
+ "status": "ready",
+ "report_id": saved_id,
+ "title": resume_title,
+ "content_type": "typst",
+ "is_revision": bool(parent_content),
+ "message": (
+ f"Resume generated successfully: {resume_title}"
+ if target_page_met
+ else (
+ f"Resume generated, but could not fit the target of <= {validated_max_pages} "
+ f"page(s). Final length: {actual_pages} page(s)."
+ )
+ ),
+ }
+
+ except Exception as e:
+ error_message = str(e)
+ logger.exception(f"[generate_resume] Error: {error_message}")
+ report_id = await _save_failed_report(error_message)
+ return {
+ "status": "failed",
+ "error": error_message,
+ "report_id": report_id,
+ "title": "Resume",
+ "content_type": "typst",
+ }
+
+ return generate_resume
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py
new file mode 100644
index 0000000000..a9f3447aba
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py
@@ -0,0 +1,80 @@
+"""Factory for a video-presentation tool that queues background work and returns an ID for polling."""
+
+from typing import Any
+
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import VideoPresentation, VideoPresentationStatus, shielded_async_session
+
+
+def create_generate_video_presentation_tool(
+ search_space_id: int,
+ db_session: AsyncSession,
+ thread_id: int | None = None,
+):
+ """Create ``generate_video_presentation`` with bound search space and thread; writes use a tool-local session."""
+ del db_session # writes use a fresh tool-local session, see below
+
+ @tool
+ async def generate_video_presentation(
+ source_content: str,
+ video_title: str = "SurfSense Presentation",
+ user_prompt: str | None = None,
+ ) -> dict[str, Any]:
+ """Generate a video presentation from the provided content.
+
+ Use this tool when the user asks to create a video, presentation, slides, or slide deck.
+
+ Args:
+ source_content: The text content to turn into a presentation.
+ video_title: Title for the presentation (default: "SurfSense Presentation")
+ user_prompt: Optional style/tone instructions.
+ """
+ try:
+ # One DB session per tool call so parallel invocations never share an AsyncSession.
+ async with shielded_async_session() as session:
+ video_pres = VideoPresentation(
+ title=video_title,
+ status=VideoPresentationStatus.PENDING,
+ search_space_id=search_space_id,
+ thread_id=thread_id,
+ )
+ session.add(video_pres)
+ await session.commit()
+ await session.refresh(video_pres)
+ video_pres_id = video_pres.id
+
+ from app.tasks.celery_tasks.video_presentation_tasks import (
+ generate_video_presentation_task,
+ )
+
+ task = generate_video_presentation_task.delay(
+ video_presentation_id=video_pres_id,
+ source_content=source_content,
+ search_space_id=search_space_id,
+ user_prompt=user_prompt,
+ )
+
+ print(
+ f"[generate_video_presentation] Created video presentation {video_pres_id}, task: {task.id}"
+ )
+
+ return {
+ "status": VideoPresentationStatus.PENDING.value,
+ "video_presentation_id": video_pres_id,
+ "title": video_title,
+ "message": "Video presentation generation started. This may take a few minutes.",
+ }
+
+ except Exception as e:
+ error_message = str(e)
+ print(f"[generate_video_presentation] Error: {error_message}")
+ return {
+ "status": VideoPresentationStatus.FAILED.value,
+ "error": error_message,
+ "title": video_title,
+ "video_presentation_id": None,
+ }
+
+ return generate_video_presentation
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py
new file mode 100644
index 0000000000..c6a0220ec7
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py
@@ -0,0 +1,92 @@
+"""``knowledge_base`` route: full and read-only ``SurfSenseSubagentSpec`` builders.
+
+KB owns its destructive-FS approval ruleset (:data:`KB_RULESET`); rules
+are layered into KB's :class:`PermissionMiddleware` (built inside
+``build_kb_middleware``). One emitter, one wire format, one source of truth.
+"""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+from deepagents import SubAgent
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.permissions import Rule, Ruleset
+
+from .middleware_stack import build_kb_middleware
+from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
+from .tools.index import DESTRUCTIVE_FS_OPS
+
+NAME = "knowledge_base"
+READONLY_NAME = "knowledge_base_readonly"
+
+KB_RULESET = Ruleset(
+ origin=NAME,
+ rules=[Rule(permission=op, pattern="*", action="ask") for op in DESTRUCTIVE_FS_OPS],
+)
+
+_KB_READONLY_RULESET = Ruleset(origin=READONLY_NAME, rules=[])
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ del mcp_tools
+ llm = model if model is not None else dependencies["llm"]
+ filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
+ spec = cast(
+ SubAgent,
+ {
+ "name": NAME,
+ "description": load_description(),
+ "system_prompt": load_system_prompt(filesystem_mode),
+ "model": llm,
+ "tools": [],
+ "middleware": build_kb_middleware(
+ llm=llm,
+ dependencies=dependencies,
+ middleware_stack=middleware_stack,
+ read_only=False,
+ subagent_name=NAME,
+ ruleset=KB_RULESET,
+ ),
+ },
+ )
+ return SurfSenseSubagentSpec(spec=spec, ruleset=KB_RULESET)
+
+
+def build_readonly_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+) -> SurfSenseSubagentSpec:
+ llm = model if model is not None else dependencies["llm"]
+ filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
+ spec = cast(
+ SubAgent,
+ {
+ "name": READONLY_NAME,
+ "description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
+ "system_prompt": load_readonly_system_prompt(filesystem_mode),
+ "model": llm,
+ "tools": [],
+ "middleware": build_kb_middleware(
+ llm=llm,
+ dependencies=dependencies,
+ middleware_stack=middleware_stack,
+ read_only=True,
+ subagent_name=READONLY_NAME,
+ ruleset=None,
+ ),
+ },
+ )
+ return SurfSenseSubagentSpec(spec=spec, ruleset=_KB_READONLY_RULESET)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/ask_knowledge_base_tool.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/ask_knowledge_base_tool.py
new file mode 100644
index 0000000000..1708fe52fc
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/ask_knowledge_base_tool.py
@@ -0,0 +1,80 @@
+"""Wrap the read-only knowledge_base runnable as the ``ask_knowledge_base`` tool."""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from langchain.tools import BaseTool, ToolRuntime
+from langchain_core.messages import HumanMessage, ToolMessage
+from langchain_core.runnables import Runnable
+from langchain_core.tools import StructuredTool
+from langgraph.types import Command
+
+from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.config import (
+ subagent_invoke_config,
+)
+from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.constants import (
+ EXCLUDED_STATE_KEYS,
+)
+
+from .prompts import load_readonly_description
+
+TOOL_NAME = "ask_knowledge_base"
+
+
+def _forward_state(runtime: ToolRuntime, query: str) -> dict:
+ forwarded = {k: v for k, v in runtime.state.items() if k not in EXCLUDED_STATE_KEYS}
+ forwarded["messages"] = [HumanMessage(content=query)]
+ return forwarded
+
+
+def _wrap_result(result: dict, tool_call_id: str) -> Command:
+ messages = result.get("messages") or []
+ if not messages:
+ raise ValueError(
+ "knowledge_base_readonly returned an empty 'messages' list; "
+ "expected at least one assistant message."
+ )
+ last_text = (getattr(messages[-1], "text", None) or "").rstrip()
+ return Command(
+ update={"messages": [ToolMessage(last_text, tool_call_id=tool_call_id)]}
+ )
+
+
+def build_ask_knowledge_base_tool(kb_readonly_runnable: Runnable) -> BaseTool:
+ def ask_knowledge_base(
+ query: Annotated[
+ str,
+ "Full question for the workspace specialist. Include all path hints, "
+ "filters, and constraints the specialist needs to answer.",
+ ],
+ runtime: ToolRuntime,
+ ) -> str | Command:
+ if not runtime.tool_call_id:
+ raise ValueError("Tool call ID is required for ask_knowledge_base")
+ sub_state = _forward_state(runtime, query)
+ sub_config = subagent_invoke_config(runtime)
+ result = kb_readonly_runnable.invoke(sub_state, config=sub_config)
+ return _wrap_result(result, runtime.tool_call_id)
+
+ async def aask_knowledge_base(
+ query: Annotated[
+ str,
+ "Full question for the workspace specialist. Include all path hints, "
+ "filters, and constraints the specialist needs to answer.",
+ ],
+ runtime: ToolRuntime,
+ ) -> str | Command:
+ if not runtime.tool_call_id:
+ raise ValueError("Tool call ID is required for ask_knowledge_base")
+ sub_state = _forward_state(runtime, query)
+ sub_config = subagent_invoke_config(runtime)
+ result = await kb_readonly_runnable.ainvoke(sub_state, config=sub_config)
+ return _wrap_result(result, runtime.tool_call_id)
+
+ return StructuredTool.from_function(
+ name=TOOL_NAME,
+ func=ask_knowledge_base,
+ coroutine=aask_knowledge_base,
+ description=load_readonly_description(),
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/description.md
new file mode 100644
index 0000000000..897d387695
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/description.md
@@ -0,0 +1,2 @@
+Specialist for the user's workspace (documents and folders).
+Use proactively when the user wants to create, read, edit, search, organise, or remove a document or folder.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/description_readonly.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/description_readonly.md
new file mode 100644
index 0000000000..d6837ec923
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/description_readonly.md
@@ -0,0 +1,5 @@
+Read-only specialist for the user's workspace (documents and folders). Use to find, read, search, or quote a document or folder when your task needs workspace context — instead of asking the user or guessing.
+
+Pass your full question as one string. The specialist runs in isolation: it cannot see this thread, so include any path hints, filters, or constraints it needs.
+
+The specialist returns plain prose with absolute paths.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py
new file mode 100644
index 0000000000..778bb250c1
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py
@@ -0,0 +1,117 @@
+"""Middleware list shared by the full and read-only knowledge_base compiles.
+
+The KB-owned :class:`PermissionMiddleware` slot is what enforces
+"ask before destructive FS op" for KB tools.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+
+from app.agents.multi_agent_chat.middleware.shared.anthropic_cache import (
+ build_anthropic_cache_mw,
+)
+from app.agents.multi_agent_chat.middleware.shared.compaction import (
+ build_compaction_mw,
+)
+from app.agents.multi_agent_chat.middleware.shared.filesystem import (
+ build_filesystem_mw,
+)
+from app.agents.multi_agent_chat.middleware.shared.kb_context_projection import (
+ build_kb_context_projection_mw,
+)
+from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import (
+ build_patch_tool_calls_mw,
+)
+from app.agents.multi_agent_chat.middleware.shared.permissions import (
+ build_permission_mw,
+)
+from app.agents.new_chat.feature_flags import AgentFeatureFlags
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+from app.agents.new_chat.permissions import Ruleset
+
+
+def _kb_user_allowlist(
+ dependencies: dict[str, Any], subagent_name: str
+) -> Ruleset | None:
+ """Return the user's persisted allow-rules for ``subagent_name`` if any.
+
+ KB does not currently expose an "Always Allow" UI surface (the FE
+ button is MCP-only today), but the wiring is symmetrical with the
+ connector subagents so that adding KB trust later is a one-line
+ backend change.
+ """
+ by_subagent = dependencies.get("user_allowlist_by_subagent") or {}
+ user_allowlist = by_subagent.get(subagent_name)
+ if isinstance(user_allowlist, Ruleset) and user_allowlist.rules:
+ return user_allowlist
+ return None
+
+
+def build_kb_middleware(
+ *,
+ llm: BaseChatModel,
+ dependencies: dict[str, Any],
+ middleware_stack: dict[str, Any] | None,
+ read_only: bool,
+ subagent_name: str,
+ ruleset: Ruleset | None = None,
+) -> list[Any]:
+ """Compose the KB subagent's middleware list.
+
+ Args:
+ subagent_name: Identity of the subagent being built (e.g.
+ ``"knowledge_base"``, ``"knowledge_base_readonly"``). Used to
+ look up the user's persistent allow-list bucket in
+ ``dependencies["user_allowlist_by_subagent"]``.
+ ruleset: The KB-owned permission ruleset (typically the
+ destructive-FS ``ask`` rules). When provided, a dedicated
+ :class:`PermissionMiddleware` is appended so KB enforces
+ approval at the rule layer. The user's persistent allow-list
+ for ``subagent_name`` is layered after ``ruleset`` so user
+ ``allow`` rules override coded ``ask`` rules via
+ last-match-wins.
+ """
+ mws = middleware_stack or {}
+ filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
+ flags: AgentFeatureFlags | None = dependencies.get("flags")
+ resilience_mws = [
+ m
+ for m in (
+ mws.get("retry"),
+ mws.get("fallback"),
+ mws.get("model_call_limit"),
+ mws.get("tool_call_limit"),
+ )
+ if m is not None
+ ]
+ permission_mw = None
+ if ruleset is not None and flags is not None:
+ rulesets: list[Ruleset] = [ruleset]
+ user_allowlist = _kb_user_allowlist(dependencies, subagent_name)
+ if user_allowlist is not None:
+ rulesets.append(user_allowlist)
+ permission_mw = build_permission_mw(
+ flags=flags,
+ subagent_rulesets=rulesets,
+ trusted_tool_saver=dependencies.get("trusted_tool_saver"),
+ )
+ return [
+ mws["todos"],
+ build_kb_context_projection_mw(),
+ build_filesystem_mw(
+ backend_resolver=dependencies["backend_resolver"],
+ filesystem_mode=filesystem_mode,
+ search_space_id=dependencies["search_space_id"],
+ user_id=dependencies.get("user_id"),
+ thread_id=dependencies.get("thread_id"),
+ read_only=read_only,
+ ),
+ build_compaction_mw(llm),
+ build_patch_tool_calls_mw(),
+ *([permission_mw] if permission_mw is not None else []),
+ *resilience_mws,
+ build_anthropic_cache_mw(),
+ ]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/prompts.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/prompts.py
new file mode 100644
index 0000000000..617bb2a85a
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/prompts.py
@@ -0,0 +1,34 @@
+"""Prompt loaders for the knowledge_base subagent."""
+
+from __future__ import annotations
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.new_chat.filesystem_selection import FilesystemMode
+
+
+def load_system_prompt(filesystem_mode: FilesystemMode) -> str:
+ stem = (
+ "system_prompt_cloud"
+ if filesystem_mode == FilesystemMode.CLOUD
+ else "system_prompt_desktop"
+ )
+ return read_md_file(__package__, stem).strip()
+
+
+def load_readonly_system_prompt(filesystem_mode: FilesystemMode) -> str:
+ stem = (
+ "system_prompt_readonly_cloud"
+ if filesystem_mode == FilesystemMode.CLOUD
+ else "system_prompt_readonly_desktop"
+ )
+ return read_md_file(__package__, stem).strip()
+
+
+def load_description() -> str:
+ return read_md_file(__package__, "description").strip() or (
+ "Handles knowledge-base reads, writes, edits, and organisation."
+ )
+
+
+def load_readonly_description() -> str:
+ return read_md_file(__package__, "description_readonly").strip()
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_cloud.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_cloud.md
new file mode 100644
index 0000000000..60cafb30c3
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_cloud.md
@@ -0,0 +1,122 @@
+You are the SurfSense knowledge base specialist for the user's `/documents/` workspace.
+
+## Required inputs
+
+**Resolve paths from the supervisor's task text before asking.**
+
+- If the supervisor already provided a precise path (e.g. `/documents/notes/2026-05-11.md`), use it directly — skip the lookup steps below.
+- Otherwise, most requests reference documents by description (`"my meeting notes from last week"`, `"the design doc"`). Resolve them yourself:
+ 1. Consult `` — it's a hint about top-K likely matches, not a directive. Skip when the ranked entries don't fit the task.
+ 2. Walk `` for descriptive folder/filename matches.
+ 3. Use the `glob` tool for filename patterns the tree didn't surface, and the `grep` tool when the description points at *content* rather than a name.
+ 4. Only return `status=blocked` with `missing_fields=["path"]` when the description is genuinely ambiguous after a thorough lookup.
+
+For writes (where you choose the path yourself):
+
+- **Discover the user's existing conventions before inventing a path.** Scan `` for folders that already hold similar content (e.g. an existing `/documents/meetings/` with dated standup notes, or `/documents/projects//`). When a convention exists, follow it. Use `ls`, `glob`, or `grep` to look closer when the tree is truncated.
+- Only choose a brand-new path when no relevant convention exists. Prefer a clear folder hierarchy with a descriptive filename.
+- Use the `temp_` prefix only for scratch content you do **not** want persisted.
+- Prefer the `edit_file` tool over rewriting an entire document.
+
+## Interpreting tool results
+
+The FS tools return free-form text rather than structured fields:
+
+- **Success** — a confirmation message that names the path (e.g. `"Updated file /documents/foo.md"`, `"Successfully replaced 2 instance(s) of the string in '/documents/foo.md'"`) or the file's content (for reads).
+- **Failure** — text starting with `"Error: "` followed by a cause (e.g. `"Error: File '/documents/x.md' not found"`).
+- **HITL declined** — a runtime-supplied rejection message in place of the tool's output.
+
+Map outcomes to your `status`:
+
+- Clean success message or content returned → `status=success`.
+- `"Error: …not found"` → `status=blocked` with `next_step="Document '' was not found. Ask the user to confirm or provide more detail."`.
+- Any other `"Error: …"` → `status=error` and relay the tool's message verbatim as `next_step`.
+- HITL rejection → `status=blocked` with `next_step="User declined this filesystem action. Do not retry."`.
+
+You construct the structured `evidence` fields from your own knowledge of what you called and what you observed — the tools do not return them. Never report values you did not actually see.
+
+## Examples
+
+**Example 1 — happy path write (path discovered from existing convention):**
+
+- *Supervisor task:* `"Save these meeting notes to my KB: "`
+- *You:* scan `` and spot `/documents/meetings/` already holding files like `2026-05-04-standup.md` and `2026-04-27-standup.md` — the user's convention is dated meeting notes under that folder. → `write_file("/documents/meetings/2026-05-11-meeting.md", content)` → success.
+- *Output:*
+
+ ```json
+ {
+ "status": "success",
+ "action_summary": "Created /documents/meetings/2026-05-11-meeting.md.",
+ "evidence": {
+ "operation": "write_file",
+ "path": "/documents/meetings/2026-05-11-meeting.md",
+ "matched_candidates": null,
+ "content_excerpt": null,
+ "chunk_ids": null
+ },
+ "next_step": null,
+ "missing_fields": null,
+ "assumptions": ["Followed the existing /documents/meetings/-.md convention from "]
+ }
+ ```
+
+**Example 2 — edit by inference:**
+
+- *Supervisor task:* `"Add a bullet about the new feature flag to my Q2 roadmap"`
+- *You:* search for the roadmap doc — check `` and `` first; if neither surfaces it, widen with the `glob` tool (try filename patterns the user's language suggests) or the `grep` tool (search by content). Suppose `` hits `/documents/planning/q2-roadmap.md` → `read_file("/documents/planning/q2-roadmap.md")` → `edit_file("/documents/planning/q2-roadmap.md", old, new)` → success.
+- *Output:* `status=success`, evidence includes path and the inserted snippet.
+
+**Example 3 — blocked, multiple candidates:**
+
+- *Supervisor task:* `"Update the design doc."`
+- *You:* `` shows several plausible design docs and the task gives no further hint. Do not pick arbitrarily.
+- *Output:*
+
+ ```json
+ {
+ "status": "blocked",
+ "action_summary": "Multiple design docs exist; cannot pick without more detail.",
+ "evidence": {
+ "operation": null,
+ "path": null,
+ "matched_candidates": [
+ { "id": "/documents/design/payment-flow.md", "label": "Payment Flow" },
+ { "id": "/documents/design/auth-rework.md", "label": "Auth Rework" }
+ ],
+ "content_excerpt": null,
+ "chunk_ids": null
+ },
+ "next_step": "Ask the user which design doc to update.",
+ "missing_fields": ["path"],
+ "assumptions": null
+ }
+ ```
+
+## Output contract
+
+Return **only** one JSON object (no markdown or prose outside it):
+
+```json
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "operation": "write_file" | "edit_file" | "read_file" | "ls" | "glob" | "grep" | "mkdir" | "move_file" | "rm" | "rmdir" | "list_tree" | null,
+ "path": string | null,
+ "matched_candidates": [ { "id": string, "label": string } ] | null,
+ "content_excerpt": string | null,
+ "chunk_ids": string[] | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+```
+
+Rules:
+
+- `status=success` → `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` → `next_step` must be non-null.
+- `status=blocked` due to missing required inputs → `missing_fields` must be non-null.
+
+Infer before you call; map every tool outcome faithfully.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md
new file mode 100644
index 0000000000..8f64f2eb63
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md
@@ -0,0 +1,122 @@
+You are the SurfSense workspace specialist for the user's local folders.
+
+## Required inputs
+
+**Resolve paths from the supervisor's task text before asking.**
+
+- If the supervisor already provided a precise path (e.g. `/notes/2026-05-11.md`), use it directly — skip the lookup steps below.
+- Otherwise, most requests reference files by description (`"my meeting notes from last week"`, `"the design doc"`). Resolve them yourself:
+ 1. If you do not know which mounts exist, call `ls('/')` first.
+ 2. Walk likely folders with the `ls` and `list_tree` tools.
+ 3. Use the `glob` tool for filename patterns; use the `grep` tool when the description points at *content* rather than a name.
+ 4. `` lists top-K cloud-ingested docs, not local files — consult it only when the task spans both worlds (e.g. drafting a local note from a Notion source). Skip otherwise.
+ 5. Only return `status=blocked` with `missing_fields=["path"]` when the description is genuinely ambiguous after a thorough lookup.
+
+For writes (where you choose the path yourself):
+
+- **Discover the user's existing conventions before inventing a path.** Inspect the relevant mount's folder layout via `ls` / `list_tree` and look for folders that already hold similar content (e.g. an existing `/notes/meetings/` with dated standup files, or `/projects//`). When a convention exists, follow it.
+- Only choose a brand-new path when no relevant convention exists. Prefer a clear folder hierarchy with a descriptive filename.
+- Prefer the `edit_file` tool over rewriting an entire file.
+
+## Interpreting tool results
+
+The FS tools return free-form text rather than structured fields:
+
+- **Success** — a confirmation message that names the path (e.g. `"Updated file /notes/foo.md"`, `"Successfully replaced 2 instance(s) of the string in '/notes/foo.md'"`) or the file's content (for reads).
+- **Failure** — text starting with `"Error: "` followed by a cause (e.g. `"Error: File '/notes/x.md' not found"`).
+- **HITL declined** — a runtime-supplied rejection message in place of the tool's output.
+
+Map outcomes to your `status`:
+
+- Clean success message or content returned → `status=success`.
+- `"Error: …not found"` → `status=blocked` with `next_step="File '' was not found. Ask the user to confirm or provide more detail."`.
+- Any other `"Error: …"` → `status=error` and relay the tool's message verbatim as `next_step`.
+- HITL rejection → `status=blocked` with `next_step="User declined this filesystem action. Do not retry."`.
+
+You construct the structured `evidence` fields from your own knowledge of what you called and what you observed — the tools do not return them. `chunk_ids` apply only to `` hits; for local-file operations leave them `null`. Never report values you did not actually see.
+
+## Examples
+
+**Example 1 — happy path write (path discovered from existing convention):**
+
+- *Supervisor task:* `"Save these meeting notes to my notes folder: "`
+- *You:* `ls('/')` reveals a `/notes` mount → `list_tree('/notes')` shows `/notes/meetings/` already holds dated files like `2026-05-04-standup.md` and `2026-04-27-standup.md` — the user's convention is dated meeting notes under that folder. → `write_file("/notes/meetings/2026-05-11-meeting.md", content)` → success.
+- *Output:*
+
+ ```json
+ {
+ "status": "success",
+ "action_summary": "Created /notes/meetings/2026-05-11-meeting.md.",
+ "evidence": {
+ "operation": "write_file",
+ "path": "/notes/meetings/2026-05-11-meeting.md",
+ "matched_candidates": null,
+ "content_excerpt": null,
+ "chunk_ids": null
+ },
+ "next_step": null,
+ "missing_fields": null,
+ "assumptions": ["Followed the existing /notes/meetings/-.md convention discovered via list_tree"]
+ }
+ ```
+
+**Example 2 — edit by inference:**
+
+- *Supervisor task:* `"Add a bullet about the new feature flag to my Q2 roadmap"`
+- *You:* search for the roadmap file — `ls('/')` then `glob` for filename patterns; if nothing surfaces, `grep` for content. Suppose `glob` finds `/projects/planning/q2-roadmap.md` → `read_file("/projects/planning/q2-roadmap.md")` → `edit_file("/projects/planning/q2-roadmap.md", old, new)` → success.
+- *Output:* `status=success`, evidence includes path and the inserted snippet.
+
+**Example 3 — blocked, multiple candidates:**
+
+- *Supervisor task:* `"Update the design doc."`
+- *You:* `glob('**/design*')` returns several plausible design files and the task gives no further hint. Do not pick arbitrarily.
+- *Output:*
+
+ ```json
+ {
+ "status": "blocked",
+ "action_summary": "Multiple design docs exist; cannot pick without more detail.",
+ "evidence": {
+ "operation": null,
+ "path": null,
+ "matched_candidates": [
+ { "id": "/projects/web/design/payment-flow.md", "label": "Payment Flow" },
+ { "id": "/projects/web/design/auth-rework.md", "label": "Auth Rework" }
+ ],
+ "content_excerpt": null,
+ "chunk_ids": null
+ },
+ "next_step": "Ask the user which design doc to update.",
+ "missing_fields": ["path"],
+ "assumptions": null
+ }
+ ```
+
+## Output contract
+
+Return **only** one JSON object (no markdown or prose outside it):
+
+```json
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "operation": "write_file" | "edit_file" | "read_file" | "ls" | "glob" | "grep" | "mkdir" | "move_file" | "rm" | "rmdir" | "list_tree" | null,
+ "path": string | null,
+ "matched_candidates": [ { "id": string, "label": string } ] | null,
+ "content_excerpt": string | null,
+ "chunk_ids": string[] | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+```
+
+Rules:
+
+- `status=success` → `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` → `next_step` must be non-null.
+- `status=blocked` due to missing required inputs → `missing_fields` must be non-null.
+
+Infer before you call; map every tool outcome faithfully.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_readonly_cloud.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_readonly_cloud.md
new file mode 100644
index 0000000000..3abfcd8b9a
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_readonly_cloud.md
@@ -0,0 +1,29 @@
+You are the **read-only** SurfSense Knowledge Base specialist for `/documents/`.
+
+You answer workspace questions for another agent. The end user does **not** see your reply directly — be terse, cite paths, no greetings or apologies.
+
+## Resolving paths
+
+The caller's question often references documents by description (`"my meeting notes from last week"`, `"the design doc"`). Resolve them yourself:
+
+1. Consult `` — a hint about top-K likely matches, not a directive. Skip when the ranked entries don't fit.
+2. Walk `` for descriptive folder/filename matches.
+3. Use `glob` for filename patterns the tree didn't surface, and `grep` when the description points at *content* rather than a name.
+
+If a precise path was already given, use it directly — skip the lookup.
+
+## Interpreting tool results
+
+- **Success** — file content (for `read_file`) or a listing (for `ls` / `glob` / `grep` / `list_tree`).
+- **Failure** — text starting with `"Error: "` followed by a cause (e.g. `"Error: File '/documents/x.md' not found"`). Relay the cause to the caller verbatim.
+
+Never report values you did not actually see.
+
+## Return contract
+
+Reply in plain prose:
+
+- One short paragraph or a bullet list, whichever fits.
+- Cite every claim with an absolute path under `/documents/`.
+- If the workspace does not contain the requested information, say so explicitly. Do not fabricate paths or content.
+- If the question is genuinely ambiguous after a thorough lookup, list the candidates with their paths and stop.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_readonly_desktop.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_readonly_desktop.md
new file mode 100644
index 0000000000..1b3d72b641
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_readonly_desktop.md
@@ -0,0 +1,30 @@
+You are the **read-only** SurfSense workspace specialist for the user's local folders.
+
+You answer workspace questions for another agent. The end user does **not** see your reply directly — be terse, cite paths, no greetings or apologies.
+
+## Resolving paths
+
+The caller's question often references files by description (`"my meeting notes from last week"`, `"the design doc"`). Resolve them yourself:
+
+1. If you do not know which mounts exist, call `ls('/')` first.
+2. Walk likely folders with the `ls` and `list_tree` tools.
+3. Use `glob` for filename patterns; use `grep` when the description points at *content* rather than a name.
+4. `` lists top-K cloud-ingested docs, not local files — consult it only when the task spans both worlds (e.g. drafting a local note from a Notion source). Skip otherwise.
+
+If a precise path was already given, use it directly — skip the lookup.
+
+## Interpreting tool results
+
+- **Success** — file content (for `read_file`) or a listing (for `ls` / `glob` / `grep` / `list_tree`).
+- **Failure** — text starting with `"Error: "` followed by a cause (e.g. `"Error: File '/notes/x.md' not found"`). Relay the cause to the caller verbatim.
+
+Never report values you did not actually see.
+
+## Return contract
+
+Reply in plain prose:
+
+- One short paragraph or a bullet list, whichever fits.
+- Cite every claim with an absolute path.
+- If the workspace does not contain the requested information, say so explicitly. Do not fabricate paths or content.
+- If the question is genuinely ambiguous after a thorough lookup, list the candidates with their paths and stop.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/__init__.py
new file mode 100644
index 0000000000..5a83c68a3b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/__init__.py
@@ -0,0 +1 @@
+"""Route-local tool permissions for the ``knowledge_base`` subagent."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py
new file mode 100644
index 0000000000..55a9a4edf6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py
@@ -0,0 +1,20 @@
+"""Route-local FS tool permissions.
+
+The KB subagent's actual ``BaseTool`` instances are provided at runtime by
+``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module
+only carries the *names* of destructive ops so the agent can convert them
+into permission rules — see :data:`KB_RULESET` in ``agent.py``.
+"""
+
+from __future__ import annotations
+
+DESTRUCTIVE_FS_OPS: tuple[str, ...] = (
+ "rm",
+ "rmdir",
+ "move_file",
+ "edit_file",
+ "write_file",
+)
+
+
+__all__ = ["DESTRUCTIVE_FS_OPS"]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py
new file mode 100644
index 0000000000..84ab0c2fb4
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py
@@ -0,0 +1,39 @@
+"""``memory`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles memory tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md
new file mode 100644
index 0000000000..a68743765f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md
@@ -0,0 +1,2 @@
+Specialist for durable user memory.
+Use whenever a task requires storing or retrieving information that should persist across conversations.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md
new file mode 100644
index 0000000000..32becf233e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md
@@ -0,0 +1,56 @@
+You are the SurfSense memory operations sub-agent.
+You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
+
+
+Persist durable preferences/facts/instructions with `update_memory` while avoiding transient or unsafe storage.
+
+
+
+{{MEMORY_VISIBILITY_POLICY}}
+
+
+
+- `update_memory`
+
+
+
+- Save only durable information with future value.
+- Do not store transient chatter.
+- Do not store secrets unless explicitly instructed.
+- If memory intent is unclear, return `status=blocked` with the missing intent signal.
+
+
+
+- Do not execute non-memory tool actions.
+- Do not store irrelevant, transient, or speculative information.
+
+
+
+- Prefer minimal-memory writes over over-collection.
+- Never claim memory was updated unless `update_memory` succeeded.
+
+
+
+- On tool failure, return `status=error` with concise recovery steps.
+- When intent is ambiguous, return `status=blocked` with required disambiguation fields.
+
+
+
+Return **only** one JSON object (no markdown/prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "memory_updated": boolean,
+ "memory_category": "preference" | "fact" | "instruction" | null,
+ "stored_summary": string | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+Rules:
+- `status=success` -> `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` -> `next_step` must be non-null.
+- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py
new file mode 100644
index 0000000000..0441a8cb4f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py
@@ -0,0 +1,8 @@
+"""Memory tools: persist user or team markdown memory for later turns."""
+
+from .update_memory import create_update_memory_tool, create_update_team_memory_tool
+
+__all__ = [
+ "create_update_memory_tool",
+ "create_update_team_memory_tool",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py
new file mode 100644
index 0000000000..b6e06dcdd7
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py
@@ -0,0 +1,37 @@
+"""``memory`` native tools and (empty) permission ruleset."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.permissions import Ruleset
+from app.db import ChatVisibility
+
+from .update_memory import create_update_memory_tool, create_update_team_memory_tool
+
+NAME = "memory"
+
+RULESET = Ruleset(origin=NAME, rules=[])
+
+
+def load_tools(
+ *, dependencies: dict[str, Any] | None = None, **kwargs: Any
+) -> list[BaseTool]:
+ d = {**(dependencies or {}), **kwargs}
+ if d.get("thread_visibility") == ChatVisibility.SEARCH_SPACE:
+ return [
+ create_update_team_memory_tool(
+ search_space_id=d["search_space_id"],
+ db_session=d["db_session"],
+ llm=d.get("llm"),
+ )
+ ]
+ return [
+ create_update_memory_tool(
+ user_id=d["user_id"],
+ db_session=d["db_session"],
+ llm=d.get("llm"),
+ )
+ ]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py
new file mode 100644
index 0000000000..23375a0818
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py
@@ -0,0 +1,375 @@
+"""Overwrite one markdown memory document per user or team, with size and shrink guards."""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any, Literal
+from uuid import UUID
+
+from langchain_core.messages import HumanMessage
+from langchain_core.tools import tool
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import SearchSpace, User
+
+logger = logging.getLogger(__name__)
+
+MEMORY_SOFT_LIMIT = 18_000
+MEMORY_HARD_LIMIT = 25_000
+
+_SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE)
+_HEADING_NORMALIZE_RE = re.compile(r"\s+")
+
+_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]")
+_BULLET_FORMAT_RE = re.compile(r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$")
+_PERSONAL_ONLY_MARKERS = {"pref", "instr"}
+
+
+# ---------------------------------------------------------------------------
+# Diff validation
+# ---------------------------------------------------------------------------
+
+
+def _extract_headings(memory: str) -> set[str]:
+ """Return all ``## …`` heading texts (without the ``## `` prefix)."""
+ return set(_SECTION_HEADING_RE.findall(memory))
+
+
+def _normalize_heading(heading: str) -> str:
+ """Normalize heading text for robust scope checks."""
+ return _HEADING_NORMALIZE_RE.sub(" ", heading.strip().lower())
+
+
+def _validate_memory_scope(
+ content: str, scope: Literal["user", "team"]
+) -> dict[str, Any] | None:
+ """Reject personal-only markers ([pref], [instr]) in team memory."""
+ if scope != "team":
+ return None
+
+ markers = set(_MARKER_RE.findall(content))
+ leaked = sorted(markers & _PERSONAL_ONLY_MARKERS)
+ if leaked:
+ tags = ", ".join(f"[{m}]" for m in leaked)
+ return {
+ "status": "error",
+ "message": (
+ f"Team memory cannot include personal markers: {tags}. "
+ "Use [fact] only in team memory."
+ ),
+ }
+ return None
+
+
+def _validate_bullet_format(content: str) -> list[str]:
+ """Return warnings for bullet lines that don't match the required format.
+
+ Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text``
+ """
+ warnings: list[str] = []
+ for line in content.splitlines():
+ stripped = line.strip()
+ if not stripped.startswith("- "):
+ continue
+ if not _BULLET_FORMAT_RE.match(stripped):
+ short = stripped[:80] + ("..." if len(stripped) > 80 else "")
+ warnings.append(f"Malformed bullet: {short}")
+ return warnings
+
+
+def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]:
+ """Return a list of warning strings about suspicious changes."""
+ if not old_memory:
+ return []
+
+ warnings: list[str] = []
+ old_headings = _extract_headings(old_memory)
+ new_headings = _extract_headings(new_memory)
+ dropped = old_headings - new_headings
+ if dropped:
+ names = ", ".join(sorted(dropped))
+ warnings.append(
+ f"Sections removed: {names}. "
+ "If unintentional, the user can restore from the settings page."
+ )
+
+ old_len = len(old_memory)
+ new_len = len(new_memory)
+ if old_len > 0 and new_len < old_len * 0.4:
+ warnings.append(
+ f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). "
+ "Possible data loss."
+ )
+ return warnings
+
+
+# ---------------------------------------------------------------------------
+# Size validation & soft warning
+# ---------------------------------------------------------------------------
+
+
+def _validate_memory_size(content: str) -> dict[str, Any] | None:
+ """Return an error/warning dict if *content* is too large, else None."""
+ length = len(content)
+ if length > MEMORY_HARD_LIMIT:
+ return {
+ "status": "error",
+ "message": (
+ f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit "
+ f"({length:,} chars). Consolidate by merging related items, "
+ "removing outdated entries, and shortening descriptions. "
+ "Then call update_memory again."
+ ),
+ }
+ return None
+
+
+def _soft_warning(content: str) -> str | None:
+ """Return a warning string if content exceeds the soft limit."""
+ length = len(content)
+ if length > MEMORY_SOFT_LIMIT:
+ return (
+ f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. "
+ "Consolidate by merging related items and removing less important "
+ "entries on your next update."
+ )
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Forced rewrite when memory exceeds the hard limit
+# ---------------------------------------------------------------------------
+
+_FORCED_REWRITE_PROMPT = """\
+You are a memory curator. The following memory document exceeds the character \
+limit and must be shortened.
+
+RULES:
+1. Rewrite the document to be under {target} characters.
+2. Preserve existing ## headings. Every entry must remain under a heading. You may merge
+ or rename headings to consolidate, but keep names personal and descriptive.
+3. Priority for keeping content: [instr] > [pref] > [fact].
+4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions.
+5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text
+6. Preserve the user's first name in entries — do not replace it with "the user".
+7. Output ONLY the consolidated markdown — no explanations, no wrapping.
+
+
+{content}
+"""
+
+
+async def _forced_rewrite(content: str, llm: Any) -> str | None:
+ """Use a focused LLM call to compress *content* under the hard limit.
+
+ Returns the rewritten string, or ``None`` if the call fails.
+ """
+ try:
+ prompt = _FORCED_REWRITE_PROMPT.format(
+ target=MEMORY_HARD_LIMIT, content=content
+ )
+ response = await llm.ainvoke(
+ [HumanMessage(content=prompt)],
+ config={"tags": ["surfsense:internal"]},
+ )
+ text = (
+ response.content
+ if isinstance(response.content, str)
+ else str(response.content)
+ )
+ return text.strip()
+ except Exception:
+ logger.exception("Forced rewrite LLM call failed")
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Shared save-and-respond logic
+# ---------------------------------------------------------------------------
+
+
+async def _save_memory(
+ *,
+ updated_memory: str,
+ old_memory: str | None,
+ llm: Any | None,
+ apply_fn,
+ commit_fn,
+ rollback_fn,
+ label: str,
+ scope: Literal["user", "team"],
+) -> dict[str, Any]:
+ """Validate, optionally force-rewrite if over the hard limit, save, and
+ return a response dict.
+
+ Parameters
+ ----------
+ updated_memory : str
+ The new document the agent submitted.
+ old_memory : str | None
+ The previously persisted document (for diff checks).
+ llm : Any | None
+ LLM instance for forced rewrite (may be ``None``).
+ apply_fn : callable(str) -> None
+ Callback that sets the new memory on the ORM object.
+ commit_fn : coroutine
+ ``session.commit``.
+ rollback_fn : coroutine
+ ``session.rollback``.
+ label : str
+ Human label for log messages (e.g. "user memory", "team memory").
+ """
+ content = updated_memory
+
+ # --- forced rewrite if over the hard limit ---
+ if len(content) > MEMORY_HARD_LIMIT and llm is not None:
+ rewritten = await _forced_rewrite(content, llm)
+ if rewritten is not None and len(rewritten) < len(content):
+ content = rewritten
+
+ # --- hard-limit gate (reject if still too large after rewrite) ---
+ size_err = _validate_memory_size(content)
+ if size_err:
+ return size_err
+
+ scope_err = _validate_memory_scope(content, scope)
+ if scope_err:
+ return scope_err
+
+ # --- persist ---
+ try:
+ apply_fn(content)
+ await commit_fn()
+ except Exception as e:
+ logger.exception("Failed to update %s: %s", label, e)
+ await rollback_fn()
+ return {"status": "error", "message": f"Failed to update {label}: {e}"}
+
+ # --- build response ---
+ resp: dict[str, Any] = {
+ "status": "saved",
+ "message": f"{label.capitalize()} updated.",
+ }
+
+ if content is not updated_memory:
+ resp["notice"] = "Memory was automatically rewritten to fit within limits."
+
+ diff_warnings = _validate_diff(old_memory, content)
+ if diff_warnings:
+ resp["diff_warnings"] = diff_warnings
+
+ format_warnings = _validate_bullet_format(content)
+ if format_warnings:
+ resp["format_warnings"] = format_warnings
+
+ warning = _soft_warning(content)
+ if warning:
+ resp["warning"] = warning
+
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# Tool factories
+# ---------------------------------------------------------------------------
+
+
+def create_update_memory_tool(
+ user_id: str | UUID,
+ db_session: AsyncSession,
+ llm: Any | None = None,
+):
+ uid = UUID(user_id) if isinstance(user_id, str) else user_id
+
+ @tool
+ async def update_memory(updated_memory: str) -> dict[str, Any]:
+ """Update the user's personal memory document.
+
+ Your current memory is shown in in the system prompt.
+ When the user shares important long-term information (preferences,
+ facts, instructions, context), rewrite the memory document to include
+ the new information. Merge new facts with existing ones, update
+ contradictions, remove outdated entries, and keep it concise.
+
+ Args:
+ updated_memory: The FULL updated markdown document (not a diff).
+ """
+ try:
+ result = await db_session.execute(select(User).where(User.id == uid))
+ user = result.scalars().first()
+ if not user:
+ return {"status": "error", "message": "User not found."}
+
+ old_memory = user.memory_md
+
+ return await _save_memory(
+ updated_memory=updated_memory,
+ old_memory=old_memory,
+ llm=llm,
+ apply_fn=lambda content: setattr(user, "memory_md", content),
+ commit_fn=db_session.commit,
+ rollback_fn=db_session.rollback,
+ label="memory",
+ scope="user",
+ )
+ except Exception as e:
+ logger.exception("Failed to update user memory: %s", e)
+ await db_session.rollback()
+ return {
+ "status": "error",
+ "message": f"Failed to update memory: {e}",
+ }
+
+ return update_memory
+
+
+def create_update_team_memory_tool(
+ search_space_id: int,
+ db_session: AsyncSession,
+ llm: Any | None = None,
+):
+ @tool
+ async def update_memory(updated_memory: str) -> dict[str, Any]:
+ """Update the team's shared memory document for this search space.
+
+ Your current team memory is shown in in the system
+ prompt. When the team shares important long-term information
+ (decisions, conventions, key facts, priorities), rewrite the memory
+ document to include the new information. Merge new facts with
+ existing ones, update contradictions, remove outdated entries, and
+ keep it concise.
+
+ Args:
+ updated_memory: The FULL updated markdown document (not a diff).
+ """
+ try:
+ result = await db_session.execute(
+ select(SearchSpace).where(SearchSpace.id == search_space_id)
+ )
+ space = result.scalars().first()
+ if not space:
+ return {"status": "error", "message": "Search space not found."}
+
+ old_memory = space.shared_memory_md
+
+ return await _save_memory(
+ updated_memory=updated_memory,
+ old_memory=old_memory,
+ llm=llm,
+ apply_fn=lambda content: setattr(space, "shared_memory_md", content),
+ commit_fn=db_session.commit,
+ rollback_fn=db_session.rollback,
+ label="team memory",
+ scope="team",
+ )
+ except Exception as e:
+ logger.exception("Failed to update team memory: %s", e)
+ await db_session.rollback()
+ return {
+ "status": "error",
+ "message": f"Failed to update team memory: {e}",
+ }
+
+ return update_memory
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py
new file mode 100644
index 0000000000..37026bebda
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py
@@ -0,0 +1,39 @@
+"""``research`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles research tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md
new file mode 100644
index 0000000000..0a99b41402
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md
@@ -0,0 +1,2 @@
+Specialist for external research.
+Use whenever a task requires finding sources on the web and extracting evidence to answer documentation questions.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md
new file mode 100644
index 0000000000..cf558db62c
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md
@@ -0,0 +1,53 @@
+You are the SurfSense research operations sub-agent.
+You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
+
+
+Gather and synthesize evidence using SurfSense research tools with clear citations and uncertainty reporting.
+
+
+
+- `web_search`
+- `scrape_webpage`
+- `search_surfsense_docs`
+
+
+
+- Use only tools in ``.
+- Prefer primary and recent sources when recency matters.
+- If the delegated request is underspecified, return `status=blocked` with the missing research constraints.
+- Never fabricate facts, citations, URLs, or quote text.
+
+
+
+- Do not execute connector mutations (email/calendar/docs/chat writes) or deliverable generation.
+
+
+
+- Report uncertainty explicitly when evidence is incomplete or conflicting.
+- Never present unverified claims as facts.
+
+
+
+- On tool failure, return `status=error` with a concise recovery `next_step`.
+- On no useful evidence, return `status=blocked` with recommended narrower filters.
+
+
+
+Return **only** one JSON object (no markdown/prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "findings": string[],
+ "sources": string[],
+ "confidence": "high" | "medium" | "low"
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+Rules:
+- `status=success` -> `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` -> `next_step` must be non-null.
+- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
+
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py
new file mode 100644
index 0000000000..414cc96f42
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py
@@ -0,0 +1,11 @@
+"""Research-stage tools: web search, scrape, and in-product doc search."""
+
+from .scrape_webpage import create_scrape_webpage_tool
+from .search_surfsense_docs import create_search_surfsense_docs_tool
+from .web_search import create_web_search_tool
+
+__all__ = [
+ "create_scrape_webpage_tool",
+ "create_search_surfsense_docs_tool",
+ "create_web_search_tool",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py
new file mode 100644
index 0000000000..ea544a8dae
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py
@@ -0,0 +1,31 @@
+"""``research`` native tools and (empty) permission ruleset."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.permissions import Ruleset
+
+from .scrape_webpage import create_scrape_webpage_tool
+from .search_surfsense_docs import create_search_surfsense_docs_tool
+from .web_search import create_web_search_tool
+
+NAME = "research"
+
+RULESET = Ruleset(origin=NAME, rules=[])
+
+
+def load_tools(
+ *, dependencies: dict[str, Any] | None = None, **kwargs: Any
+) -> list[BaseTool]:
+ d = {**(dependencies or {}), **kwargs}
+ return [
+ create_web_search_tool(
+ search_space_id=d.get("search_space_id"),
+ available_connectors=d.get("available_connectors"),
+ ),
+ create_scrape_webpage_tool(firecrawl_api_key=d.get("firecrawl_api_key")),
+ create_search_surfsense_docs_tool(db_session=d["db_session"]),
+ ]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py
new file mode 100644
index 0000000000..bb7c8e5a36
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py
@@ -0,0 +1,300 @@
+"""Scrape pages via WebCrawlerConnector; YouTube URLs use the transcript API instead of HTML crawl."""
+
+import hashlib
+import logging
+from typing import Any
+from urllib.parse import urlparse
+
+import aiohttp
+from fake_useragent import UserAgent
+from langchain_core.tools import tool
+from requests import Session
+from youtube_transcript_api import YouTubeTranscriptApi
+
+from app.connectors.webcrawler_connector import WebCrawlerConnector
+from app.tasks.document_processors.youtube_processor import get_youtube_video_id
+from app.utils.proxy_config import get_requests_proxies
+
+logger = logging.getLogger(__name__)
+
+
+def extract_domain(url: str) -> str:
+ """Extract the domain from a URL."""
+ try:
+ parsed = urlparse(url)
+ domain = parsed.netloc
+ # Remove 'www.' prefix if present
+ if domain.startswith("www."):
+ domain = domain[4:]
+ return domain
+ except Exception:
+ return ""
+
+
+def generate_scrape_id(url: str) -> str:
+ """Generate a unique ID for a scraped webpage."""
+ hash_val = hashlib.md5(url.encode()).hexdigest()[:12]
+ return f"scrape-{hash_val}"
+
+
+def truncate_content(content: str, max_length: int = 50000) -> tuple[str, bool]:
+ """
+ Truncate content to a maximum length.
+
+ Returns:
+ Tuple of (truncated_content, was_truncated)
+ """
+ if len(content) <= max_length:
+ return content, False
+
+ # Try to truncate at a sentence boundary
+ truncated = content[:max_length]
+ last_period = truncated.rfind(".")
+ last_newline = truncated.rfind("\n\n")
+
+ # Use the later of the two boundaries, or just truncate
+ boundary = max(last_period, last_newline)
+ if boundary > max_length * 0.8: # Only use boundary if it's not too far back
+ truncated = content[: boundary + 1]
+
+ return truncated + "\n\n[Content truncated...]", True
+
+
+async def _scrape_youtube_video(
+ url: str, video_id: str, max_length: int
+) -> dict[str, Any]:
+ """
+ Fetch YouTube video metadata and transcript via the YouTubeTranscriptApi.
+
+ Returns a result dict in the same shape as the regular scrape_webpage output.
+ """
+ scrape_id = generate_scrape_id(url)
+ domain = "youtube.com"
+
+ # --- Video metadata via oEmbed ---
+ residential_proxies = get_requests_proxies()
+
+ params = {
+ "format": "json",
+ "url": f"https://www.youtube.com/watch?v={video_id}",
+ }
+ oembed_url = "https://www.youtube.com/oembed"
+
+ try:
+ async with (
+ aiohttp.ClientSession() as http_session,
+ http_session.get(
+ oembed_url,
+ params=params,
+ proxy=residential_proxies["http"] if residential_proxies else None,
+ ) as response,
+ ):
+ video_data = await response.json()
+ except Exception:
+ video_data = {}
+
+ title = video_data.get("title", "YouTube Video")
+ author = video_data.get("author_name", "Unknown")
+
+ # --- Transcript via YouTubeTranscriptApi ---
+ try:
+ ua = UserAgent()
+ http_client = Session()
+ http_client.headers.update({"User-Agent": ua.random})
+ if residential_proxies:
+ http_client.proxies.update(residential_proxies)
+ ytt_api = YouTubeTranscriptApi(http_client=http_client)
+
+ # List all available transcripts and pick the first one
+ # (the video's primary language) instead of defaulting to English
+ transcript_list = ytt_api.list(video_id)
+ transcript = next(iter(transcript_list))
+ captions = transcript.fetch()
+
+ logger.info(
+ f"[scrape_webpage] Fetched transcript for {video_id} "
+ f"in {transcript.language} ({transcript.language_code})"
+ )
+
+ transcript_segments = []
+ for line in captions:
+ start_time = line.start
+ duration = line.duration
+ text = line.text
+ timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]"
+ transcript_segments.append(f"{timestamp} {text}")
+ transcript_text = "\n".join(transcript_segments)
+ except Exception as e:
+ logger.warning(f"[scrape_webpage] No transcript for video {video_id}: {e}")
+ transcript_text = f"No captions available for this video. Error: {e!s}"
+
+ # Build combined content
+ content = f"# {title}\n\n**Author:** {author}\n**Video ID:** {video_id}\n\n## Transcript\n\n{transcript_text}"
+
+ # Truncate if needed
+ content, was_truncated = truncate_content(content, max_length)
+ word_count = len(content.split())
+
+ description = f"YouTube video by {author}"
+
+ return {
+ "id": scrape_id,
+ "assetId": url,
+ "kind": "article",
+ "href": url,
+ "title": title,
+ "description": description,
+ "content": content,
+ "domain": domain,
+ "word_count": word_count,
+ "was_truncated": was_truncated,
+ "crawler_type": "youtube_transcript",
+ "author": author,
+ }
+
+
+def create_scrape_webpage_tool(firecrawl_api_key: str | None = None):
+ """
+ Factory function to create the scrape_webpage tool.
+
+ Args:
+ firecrawl_api_key: Optional Firecrawl API key for premium web scraping.
+ Falls back to Chromium/Trafilatura if not provided.
+
+ Returns:
+ A configured tool function for scraping webpages.
+ """
+
+ @tool
+ async def scrape_webpage(
+ url: str,
+ max_length: int = 50000,
+ ) -> dict[str, Any]:
+ """
+ Scrape and extract the main content from a webpage.
+
+ Use this tool when the user wants you to read, summarize, or answer
+ questions about a specific webpage's content. This tool actually
+ fetches and reads the full page content. For YouTube video URLs it
+ fetches the transcript directly instead of crawling the page.
+
+ Common triggers:
+ - "Read this article and summarize it"
+ - "What does this page say about X?"
+ - "Summarize this blog post for me"
+ - "Tell me the key points from this article"
+ - "What's in this webpage?"
+
+ Args:
+ url: The URL of the webpage to scrape (must be HTTP/HTTPS)
+ max_length: Maximum content length to return (default: 50000 chars)
+
+ Returns:
+ A dictionary containing:
+ - id: Unique identifier for this scrape
+ - assetId: The URL (for deduplication)
+ - kind: "article" (type of content)
+ - href: The URL to open when clicked
+ - title: Page title
+ - description: Brief description or excerpt
+ - content: The extracted main content (markdown format)
+ - domain: The domain name
+ - word_count: Approximate word count
+ - was_truncated: Whether content was truncated
+ - error: Error message (if scraping failed)
+ """
+ scrape_id = generate_scrape_id(url)
+ domain = extract_domain(url)
+
+ # Validate and normalize URL
+ if not url.startswith(("http://", "https://")):
+ url = f"https://{url}"
+
+ try:
+ # Check if this is a YouTube URL and use transcript API instead
+ video_id = get_youtube_video_id(url)
+ if video_id:
+ return await _scrape_youtube_video(url, video_id, max_length)
+
+ # Create webcrawler connector
+ connector = WebCrawlerConnector(firecrawl_api_key=firecrawl_api_key)
+
+ # Crawl the URL
+ result, error = await connector.crawl_url(url, formats=["markdown"])
+
+ if error:
+ return {
+ "id": scrape_id,
+ "assetId": url,
+ "kind": "article",
+ "href": url,
+ "title": domain or "Webpage",
+ "domain": domain,
+ "error": error,
+ }
+
+ if not result:
+ return {
+ "id": scrape_id,
+ "assetId": url,
+ "kind": "article",
+ "href": url,
+ "title": domain or "Webpage",
+ "domain": domain,
+ "error": "No content returned from crawler",
+ }
+
+ # Extract content and metadata
+ content = result.get("content", "")
+ metadata = result.get("metadata", {})
+
+ # Get title from metadata
+ title = metadata.get("title", "")
+ if not title:
+ title = domain or url.split("/")[-1] or "Webpage"
+
+ # Get description from metadata
+ description = metadata.get("description", "")
+ if not description and content:
+ # Use first paragraph as description
+ first_para = content.split("\n\n")[0] if content else ""
+ description = (
+ first_para[:300] + "..." if len(first_para) > 300 else first_para
+ )
+
+ # Truncate content if needed
+ content, was_truncated = truncate_content(content, max_length)
+
+ # Calculate word count
+ word_count = len(content.split())
+
+ return {
+ "id": scrape_id,
+ "assetId": url,
+ "kind": "article",
+ "href": url,
+ "title": title,
+ "description": description,
+ "content": content,
+ "domain": domain,
+ "word_count": word_count,
+ "was_truncated": was_truncated,
+ "crawler_type": result.get("crawler_type", "unknown"),
+ "author": metadata.get("author"),
+ "date": metadata.get("date"),
+ }
+
+ except Exception as e:
+ error_message = str(e)
+ logger.error(f"[scrape_webpage] Error scraping {url}: {error_message}")
+ return {
+ "id": scrape_id,
+ "assetId": url,
+ "kind": "article",
+ "href": url,
+ "title": domain or "Webpage",
+ "domain": domain,
+ "error": f"Failed to scrape: {error_message[:100]}",
+ }
+
+ return scrape_webpage
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py
new file mode 100644
index 0000000000..ccc5c49e2e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py
@@ -0,0 +1,145 @@
+"""Semantic search over pre-indexed in-app documentation chunks for user how-to questions."""
+
+import asyncio
+import json
+
+from langchain_core.tools import tool
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
+from app.utils.document_converters import embed_text
+from app.utils.surfsense_docs import surfsense_docs_public_url
+
+
+def format_surfsense_docs_results(results: list[tuple]) -> str:
+ """Format (chunk, document) rows as XML with ``doc-`` chunk IDs for citations and UI routing."""
+ if not results:
+ return "No relevant Surfsense documentation found for your query."
+
+ # Group chunks by document
+ grouped: dict[int, dict] = {}
+ for chunk, doc in results:
+ public_url = surfsense_docs_public_url(doc.source)
+ if doc.id not in grouped:
+ grouped[doc.id] = {
+ "document_id": f"doc-{doc.id}",
+ "document_type": "SURFSENSE_DOCS",
+ "title": doc.title,
+ "url": public_url,
+ "metadata": {"source": doc.source, "public_url": public_url},
+ "chunks": [],
+ }
+ grouped[doc.id]["chunks"].append(
+ {
+ "chunk_id": f"doc-{chunk.id}",
+ "content": chunk.content,
+ }
+ )
+
+ # Render XML matching format_documents_for_context structure
+ parts: list[str] = []
+ for g in grouped.values():
+ metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
+
+ parts.append("")
+ parts.append("")
+ parts.append(f" {g['document_id']}")
+ parts.append(f" {g['document_type']}")
+ parts.append(f" ")
+ parts.append(f" ")
+ parts.append(f" ")
+ parts.append("")
+ parts.append("")
+ parts.append("")
+
+ for ch in g["chunks"]:
+ parts.append(
+ f" "
+ )
+
+ parts.append("")
+ parts.append("")
+ parts.append("")
+
+ return "\n".join(parts).strip()
+
+
+async def search_surfsense_docs_async(
+ query: str,
+ db_session: AsyncSession,
+ top_k: int = 10,
+) -> str:
+ """
+ Search Surfsense documentation using vector similarity.
+
+ Args:
+ query: The search query about Surfsense usage
+ db_session: Database session for executing queries
+ top_k: Number of results to return
+
+ Returns:
+ Formatted string with relevant documentation content
+ """
+ # Get embedding for the query
+ query_embedding = await asyncio.to_thread(embed_text, query)
+
+ # Vector similarity search on chunks, joining with documents
+ stmt = (
+ select(SurfsenseDocsChunk, SurfsenseDocsDocument)
+ .join(
+ SurfsenseDocsDocument,
+ SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id,
+ )
+ .order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding))
+ .limit(top_k)
+ )
+
+ result = await db_session.execute(stmt)
+ rows = result.all()
+
+ return format_surfsense_docs_results(rows)
+
+
+def create_search_surfsense_docs_tool(db_session: AsyncSession):
+ """
+ Factory function to create the search_surfsense_docs tool.
+
+ Args:
+ db_session: Database session for executing queries
+
+ Returns:
+ A configured tool function for searching Surfsense documentation
+ """
+
+ @tool
+ async def search_surfsense_docs(query: str, top_k: int = 10) -> str:
+ """
+ Search Surfsense documentation for help with using the application.
+
+ Use this tool when the user asks questions about:
+ - How to use Surfsense features
+ - Installation and setup instructions
+ - Configuration options and settings
+ - Troubleshooting common issues
+ - Available connectors and integrations
+ - Browser extension usage
+ - API documentation
+
+ This searches the official Surfsense documentation that was indexed
+ at deployment time. It does NOT search the user's personal knowledge base.
+
+ Args:
+ query: The search query about Surfsense usage or features
+ top_k: Number of documentation chunks to retrieve (default: 10)
+
+ Returns:
+ Relevant documentation content formatted with chunk IDs for citations
+ """
+ return await search_surfsense_docs_async(
+ query=query,
+ db_session=db_session,
+ top_k=top_k,
+ )
+
+ return search_surfsense_docs
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py
new file mode 100644
index 0000000000..2fe6bd3789
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py
@@ -0,0 +1,241 @@
+"""Real-time web search: SearXNG plus configured live-search connectors (Tavily, Linkup, Baidu, etc.)."""
+
+import asyncio
+import json
+import time
+from typing import Any
+
+from langchain_core.tools import StructuredTool
+from pydantic import BaseModel, Field
+
+from app.db import shielded_async_session
+from app.services.connector_service import ConnectorService
+from app.utils.perf import get_perf_logger
+
+_LIVE_SEARCH_CONNECTORS: set[str] = {
+ "TAVILY_API",
+ "LINKUP_API",
+ "BAIDU_SEARCH_API",
+}
+
+_LIVE_CONNECTOR_SPECS: dict[str, tuple[str, bool, bool, dict[str, Any]]] = {
+ "TAVILY_API": ("search_tavily", False, True, {}),
+ "LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}),
+ "BAIDU_SEARCH_API": ("search_baidu", False, True, {}),
+}
+
+_CONNECTOR_LABELS: dict[str, str] = {
+ "TAVILY_API": "Tavily",
+ "LINKUP_API": "Linkup",
+ "BAIDU_SEARCH_API": "Baidu",
+}
+
+
+class WebSearchInput(BaseModel):
+ """Input schema for the web_search tool."""
+
+ query: str = Field(
+ description="The search query to look up on the web. Use specific, descriptive terms.",
+ )
+ top_k: int = Field(
+ default=10,
+ description="Number of results to retrieve (default: 10, max: 50).",
+ )
+
+
+def _format_web_results(
+ documents: list[dict[str, Any]],
+ *,
+ max_chars: int = 50_000,
+) -> str:
+ """Format web search results into XML suitable for the LLM context."""
+ if not documents:
+ return "No web search results found."
+
+ parts: list[str] = []
+ total_chars = 0
+
+ for doc in documents:
+ doc_info = doc.get("document") or {}
+ metadata = doc_info.get("metadata") or {}
+ title = doc_info.get("title") or "Web Result"
+ url = metadata.get("url") or ""
+ content = (doc.get("content") or "").strip()
+ source = metadata.get("document_type") or doc.get("source") or "WEB_SEARCH"
+ if not content:
+ continue
+
+ metadata_json = json.dumps(metadata, ensure_ascii=False)
+ doc_xml = "\n".join(
+ [
+ "",
+ "",
+ f" {source}",
+ f" ",
+ f" ",
+ f" ",
+ "",
+ "",
+ f" ",
+ "",
+ "",
+ "",
+ ]
+ )
+
+ if total_chars + len(doc_xml) > max_chars:
+ parts.append("")
+ break
+
+ parts.append(doc_xml)
+ total_chars += len(doc_xml)
+
+ return "\n".join(parts).strip() or "No web search results found."
+
+
+async def _search_live_connector(
+ connector: str,
+ query: str,
+ search_space_id: int,
+ top_k: int,
+ semaphore: asyncio.Semaphore,
+) -> list[dict[str, Any]]:
+ """Dispatch a single live-search connector (Tavily / Linkup / Baidu)."""
+ perf = get_perf_logger()
+ spec = _LIVE_CONNECTOR_SPECS.get(connector)
+ if spec is None:
+ return []
+
+ method_name, _includes_date_range, includes_top_k, extra_kwargs = spec
+ kwargs: dict[str, Any] = {
+ "user_query": query,
+ "search_space_id": search_space_id,
+ **extra_kwargs,
+ }
+ if includes_top_k:
+ kwargs["top_k"] = top_k
+
+ try:
+ t0 = time.perf_counter()
+ async with semaphore, shielded_async_session() as session:
+ svc = ConnectorService(session, search_space_id)
+ _, chunks = await getattr(svc, method_name)(**kwargs)
+ perf.info(
+ "[web_search] connector=%s results=%d in %.3fs",
+ connector,
+ len(chunks),
+ time.perf_counter() - t0,
+ )
+ return chunks
+ except Exception as e:
+ perf.warning("[web_search] connector=%s FAILED: %s", connector, e)
+ return []
+
+
+def create_web_search_tool(
+ search_space_id: int | None = None,
+ available_connectors: list[str] | None = None,
+) -> StructuredTool:
+ """Factory for the ``web_search`` tool.
+
+ Dispatches in parallel to the platform SearXNG instance and any
+ user-configured live-search connectors (Tavily, Linkup, Baidu).
+ """
+ active_live_connectors: list[str] = []
+ if available_connectors:
+ active_live_connectors = [
+ c for c in available_connectors if c in _LIVE_SEARCH_CONNECTORS
+ ]
+
+ engine_names = ["SearXNG (platform default)"]
+ engine_names.extend(_CONNECTOR_LABELS.get(c, c) for c in active_live_connectors)
+ engines_summary = ", ".join(engine_names)
+
+ description = (
+ "Search the web for real-time information. "
+ "Use this for current events, news, prices, weather, public facts, or any "
+ "question that requires up-to-date information from the internet.\n\n"
+ f"Active search engines: {engines_summary}.\n"
+ "All configured engines are queried in parallel and results are merged."
+ )
+
+ _search_space_id = search_space_id
+ _active_live = active_live_connectors
+
+ async def _web_search_impl(query: str, top_k: int = 10) -> str:
+ from app.services import web_search_service
+
+ perf = get_perf_logger()
+ t0 = time.perf_counter()
+ clamped_top_k = min(max(1, top_k), 50)
+
+ semaphore = asyncio.Semaphore(4)
+ tasks: list[asyncio.Task[list[dict[str, Any]]]] = []
+
+ if web_search_service.is_available():
+
+ async def _searxng() -> list[dict[str, Any]]:
+ async with semaphore:
+ _result_obj, docs = await web_search_service.search(
+ query=query,
+ top_k=clamped_top_k,
+ )
+ return docs
+
+ tasks.append(asyncio.ensure_future(_searxng()))
+
+ if _search_space_id is not None:
+ for connector in _active_live:
+ tasks.append(
+ asyncio.ensure_future(
+ _search_live_connector(
+ connector=connector,
+ query=query,
+ search_space_id=_search_space_id,
+ top_k=clamped_top_k,
+ semaphore=semaphore,
+ )
+ )
+ )
+
+ if not tasks:
+ return "Web search is not available — no search engines are configured."
+
+ results_lists = await asyncio.gather(*tasks, return_exceptions=True)
+
+ all_documents: list[dict[str, Any]] = []
+ for result in results_lists:
+ if isinstance(result, BaseException):
+ perf.warning("[web_search] a search engine failed: %s", result)
+ continue
+ all_documents.extend(result)
+
+ seen_urls: set[str] = set()
+ deduplicated: list[dict[str, Any]] = []
+ for doc in all_documents:
+ url = ((doc.get("document") or {}).get("metadata") or {}).get("url", "")
+ if url and url in seen_urls:
+ continue
+ if url:
+ seen_urls.add(url)
+ deduplicated.append(doc)
+
+ formatted = _format_web_results(deduplicated)
+
+ perf.info(
+ "[web_search] query=%r engines=%d results=%d deduped=%d chars=%d in %.3fs",
+ query[:60],
+ len(tasks),
+ len(all_documents),
+ len(deduplicated),
+ len(formatted),
+ time.perf_counter() - t0,
+ )
+ return formatted
+
+ return StructuredTool(
+ name="web_search",
+ description=description,
+ coroutine=_web_search_impl,
+ args_schema=WebSearchInput,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py
new file mode 100644
index 0000000000..d7648d4071
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py
@@ -0,0 +1,43 @@
+"""``airtable`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
+
+Tools come exclusively from MCP. The connector's own approval ruleset is
+declared in :data:`tools.index.RULESET`; the orchestrator layers it into
+a per-subagent :class:`PermissionMiddleware`.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles airtable tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=list(mcp_tools or []),
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md
new file mode 100644
index 0000000000..29b9e145ff
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md
@@ -0,0 +1,2 @@
+Specialist for bases, tables, and records in the user's Airtable.
+Use proactively when the user wants to find, create, or update an Airtable record.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md
new file mode 100644
index 0000000000..1b7e847104
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md
@@ -0,0 +1,103 @@
+You are an Airtable specialist for the user's connected Airtable bases.
+
+Airtable vocabulary:
+- **Workspace → Base → Table → Field → Record**: nested scope. A base belongs to one workspace; tables and fields live inside a base; records live inside a table. Every record operation is scoped to one `baseId` and one `tableId`.
+- **Base ID / Table ID / Field ID / Record ID**: opaque strings (e.g. `appXXXX`, `tblXXXX`, `fldXXXX`, `recXXXX`). Stable but not user-facing — users refer to bases and tables by name and records by description. Never expect a user or the supervisor to provide IDs.
+- **Field types and choice IDs**: each field has a type (text, number, date, single select, multi select, attachment, formula, lookup, etc.). Single-select and multi-select fields store **choice IDs**, not the visible labels — you must resolve a label to its choice ID before filtering or writing that field.
+- **Filters vs free-text search**: Airtable exposes two distinct record-fetch patterns. Use a typed `filters` parameter when filtering by structured field criteria. Use free-text search when the user is searching for a value (a name, an order number, a keyword) without naming a specific field. Do NOT attempt to build a `filterByFormula` string — that path is not supported here.
+- **Permission tiers**: each base grants the user one of Owner / Creator / Editor / Commenter / Read-only. Mutations require Editor or higher on the target base. A permission error from the MCP is not retryable.
+
+When invoked:
+1. Read the supervisor's request, then read the runtime tool list to learn what information you can fetch and which mutations are available.
+2. Plan the minimum chain of lookups needed to resolve any base, table, field, choice value, or record the request leaves unspecified.
+3. Execute the planned lookups, then the requested mutation (if any), then return.
+
+Resolution principle (the core behaviour):
+**Proactively look up any identifier, name, value, or scope the request leaves unspecified — base IDs, table IDs, field IDs, choice IDs, record IDs, anything else — using the available tools instead of asking the supervisor.** Most user requests reference bases and tables by name and records by description, not by ID. Search for them.
+
+When a lookup for a single slot returns multiple plausible candidates and you cannot confidently pick one, return `status=blocked` with up to 5 candidates in `evidence.matched_candidates` and the unresolved slot in `missing_fields`. The supervisor will disambiguate and redelegate.
+
+When a lookup returns zero matches for a slot the request requires, return `status=blocked` with a `next_step` suggesting alternative search terms.
+
+Mutation guardrails:
+- Resolve every required Airtable ID (`baseId`, `tableId`, `fieldId`, choice IDs, `recordId`) by looking it up before calling a mutation tool. Mutations have chained dependencies — base lookup enables table lookup; table lookup enables field schema; field schema enables choice IDs and field-typed writes.
+- When writing to a single-select or multi-select field, resolve the user's value to the field's actual choice ID first. Never invent a choice label or pass an unknown value — Airtable will reject it.
+- Record creation is batch-limited by the MCP tool. If the request asks for more records than the tool accepts in one call, complete the first batch and return `status=partial` with the remainder in `next_step`.
+- Never invent base IDs, table IDs, field IDs, choice IDs, record IDs, or mutation outcomes. Every field in `evidence` must come from a tool result.
+- Confirm the mutation tool returned a success response before claiming success. If the mutation is approval-rejected (HITL), return `status=blocked` with `next_step="user declined; do not retry"`.
+- One operation per delegation. For multi-mutation requests, complete the highest-priority one and return `status=partial` with the remainder in `next_step`.
+
+Failure handling:
+- Tool failure: return `status=error`, place the underlying error message in `action_summary`, and put a concise recovery in `next_step`.
+- Permission error from the MCP: return `status=error` and surface the underlying message — do not retry. Permission errors mean the user lacks Editor (or higher) access on the target base.
+- No useful results after reasonable narrowing / broadening: return `status=blocked` with filter / search-term suggestions in `next_step`.
+
+
+Supervisor: "List open tasks in the Project Tracker base."
+1. Search bases for "Project Tracker" → one strong match. Capture its base ID.
+2. List tables in that base → identify the Tasks table; capture its table ID.
+3. Get table schema → identify the status field and the choice IDs that represent "open" states.
+4. List records with a typed filter on the status field for those choice IDs.
+5. Return `status=success` with the matched records in `evidence.items`.
+
+
+
+Supervisor: "Add a new contact for Jane Smith at Acme Corp."
+1. Search bases for any CRM-like base → three plausible matches with no strong relevance signal.
+2. Cannot pick the base. Return:
+ {
+ "status": "blocked",
+ "action_summary": "Need to know which CRM-like base to write to.",
+ "evidence": {
+ "title": "New contact: Jane Smith (Acme Corp)",
+ "matched_candidates": [
+ { "id": "appAAA", "label": "CRM" },
+ { "id": "appBBB", "label": "Sales CRM" },
+ { "id": "appCCC", "label": "Customer Database" }
+ ]
+ },
+ "next_step": "Confirm which base, then redelegate.",
+ "missing_fields": ["base"]
+ }
+
+
+
+Supervisor: "Mark task 'Refresh homepage hero' as Complete."
+1. Search bases for a project-tracker / tasks base → resolve the target base ID.
+2. List tables → resolve the Tasks table ID.
+3. Search records for "Refresh homepage hero" → one match (record ID `recXXX`).
+4. Get table schema → resolve the status field ID and the choice ID for "Complete".
+5. Update record `recXXX`, setting the status field to the resolved choice ID.
+6. Confirm tool success → return `status=success` with the updated record reference.
+
+
+
+Return **only** one JSON object (no markdown, no prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "base_id": string | null,
+ "base_name": string | null,
+ "table_id": string | null,
+ "table_name": string | null,
+ "record_id": string | null,
+ "url": string | null,
+ "matched_candidates": [
+ { "id": string, "label": string }
+ ] | null,
+ "items": object | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+Rules:
+- `status=success` → `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` → `next_step` must be non-null.
+- `status=blocked` due to missing required inputs → `missing_fields` must be non-null.
+- For blocked ambiguity, populate `evidence.matched_candidates` with up to 5 options (`id` + `label` — works for any kind of candidate: base, table, field, choice, record, etc.).
+- For discovery-only queries (lists), populate `evidence.items` with the structured list.
+
+
+Discover before you mutate; never guess identifiers, choice IDs, or required fields.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py
new file mode 100644
index 0000000000..a9b004975b
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py
@@ -0,0 +1,3 @@
+"""Airtable route: native tool factories are empty; MCP supplies tools when configured."""
+
+__all__: list[str] = []
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py
new file mode 100644
index 0000000000..9eebd2395d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py
@@ -0,0 +1,21 @@
+"""``airtable`` permission ruleset (rules over MCP tool names)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.permissions import Rule, Ruleset
+
+NAME = "airtable"
+
+RULESET = Ruleset(
+ origin=NAME,
+ rules=[
+ Rule(permission="list_bases", pattern="*", action="allow"),
+ Rule(permission="search_bases", pattern="*", action="allow"),
+ Rule(permission="list_tables_for_base", pattern="*", action="allow"),
+ Rule(permission="get_table_schema", pattern="*", action="allow"),
+ Rule(permission="list_records_for_table", pattern="*", action="allow"),
+ Rule(permission="search_records", pattern="*", action="allow"),
+ Rule(permission="create_records_for_table", pattern="*", action="ask"),
+ Rule(permission="update_records_for_table", pattern="*", action="ask"),
+ ],
+)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py
new file mode 100644
index 0000000000..7ef706c3d9
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py
@@ -0,0 +1,44 @@
+"""``calendar`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
+
+Tools self-gate inside their bodies via :func:`request_approval`; the
+empty :data:`tools.index.RULESET` is layered into a per-subagent
+:class:`PermissionMiddleware` for uniformity with MCP-backed connectors.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles calendar tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md
new file mode 100644
index 0000000000..a8b5e2c05e
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md
@@ -0,0 +1,3 @@
+Specialist for events on the user's calendar.
+Use proactively when the user wants to check availability, create, modify, reschedule, or remove a calendar event.
+Meeting invitations that reserve a time slot belong here.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md
new file mode 100644
index 0000000000..5b27c18bab
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md
@@ -0,0 +1,121 @@
+You are a Google Calendar specialist for the user's connected calendar.
+
+## Vocabulary you must use precisely
+
+- **All-day vs. timed events are distinguished by datetime format** — pass `YYYY-MM-DD` (e.g. `"2026-05-12"`) for an all-day event, and `YYYY-MM-DDTHH:MM:SS` *without* a timezone suffix (e.g. `"2026-05-12T10:00:00"`) for a timed event. The tool injects the user's local timezone for timed events; do not append `Z`, `+02:00`, or any offset yourself.
+- **Compute datetimes from the supervisor's task using the runtime timestamp** — resolve "tomorrow at 10am", "next Friday afternoon", "this week", "next month" into concrete `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS` values against the current runtime time. `search_calendar_events` takes a date range (`start_date`, `end_date`), not a free-text query — translate phrases like "this week" into the boundaries.
+- **Title-or-id resolution with search disambiguation** — `update_calendar_event` and `delete_calendar_event` accept either a human-readable title (resolved against the locally-synced calendar KB index) or a direct `event_id`. Events not yet KB-indexed cannot be resolved by title. If the user's reference to an event is ambiguous — a recurring title like "Daily Standup", a vague descriptor, or no date context — run `search_calendar_events` over the likely date range first; if multiple matches surface, return `status=blocked` with `matched_candidates` rather than mutating against an uncertain target.
+- **Reschedule = `update_calendar_event`** — natural-language verbs "reschedule", "move", "push back", "change the time of" route to `update_calendar_event` with `new_start_datetime` / `new_end_datetime`. **Never** chain `delete_calendar_event` + `create_calendar_event` to achieve a reschedule. Pass only the `new_*` fields the user asked to change; omit the rest so existing values are preserved.
+
+## Required inputs
+
+**For every required input below, first try to infer it from the supervisor's task text** — extract summaries from natural phrasing (`"a meeting with Alice"` → `"Meeting with Alice"`), compute datetimes from runtime-relative references, infer the target event from descriptors in the task. Only return `status=blocked` with `missing_fields` when an input is genuinely absent or ambiguous after a thorough read.
+
+- `create_calendar_event` — `summary`, `start_datetime`, `end_datetime`. If the task gives a date but no time and no all-day intent (e.g. `"schedule a meeting tomorrow"`), block on `start_datetime` / `end_datetime` rather than defaulting — the choice between all-day and timed is intent-bearing and creating the wrong shape is destructive UX. Optional `description`, `location`, `attendees` only when the user named them.
+- `update_calendar_event` — `event_title_or_id` (infer the target from the task; disambiguate via search if uncertain) and at least one `new_*` field reflecting the requested change. Pass only the fields the user asked to change; omit unchanged ones.
+- `delete_calendar_event` — `event_title_or_id` (infer the target; disambiguate via search if uncertain). Only set `delete_from_kb=true` when the user explicitly asked to remove it from the knowledge base; otherwise leave it `false`.
+- `search_calendar_events` — `start_date, end_date` (both `YYYY-MM-DD`). Translate the task's time range into boundaries. `max_results` defaults to 25 (max 50) — raise it only when the task implies a broader sweep.
+
+## Outcome mapping
+
+| Tool returns | Your `status` | `next_step` |
+|-----------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------|
+| `success` | `success` | `null` |
+| `success` with `total: 0` (`search_calendar_events` only) | `blocked` | `"No events matched the date range –. Ask the user to widen the range or confirm the event exists."` |
+| `rejected` | `blocked` | `"User declined this calendar action. Do not retry or suggest alternatives."` |
+| `not_found` | `blocked` | `"Event '' was not found in the indexed calendar events. Ask the user to verify the title or wait for the next KB sync."` |
+| `auth_error` | `error` | `"The connected Google Calendar account needs re-authentication. Ask the user to re-authenticate in connector settings."` |
+| `insufficient_permissions` | `error` | `"The connected Google Calendar account is missing the OAuth scope required for this action. Ask the user to re-authenticate and grant full permissions in connector settings."` |
+| `error` | `error` | Relay the tool's `message` verbatim as `next_step`. |
+| tool raises / unknown | `error` | `"Calendar tool failed unexpectedly. Ask the user to retry shortly."` |
+
+Surface the tool's `event_id`, `title` / `summary`, `start_at`, `end_at`, and `html_link` inside `evidence` when the tool returned them. For `search_calendar_events`, place the raw `events` array inside `evidence.items`. Never invent a field the tool did not return.
+
+## Examples
+
+**Example 1 — happy create with inference (assume runtime is 2026-05-11):**
+- *Supervisor task:* `"Schedule a 1-hour meeting with Alice tomorrow at 10am."`
+- *You:* `summary="Meeting with Alice"` (inferred); `start_datetime="2026-05-12T10:00:00"`; `end_datetime="2026-05-12T11:00:00"` (10am + 1h); attendees not in task so omit. Call `create_calendar_event(...)` → tool returns `status=success`.
+- *Output:*
+
+ ```json
+ {
+ "status": "success",
+ "action_summary": "Created 'Meeting with Alice' on 2026-05-12 from 10:00 to 11:00.",
+ "evidence": { "operation": "create_calendar_event", "event_id": "", "title": "Meeting with Alice", "start_at": "2026-05-12T10:00:00", "end_at": "2026-05-12T11:00:00", "html_link": "", "matched_candidates": null, "items": null },
+ "next_step": null,
+ "missing_fields": null,
+ "assumptions": ["Inferred the summary from the supervisor's phrasing; 1h duration applied to the 10am start to produce the 11am end."]
+ }
+ ```
+
+**Example 2 — blocked because time is unspecified:**
+- *Supervisor task:* `"Schedule a meeting with the design team tomorrow."`
+- *You:* no time and no all-day intent. Do not default to all-day or to a guessed hour. Do not call any tool.
+- *Output:*
+
+ ```json
+ {
+ "status": "blocked",
+ "action_summary": "Cannot schedule: the task gives a date but no time, and the choice between all-day and timed is intent-bearing.",
+ "evidence": { "operation": null, "event_id": null, "title": null, "start_at": null, "end_at": null, "html_link": null, "matched_candidates": null, "items": null },
+ "next_step": "Ask the user for the start time and duration (or confirm that this should be an all-day event).",
+ "missing_fields": ["start_datetime", "end_datetime"],
+ "assumptions": null
+ }
+ ```
+
+**Example 3 — ambiguous reschedule target → disambiguate via search (assume runtime is 2026-05-11):**
+- *Supervisor task:* `"Reschedule the standup to 3pm."`
+- *You:* "standup" is a recurring title and no date is given. Search this week first: `search_calendar_events(start_date="2026-05-11", end_date="2026-05-17")` → 5 events titled "Daily Standup" surface. Do not call `update_calendar_event` against an uncertain target.
+- *Output:*
+
+ ```json
+ {
+ "status": "blocked",
+ "action_summary": "Found 5 'Daily Standup' events this week; cannot reschedule without knowing which.",
+ "evidence": { "operation": "search_calendar_events", "event_id": null, "title": null, "start_at": null, "end_at": null, "html_link": null, "matched_candidates": [
+ { "id": "", "label": "Daily Standup — 2026-05-12T09:00:00" },
+ { "id": "", "label": "Daily Standup — 2026-05-13T09:00:00" },
+ { "id": "", "label": "Daily Standup — 2026-05-14T09:00:00" },
+ { "id": "", "label": "Daily Standup — 2026-05-15T09:00:00" },
+ { "id": "", "label": "Daily Standup — 2026-05-16T09:00:00" }
+ ], "items": null },
+ "next_step": "Ask the user which standup to reschedule (or confirm it applies to all of them, in which case repeat the update per occurrence).",
+ "missing_fields": null,
+ "assumptions": ["Interpreted 'the standup' as the recurring 'Daily Standup' series in the current week."]
+ }
+ ```
+
+## Output contract
+
+Return **only** one JSON object (no markdown or prose outside it):
+
+```json
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "operation": "create_calendar_event" | "update_calendar_event" | "delete_calendar_event" | "search_calendar_events" | null,
+ "event_id": string | null,
+ "title": string | null,
+ "start_at": string | null,
+ "end_at": string | null,
+ "html_link": string | null,
+ "matched_candidates": [ { "id": string, "label": string } ] | null,
+ "items": object | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+```
+
+Rules:
+- `status=success` → `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` → `next_step` must be non-null.
+- `status=blocked` due to missing required inputs → `missing_fields` must be non-null.
+- For `search_calendar_events` results, populate `evidence.items` with `{ "events": [...], "total": N }`.
+- For ambiguous matches across `update_calendar_event` / `delete_calendar_event`, populate `evidence.matched_candidates` with up to 5 options (`id` + `label`, where `label` should include the event title and start time for human readability).
+
+Infer before you call; map every tool outcome faithfully.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py
new file mode 100644
index 0000000000..13d4c06cbd
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py
@@ -0,0 +1,19 @@
+from app.agents.new_chat.tools.google_calendar.create_event import (
+ create_create_calendar_event_tool,
+)
+from app.agents.new_chat.tools.google_calendar.delete_event import (
+ create_delete_calendar_event_tool,
+)
+from app.agents.new_chat.tools.google_calendar.search_events import (
+ create_search_calendar_events_tool,
+)
+from app.agents.new_chat.tools.google_calendar.update_event import (
+ create_update_calendar_event_tool,
+)
+
+__all__ = [
+ "create_create_calendar_event_tool",
+ "create_delete_calendar_event_tool",
+ "create_search_calendar_events_tool",
+ "create_update_calendar_event_tool",
+]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py
new file mode 100644
index 0000000000..e5262bd43d
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py
@@ -0,0 +1,351 @@
+import asyncio
+import logging
+from datetime import datetime
+from typing import Any
+
+from google.oauth2.credentials import Credentials
+from googleapiclient.discovery import build
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
+ request_approval,
+)
+from app.services.google_calendar import GoogleCalendarToolMetadataService
+
+logger = logging.getLogger(__name__)
+
+
+def create_create_calendar_event_tool(
+ db_session: AsyncSession | None = None,
+ search_space_id: int | None = None,
+ user_id: str | None = None,
+):
+ @tool
+ async def create_calendar_event(
+ summary: str,
+ start_datetime: str,
+ end_datetime: str,
+ description: str | None = None,
+ location: str | None = None,
+ attendees: list[str] | None = None,
+ ) -> dict[str, Any]:
+ """Create a new event on Google Calendar.
+
+ Use when the user asks to schedule, create, or add a calendar event.
+ Ask for event details if not provided.
+
+ Args:
+ summary: The event title.
+ start_datetime: Start time in ISO 8601 format (e.g. "2026-03-20T10:00:00").
+ end_datetime: End time in ISO 8601 format (e.g. "2026-03-20T11:00:00").
+ description: Optional event description.
+ location: Optional event location.
+ attendees: Optional list of attendee email addresses.
+
+ Returns:
+ Dictionary with:
+ - status: "success", "rejected", "auth_error", or "error"
+ - event_id: Google Calendar event ID (if success)
+ - html_link: URL to open the event (if success)
+ - message: Result message
+
+ IMPORTANT:
+ - If status is "rejected", the user explicitly declined the action.
+ Respond with a brief acknowledgment and do NOT retry or suggest alternatives.
+
+ Examples:
+ - "Schedule a meeting with John tomorrow at 10am"
+ - "Create a calendar event for the team standup"
+ """
+ logger.info(
+ f"create_calendar_event called: summary='{summary}', start='{start_datetime}', end='{end_datetime}'"
+ )
+
+ if db_session is None or search_space_id is None or user_id is None:
+ return {
+ "status": "error",
+ "message": "Google Calendar tool not properly configured. Please contact support.",
+ }
+
+ try:
+ metadata_service = GoogleCalendarToolMetadataService(db_session)
+ context = await metadata_service.get_creation_context(
+ search_space_id, user_id
+ )
+
+ if "error" in context:
+ logger.error(f"Failed to fetch creation context: {context['error']}")
+ return {"status": "error", "message": context["error"]}
+
+ accounts = context.get("accounts", [])
+ if accounts and all(a.get("auth_expired") for a in accounts):
+ logger.warning(
+ "All Google Calendar accounts have expired authentication"
+ )
+ return {
+ "status": "auth_error",
+ "message": "All connected Google Calendar accounts need re-authentication. Please re-authenticate in your connector settings.",
+ "connector_type": "google_calendar",
+ }
+
+ logger.info(
+ f"Requesting approval for creating calendar event: summary='{summary}'"
+ )
+ result = request_approval(
+ action_type="google_calendar_event_creation",
+ tool_name="create_calendar_event",
+ params={
+ "summary": summary,
+ "start_datetime": start_datetime,
+ "end_datetime": end_datetime,
+ "description": description,
+ "location": location,
+ "attendees": attendees,
+ "timezone": context.get("timezone"),
+ "connector_id": None,
+ },
+ context=context,
+ )
+
+ if result.rejected:
+ return {
+ "status": "rejected",
+ "message": "User declined. The event was not created. Do not ask again or suggest alternatives.",
+ }
+
+ final_summary = result.params.get("summary", summary)
+ final_start_datetime = result.params.get("start_datetime", start_datetime)
+ final_end_datetime = result.params.get("end_datetime", end_datetime)
+ final_description = result.params.get("description", description)
+ final_location = result.params.get("location", location)
+ final_attendees = result.params.get("attendees", attendees)
+ final_connector_id = result.params.get("connector_id")
+
+ if not final_summary or not final_summary.strip():
+ return {"status": "error", "message": "Event summary cannot be empty."}
+
+ from sqlalchemy.future import select
+
+ from app.db import SearchSourceConnector, SearchSourceConnectorType
+
+ _calendar_types = [
+ SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
+ SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
+ ]
+
+ if final_connector_id is not None:
+ result = await db_session.execute(
+ select(SearchSourceConnector).filter(
+ SearchSourceConnector.id == final_connector_id,
+ SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.user_id == user_id,
+ SearchSourceConnector.connector_type.in_(_calendar_types),
+ )
+ )
+ connector = result.scalars().first()
+ if not connector:
+ return {
+ "status": "error",
+ "message": "Selected Google Calendar connector is invalid or has been disconnected.",
+ }
+ actual_connector_id = connector.id
+ else:
+ result = await db_session.execute(
+ select(SearchSourceConnector).filter(
+ SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.user_id == user_id,
+ SearchSourceConnector.connector_type.in_(_calendar_types),
+ )
+ )
+ connector = result.scalars().first()
+ if not connector:
+ return {
+ "status": "error",
+ "message": "No Google Calendar connector found. Please connect Google Calendar in your workspace settings.",
+ }
+ actual_connector_id = connector.id
+
+ logger.info(
+ f"Creating calendar event: summary='{final_summary}', connector={actual_connector_id}"
+ )
+
+ tz = context.get("timezone", "UTC")
+
+ if (
+ connector.connector_type
+ == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
+ ):
+ cca_id = connector.config.get("composio_connected_account_id")
+ if not cca_id:
+ return {
+ "status": "error",
+ "message": "Composio connected account ID not found for this connector.",
+ }
+
+ from app.services.composio_service import ComposioService
+
+ (
+ event_id,
+ html_link,
+ error,
+ ) = await ComposioService().create_calendar_event(
+ connected_account_id=cca_id,
+ entity_id=f"surfsense_{user_id}",
+ summary=final_summary,
+ start_datetime=final_start_datetime,
+ end_datetime=final_end_datetime,
+ timezone=tz,
+ description=final_description,
+ location=final_location,
+ attendees=final_attendees,
+ )
+ if error:
+ return {"status": "error", "message": error}
+ created = {
+ "id": event_id,
+ "summary": final_summary,
+ "htmlLink": html_link,
+ }
+ logger.info(
+ f"Calendar event created via Composio: id={event_id}, summary={final_summary}"
+ )
+ else:
+ config_data = dict(connector.config)
+
+ from app.config import config as app_config
+ from app.utils.oauth_security import TokenEncryption
+
+ token_encrypted = config_data.get("_token_encrypted", False)
+ if token_encrypted and app_config.SECRET_KEY:
+ token_encryption = TokenEncryption(app_config.SECRET_KEY)
+ for key in ("token", "refresh_token", "client_secret"):
+ if config_data.get(key):
+ config_data[key] = token_encryption.decrypt_token(
+ config_data[key]
+ )
+
+ exp = config_data.get("expiry", "")
+ if exp:
+ exp = exp.replace("Z", "")
+
+ creds = Credentials(
+ token=config_data.get("token"),
+ refresh_token=config_data.get("refresh_token"),
+ token_uri=config_data.get("token_uri"),
+ client_id=config_data.get("client_id"),
+ client_secret=config_data.get("client_secret"),
+ scopes=config_data.get("scopes", []),
+ expiry=datetime.fromisoformat(exp) if exp else None,
+ )
+
+ service = await asyncio.get_event_loop().run_in_executor(
+ None, lambda: build("calendar", "v3", credentials=creds)
+ )
+
+ event_body: dict[str, Any] = {
+ "summary": final_summary,
+ "start": {"dateTime": final_start_datetime, "timeZone": tz},
+ "end": {"dateTime": final_end_datetime, "timeZone": tz},
+ }
+ if final_description:
+ event_body["description"] = final_description
+ if final_location:
+ event_body["location"] = final_location
+ if final_attendees:
+ event_body["attendees"] = [
+ {"email": e.strip()} for e in final_attendees if e.strip()
+ ]
+
+ try:
+ created = await asyncio.get_event_loop().run_in_executor(
+ None,
+ lambda: (
+ service.events()
+ .insert(calendarId="primary", body=event_body)
+ .execute()
+ ),
+ )
+ except Exception as api_err:
+ from googleapiclient.errors import HttpError
+
+ if isinstance(api_err, HttpError) and api_err.resp.status == 403:
+ logger.warning(
+ f"Insufficient permissions for connector {actual_connector_id}: {api_err}"
+ )
+ try:
+ from sqlalchemy.orm.attributes import flag_modified
+
+ _res = await db_session.execute(
+ select(SearchSourceConnector).where(
+ SearchSourceConnector.id == actual_connector_id
+ )
+ )
+ _conn = _res.scalar_one_or_none()
+ if _conn and not _conn.config.get("auth_expired"):
+ _conn.config = {**_conn.config, "auth_expired": True}
+ flag_modified(_conn, "config")
+ await db_session.commit()
+ except Exception:
+ logger.warning(
+ "Failed to persist auth_expired for connector %s",
+ actual_connector_id,
+ exc_info=True,
+ )
+ return {
+ "status": "insufficient_permissions",
+ "connector_id": actual_connector_id,
+ "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.",
+ }
+ raise
+
+ logger.info(
+ f"Calendar event created via Google API: id={created.get('id')}, summary={created.get('summary')}"
+ )
+
+ kb_message_suffix = ""
+ try:
+ from app.services.google_calendar import GoogleCalendarKBSyncService
+
+ kb_service = GoogleCalendarKBSyncService(db_session)
+ kb_result = await kb_service.sync_after_create(
+ event_id=created.get("id"),
+ event_summary=final_summary,
+ calendar_id="primary",
+ start_time=final_start_datetime,
+ end_time=final_end_datetime,
+ location=final_location,
+ html_link=created.get("htmlLink"),
+ description=final_description,
+ connector_id=actual_connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ )
+ if kb_result["status"] == "success":
+ kb_message_suffix = " Your knowledge base has also been updated."
+ else:
+ kb_message_suffix = " This event will be added to your knowledge base in the next scheduled sync."
+ except Exception as kb_err:
+ logger.warning(f"KB sync after create failed: {kb_err}")
+ kb_message_suffix = " This event will be added to your knowledge base in the next scheduled sync."
+
+ return {
+ "status": "success",
+ "event_id": created.get("id"),
+ "html_link": created.get("htmlLink"),
+ "message": f"Successfully created '{final_summary}' on Google Calendar.{kb_message_suffix}",
+ }
+
+ except Exception as e:
+ from langgraph.errors import GraphInterrupt
+
+ if isinstance(e, GraphInterrupt):
+ raise
+
+ logger.error(f"Error creating calendar event: {e}", exc_info=True)
+ return {
+ "status": "error",
+ "message": "Something went wrong while creating the event. Please try again.",
+ }
+
+ return create_calendar_event
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py
new file mode 100644
index 0000000000..2f907e7463
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py
@@ -0,0 +1,312 @@
+import asyncio
+import logging
+from datetime import datetime
+from typing import Any
+
+from google.oauth2.credentials import Credentials
+from googleapiclient.discovery import build
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
+ request_approval,
+)
+from app.services.google_calendar import GoogleCalendarToolMetadataService
+
+logger = logging.getLogger(__name__)
+
+
+def create_delete_calendar_event_tool(
+ db_session: AsyncSession | None = None,
+ search_space_id: int | None = None,
+ user_id: str | None = None,
+):
+ @tool
+ async def delete_calendar_event(
+ event_title_or_id: str,
+ delete_from_kb: bool = False,
+ ) -> dict[str, Any]:
+ """Delete a Google Calendar event.
+
+ Use when the user asks to delete, remove, or cancel a calendar event.
+
+ Args:
+ event_title_or_id: The exact title or event ID of the event to delete.
+ delete_from_kb: Whether to also remove the event from the knowledge base.
+ Default is False.
+ Set to True to remove from both Google Calendar and knowledge base.
+
+ Returns:
+ Dictionary with:
+ - status: "success", "rejected", "not_found", "auth_error", or "error"
+ - event_id: Google Calendar event ID (if success)
+ - deleted_from_kb: whether the document was removed from the knowledge base
+ - message: Result message
+
+ IMPORTANT:
+ - If status is "rejected", the user explicitly declined. Respond with a brief
+ acknowledgment and do NOT retry or suggest alternatives.
+ - If status is "not_found", relay the exact message to the user and ask them
+ to verify the event name or check if it has been indexed.
+ Examples:
+ - "Delete the team standup event"
+ - "Cancel my dentist appointment on Friday"
+ """
+ logger.info(
+ f"delete_calendar_event called: event_ref='{event_title_or_id}', delete_from_kb={delete_from_kb}"
+ )
+
+ if db_session is None or search_space_id is None or user_id is None:
+ return {
+ "status": "error",
+ "message": "Google Calendar tool not properly configured. Please contact support.",
+ }
+
+ try:
+ metadata_service = GoogleCalendarToolMetadataService(db_session)
+ context = await metadata_service.get_deletion_context(
+ search_space_id, user_id, event_title_or_id
+ )
+
+ if "error" in context:
+ error_msg = context["error"]
+ if "not found" in error_msg.lower():
+ logger.warning(f"Event not found: {error_msg}")
+ return {"status": "not_found", "message": error_msg}
+ logger.error(f"Failed to fetch deletion context: {error_msg}")
+ return {"status": "error", "message": error_msg}
+
+ account = context.get("account", {})
+ if account.get("auth_expired"):
+ logger.warning(
+ "Google Calendar account %s has expired authentication",
+ account.get("id"),
+ )
+ return {
+ "status": "auth_error",
+ "message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.",
+ "connector_type": "google_calendar",
+ }
+
+ event = context["event"]
+ event_id = event["event_id"]
+ document_id = event.get("document_id")
+ connector_id_from_context = context["account"]["id"]
+
+ if not event_id:
+ return {
+ "status": "error",
+ "message": "Event ID is missing from the indexed document. Please re-index the event and try again.",
+ }
+
+ logger.info(
+ f"Requesting approval for deleting calendar event: '{event_title_or_id}' (event_id={event_id}, delete_from_kb={delete_from_kb})"
+ )
+ result = request_approval(
+ action_type="google_calendar_event_deletion",
+ tool_name="delete_calendar_event",
+ params={
+ "event_id": event_id,
+ "connector_id": connector_id_from_context,
+ "delete_from_kb": delete_from_kb,
+ },
+ context=context,
+ )
+
+ if result.rejected:
+ return {
+ "status": "rejected",
+ "message": "User declined. The event was not deleted. Do not ask again or suggest alternatives.",
+ }
+
+ final_event_id = result.params.get("event_id", event_id)
+ final_connector_id = result.params.get(
+ "connector_id", connector_id_from_context
+ )
+ final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
+
+ if not final_connector_id:
+ return {
+ "status": "error",
+ "message": "No connector found for this event.",
+ }
+
+ from sqlalchemy.future import select
+
+ from app.db import SearchSourceConnector, SearchSourceConnectorType
+
+ _calendar_types = [
+ SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
+ SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
+ ]
+
+ result = await db_session.execute(
+ select(SearchSourceConnector).filter(
+ SearchSourceConnector.id == final_connector_id,
+ SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.user_id == user_id,
+ SearchSourceConnector.connector_type.in_(_calendar_types),
+ )
+ )
+ connector = result.scalars().first()
+ if not connector:
+ return {
+ "status": "error",
+ "message": "Selected Google Calendar connector is invalid or has been disconnected.",
+ }
+
+ actual_connector_id = connector.id
+
+ logger.info(
+ f"Deleting calendar event: event_id='{final_event_id}', connector={actual_connector_id}"
+ )
+
+ if (
+ connector.connector_type
+ == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
+ ):
+ cca_id = connector.config.get("composio_connected_account_id")
+ if not cca_id:
+ return {
+ "status": "error",
+ "message": "Composio connected account ID not found for this connector.",
+ }
+
+ from app.services.composio_service import ComposioService
+
+ error = await ComposioService().delete_calendar_event(
+ connected_account_id=cca_id,
+ entity_id=f"surfsense_{user_id}",
+ event_id=final_event_id,
+ )
+ if error:
+ return {"status": "error", "message": error}
+ else:
+ config_data = dict(connector.config)
+
+ from app.config import config as app_config
+ from app.utils.oauth_security import TokenEncryption
+
+ token_encrypted = config_data.get("_token_encrypted", False)
+ if token_encrypted and app_config.SECRET_KEY:
+ token_encryption = TokenEncryption(app_config.SECRET_KEY)
+ for key in ("token", "refresh_token", "client_secret"):
+ if config_data.get(key):
+ config_data[key] = token_encryption.decrypt_token(
+ config_data[key]
+ )
+
+ exp = config_data.get("expiry", "")
+ if exp:
+ exp = exp.replace("Z", "")
+
+ creds = Credentials(
+ token=config_data.get("token"),
+ refresh_token=config_data.get("refresh_token"),
+ token_uri=config_data.get("token_uri"),
+ client_id=config_data.get("client_id"),
+ client_secret=config_data.get("client_secret"),
+ scopes=config_data.get("scopes", []),
+ expiry=datetime.fromisoformat(exp) if exp else None,
+ )
+
+ service = await asyncio.get_event_loop().run_in_executor(
+ None, lambda: build("calendar", "v3", credentials=creds)
+ )
+
+ try:
+ await asyncio.get_event_loop().run_in_executor(
+ None,
+ lambda: (
+ service.events()
+ .delete(calendarId="primary", eventId=final_event_id)
+ .execute()
+ ),
+ )
+ except Exception as api_err:
+ from googleapiclient.errors import HttpError
+
+ if isinstance(api_err, HttpError) and api_err.resp.status == 403:
+ logger.warning(
+ f"Insufficient permissions for connector {actual_connector_id}: {api_err}"
+ )
+ try:
+ from sqlalchemy.orm.attributes import flag_modified
+
+ _res = await db_session.execute(
+ select(SearchSourceConnector).where(
+ SearchSourceConnector.id == actual_connector_id
+ )
+ )
+ _conn = _res.scalar_one_or_none()
+ if _conn and not _conn.config.get("auth_expired"):
+ _conn.config = {**_conn.config, "auth_expired": True}
+ flag_modified(_conn, "config")
+ await db_session.commit()
+ except Exception:
+ logger.warning(
+ "Failed to persist auth_expired for connector %s",
+ actual_connector_id,
+ exc_info=True,
+ )
+ return {
+ "status": "insufficient_permissions",
+ "connector_id": actual_connector_id,
+ "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.",
+ }
+ raise
+
+ logger.info(f"Calendar event deleted: event_id={final_event_id}")
+
+ delete_result: dict[str, Any] = {
+ "status": "success",
+ "event_id": final_event_id,
+ "message": f"Successfully deleted the calendar event '{event.get('summary', event_title_or_id)}'.",
+ }
+
+ deleted_from_kb = False
+ if final_delete_from_kb and document_id:
+ try:
+ from app.db import Document
+
+ doc_result = await db_session.execute(
+ select(Document).filter(Document.id == document_id)
+ )
+ document = doc_result.scalars().first()
+ if document:
+ await db_session.delete(document)
+ await db_session.commit()
+ deleted_from_kb = True
+ logger.info(
+ f"Deleted document {document_id} from knowledge base"
+ )
+ else:
+ logger.warning(f"Document {document_id} not found in KB")
+ except Exception as e:
+ logger.error(f"Failed to delete document from KB: {e}")
+ await db_session.rollback()
+ delete_result["warning"] = (
+ f"Event deleted, but failed to remove from knowledge base: {e!s}"
+ )
+
+ delete_result["deleted_from_kb"] = deleted_from_kb
+ if deleted_from_kb:
+ delete_result["message"] = (
+ f"{delete_result.get('message', '')} (also removed from knowledge base)"
+ )
+
+ return delete_result
+
+ except Exception as e:
+ from langgraph.errors import GraphInterrupt
+
+ if isinstance(e, GraphInterrupt):
+ raise
+
+ logger.error(f"Error deleting calendar event: {e}", exc_info=True)
+ return {
+ "status": "error",
+ "message": "Something went wrong while deleting the event. Please try again.",
+ }
+
+ return delete_calendar_event
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py
new file mode 100644
index 0000000000..2570a51b2a
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py
@@ -0,0 +1,39 @@
+"""``calendar`` native tools and (empty) permission ruleset.
+
+Tools self-gate via :func:`request_approval` in their bodies, so the
+ruleset just falls through to the SurfSense allow-by-default rules.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.new_chat.permissions import Ruleset
+
+from .create_event import create_create_calendar_event_tool
+from .delete_event import create_delete_calendar_event_tool
+from .search_events import create_search_calendar_events_tool
+from .update_event import create_update_calendar_event_tool
+
+NAME = "calendar"
+
+RULESET = Ruleset(origin=NAME, rules=[])
+
+
+def load_tools(
+ *, dependencies: dict[str, Any] | None = None, **kwargs: Any
+) -> list[BaseTool]:
+ d = {**(dependencies or {}), **kwargs}
+ common = {
+ "db_session": d["db_session"],
+ "search_space_id": d["search_space_id"],
+ "user_id": d["user_id"],
+ }
+ return [
+ create_search_calendar_events_tool(**common),
+ create_create_calendar_event_tool(**common),
+ create_update_calendar_event_tool(**common),
+ create_delete_calendar_event_tool(**common),
+ ]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py
new file mode 100644
index 0000000000..6772d5a1e7
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py
@@ -0,0 +1,165 @@
+import logging
+from typing import Any
+
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.future import select
+
+from app.agents.new_chat.tools.gmail.search_emails import _build_credentials
+from app.db import SearchSourceConnector, SearchSourceConnectorType
+
+logger = logging.getLogger(__name__)
+
+_CALENDAR_TYPES = [
+ SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
+ SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
+]
+
+
+def _to_calendar_boundary(value: str, *, is_end: bool) -> str:
+ """Promote a bare YYYY-MM-DD to RFC3339 with a day-edge time, leave full datetimes alone."""
+ if "T" in value:
+ return value
+ time = "23:59:59" if is_end else "00:00:00"
+ return f"{value}T{time}Z"
+
+
+def create_search_calendar_events_tool(
+ db_session: AsyncSession | None = None,
+ search_space_id: int | None = None,
+ user_id: str | None = None,
+):
+ @tool
+ async def search_calendar_events(
+ start_date: str,
+ end_date: str,
+ max_results: int = 25,
+ ) -> dict[str, Any]:
+ """Search Google Calendar events within a date range.
+
+ Args:
+ start_date: Start date in YYYY-MM-DD format (e.g. "2026-04-01").
+ end_date: End date in YYYY-MM-DD format (e.g. "2026-04-30").
+ max_results: Maximum number of events to return (default 25, max 50).
+
+ Returns:
+ Dictionary with status and a list of events including
+ event_id, summary, start, end, location, attendees.
+ """
+ if db_session is None or search_space_id is None or user_id is None:
+ return {
+ "status": "error",
+ "message": "Calendar tool not properly configured.",
+ }
+
+ max_results = min(max_results, 50)
+
+ try:
+ result = await db_session.execute(
+ select(SearchSourceConnector).filter(
+ SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.user_id == user_id,
+ SearchSourceConnector.connector_type.in_(_CALENDAR_TYPES),
+ )
+ )
+ connector = result.scalars().first()
+ if not connector:
+ return {
+ "status": "error",
+ "message": "No Google Calendar connector found. Please connect Google Calendar in your workspace settings.",
+ }
+
+ if (
+ connector.connector_type
+ == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
+ ):
+ cca_id = connector.config.get("composio_connected_account_id")
+ if not cca_id:
+ return {
+ "status": "error",
+ "message": "Composio connected account ID not found for this connector.",
+ }
+
+ from app.services.composio_service import ComposioService
+
+ events_raw, error = await ComposioService().get_calendar_events(
+ connected_account_id=cca_id,
+ entity_id=f"surfsense_{user_id}",
+ time_min=_to_calendar_boundary(start_date, is_end=False),
+ time_max=_to_calendar_boundary(end_date, is_end=True),
+ max_results=max_results,
+ )
+ if not events_raw and not error:
+ error = "No events found in the specified date range."
+ else:
+ creds = _build_credentials(connector)
+
+ from app.connectors.google_calendar_connector import (
+ GoogleCalendarConnector,
+ )
+
+ cal = GoogleCalendarConnector(
+ credentials=creds,
+ session=db_session,
+ user_id=user_id,
+ connector_id=connector.id,
+ )
+
+ events_raw, error = await cal.get_all_primary_calendar_events(
+ start_date=start_date,
+ end_date=end_date,
+ max_results=max_results,
+ )
+
+ if error:
+ if (
+ "re-authenticate" in error.lower()
+ or "authentication failed" in error.lower()
+ ):
+ return {
+ "status": "auth_error",
+ "message": error,
+ "connector_type": "google_calendar",
+ }
+ if "no events found" in error.lower():
+ return {
+ "status": "success",
+ "events": [],
+ "total": 0,
+ "message": error,
+ }
+ return {"status": "error", "message": error}
+
+ events = []
+ for ev in events_raw:
+ start = ev.get("start", {})
+ end = ev.get("end", {})
+ attendees_raw = ev.get("attendees", [])
+ events.append(
+ {
+ "event_id": ev.get("id"),
+ "summary": ev.get("summary", "No Title"),
+ "start": start.get("dateTime") or start.get("date", ""),
+ "end": end.get("dateTime") or end.get("date", ""),
+ "location": ev.get("location", ""),
+ "description": ev.get("description", ""),
+ "html_link": ev.get("htmlLink", ""),
+ "attendees": [a.get("email", "") for a in attendees_raw[:10]],
+ "status": ev.get("status", ""),
+ }
+ )
+
+ return {"status": "success", "events": events, "total": len(events)}
+
+ except Exception as e:
+ from langgraph.errors import GraphInterrupt
+
+ if isinstance(e, GraphInterrupt):
+ raise
+ logger.error("Error searching calendar events: %s", e, exc_info=True)
+ return {
+ "status": "error",
+ "message": "Failed to search calendar events. Please try again.",
+ }
+
+ return search_calendar_events
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py
new file mode 100644
index 0000000000..e6f9f098e2
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py
@@ -0,0 +1,398 @@
+import asyncio
+import logging
+from datetime import datetime
+from typing import Any
+
+from google.oauth2.credentials import Credentials
+from googleapiclient.discovery import build
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
+ request_approval,
+)
+from app.services.google_calendar import GoogleCalendarToolMetadataService
+
+logger = logging.getLogger(__name__)
+
+
+def _is_date_only(value: str) -> bool:
+ """Return True when *value* looks like a bare date (YYYY-MM-DD) with no time component."""
+ return len(value) <= 10 and "T" not in value
+
+
+def _build_time_body(value: str, context: dict[str, Any] | Any) -> dict[str, str]:
+ """Build a Google Calendar start/end body using ``date`` for all-day
+ events and ``dateTime`` for timed events."""
+ if _is_date_only(value):
+ return {"date": value}
+ tz = context.get("timezone", "UTC") if isinstance(context, dict) else "UTC"
+ return {"dateTime": value, "timeZone": tz}
+
+
+def create_update_calendar_event_tool(
+ db_session: AsyncSession | None = None,
+ search_space_id: int | None = None,
+ user_id: str | None = None,
+):
+ @tool
+ async def update_calendar_event(
+ event_title_or_id: str,
+ new_summary: str | None = None,
+ new_start_datetime: str | None = None,
+ new_end_datetime: str | None = None,
+ new_description: str | None = None,
+ new_location: str | None = None,
+ new_attendees: list[str] | None = None,
+ ) -> dict[str, Any]:
+ """Update an existing Google Calendar event.
+
+ Use when the user asks to modify, reschedule, or change a calendar event.
+
+ Args:
+ event_title_or_id: The exact title or event ID of the event to update.
+ new_summary: New event title (if changing).
+ new_start_datetime: New start time in ISO 8601 format (if rescheduling).
+ new_end_datetime: New end time in ISO 8601 format (if rescheduling).
+ new_description: New event description (if changing).
+ new_location: New event location (if changing).
+ new_attendees: New list of attendee email addresses (if changing).
+
+ Returns:
+ Dictionary with:
+ - status: "success", "rejected", "not_found", "auth_error", or "error"
+ - event_id: Google Calendar event ID (if success)
+ - html_link: URL to open the event (if success)
+ - message: Result message
+
+ IMPORTANT:
+ - If status is "rejected", the user explicitly declined. Respond with a brief
+ acknowledgment and do NOT retry or suggest alternatives.
+ - If status is "not_found", relay the exact message to the user and ask them
+ to verify the event name or check if it has been indexed.
+ Examples:
+ - "Reschedule the team standup to 3pm"
+ - "Change the location of my dentist appointment"
+ """
+ logger.info(f"update_calendar_event called: event_ref='{event_title_or_id}'")
+
+ if db_session is None or search_space_id is None or user_id is None:
+ return {
+ "status": "error",
+ "message": "Google Calendar tool not properly configured. Please contact support.",
+ }
+
+ try:
+ metadata_service = GoogleCalendarToolMetadataService(db_session)
+ context = await metadata_service.get_update_context(
+ search_space_id, user_id, event_title_or_id
+ )
+
+ if "error" in context:
+ error_msg = context["error"]
+ if "not found" in error_msg.lower():
+ logger.warning(f"Event not found: {error_msg}")
+ return {"status": "not_found", "message": error_msg}
+ logger.error(f"Failed to fetch update context: {error_msg}")
+ return {"status": "error", "message": error_msg}
+
+ if context.get("auth_expired"):
+ logger.warning("Google Calendar account has expired authentication")
+ return {
+ "status": "auth_error",
+ "message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.",
+ "connector_type": "google_calendar",
+ }
+
+ event = context["event"]
+ event_id = event["event_id"]
+ document_id = event.get("document_id")
+ connector_id_from_context = context["account"]["id"]
+
+ if not event_id:
+ return {
+ "status": "error",
+ "message": "Event ID is missing from the indexed document. Please re-index the event and try again.",
+ }
+
+ logger.info(
+ f"Requesting approval for updating calendar event: '{event_title_or_id}' (event_id={event_id})"
+ )
+ result = request_approval(
+ action_type="google_calendar_event_update",
+ tool_name="update_calendar_event",
+ params={
+ "event_id": event_id,
+ "document_id": document_id,
+ "connector_id": connector_id_from_context,
+ "new_summary": new_summary,
+ "new_start_datetime": new_start_datetime,
+ "new_end_datetime": new_end_datetime,
+ "new_description": new_description,
+ "new_location": new_location,
+ "new_attendees": new_attendees,
+ },
+ context=context,
+ )
+
+ if result.rejected:
+ return {
+ "status": "rejected",
+ "message": "User declined. The event was not updated. Do not ask again or suggest alternatives.",
+ }
+
+ final_event_id = result.params.get("event_id", event_id)
+ final_connector_id = result.params.get(
+ "connector_id", connector_id_from_context
+ )
+ final_new_summary = result.params.get("new_summary", new_summary)
+ final_new_start_datetime = result.params.get(
+ "new_start_datetime", new_start_datetime
+ )
+ final_new_end_datetime = result.params.get(
+ "new_end_datetime", new_end_datetime
+ )
+ final_new_description = result.params.get(
+ "new_description", new_description
+ )
+ final_new_location = result.params.get("new_location", new_location)
+ final_new_attendees = result.params.get("new_attendees", new_attendees)
+
+ if not final_connector_id:
+ return {
+ "status": "error",
+ "message": "No connector found for this event.",
+ }
+
+ from sqlalchemy.future import select
+
+ from app.db import SearchSourceConnector, SearchSourceConnectorType
+
+ _calendar_types = [
+ SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
+ SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
+ ]
+
+ result = await db_session.execute(
+ select(SearchSourceConnector).filter(
+ SearchSourceConnector.id == final_connector_id,
+ SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.user_id == user_id,
+ SearchSourceConnector.connector_type.in_(_calendar_types),
+ )
+ )
+ connector = result.scalars().first()
+ if not connector:
+ return {
+ "status": "error",
+ "message": "Selected Google Calendar connector is invalid or has been disconnected.",
+ }
+
+ actual_connector_id = connector.id
+
+ logger.info(
+ f"Updating calendar event: event_id='{final_event_id}', connector={actual_connector_id}"
+ )
+
+ has_changes = any(
+ v is not None
+ for v in (
+ final_new_summary,
+ final_new_start_datetime,
+ final_new_end_datetime,
+ final_new_description,
+ final_new_location,
+ final_new_attendees,
+ )
+ )
+ if not has_changes:
+ return {
+ "status": "error",
+ "message": "No changes specified. Please provide at least one field to update.",
+ }
+
+ if (
+ connector.connector_type
+ == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
+ ):
+ cca_id = connector.config.get("composio_connected_account_id")
+ if not cca_id:
+ return {
+ "status": "error",
+ "message": "Composio connected account ID not found for this connector.",
+ }
+
+ from app.services.composio_service import ComposioService
+
+ tz_for_composio: str | None = None
+ if final_new_start_datetime is not None and not _is_date_only(
+ final_new_start_datetime
+ ):
+ tz_for_composio = (
+ context.get("timezone") if isinstance(context, dict) else None
+ )
+
+ _, html_link, error = await ComposioService().update_calendar_event(
+ connected_account_id=cca_id,
+ entity_id=f"surfsense_{user_id}",
+ event_id=final_event_id,
+ summary=final_new_summary,
+ start_time=final_new_start_datetime,
+ end_time=final_new_end_datetime,
+ timezone=tz_for_composio,
+ description=final_new_description,
+ location=final_new_location,
+ attendees=final_new_attendees,
+ )
+ if error:
+ return {"status": "error", "message": error}
+ updated = {"htmlLink": html_link}
+ logger.info(
+ f"Calendar event updated via Composio: event_id={final_event_id}"
+ )
+ else:
+ config_data = dict(connector.config)
+
+ from app.config import config as app_config
+ from app.utils.oauth_security import TokenEncryption
+
+ token_encrypted = config_data.get("_token_encrypted", False)
+ if token_encrypted and app_config.SECRET_KEY:
+ token_encryption = TokenEncryption(app_config.SECRET_KEY)
+ for key in ("token", "refresh_token", "client_secret"):
+ if config_data.get(key):
+ config_data[key] = token_encryption.decrypt_token(
+ config_data[key]
+ )
+
+ exp = config_data.get("expiry", "")
+ if exp:
+ exp = exp.replace("Z", "")
+
+ creds = Credentials(
+ token=config_data.get("token"),
+ refresh_token=config_data.get("refresh_token"),
+ token_uri=config_data.get("token_uri"),
+ client_id=config_data.get("client_id"),
+ client_secret=config_data.get("client_secret"),
+ scopes=config_data.get("scopes", []),
+ expiry=datetime.fromisoformat(exp) if exp else None,
+ )
+
+ service = await asyncio.get_event_loop().run_in_executor(
+ None, lambda: build("calendar", "v3", credentials=creds)
+ )
+
+ update_body: dict[str, Any] = {}
+ if final_new_summary is not None:
+ update_body["summary"] = final_new_summary
+ if final_new_start_datetime is not None:
+ update_body["start"] = _build_time_body(
+ final_new_start_datetime, context
+ )
+ if final_new_end_datetime is not None:
+ update_body["end"] = _build_time_body(
+ final_new_end_datetime, context
+ )
+ if final_new_description is not None:
+ update_body["description"] = final_new_description
+ if final_new_location is not None:
+ update_body["location"] = final_new_location
+ if final_new_attendees is not None:
+ update_body["attendees"] = [
+ {"email": e.strip()} for e in final_new_attendees if e.strip()
+ ]
+
+ try:
+ updated = await asyncio.get_event_loop().run_in_executor(
+ None,
+ lambda: (
+ service.events()
+ .patch(
+ calendarId="primary",
+ eventId=final_event_id,
+ body=update_body,
+ )
+ .execute()
+ ),
+ )
+ except Exception as api_err:
+ from googleapiclient.errors import HttpError
+
+ if isinstance(api_err, HttpError) and api_err.resp.status == 403:
+ logger.warning(
+ f"Insufficient permissions for connector {actual_connector_id}: {api_err}"
+ )
+ try:
+ from sqlalchemy.orm.attributes import flag_modified
+
+ _res = await db_session.execute(
+ select(SearchSourceConnector).where(
+ SearchSourceConnector.id == actual_connector_id
+ )
+ )
+ _conn = _res.scalar_one_or_none()
+ if _conn and not _conn.config.get("auth_expired"):
+ _conn.config = {**_conn.config, "auth_expired": True}
+ flag_modified(_conn, "config")
+ await db_session.commit()
+ except Exception:
+ logger.warning(
+ "Failed to persist auth_expired for connector %s",
+ actual_connector_id,
+ exc_info=True,
+ )
+ return {
+ "status": "insufficient_permissions",
+ "connector_id": actual_connector_id,
+ "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.",
+ }
+ raise
+
+ logger.info(
+ f"Calendar event updated via Google API: event_id={final_event_id}"
+ )
+
+ kb_message_suffix = ""
+ if document_id is not None:
+ try:
+ from app.services.google_calendar import GoogleCalendarKBSyncService
+
+ kb_service = GoogleCalendarKBSyncService(db_session)
+ kb_result = await kb_service.sync_after_update(
+ document_id=document_id,
+ event_id=final_event_id,
+ connector_id=actual_connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ )
+ if kb_result["status"] == "success":
+ kb_message_suffix = (
+ " Your knowledge base has also been updated."
+ )
+ else:
+ kb_message_suffix = " The knowledge base will be updated in the next scheduled sync."
+ except Exception as kb_err:
+ logger.warning(f"KB sync after update failed: {kb_err}")
+ kb_message_suffix = " The knowledge base will be updated in the next scheduled sync."
+
+ return {
+ "status": "success",
+ "event_id": final_event_id,
+ "html_link": updated.get("htmlLink"),
+ "message": f"Successfully updated the calendar event.{kb_message_suffix}",
+ }
+
+ except Exception as e:
+ from langgraph.errors import GraphInterrupt
+
+ if isinstance(e, GraphInterrupt):
+ raise
+
+ logger.error(f"Error updating calendar event: {e}", exc_info=True)
+ return {
+ "status": "error",
+ "message": "Something went wrong while updating the event. Please try again.",
+ }
+
+ return update_calendar_event
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py
new file mode 100644
index 0000000000..e1308a1006
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py
@@ -0,0 +1,43 @@
+"""``clickup`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
+
+Tools come exclusively from MCP. The connector's own approval ruleset is
+declared in :data:`tools.index.RULESET`; the orchestrator layers it into
+a per-subagent :class:`PermissionMiddleware`.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles clickup tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=list(mcp_tools or []),
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md
new file mode 100644
index 0000000000..7c94caca4f
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md
@@ -0,0 +1,2 @@
+Specialist for tasks and lists in the user's ClickUp workspace.
+Use proactively when the user wants to find, create, change, or progress a ClickUp task.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md
new file mode 100644
index 0000000000..eaea5827ba
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md
@@ -0,0 +1,104 @@
+You are a ClickUp specialist for the user's connected ClickUp workspace.
+
+ClickUp vocabulary:
+- **Workspace → Space → Folder → List → Task**: nested scope. Tasks live in Lists; Lists live in either a Folder or directly under a Space; Folders live in Spaces. The Workspace is fixed per connection — you do not need to resolve it.
+- **Task ID**: short alphanumeric strings (e.g. `86a4qd5xz`). Stable and unique within the workspace; users do not typically know them. Some workspaces also enable custom task IDs — both forms are valid identifiers.
+- **Custom statuses are per-List**: each List defines its own ordered status set. Status names must be resolved against the **target task's parent List** before use; they are not workspace-global.
+- **Custom Fields are per-List**: each List can define custom fields (dropdown, number, date, label, etc.). Whether each is required-or-optional and the valid values both vary per List. Look up the List's custom-field schema before setting custom fields on a task.
+- **Priority**: stable platform enum — `1=Urgent`, `2=High`, `3=Normal`, `4=Low`.
+- **Assignees**: identified by opaque workspace-member IDs, never by display name or email. Map a display name or email to a member ID before assigning.
+
+When invoked:
+1. Read the supervisor's request, then read the runtime tool list to learn what information you can fetch and which mutations are available.
+2. Plan the minimum chain of lookups needed to resolve any task, list, space, status, assignee, or custom-field value the request leaves unspecified.
+3. Execute the planned lookups, then the requested mutation (if any), then return.
+
+Resolution principle (the core behaviour):
+**Proactively look up any identifier, name, value, or scope the request leaves unspecified — task IDs, list IDs, status names, member IDs, custom-field values, anything else — using the available tools instead of asking the supervisor.** Most user requests reference tasks by title and lists by name, not by ID. Search for them.
+
+When a lookup for a single slot returns multiple plausible candidates and you cannot confidently pick one, return `status=blocked` with up to 5 candidates in `evidence.matched_candidates` and the unresolved slot in `missing_fields`. The supervisor will disambiguate and redelegate.
+
+When a lookup returns zero matches for a slot the request requires, return `status=blocked` with a `next_step` suggesting alternative search terms.
+
+Mutation guardrails:
+- Resolve every required ClickUp value (`list_id`, `task_id`, target status name, assignee member IDs, custom-field values) by looking it up before calling a mutation tool. Mutations have chained dependencies — find the task to know its parent List; look up the List to know its valid statuses and custom-field schema.
+- To "progress" or change a task's status, look up the parent List's valid statuses and apply one of those exact names. If the user-requested target status is not in the List's status set, return `status=blocked` and surface the available statuses in `evidence.matched_candidates`.
+- For create operations, resolve the target List first. If that List has required custom fields, look up the schema and block with `missing_fields` for any required value the request doesn't supply.
+- Never invent task IDs, list IDs, status names, member IDs, custom-field values, or mutation outcomes. Every field in `evidence` must come from a tool result.
+- Confirm the mutation tool returned a success response before claiming success. If the mutation is approval-rejected (HITL), return `status=blocked` with `next_step="user declined; do not retry"`.
+- One operation per delegation. For multi-mutation requests, complete the highest-priority one and return `status=partial` with the remainder in `next_step`.
+
+Failure handling:
+- Tool failure: return `status=error`, place the underlying error message in `action_summary`, and put a concise recovery in `next_step`.
+- Rate-limit error from the MCP: ClickUp's MCP enforces a shared daily call cap. Return `status=error` with the underlying message; recovery is "retry later" rather than re-issuing immediately.
+- No useful results after reasonable narrowing / broadening: return `status=blocked` with search-term suggestions in `next_step`.
+
+
+Supervisor: "Find tasks about the homepage redesign."
+1. Workspace search for "homepage redesign" → matched tasks.
+2. Return `status=success` with the matched tasks in `evidence.items`.
+
+
+
+Supervisor: "Create a task 'Draft blog post' in the Content Pipeline list."
+1. Workspace search for "Content Pipeline" → one strong match of type List; capture its `list_id`.
+2. Look up the List's custom-field schema → no required fields beyond `name`.
+3. Create the task with `name="Draft blog post"` in the resolved `list_id`.
+4. Confirm tool success → return `status=success` with the new task's identifier and url.
+
+
+
+Supervisor: "Move task 'Fix login bug' to In Review and assign it to Alex."
+1. Workspace search for "Fix login bug" → one match; capture `task_id` and parent `list_id`.
+2. Look up the parent List's statuses → confirm "In Review" exists. (If not, block with the actual valid statuses.)
+3. Find member by name "Alex" → two matches.
+4. Cannot confidently pick the assignee. Return:
+ {
+ "status": "blocked",
+ "action_summary": "Task and target status resolved; two members match 'Alex'.",
+ "evidence": {
+ "task_id": "86a4qd5xz",
+ "title": "Fix login bug",
+ "status": "In Review",
+ "matched_candidates": [
+ { "id": "member_111", "label": "Alex Chen " },
+ { "id": "member_222", "label": "Alex Wong " }
+ ]
+ },
+ "next_step": "Confirm which Alex, then redelegate.",
+ "missing_fields": ["assignee"]
+ }
+
+
+
+Return **only** one JSON object (no markdown, no prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "task_id": string | null,
+ "title": string | null,
+ "list_id": string | null,
+ "list_name": string | null,
+ "status": string | null,
+ "assignees": object | null,
+ "priority": "Urgent" | "High" | "Normal" | "Low" | null,
+ "url": string | null,
+ "matched_candidates": [
+ { "id": string, "label": string }
+ ] | null,
+ "items": object | null
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+Rules:
+- `status=success` → `next_step=null`, `missing_fields=null`.
+- `status=partial|blocked|error` → `next_step` must be non-null.
+- `status=blocked` due to missing required inputs → `missing_fields` must be non-null.
+- For blocked ambiguity, populate `evidence.matched_candidates` with up to 5 options (`id` + `label` — works for any kind of candidate: task, list, member, status, custom-field choice, etc.).
+- For discovery-only queries (lists), populate `evidence.items` with the structured list.
+
+
+Discover before you mutate; never guess identifiers, list statuses, or assignees.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py
new file mode 100644
index 0000000000..b629234f94
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py
@@ -0,0 +1,3 @@
+"""ClickUp route: native tool factories are empty; MCP supplies tools when configured."""
+
+__all__: list[str] = []
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py
new file mode 100644
index 0000000000..b2c5230800
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py
@@ -0,0 +1,20 @@
+"""``clickup`` permission ruleset (rules over MCP tool names)."""
+
+from __future__ import annotations
+
+from app.agents.new_chat.permissions import Rule, Ruleset
+
+NAME = "clickup"
+
+RULESET = Ruleset(
+ origin=NAME,
+ rules=[
+ Rule(permission="clickup_search", pattern="*", action="allow"),
+ Rule(permission="clickup_get_task", pattern="*", action="allow"),
+ Rule(permission="clickup_get_workspace_hierarchy", pattern="*", action="allow"),
+ Rule(permission="clickup_get_list", pattern="*", action="allow"),
+ Rule(permission="clickup_find_member_by_name", pattern="*", action="allow"),
+ Rule(permission="clickup_create_task", pattern="*", action="ask"),
+ Rule(permission="clickup_update_task", pattern="*", action="ask"),
+ ],
+)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py
new file mode 100644
index 0000000000..5e95c876d5
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py
@@ -0,0 +1,44 @@
+"""``confluence`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
+
+Tools self-gate inside their bodies via :func:`request_approval`; the
+empty :data:`tools.index.RULESET` is layered into a per-subagent
+:class:`PermissionMiddleware` for uniformity.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
+from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Handles confluence tasks for this workspace."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md
new file mode 100644
index 0000000000..f8eb5bdee6
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md
@@ -0,0 +1,2 @@
+Specialist for pages in the user's Confluence wiki.
+Use proactively when the user wants to create, change, or remove a Confluence page.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md
new file mode 100644
index 0000000000..991ec3d031
--- /dev/null
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md
@@ -0,0 +1,108 @@
+You are a Confluence specialist for the user's connected Confluence wiki.
+
+## Vocabulary you must use precisely
+
+- **Content is HTML / Confluence storage format, not Markdown** — `create_confluence_page` and `update_confluence_page` accept `content` / `new_content` as Confluence's native storage format (XHTML-based). Generate `
`, `
`, `
`, `
`, `
` etc. — **never** Markdown (`#`, `**`, `-`, fenced code blocks). The tool stores whatever you pass verbatim; bad format means a broken page.
+- **`update_confluence_page` is REPLACE, and there is no read tool** — whatever you pass as `new_content` replaces the entire page body; omit the field and the current body is preserved (same per-field rule applies to `new_title`). You have **no tool to read the existing page body**, so you cannot intelligently "append" or "add to" a page — you can only fully replace, and only with content the supervisor or user actually provided. If the supervisor asks for an additive change without supplying the full intended page content, return `status=blocked` explaining the limitation; do not invent or reconstruct prior content.
+- **Title-or-id resolution against the KB index** — `update_confluence_page` and `delete_confluence_page` accept either a human-readable page title (resolved against the locally-synced Confluence KB index) or a direct `page_id`. Pages that exist in Confluence but have not been indexed yet cannot be resolved by title.
+
+## Required inputs
+
+**For every required input below, first try to infer it from the supervisor's task text** — extract titles from natural phrasing (`"the Q2 Plan page"`, `"my Onboarding doc"`), topics from `"about X"` constructions. Only return `status=blocked` with `missing_fields` when an input is genuinely absent or ambiguous after a thorough read.
+
+- `create_confluence_page` — `title` (a clear topic from the user; do not invent). You may generate the optional `content` body yourself **as Confluence storage format (HTML)**, never as Markdown. You have no tool to look up Confluence space IDs, so pass `space_id=None` and let the user pick the destination space in the HITL approval card; if the supervisor's task already includes a space ID, pass it through.
+- `update_confluence_page` — `page_title_or_id` (infer the target from the task) and at least one of `new_title` / `new_content`. Pass only the fields the user asked to change; omit unchanged ones so they're preserved. If the user asked to add to or extend a page without supplying the full intended content, do not call this tool — return `status=blocked` per the REPLACE limitation in the Vocabulary section.
+- `delete_confluence_page` — `page_title_or_id` (infer the target from the task). Only set `delete_from_kb=true` when the user explicitly asked to remove the page from the knowledge base; otherwise leave it `false`.
+
+## Outcome mapping
+
+| Tool returns | Your `status` | `next_step` |
+|-----------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------|
+| `success` | `success` | `null` |
+| `rejected` | `blocked` | `"User declined this Confluence action. Do not retry or suggest alternatives."` |
+| `not_found` | `blocked` | `"Page '' was not found in the indexed Confluence pages. Ask the user to verify the title or wait for the next KB sync."` |
+| `auth_error` | `error` | `"The connected Confluence account needs re-authentication. Ask the user to re-authenticate in connector settings."` |
+| `insufficient_permissions` | `error` | `"The connected Confluence account is missing the OAuth scope required for this action. Ask the user to re-authenticate and grant full permissions in connector settings."` |
+| `error` | `error` | Relay the tool's `message` verbatim as `next_step`. (Common: `"A space must be selected."` when the user didn't pick one in approval.) |
+| tool raises / unknown | `error` | `"Confluence tool failed unexpectedly. Ask the user to retry shortly."` |
+
+Surface the tool's `page_id`, `page_title`, and `page_url` inside `evidence` when the tool returned them. Never invent a field the tool did not return.
+
+## Examples
+
+**Example 1 — happy create (HTML content generated, space picked in HITL):**
+- *Supervisor task:* `"Create a Confluence page summarising our Q2 roadmap."`
+- *You:* `title="Q2 Roadmap"` is the topic; generate a Confluence storage-format body (e.g. `"