agentsclimarketplace

Open geo

Skill Pupok462/open-geo/.claude/skills/open-geo

Run a list of queries through a chosen AI engine, measure the target domain's visibility/citation in the AI answers, and produce a dashboard or PDF report. Use when the user runs /open-geo or asks to measure a brand's GEO / AI-search visibility (citations in Google AI Overview, etc.).From its SKILL.md

Install
npx -y skills add Pupok462/open-geo --skill open-geo

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

3 things to look at

  • skips confirmationTells the agent to proceed without asking first, 2 times: "do not print the intro or ask anything" and 1 more.
  • 18 stars18 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
  • runs commandsInstructs the agent to run 3 commands, including `.venv/bin/python -m audit.gate --domain <domain> --engine <engine>` and 2 more.

SKILL.md

39.8 KB, ~10.5k tokens by cl100k_base, as published. Nobody here has run it

open-geo — GEO visibility run orchestrator

You are the orchestrator for one open-geo run: drive a list of queries through one AI engine, capture how the target domain shows up in the answers, ingest the captures through the validated pipeline, aggregate metrics, and emit a dashboard and/or a PDF report — finishing with a short summary.

This skill is the single operator entry point. It coordinates components that are specified in pipeline/INTERFACES.md (the authoritative contract). Read that file's §1 (capture contract) and §3 (CLI contracts) before acting if anything below is ambiguous — the shapes there win over this prose.

Conventions (from CLAUDE.md): code/identifiers and intermediate JSON are English. The final summary printed to the user follows --lang (default English). Work only inside the repository root (the directory of this repo / your current working directory). Run all Python with the project venv (.venv/bin/python) so pipeline.* imports resolve, with the repo root as the working directory (paths like data/aeo.db are repo-root-relative).


INVOCATION

/open-geo <questions.csv> <engine> <domain> --brand "<name>" --n-worker <N> \
          [--output dashboard|pdf|both] [--period today|all] [--lang en|ru|zh|ar] [--force]

Positional arguments

argmeaning
<questions.csv>Path to the input CSV. Columns: query,lens where lens ∈ general | branded | comparative. See examples/questions.csv for a ready sample. general = neutral query, no brand named; branded = brand explicitly named; comparative = brand vs alternatives. Either a hand-made CSV or one generated by STEP A.5 (question harvesting, Feature 1 — harvest/METHODOLOGY.md); both are first-class.
<engine>Engine id, snake_case, e.g. google. This value is (a) the engine field written into every QueryCapture and the run, and (b) the basename of the capture playbook the workers load: engines/<engine>.md (so googleengines/google.md). This is the multi-engine extension pointgoogle (Google AI Overview), chatgpt_search (ChatGPT web search), claude_search (Claude web search), yandex_neuro (Yandex Alice / Нейро), gemini (Google Gemini) and deepseek (DeepSeek web search) ship today; perplexity (Perplexity) has an authored playbook (engines/perplexity.md) pending its first live-validation run; the others are on the roadmap (ROADMAP Feature 3), and adding one is mainly authoring engines/<engine>.md (see engines/README.md).
<domain>The target — a registrable domain (example.com) or a URL prefix (github.com/user/repo). Accept any spelling; normalized via pipeline.schema.normalize_target. Workers match links against the target via matches_target/target_ranks (same semantics pipeline-wide).

Flags

