# Saas Study

> Systematically study a SaaS product to build a knowledge library for "should I build this?" / "how would I clone this?" decisions. Captures public surface, generates a deliberate authed-extraction gameplan, executes it against the user's real signed-in browser via the chrome-devtools MCP, fingerprints third-party providers from network traffic, and writes a token-tiered evidence folder with BRIEF…

- **Type:** Skill
- **Install:** `agentstack add skill-nulightjens-jensai-skills-saas-study`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [NulightJens](https://agentstack.voostack.com/s/nulightjens)
- **Installs:** 0
- **Category:** [Web & Browser](https://agentstack.voostack.com/c/web-and-browser)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [NulightJens](https://github.com/NulightJens)
- **Source:** https://github.com/NulightJens/jensai-skills/tree/main/saas-study

## Install

```sh
agentstack add skill-nulightjens-jensai-skills-saas-study
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# saas-study

Study a SaaS product, build evidence-first knowledge artifacts for the user's library.

## When to use

- User says "study saas X", "/saas-study ", "deep-dive this SaaS", "reverse engineer this app for inspiration"
- User wants to add a SaaS to their inspiration/build library
- User asks "how does product X work mechanically?" or "what stack is this on?". First check if `//` already has a recent study, otherwise propose running one

## When NOT to use

- Replicating proprietary content
- Scraping for resale or competitive harm
- Evading bot management on hostile targets
- Endpoint fuzzing / unauth probing
- Studying sites the user has no legitimate interest in

## Orchestration

This skill is **you (Claude) walking the user through a four-phase study**, with Python scripts handling deterministic mechanical work and the chrome-devtools MCP driving the browser. You author GAMEPLAN.md yourself (Phase 2.5); do not skip that reasoning step.

The skill **does not drive Phase 3 clicks**. The user drives their own browser; you just record via the chrome-devtools MCP attached to their Chrome.

## Paths and parameters

Two paths matter. Resolve both before running anything.

| Name | What it is | Default |
|---|---|---|
| `$SKILL` | Where this skill is installed. Use `${CLAUDE_SKILL_DIR}` when the harness sets it, otherwise the directory containing this `SKILL.md`. | n/a |
| `$LIBRARY` | Where studies are written. Passed to `new_study.py --library-root`. | `./saas-library/` in the current working directory |

```bash
SKILL="${CLAUDE_SKILL_DIR:-$(dirname "$0")}"   # or the install path you copied the skill to
LIBRARY="./saas-library"                        # override per-run with --library-root
```

Every script path below is written as `$SKILL/scripts/.py`. Every output path is under `$LIBRARY///`, captured as `$STUDY`.

## Pre-flight (one-time per machine)

Most scripts are stdlib-only. Three of them need packages: `extract_html.py` (trafilatura, with a beautifulsoup4 + lxml fallback), `extract_pricing.py` (beautifulsoup4 + lxml), and `render_brief.py` (jinja2).

Create an environment however you normally do. A project-local venv works fine:

```bash
python3 -m venv .venv
.venv/bin/pip install -r "$SKILL/scripts/requirements.txt"
PY=.venv/bin/python
```

Or install into whatever Python you already use:

```bash
python3 -m pip install trafilatura beautifulsoup4 lxml jinja2
PY=python3
```

`$SKILL/scripts/install.sh [venv-path]` does the venv route for you and prints the interpreter path. Optional screenshot redaction (`--redact`) additionally needs `pillow` + `pytesseract` plus a system `tesseract` binary; it skips gracefully when they are missing.

Set `PY` to the interpreter that has those packages. The rest of this file uses `$PY` for scripts that need them and `python3` for the stdlib-only ones.

### Chrome attach (this is the #1 source of friction)

Phase 2 and Phase 3 run through the **chrome-devtools MCP**, which attaches to a Chrome instance exposing a remote debugging port. Enable the MCP in your Claude Code session first (`/mcp` menu, or add the server to your MCP config; see the chrome-devtools MCP project docs for the exact server entry). Whatever port your MCP config points at is the port Chrome must be listening on.

Check the attach early via `mcp__chrome-devtools__list_pages`. Three failure modes you will hit:

1. **`Network.enable timed out`**. A stale Chrome instance is hanging on the debug port. Find and kill it (confirm with the user first, and never kill a browser they are actively using):
   ```bash
   pgrep -af "remote-debugging-port=" | head -1   # -> root PID
   kill -9 
   ```
2. **`Failed to fetch browser webSocket URL`**. No Chrome is listening on the port. Launch one. A separate `--user-data-dir` keeps the study isolated from the user's everyday profile (no cookies or sessions leak in), which is the right default for public capture; for authed capture the user signs in inside that instance. Example using port 9222:
   ```bash
   mkdir -p /tmp/saas-study-chrome-profile
   open -na "Google Chrome" --args \
     --remote-debugging-port=9222 \
     --user-data-dir=/tmp/saas-study-chrome-profile \
     --no-first-run --no-default-browser-check &
   sleep 3
   curl -s http://127.0.0.1:9222/json/version  # verify
   ```
   On Linux, `google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/saas-study-chrome-profile &` is the equivalent. Match the port to your MCP config.
3. **MCP can't see new tabs**. Call `list_pages` again. The MCP enumerates fresh.

After launching, retry `mcp__chrome-devtools__list_pages` to confirm attach.

## Argument parsing

Standard invocation: `/saas-study  [flags]`

Flags:
- `--library-root PATH` (default: `./saas-library`) where the study folder is written
- `--stealth=fast|balanced|high` (default: balanced)
- `--no-authed` skip Phase 3 entirely
- `--no-screenshots` skip all screenshot capture
- `--redact "name=X,email=Y"` OCR-blur named strings in screenshots

Parse `` to derive `` (strip protocol, www, path).

## Phase 1: Public harvest

1. Run `python3 "$SKILL/scripts/new_study.py"  --stealth  [--library-root $LIBRARY]`. This creates:
   - `$LIBRARY///` with `.gitignore`, empty `study.json`, subfolders
   - `$LIBRARY/.gitignore` if first study
   - Prints the study folder path; capture this as `$STUDY` for later commands

2. Run `python3 "$SKILL/scripts/fetch_public.py"  $STUDY`. This:
   - Fetches `robots.txt` and `sitemap.xml` over stdlib urllib with realistic browser headers
   - Writes `$STUDY/raw/public/robots.txt`, `$STUDY/raw/public/sitemap.xml`
   - Writes `$STUDY/derived/public-urls.json`, a sitemap-derived URL list scored by likely interest (pricing, about, docs, blog, changelog, features, login URL)
   - Honors robots.txt; logs skipped paths

3. **First-run consent check.** If `$LIBRARY/.saas-study-consented` does not exist AND `--no-authed` was not passed, show the consent prompt verbatim (see §First-run consent below). On Y, `touch $LIBRARY/.saas-study-consented`.

## Phase 2: Public browser pass

### Fast path: bulk curl for SSG marketing sites

Before driving chrome-devtools through 10+ pages, **quickly check if the marketing site is server-rendered SSG** (Astro / Next.js static export / Hugo / Webflow / WordPress). If yes, you can skip chrome-devtools entirely for public pages and curl them all in parallel: 10x faster, fewer tool calls, no token spend on JS bundles.

How to detect: navigate to the landing page once via chrome-devtools, capture the HTML, and grep for SSG markers (`/_astro/`, `_next/static` without `__NEXT_DATA__`, `wp-content/`, `webflow`, etc.). If the page text content is present in the raw HTML without JS hydration, it's SSG and you can curl.

```bash
UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
cd $STUDY/raw/html

# All public URLs in parallel
for url in $(jq -r '.urls[] | "\(.slug)|\(.url)"' $STUDY/derived/public-urls.json); do
  slug="${url%%|*}"; url="${url#*|}"
  curl -s -A "$UA" "$url" > "$slug.html" &
done
wait
ls -la *.html | awk '{print $5, $9}'   # verify sizes
```

For chrome-devtools-only sites (auth-walled, JS-rendered), use the per-page flow below.

### Per-page browser flow (when needed)

For each URL in `$STUDY/derived/public-urls.json` (top 10 to 15 by score):

1. `mcp__chrome-devtools__new_page` (or reuse) then `navigate_page` to the URL
2. `wait_for { networkIdle: true }` (or 8s timeout). If unavailable, evaluate_script a `new Promise(r => setTimeout(() => r(document.title), 2500))` instead.
3. Apply pacing: read profile from `$STUDY/.stealth-profile`; sleep randomized [browser_nav_delay_seconds]; if `scroll_after_navigate` is true, evaluate_script to `window.scrollTo({top: window.innerHeight * 0.55, behavior: 'smooth'})`, wait 1s, scroll back to top
4. `take_screenshot { fullPage: true, filePath: "$STUDY/screenshots/.png" }`
5. **HTML capture.** DO NOT use `take_snapshot` (that returns a11y text, not HTML). Use:
   ```js
   evaluate_script: () => document.documentElement.outerHTML
   ```
   The MCP returns one of two formats. Both are handled by the helper:

   **Format A (inline, smaller pages):** the response text contains:
   ```
   Script ran on page and returned:
   ```json
   ""
   ```
   ```
   **Format B (buffered to disk, larger pages):** the MCP saves a path to a `mcp-chrome-devtools-evaluate_script-.txt` tool-results file, and the file content is the same plain text from format A (NOT a JSON envelope on current MCP versions).

   Extract HTML via the bundled helper (handles both formats; do not use `jq`, it only worked on legacy MCP versions):
   ```bash
   python3 "$SKILL/scripts/extract_html_buffer.py"  $STUDY/raw/html/.html
   ```
6. After visiting each URL: `list_network_requests` (filter `resourceTypes: ["xhr", "fetch", "document"]` to skip CSS/JS/font noise), save as JSON to `$STUDY/raw/network/.json`. Format expected by the scrubber is a list of `{url, method, status}` objects (you can hand-construct from `list_network_requests` text output if HAR export isn't available).

Then extract markdown:
```bash
$PY "$SKILL/scripts/extract_html.py" $STUDY
```
This walks `raw/html/*.html` and writes `extracted/*.md` via trafilatura. **Important:** use the interpreter that has the packages installed (`$PY`), not a bare system python that lacks trafilatura.

## Phase 2.5: Author GAMEPLAN.md

This is **your** reasoning step. Do not delegate to a script.

1. Read `extracted/landing.md`, `extracted/pricing.md` (if present), `extracted/about.md` (if present), and a couple of feature pages.
2. Read `$SKILL/references/gameplan-template.md` for shape and tone calibration.
3. Author `$STUDY/GAMEPLAN.md` with sections A through E:
   - **A.1** stated value prop (verbatim from landing hero where possible)
   - **A.2** visible feature inventory as a table with "needs authed verification?" column
   - **A.3** pricing/metering model inferred (only if pricing page exists)
   - **B.1** expected provider table (one row per category you can guess from observed clues)
   - **B.2** expected own-API endpoint patterns (predictions based on feature names)
   - **B.3** interesting behaviors to watch (debouncing, streaming, rate limits, gating)
   - **C** ordered Authed Capture Plan, one numbered step per critical feature, each with goal/action/capture mode/endpoints expected/what to inspect
   - **D** out of scope (explicitly)
   - **E** done criteria (checklist)

Aim for ~2-3k tokens. Be deliberate, not exhaustive. Every Step in §C must trace back to a public-surface observation.

If user passed `--no-authed`, write a minimal GAMEPLAN.md noting auth was skipped and proceed to Phase 4.

4. Tell the user: "Gameplan written to $STUDY/GAMEPLAN.md. Review and edit if needed, then say 'go' to start authed capture."

5. **Wait for user "go"** before proceeding to Phase 3. The user may edit the file.

## Phase 3: Authed execution

### Phase 3 prelude: credit/quota budget check (do this before any gameplan step)

Many target SaaS apps gate authed actions with **credits or quota**. If you walk the gameplan without budget awareness, you can burn the user's entire trial in 5 minutes, and they'll feel it (every generation, every scrape, every AI call has a real cost). Surface the situation explicitly:

1. From the post-login bootstrap (§3a), extract the trial / quota state. Look for fields like `credits_total`, `credits_used`, `trial_credit_cap`, `daily_credits_limit`, `feature_restricted_credits`, `subscription_status: trialing`. These usually appear in `/api/.../subscription-status`, `/api/user/stats`, or direct database-backed queries to a `user_credits`-style table.
2. Walk the gameplan §C steps and try to infer or capture the credit cost per step (UI modals often show "Cost: X credits" before launching). Costs are commonly formulaic, for example a base charge plus a per-keyword or per-item charge on search, and a much larger one-time charge the first time a new entity is added and enriched.
3. **Ask the user how to allocate the budget** via `AskUserQuestion`. Offer 3-4 tiers (minimal / typical / full / no-paid-endpoints) with credit estimates. Be honest that a few features may not get an endpoint captured if budget is tight.
4. Re-check credits after every step so you can warn the user before a single action burns 20% of the remaining budget.

A typical 100-credit trial covers one search, one generation, one AI chat, one enrichment-style add, plus all the free CRUD and route probes, at roughly 40 credits with the most informative endpoints captured. Leave the rest for the user to actually try the product.

### Errors are often the richest signal

Failed requests usually leak more architecture than successful ones. In one study, a `429 quota exceeded` error from an AI chat endpoint returned an error body that named **the entire model-fallback chain in plain text**:

```
: -> 429 ... | : -> 404 ... | : -> 429 ...
```

That single error answered:

- Which provider the router actually uses (one provider, not the several shown in the UI model picker)
- The exact model rotation order
- Whether they cross-provider failover (they didn't)
- That a versioned snapshot id was tried but doesn't exist (404)

**Behaviour to add to your loop:** when an authed action returns a non-200 status, *always* capture the response body via `get_network_request --responseFilePath`. Errors are free intelligence. If a feature happens to be in a quota outage during the study, don't treat it as a failure, treat it as a gift.

### 3a Login pause

Tell the user: "Sign in to  in your Chrome window now. When you're on the authed surface (dashboard or equivalent), type 'continue'."

When the user says continue:

- `take_snapshot` to verify the URL changed off a public page (a11y output; just for sanity check)
- `evaluate_script: () => location.href`, record the post-login landing URL
- `take_screenshot` to `$STUDY/screenshots/post-login.png`
- `evaluate_script: () => document.documentElement.outerHTML`, extract via `extract_html_buffer.py` (see Phase 2) to `$STUDY/raw/html/post-login.html`
- `list_network_requests` since session start, filtered to xhr/fetch, dump to `$STUDY/raw/network/post-login-bootstrap.json`
- For any tRPC/GraphQL/REST endpoints that look load-bearing (`user.current`, `agency.get`, `me`, `session`, etc.), call `get_network_request { reqid, responseFilePath: "$STUDY/raw/network/sample_.network-response" }` to capture **response bodies**. These feed `fingerprint_data_shapes.py` in Phase 4 to detect vendors that proxy server-side.

### 3a.1 Auth-cookie inspection (high-signal, low-effort)

Right after login, **decode the auth cookie** to identify the auth provider. This is one of the highest-signal moves in the whole study. Most apps store a session cookie named `*-auth`, `sb-*-auth-token`, `*_session`, or similar. The cookie value is often `base64-{json}` or a JWT.

Look at one of the captured `get_network_request` outputs and pull the `cookie:` header. Common patterns:

| Auth provider | Cookie / token signature |
|---|---|
| **Supabase Auth** | Cookie value is `base64-` containing `access_token` (JWT). Decoded JWT `iss` field is `https://.supabase.co/auth/v1`. Project ID is a unique fingerprint. |
| **Clerk** | Cookie name starts with `__client_uat`, `__session`, or `__clerk_*`. Domain headers reference `*.clerk.accounts.dev` or `*.clerk.com`. |
| **Auth0** | Cookie names start with `auth0.`, `a0:state`, or `auth0_compat`. Redirect URLs hit `*.auth0.com`. |
| **NextAuth / Auth.js** | Cookies `next-auth.session-token`, `next-auth.csrf-token`, `next-auth.callback-url`. JWT or DB-session. |
| **Stytch** | Cookies `stytch_session`, `stytch_session_jwt`. Calls to `*.stytch.com`. |
| **WorkOS** | Cookies often named after the app's brand; SSO flow hits `api.workos.com` with `connection_id` query. |
| **Custom JWT** | Cookie usually named aft

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [NulightJens](https://github.com/NulightJens)
- **Source:** [NulightJens/jensai-skills](https://github.com/NulightJens/jensai-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-nulightjens-jensai-skills-saas-study
- Seller: https://agentstack.voostack.com/s/nulightjens
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