flagrequireddefaultmeaning
--brand "<name>"yesHuman brand name (free text, may contain spaces — keep it quoted). Stored on the run; used in report/dashboard titles and the summary.
--n-worker <N>yesNumber of capture sub-agents to run in parallel — the run's concurrency. Step 2 splits the queries into N chunks, one per worker.
--output dashboard|pdf|bothnodashboardWhich deliverable(s) to produce in step 6.
--period today|allnoallReporting window passed to the dashboard/report: today = just this run's date, all = full history for this brand+engine (adds the PDF trend chart / the dashboard's whole-period view). Previous-run deltas (INTERFACES §4.1) render whenever an earlier completed run exists — in the PDF for either period, and in the dashboard's latest-run view.
--lang en|ru|zh|arnoenUI language for the deliverables: it is passed to the report (report.generate --lang) and is the dashboard's default language (the switcher can still change it in the browser). Extensible to any code registered in i18n/locales.json. It also sets the language of the final summary you print in step 7.
--forcenooffOverride the GEO-audit gate (STEP 0): proceed with the run even when the audit verdict is blocked (a category-A blocker — the domain is unreadable by the engine's search bot / unreachable / JS-only). Without it, a blocked verdict hard-stops before any run and prints the remediation. Advisory (ready_with_warnings) verdicts never need --force.
--repeat Rno1Repeat-run group (INTERFACES §2.1, Feature 5): capture the SAME question set R times as R ordinary runs sharing one group_id. Costs R× capture — a deliberate operator choice to separate signal from LLM noise. The dashboard then reads the group as one measurement: weighted mean of the seven metrics + a min–max spread chip per card (deltas are suppressed inside a group). R=1 = today's behavior, no group. See "Repeats" note under STEP 1.

If a required argument is missing, go to STEP A (the parameter wizard) to collect it interactively. Only hard-stop — a short error (in --lang), no empty run — if a required value is still unresolved after the wizard (or the user abandons it), or if questions.csv does not exist / has no data rows.


STEP R — REPO-ROOT GUARD (always the very first check)

This skill reaches users two ways: a clone of the open-geo repository (the primary, fully supported path) or the Claude Code plugin (a discovery wrapper: its manifest registers this skill and the worker agents — nothing else). The pipeline itself (pipeline/*, engines/*.md, .venv, data/aeo.db) exists only inside a repo clone, and every path in this skill is repo-root-relative.

Before STEP A, probe the current working directory with Read:

  1. pipeline/INTERFACES.md is missing → you are NOT in a repo clone (typical when the plugin-installed skill is invoked from an unrelated project). Do not run the wizard, do not call .venv/bin/python, do not create any file or directory here. Print (in --lang): the /open-geo command is installed, but the open-geo pipeline runs from a clone of the repository — then the exact steps:

    git clone https://github.com/Pupok462/open-geo
    cd open-geo && scripts/setup.sh
    

    and re-run /open-geo from that directory (the user can also ask you to run these steps for them outside this skill). Stop.

  2. Repo present but .venv/ is missing → setup has not been run. Tell the user to run scripts/setup.sh (creates the venv, installs Python deps and the dashboard frontend), then re-run /open-geo. Stop.


STEP A — RESOLVE PARAMETERS (intro + wizard, with fast-path bypass)

Run this after the STEP R guard, before STEP 0. Goal: end up with every required parameter resolved.

Required: questions.csv, engine, domain, --brand, --n-worker. Optional (defaults): --output (dashboard), --period (all), --lang (en).

  1. Parse the invocation — gather values from positional args, flags, AND anything the user expressed in free text (e.g. "measure example.com on google, 5 workers, pdf").
  2. FAST PATH — all required resolved: do not print the intro or ask anything. Echo one confirmation line — Running: csv=… engine=… domain=… brand=… n-worker=… output=… period=… lang=… — then proceed to STEP 0/1. (This is the path loops/headless use: pass full args, skip the wizard.)
  3. GUIDED PATH — something required is missing: a. Print a short intro (2–4 lines): what open-geo does (drives queries through an AI engine, measures the target domain's visibility/citation, emits a dashboard and/or PDF) and what it produces. b. Ask only for the missing parameters, using AskUserQuestion for the enumerable ones:
    • engine — offer only engines that actually have a playbook: .venv/bin/python -c "import glob,os; print('\n'.join(sorted(os.path.basename(p)[:-3] for p in glob.glob('engines/*.md') if os.path.basename(p)!='README.md')))" (today, sorted: chatgpt_search, claude_search, deepseek, gemini, google, perplexity, yandex_neuro). If the user names an engine without a playbook, say it is not available yet (ROADMAP Feature 3) and stop.
    • --n-worker — presets 1 / 3 / 5 / 10 (+ custom).
    • --outputdashboard / pdf / both. --periodtoday / all. --langen / ru / zh / ar.
    • questions.csv — offer found CSVs (+ "other path"), and a "Generate a set" option: .venv/bin/python -c "import glob; print('\n'.join(glob.glob('*.csv')+glob.glob('examples/*.csv')))" If the user picks Generate, leave questions.csv unresolved here and let STEP A.5 harvest it (it writes the CSV and sets the path). If they pick a file / give a path, that is the input CSV and STEP A.5 is skipped.
    • domain and --brand — free text. c. Echo the resolved parameters for a quick confirm, then proceed to STEP 0/1.
  4. If a required value is still unknown after the wizard (or it is abandoned), apply the guard from INVOCATION: a short error in --lang, no empty run.

STEP 0 — GEO-AUDIT GATE (runs FIRST: after the domain is known, before harvesting or a run)

Run this right after STEP A (so <domain> and <engine> are resolved) and before STEP A.5 and STEP 1 — there is no point harvesting questions or spending capture tokens on a domain an AI engine cannot even read. This is the Domain GEO-Audit Gate (ROADMAP Feature 2); the contract is pipeline/INTERFACES.md §7, the check semantics audit/CHECKS.md. It is deterministic Python (non-LLM, no browser).

  1. Run the audit — it fetches robots.txt / homepage / sitemap.xml / llms.txt / /.well-known, grades each check by severity, and writes the result to the audits table so the PDF/dashboard can show it later:

    .venv/bin/python -m audit.gate --domain <domain> --engine <engine>
    

    Parse stdout — a single AuditResult JSON (INTERFACES §7.1): verdict (ready | ready_with_warnings | blocked), score (0–100), passed, blockers (check ids), and checks[] (each id, severity, status, detail, remediation). A human summary is on STDERR. Add --no-cache to force a fresh audit (by default a recent audit for the same domain is reused within its TTL).

  2. Decide, per verdict:

    • blocked (a category-A blocker failed — the site is unreachable, non-200, JS-only, or robots.txt blocks the engine's search bot) and no --force given: hard-stop before any run. Print (in --lang) a short remediation report — for each blocker its detail + the concrete remediation fix, then the advisory warn/fail checks below it — and say plainly: the domain is not visibility-ready, so a capture run would waste tokens; fix the blockers, or re-run with --force to measure anyway. Do not create a run and do not harvest. Stop.
    • blocked with --force: warn loudly (list the blockers + their fixes), then continue — the operator chose to measure an unready domain.
    • ready_with_warnings: briefly surface the advisory problems (the warn/fail checks with their detail) and the score, then continue to STEP A.5.
    • ready: one line — GEO-audit: ready (score N/100) — continue.
  3. The audit is now stored (keyed by the registrable domain), so STEP 6's PDF/dashboard read it back (get_latest_audit) and render the full check table — you need not repeat the audit there.

Boundary. The gate is deterministic and only emits structured JSON; you (the orchestrator) turn that JSON into the human-language remediation the operator reads — the same division as the lens_sentiment prose vs the aggregate math. Only category-A failures block; everything else is advisory. Authority: pipeline/INTERFACES.md §7 + audit/CHECKS.md.


STEP A.5 — SOURCE THE QUESTIONS (bring-your-own vs harvest a grounded set)

Run this after STEP A and STEP 0, before STEP 1. Goal: end up with a real <questions.csv> on disk. It is the operator entry point for question harvesting (Feature 1) — the process authority is harvest/METHODOLOGY.md, the contract is pipeline/INTERFACES.md §6. Harvesting is agentic (recon sub-agents under the methodology), not an algorithm, and it is opt-in.

  1. FAST PATH / bring-your-own — a real CSV is already resolved. If STEP A resolved <questions.csv> to a path that exists and has data rows, this step is a no-op — use that file and go straight to STEP 0. (A user's own hand-made query,lens CSV is a first-class input; loops/headless always take this path.)

  2. GENERATE PATH — the user chose "Generate a set" (or no CSV is resolved). Harvest one:

    a. Collect harvest inputs (reuse what STEP A already has — brand, domain, --lang). Ask only for what is missing, via AskUserQuestion:

    • market / category (free text) and known competitors (free text seed; recon extends).
    • how many questions — presets 20 / 36 / 60 (+ custom). Default split is a deliberate general-tilt derived from the count (for ~36: 16 / 10 / 10); offer to override the general/branded/comparative split.
    • language(s) of the queries — default to --lang, but note the query language is the language people really ask in, independent of the deliverable --lang; a distinct-language slice goes to its own file (<name>_<code>.csv). Do not machine-translate for coverage.

    b. Plan the segments from the inputs (METHODOLOGY §5) — the "different angles" on the product (demand primary/secondary, supply if two-sided, category/discovery, branded-reputation, comparative-rivals, regional slice). A two-sided product adds a supply segment; a single-sided one may not. Keep the plan to the segments the product actually has.

    c. Phase A — fan-out grounded recon. Spawn one harvest-worker sub-agent per segment (Task tool), in parallel. Its full contract lives in .claude/agents/harvest-worker.md — do not restate it. Give each a self-contained brief:

    • the full text of harvest/METHODOLOGY.md (authoritative process + iron reality rule);
    • the product context (brand, domain, market, competitors);
    • its one segment + dominant lens(es), its worker index (for its unique temp file /tmp/open_geo_harvest_<idx>.json), the target 15–25 candidates, and the language(s);
    • authority pointers: pipeline/INTERFACES.md §6 and harvest/schema.py :: QuestionCandidate.

    A harvest worker grounds every candidate in an observable signal, returns a QuestionCandidate JSON pool, and cleans up its own browser tabs — it never writes questions.csv, never touches data/aeo.db, never balances or trims (that is your Phase B).

    d. Phase B — synthesize (you, the orchestrator). Merge all pools; dedup by meaning (not just text); drop anything without a real signal or violating its lens (METHODOLOGY §3/§4); balance to the target split with the general-tilt, maximizing intent diversity within each lens; split any non-primary-language slice into its own list.

    e. Phase C — adversarial skeptic. Spawn 1–2 harvest-skeptic sub-agents (Task tool; contract in .claude/agents/harvest-skeptic.md) with the thesis + the final {query, lens} list. They return KEEP/CUT verdicts. Apply the cuts, backfill each with the next-strongest distinct Phase-A candidate, until every shipped line survives.

    f. Commit to CSV via the build CLI (INTERFACES §6.2). Write your final candidate array (each a QuestionCandidate with query,lens,segment,signal,source_url) to a UTF-8 temp file, then:

    .venv/bin/python -m harvest.build --out <name>_questions.csv --brand "<name>" \
      < /tmp/open_geo_harvest_final.json
    

    Read stdout {"out","written","by_lens","dropped_dups","errors"}. errors must be empty — fix any flagged row (usually a mislabeled lens: general-with-brand or branded-without-brand) and re-run until errors: []. For a separate-language slice, call harvest.build again with its own --out <name>_<code>.csv.

    g. Write <name>_rationale.md — per segment: who we catch, on which observable signals (from the workers' signal/source_url), why this lens; plus the competitors that surfaced. This is the provenance the CSV omits (see gonka_questions_rationale.md for the shape). Keep it in the language of the audit's stakeholders.

    h. REVIEW GATE (human-in-the-loop). Show a short summary — total, by_lens, and the full query list — and ask (AskUserQuestion): Apply (use this CSV for the run), Edit (you open <name>_questions.csv, the user tweaks rows / you adjust per their notes, then re-run harvest.build to re-validate — errors: [] before proceeding), or Discard (fall back to bring-your-own: re-offer file selection / a path). On Apply/Edit, set <questions.csv> to the written path and proceed to STEP 0. This gate is deliberate — never skip straight to capture on a generated set without the operator seeing it (moat #3, trust).

Boundary. Harvesting only produces the CSV; nothing downstream changes. The capture contract (§1), the run, ingest/aggregate are untouched — STEP 1 onward treats a harvested CSV exactly like a hand-made one.


STEP 1 — CREATE OR RESUME THE RUN

First check for an unfinished run to resume — a previous run of this brand+engine left status='running' by a crash (INTERFACES §2.1). Look before creating anything:

.venv/bin/python -c "
import json
from pipeline.db import get_conn, init_db, get_or_create_brand, find_unfinished_run
conn = get_conn('data/aeo.db'); init_db(conn)
bid = get_or_create_brand(conn, '<name>', '<domain>')
print(json.dumps({'run_id': find_unfinished_run(conn, bid, '<engine>')}))
"
  • run_id non-null → an unfinished run exists. Offer to resume it (reuse that run_id; STEP 2 captures only the rows it is still missing) vs. start fresh. On the fast path (loops/headless, all args supplied) resume automatically — unattended recovery is the whole point. Keep the chosen <run_id> and skip the --new-run call.

  • run_id null (or the user chose fresh) → create a fresh run and capture its run_id from JSON stdout:

    .venv/bin/python -m pipeline.ingest \
      --brand "<name>" --domain <domain> --engine <engine> --new-run
    

    stdout: {"run_id": <int>} (per INTERFACES §3.1). Parse it and keep <run_id> for every later step. Human/log noise goes to STDERR — only the JSON object is on STDOUT.

  • If creation errors or stdout is not parseable JSON with a run_id, stop and report it (in --lang). Nothing downstream can proceed without run_id.

Repeats (--repeat R, R > 1)

The whole point is R independent captures of the same CSV, grouped so readers can see mean + spread instead of trusting one noisy run (INTERFACES §2.1). Flow:

  1. Mint one group tag for the whole invocation — grp_<YYYYMMDD-HHMM>_<engine> is fine.
  2. For each repeat i = 1..R sequentially: create its run with python -m pipeline.ingest --brand … --domain … --engine … --new-run --group-id <tag>, then execute STEPS 2–5b for that run exactly as for a single run (full CSV each time — do NOT dedupe across repeats; a repeat IS the same question asked again).
  3. Resume semantics are per repeat: a crashed repeat is found by find_unfinished_run and finished into its own run; already-done repeats of the group are never re-captured.
  4. Deliverables (STEP 6) run once, after the last repeat. The dashboard detects the group automatically (latest run carries the group_id) and shows the mean + min–max spread; nothing extra to pass.

STEP 2 — PREPARE THE WORK & THE PLAYBOOK

  1. Read all data rows from <questions.csv> (header query,lens). Validate each lens is one of general|branded|comparative; drop/flag malformed rows (note them for the summary). Let rows be the validated list, preserving file order.
  2. Locate the capture playbook engines/<engine>.md. This file is the per-engine capture instructions the subagents follow (e.g. engines/google.md for Google AI Overview — referenced in the house rules as "the capture playbook").
    • If engines/<engine>.md is missing, do not invent a procedure. Stop and tell the user (in --lang) that the playbook for this engine is not present yet and must be added before a run — the capture contract still applies, but the engine-specific "how to drive it" lives in that file. The pattern for authoring a new engine playbook is in engines/README.md (multi-engine is ROADMAP Feature 3). (engines/google.md, engines/chatgpt_search.md, engines/claude_search.md, engines/yandex_neuro.md, engines/gemini.md, engines/deepseek.md and engines/perplexity.md ship today; passing any other engine id needs its playbook written first.)
  3. If resuming an existing run (STEP 1 returned one), drop rows already captured — read the captured keys and keep only the missing (query, lens):
    .venv/bin/python -c "
    import json
    from pipeline.db import get_conn, get_captured_keys
    conn = get_conn('data/aeo.db')
    print(json.dumps(sorted(list(t) for t in get_captured_keys(conn, <run_id>))))
    "
    
    Subtract those from rows. If nothing remains, skip capture entirely and jump to STEP 4.2 (finalize) → STEP 5. (Ingest is idempotent, so re-capturing a stored row is harmless — skipping just saves a browser hit.)
  4. Split the rows to capture into min(N, len(rows)) contiguous chunks of roughly equal size, where N = --n-worker. Each chunk keeps its rows' original (query, lens) pairs.

STEP 3 — FAN-OUT CAPTURE (one capture-worker subagent per chunk)

Spawn N = --n-worker subagents of type capture-worker (Task tool), one per chunk, and run them in parallel — each drives its chunk concurrently in its own browser tab/context. A capture worker's only job is to capture and RETURN data; it never ingests, creates runs, starts servers, or writes the DB. Its full step-by-step contract lives in .claude/agents/capture-worker.md — do not restate it here. Give each capture-worker a self-contained brief containing:

  • The full text of engines/<engine>.md (the capture playbook).
  • Its chunk of (query, lens) rows, and its chunk index (1..N) — used to name its validation temp file uniquely (/tmp/open_geo_cap_<idx>.json), since parallel workers share /tmp.
  • The target <domain>, the --brand name, and the <engine> id.
  • A pointer to pipeline/INTERFACES.md §1 as the authoritative capture contract, and to pipeline/schema.py :: QueryCapture / normalize_domain.

Do not give the worker the run_id, the DB path, or any ingest command — a capture worker never writes to the DB and never starts a server. The orchestrator owns all DB writes and the deliverables (steps 4 and 6).

The worker's full step-by-step contract — output fields, the no-DB and no-source-visit rules, per-worker temp-file self-validation, what to return — lives in .claude/agents/capture-worker.md. It is engine-agnostic; the injected engines/<engine>.md playbook is authoritative for how to drive the specific engine, and INTERFACES §1 for the QueryCapture shape.

Parallelism — N workers run concurrently

The skill spawns N = --n-worker capture sub-agents and runs them in parallel: step 2 splits the query rows into N chunks and each sub-agent drives its chunk concurrently, each in its own browser tab/context. --n-worker IS the run's real concurrency — raise it to go wider.

  • If Google shows a reCAPTCHA / "unusual traffic" challenge, the affected worker stops and surfaces it to the human (per the playbook) instead of solving or hammering it; the other workers keep going.

STEP 4 — INGEST & FINALIZE (orchestrator owns all DB writes)

The database is written only by you (the orchestrator), as each worker returns its chunk — incrementally, so a crash mid-run never loses already-captured work (INTERFACES §2.1). The workers never touched the DB.

  1. Ingest each worker's chunk as it returns — incrementally, not one batch at the end (durability: a crash can't lose chunks already returned). For each returned QueryCapture array, write it to a temp file (UTF-8/Cyrillic-safe) and ingest into the run:

    .venv/bin/python -m pipeline.ingest --run-id <run_id> < /tmp/open_geo_chunk_<idx>.json
    

    Read stdout {"run_id", "ok": [...], "skipped": [...], "errors": [...]} (INTERFACES §3.2). Ingest is idempotent on (run_id, query, lens), so skipped (already-stored rows — normal on a resume/retry) is safe, never a duplicate. Fix any row in errors — correct the field from the returned data, or re-dispatch that one (query, lens) to a worker — and re-send only the fixed objects to the same --run-id. Repeat until errors is empty (bounded retries; then report residual failures).

  2. Finalize counts + status. There is no "finalize" CLI; use the documented helper pipeline.db.update_run_counts (INTERFACES §2) inline:

    .venv/bin/python -c "
    from pipeline.db import get_conn, update_run_counts
    conn = get_conn('data/aeo.db')
    update_run_counts(conn, run_id=<run_id>,
                      n_queries=<total rows attempted>,
                      n_ok=<rows accepted by ingest>,
                      n_failed=<rows never accepted>,
                      status='done')
    "
    

    n_queries = total (query, lens) rows attempted (from the full CSV, including a resume's already-done rows); n_ok = rows captured (ingest keeps this live, = COUNT(results)); n_failed = n_queries − n_ok. Set status='done' on success, or 'failed' if the run collapsed (playbook missing, engine unreachable for everything). Finalizing status is the orchestrator's job — ingest never sets it (INTERFACES §2.1/§3.2); only runs with status='done' feed previous-run deltas and the --period all rollup (INTERFACES §4.1). Never leave a run stuck in status='running'.


STEP 5 — AGGREGATE METRICS

.venv/bin/python -m pipeline.aggregate --run-id <run_id>
  • Computes metrics per lens plus one lens="all" aggregate row, writes them to the metrics table, and prints a JSON summary on stdout (INTERFACES §3.3). Capture this stdout — step 7's summary reads its metrics (lens="all" row) directly.
  • In the same pass it also builds the top-domains leaderboard into domain_stats (INTERFACES §2/§4.2): for every domain in sources/citations (not just the target) — appearances + average source/citation position, per lens + all. This is deterministic math (no extra step for you); the summary's top_domains echoes the all-scope top 10. It powers the dashboard's "Top domains in answer space" panel and the report's top-domains section, and recomputes idempotently on re-aggregate.

STEP 5b — SYNTHESIZE PER-LENS SENTIMENT (orchestrator writes the qualitative roll-up)

pipeline.aggregate (STEP 5) stays deterministic math — it does not touch sentiment. You (the orchestrator, already an LLM) write the qualitative per-lens roll-up here, then persist it via pipeline.lens_sentiment (INTERFACES §3.4) into the lens_sentiment table (INTERFACES §2). This is separate from metrics on purpose, so a re-aggregate never clobbers the synthesized prose.

  1. Gather the per-query sentiments grouped by lens for this run. You already have them from the STEP 4 captures; if not handy, read them back from results inline:
    .venv/bin/python -c "
    import json
    from pipeline.db import get_conn
    conn = get_conn('data/aeo.db')
    rows = conn.execute(
        'SELECT lens, sentiment FROM results WHERE run_id=? ORDER BY lens',
        (<run_id>,)).fetchall()
    print(json.dumps([dict(r) for r in rows], ensure_ascii=False))
    "
    
  2. Write ONE short, neutral sentence per lens that appears in the run (general, branded, comparative), plus an all synthesis across them. Summarize ONLY what the per-query sentiment strings of that lens actually say — never invent ranks, competitors, numbers, or praise the captures don't contain; keep it ~1 sentence.
    • Language: follow the DATA, not --lang. The summary is a roll-up of captured sentiment text, so write it in the language those sentiment strings are in (e.g. Russian captures → Russian summary), regardless of the deliverable --lang.
    • If a lens had the brand in no query (every sentiment null), set that lens's summary to null (the UI then shows a "not mentioned" fallback). Likewise all is null only if the brand appeared in no query at all.
  3. Persist by piping a JSON object {lens: summary} to pipeline.lens_sentiment. Write the JSON to a temp file first for UTF-8/Cyrillic safety, exactly like the STEP 4 batch ingest does:
    # /tmp/open_geo_sentiment.json holds e.g.
    # {"all": "...", "general": "...", "branded": "...", "comparative": null}
    .venv/bin/python -m pipeline.lens_sentiment --run-id <run_id> < /tmp/open_geo_sentiment.json
    
    Read stdout {"run_id": <run_id>, "written": [...]} (INTERFACES §3.4) to confirm which lenses were upserted. Only the lenses you include are written; an unknown run_id exits 1.

The dashboard then renders these as a "Sentiment by lens" card strip above the results table, and the PDF report shows them as the lead line of its sentiment section.


STEP 6 — EMIT DELIVERABLE(S) per --output

Ordering — the skill does this, not a worker, and only after steps 3–5. Deliverables are produced by the orchestrator once every capture is collected & ingested, the run is finalized, and metrics are aggregated. A capture worker never starts a server or generates a report. Start long-running servers in the background on a free port.

The report and dashboard components are built and their entry points are verified working (commands below are the real ones). They are intentionally not in INTERFACES — their contracts live in their own dirs (report/generate.py and dashboard/README.md). If you need detail beyond what's shown, read those.

--output dashboard (default) — or as part of both

Start the dashboard (FastAPI backend + Vite/React frontend) and print the local URL. The frontend selects brand/engine/period through its own UI controls (read from the API), so you do not scope brand/engine/period via the query string — only the UI language: hand the operator http://localhost:5173/?lang=<lang>, which seeds the dashboard's initial language from the run's --lang (the in-browser switcher still overrides it, and the choice persists in localStorage).

# Run BOTH in the background (they are long-running dev servers).
# Background shells do NOT inherit the repo-root CWD, so use ABSOLUTE paths anchored at
# <REPO> = the repository root (your working directory). Do NOT use a relative
# `.venv/bin/python` or `cd dashboard/web` here — backgrounded, they fail (exit 127 /
# wrong CWD). `--app-dir <REPO>` lets uvicorn import `dashboard.api` regardless of CWD.
#
# 1) API (read-only over data/aeo.db). PICK A FREE PORT — 8000 is often taken:
OPEN_GEO_DB=<REPO>/data/aeo.db <REPO>/.venv/bin/python -m uvicorn dashboard.api:app \
    --host 127.0.0.1 --port <PORT> --app-dir <REPO>

# 2) Web (Vite dev server), pointed at the API's port. Use `npm --prefix` instead of `cd`
#    (run `npm --prefix <REPO>/dashboard/web install` once if node_modules is missing):
VITE_API_BASE=http://127.0.0.1:<PORT> npm --prefix <REPO>/dashboard/web run dev
  • Port caveat: local port 8000 is often already occupied by another service on this machine. Pick a free port for the API (e.g. 8077) and point the frontend at it via VITE_API_BASE (CORS is open, so a cross-origin base works without the dev proxy):
    VITE_API_BASE=http://127.0.0.1:<PORT> npm --prefix <REPO>/dashboard/web run dev
    
  • Verify before handing off (a backgrounded server can exit non-zero or the port can clash): probe both before printing the URL —
    curl -s http://127.0.0.1:<PORT>/api/health
    curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173/
    
    so you surface a working URL, not a hopeful one.
  • After both are up, print the Vite dev URL the operator should open — http://localhost:5173/?lang=<lang> (the frontend's own controls drive brand/engine/period; ?lang=<lang> seeds the UI language from the run's --lang, and the switcher still overrides). If dashboard/ cannot be started, say so (in --lang) and skip gracefully (still finish steps 5 and 7).

--output pdf — or as part of both

.venv/bin/python -m report.generate \
  --brand "<name>" --domain <domain> --engine <engine> \
  --period <period> --lang <lang> \
  --out reports/<brand>_<date>.pdf [--db data/aeo.db]
  • This is the real, built CLI. It prints progress/status to stderr; the output path (--out) is what to surface to the operator. Pass --lang <lang> (the run's --lang, default en) so the report renders in that language.
  • Use <date> = today (YYYY-MM-DD). Create reports/ if missing. Print the resulting file path. If the command fails, say so (in --lang) and skip gracefully.
  • Combined multi-engine document (Feature 7): when the operator asks for one document across every engine the brand has runs on, swap --engine <engine> for --engines all (or an explicit comma list) — one PDF: engines side-by-side table, then a chapter per engine. Numbers are never blended across engines.

--output both

Do both of the above: start the dashboard (print URL) and generate the PDF (print path).


STEP 7 — SUMMARY (printed to the user, in --lang)

Read the lens="all" row from the pipeline.aggregate JSON captured in step 5 and print a short summary of headline metrics for this run, in the --lang language (default English). Cover:

  • Answer coverage (overview_coverage) — share of queries where a grounded, source-backed answer rendered at all (an AI Overview on google; a web-search-backed answer on the chat engines).
  • Visibility in sources (visibility_in_sources) — share of overview queries where the target domain made it into sources (n_in_sources / n_overviews).
  • Visibility in citations (visibility_in_citations) — share of overview queries where the domain is cited in the answer (n_cited / n_overviews).
  • Average source position (avg_source_position) — average best (min) rank of the domain among sources (lower = better; if the domain never appears in sources).
  • Average citation position (avg_citation_position) — average best (min) rank of the domain among citations (lower = better; if the domain is never cited).
  • Relative citation (relative_citation) — the source→citation conversion: of the queries where the domain was in sources, the share where it was actually cited (n_cited / n_in_sources; higher = better, ∈ [0, 1]; if the domain never appears in sources). This is the last step of the visibility funnel (n_cited ≤ n_in_sources ≤ n_overviews ≤ n_queries).
  • Brand mention rate (brand_mention_rate) — of the grounded answers, the share whose prose mentions the brand name, linked or not (n_brand_mentions / n_overviews; higher = better). An adjacent axis, not a funnel stage — an unlinked mention is invisible to the link funnel, so do not read it as nested in sources/citations (INTERFACES §4).

Format as percentages where natural, and note guard cases (null → "no data" / "—", not 0). End by pointing to the produced deliverable(s): the dashboard URL and/or the PDF path. If a previous completed run exists, you may mention the direction of change (deltas are computed at read-time per INTERFACES §4.1) — otherwise omit.

Example shape (English; fill with real numbers; one lens="all" row drives it):

Run for brand "Example" (engine google), queries: 30.
• Answer coverage: 73% (22 of 30 queries).
• Visibility in sources: 41% of grounded answers.
• Visibility in citations: 32% of grounded answers.
• Average source position: 2.4 (lower is better).
• Average citation position: 1.7 (lower is better).
• Source→citation conversion (relative citation): 78% (higher is better).
• Brand mention rate: 55% of grounded answers name the brand.
Report: reports/example_2026-06-19.pdf · Dashboard: http://localhost:5173/?lang=en

COMPONENT DEPENDENCY MAP (where this skill leans on others)

stepcallsstatus
A.5harvest-worker + harvest-skeptic subagents under harvest/METHODOLOGY.md; python -m harvest.buildBuilt — Feature 1 (question harvesting); opt-in, contract in INTERFACES §6. Skipped when a CSV is supplied.
0python -m audit.gate --domain <d> --engine <e> (deterministic, non-LLM)Built — Feature 2 (GEO-audit gate); runs before A.5, hard-stops a blocked domain (overridable with --force). Contract in INTERFACES §7, checks in audit/CHECKS.md.
1python -m pipeline.ingest --new-runContract in INTERFACES §3.1
3capture-worker subagent (.claude/agents/capture-worker.md) driving engines/<engine>.md (workers capture & return JSON — no DB writes)Capture contract §1; playbook file may be absent early
4python -m pipeline.ingest --run-id (orchestrator) + pipeline.db.update_run_countsIncremental per-chunk ingest §3.2 (idempotent) + finalize helper §2 (call inline)
5python -m pipeline.aggregate --run-idContract in INTERFACES §3.3
5bpython -m pipeline.lens_sentiment --run-id (orchestrator-written qualitative per-lens roll-up)Contract in INTERFACES §3.4
6python -m report.generate … --lang <lang>; dashboard API (uvicorn dashboard.api:app) + web (npm run dev)Built — entry points confirmed/working; contracts live in report/ & dashboard/ (intentionally not in INTERFACES)

Keep the run operator-friendly: parse JSON from stdout (never scrape logs), fail loudly (in --lang) on missing prerequisites, and never leave a run stuck in status='running'.

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.