@appeeky/spec 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +158 -5
  2. package/agents/appeeky-ads.md +50 -0
  3. package/agents/appeeky-aso.md +58 -0
  4. package/agents/appeeky-releases.md +47 -0
  5. package/agents/appeeky-reviews.md +53 -0
  6. package/agents/appeeky-social.md +54 -0
  7. package/agents/appeeky-ugc.md +56 -0
  8. package/agents/cofounder.md +36 -0
  9. package/dist/ads.d.ts +5 -2
  10. package/dist/ads.js +8 -4
  11. package/dist/agents.d.ts +47 -0
  12. package/dist/agents.js +88 -0
  13. package/dist/cli.js +582 -9
  14. package/dist/dataforseo.d.ts +139 -0
  15. package/dist/dataforseo.js +354 -0
  16. package/dist/experiments.d.ts +4 -4
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.js +4 -0
  19. package/dist/report.d.ts +5 -0
  20. package/dist/report.js +42 -0
  21. package/dist/reviews.d.ts +2 -2
  22. package/dist/schema.d.ts +95 -2
  23. package/dist/schema.js +35 -0
  24. package/dist/search-ads.d.ts +137 -0
  25. package/dist/search-ads.js +354 -0
  26. package/dist/seo.d.ts +87 -0
  27. package/dist/seo.js +391 -0
  28. package/dist/setup.js +39 -6
  29. package/dist/social.d.ts +129 -0
  30. package/dist/social.js +189 -0
  31. package/dist/transport.d.ts +1 -0
  32. package/dist/transport.js +1 -1
  33. package/dist/ugc-core.js +1 -0
  34. package/dist/ugc-local.d.ts +116 -0
  35. package/dist/ugc-local.js +174 -0
  36. package/dist/ugc.d.ts +149 -0
  37. package/dist/ugc.js +111 -0
  38. package/dist/visibility-core.js +96 -0
  39. package/dist/visibility-local.d.ts +159 -0
  40. package/dist/visibility-local.js +246 -0
  41. package/dist/visibility.d.ts +3 -0
  42. package/dist/visibility.js +1 -1
  43. package/package.json +7 -3
  44. package/skills/appeeky-spec/SKILL.md +14 -18
  45. package/skills/appeeky-spec-experiments/SKILL.md +12 -2
  46. package/skills/appeeky-spec-visibility/SKILL.md +33 -6
@@ -0,0 +1,246 @@
1
+ import { existsSync } from "node:fs";
2
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { indexedLocales, playLanguageFor } from "./storefronts.js";
6
+ import { languageFor, readVisibility, recordChanges, visibilityChanges, visibilityDir, visibilityPath, visibilityTarget } from "./visibility.js";
7
+ export const VISIBILITY_CORE_FILE = "./visibility-core.js";
8
+ export async function loadVisibilityCore(load = (s) => import(s)) {
9
+ try {
10
+ return (await load(VISIBILITY_CORE_FILE));
11
+ }
12
+ catch {
13
+ throw new Error("This build of @appeeky/spec has no AI visibility pipeline (dist/visibility-core.js). Update it: npm install -g @appeeky/spec@latest");
14
+ }
15
+ }
16
+ export const KEY_NAMES = "OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or PERPLEXITY_API_KEY";
17
+ /** The listing the storefront shows, as context for intents and prompts. */
18
+ export function appContext(project, country) {
19
+ const as = project.listings["app-store"][indexedLocales(project, country)[0] ?? ""];
20
+ const playLang = playLanguageFor(project, country);
21
+ const gp = playLang ? project.listings["google-play"][playLang] : undefined;
22
+ return {
23
+ title: as?.name ?? gp?.title ?? project.spec.app.name,
24
+ subtitle: as?.subtitle ?? gp?.shortDescription,
25
+ description: as?.description ?? gp?.fullDescription,
26
+ keywords: project.spec.targets[country] ?? [],
27
+ competitors: project.spec.competitors.map((c) => c.name).filter((n) => Boolean(n)),
28
+ country,
29
+ language: languageFor(project, country),
30
+ };
31
+ }
32
+ /** Every name the assistants may use for the app. */
33
+ export function ownerNames(project) {
34
+ const names = new Set([project.spec.app.name, ...(project.spec.aiVisibility?.aliases ?? [])]);
35
+ for (const l of Object.values(project.listings["app-store"]))
36
+ if (l.name)
37
+ names.add(l.name);
38
+ for (const l of Object.values(project.listings["google-play"]))
39
+ if (l.title)
40
+ names.add(l.title);
41
+ return [...names];
42
+ }
43
+ /** The app's own domains: its website and the listing's marketing and support URLs. */
44
+ export function ownDomains(project) {
45
+ const urls = [project.spec.web?.url, ...Object.values(project.listings["app-store"]).flatMap((l) => [l.marketingUrl, l.supportUrl])];
46
+ const out = new Set();
47
+ for (const u of urls) {
48
+ if (!u)
49
+ continue;
50
+ try {
51
+ const host = new URL(u).hostname.replace(/^www\./, "");
52
+ if (!/(^|\.)(apple\.com|google\.com|notion\.site|github\.io)$/.test(host))
53
+ out.add(host);
54
+ }
55
+ catch {
56
+ // not a URL
57
+ }
58
+ }
59
+ return [...out];
60
+ }
61
+ export function planLocalScan(core, project, country, env) {
62
+ const intents = project.spec.aiVisibility?.intents[country] ?? [];
63
+ const models = core.availableModels(env, project.spec.aiVisibility?.models);
64
+ const prompts = intents.reduce((n, i) => n + i.prompts.length, 0);
65
+ return { country, intents, prompts, models, helper: core.helperFor(env), quoteUsd: core.quoteScan(prompts, models, env) };
66
+ }
67
+ async function pool(items, size, run) {
68
+ let next = 0;
69
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
70
+ while (next < items.length)
71
+ await run(items[next++]);
72
+ }));
73
+ }
74
+ export const historyFile = (root) => join(visibilityDir(root), "history.csv");
75
+ export const answersFile = (root, country) => join(visibilityDir(root), "answers", `${country}.jsonl`);
76
+ const HISTORY_HEADER = "date,country,intent,model,visibility_pct,position,prompts\n";
77
+ const csv = (v) => (v === null || v === undefined ? "" : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
78
+ /** Ask every assistant every prompt for one storefront, then write the snapshot, the answers and a history row per intent and assistant. */
79
+ export async function scanLocal(core, project, plan, env, options = {}) {
80
+ const { country } = plan;
81
+ if (!plan.helper)
82
+ throw new Error(`Set ${KEY_NAMES} to scan with your own keys.`);
83
+ if (plan.models.length === 0)
84
+ throw new Error(`None of the assistants in aiVisibility.models has a key. Set ${KEY_NAMES}.`);
85
+ const target = visibilityTarget(project);
86
+ const now = options.now ?? new Date();
87
+ const owner = ownerNames(project);
88
+ const ctx = appContext(project, country);
89
+ const jobs = plan.intents.flatMap((i) => i.prompts.map((prompt) => plan.models.map((model) => ({ intent: i.label, prompt, model })))).flat();
90
+ const scored = [];
91
+ const raw = [];
92
+ const failed = [];
93
+ let cost = 0;
94
+ let done = 0;
95
+ await pool(jobs, options.concurrency ?? 4, async (job) => {
96
+ const answer = await core.ask(job.model, core.keyFor(job.model, env), { prompt: job.prompt, country, language: ctx.language }, options.fetcher);
97
+ cost += answer.costUsd;
98
+ let mentions = [];
99
+ let status = answer.status;
100
+ if (answer.status === "ok") {
101
+ try {
102
+ const x = await core.extract(plan.helper, { answer: answer.text, prompt: job.prompt, owner: owner[0], competitors: ctx.competitors ?? [], citations: answer.citations }, options.fetcher);
103
+ cost += x.costUsd;
104
+ mentions = x.mentions;
105
+ if (x.refusal)
106
+ status = "refusal";
107
+ }
108
+ catch (e) {
109
+ status = "error";
110
+ answer.error = `extracting the apps: ${e.message}`;
111
+ }
112
+ }
113
+ if (status === "error")
114
+ failed.push({ model: job.model, error: answer.error ?? "no answer" });
115
+ const row = { ...job, status, mentions, citations: answer.citations };
116
+ scored.push(row);
117
+ raw.push({ ...row, text: answer.text, ...(answer.error ? { error: answer.error } : {}) });
118
+ done++;
119
+ if (done % 10 === 0 || done === jobs.length)
120
+ options.log?.(`${done}/${jobs.length} answers`);
121
+ });
122
+ const labels = plan.intents.map((i) => i.label);
123
+ const scores = core.score(scored, labels, owner);
124
+ const sources = core.citedDomains(scored, ownDomains(project));
125
+ const summary = { winning: 0, weak: 0, missing: 0, noData: 0 };
126
+ for (const s of scores) {
127
+ if (s.status === "no_data")
128
+ summary.noData++;
129
+ else
130
+ summary[s.status]++;
131
+ }
132
+ const order = { missing: 0, weak: 1, winning: 2, no_data: 3 };
133
+ const date = now.toISOString().slice(0, 10);
134
+ const data = {
135
+ pulledAt: date,
136
+ source: "local",
137
+ country,
138
+ store: target?.store ?? "app-store",
139
+ appId: target?.appId ?? project.spec.app.name,
140
+ summary,
141
+ intents: [...scores]
142
+ .sort((a, b) => order[a.status] - order[b.status] || (a.you ?? 0) - (b.you ?? 0))
143
+ .map((s) => {
144
+ const byModel = Object.fromEntries(Object.entries(s.byModel).map(([m, v]) => [m, Math.round(v.pct)]));
145
+ return {
146
+ label: s.label,
147
+ status: s.status,
148
+ ...(s.you !== undefined ? { you: Math.round(s.you) } : {}),
149
+ ...(Object.keys(byModel).length ? { byModel } : {}),
150
+ ...(s.namedInstead.length ? { namedInstead: s.namedInstead.map((w) => `${w.name} (${Math.round(w.pct)}%)`) } : {}),
151
+ };
152
+ }),
153
+ sources: sources.slice(0, 15).map((s) => ({ domain: s.domain, count: s.count, ...(s.yours ? { yours: true } : {}), url: s.url })),
154
+ fixes: [],
155
+ };
156
+ await mkdir(join(visibilityDir(project.root), "answers"), { recursive: true });
157
+ const file = visibilityPath(project.root, country);
158
+ const prev = await readVisibility(project.root, country);
159
+ const changes = prev ? visibilityChanges(prev, data) : [];
160
+ await recordChanges(project.root, date, country, changes);
161
+ await writeFile(file, YAML.stringify(data, { lineWidth: 0 }));
162
+ const byPrompt = (a) => `${a.intent}\u0000${a.prompt}\u0000${a.model}`;
163
+ raw.sort((a, b) => byPrompt(a).localeCompare(byPrompt(b)));
164
+ await writeFile(answersFile(project.root, country), raw.map((r) => JSON.stringify({ date, ...r })).join("\n") + "\n");
165
+ const hist = historyFile(project.root);
166
+ if (!existsSync(hist))
167
+ await writeFile(hist, HISTORY_HEADER);
168
+ const rows = scores.flatMap((s) => Object.entries(s.byModel).map(([m, v]) => [date, country, s.label, m, v.pct, v.position, v.prompts].map(csv).join(",")));
169
+ if (rows.length)
170
+ await appendFile(hist, rows.join("\n") + "\n");
171
+ return { country, file, data, changes, costUsd: Math.round(cost * 100) / 100, answered: scored.filter((a) => a.status !== "error").length, failed };
172
+ }
173
+ /** Days since the last local scan of a storefront, from history.csv; null when it never ran. */
174
+ export async function daysSinceLocalScan(root, country, now = new Date()) {
175
+ const rows = await readHistory(root);
176
+ const last = rows.filter((r) => r.country === country).map((r) => r.date).sort().at(-1);
177
+ return last ? Math.floor((now.getTime() - Date.parse(last)) / 86_400_000) : null;
178
+ }
179
+ function splitCsv(line) {
180
+ const out = [];
181
+ let cur = "";
182
+ let quoted = false;
183
+ for (let i = 0; i < line.length; i++) {
184
+ const ch = line[i];
185
+ if (quoted) {
186
+ if (ch === '"' && line[i + 1] === '"') {
187
+ cur += '"';
188
+ i++;
189
+ }
190
+ else if (ch === '"')
191
+ quoted = false;
192
+ else
193
+ cur += ch;
194
+ }
195
+ else if (ch === '"')
196
+ quoted = true;
197
+ else if (ch === ",") {
198
+ out.push(cur);
199
+ cur = "";
200
+ }
201
+ else
202
+ cur += ch;
203
+ }
204
+ out.push(cur);
205
+ return out;
206
+ }
207
+ async function readHistory(root) {
208
+ const file = historyFile(root);
209
+ if (!existsSync(file))
210
+ return [];
211
+ return (await readFile(file, "utf8"))
212
+ .split("\n")
213
+ .slice(1)
214
+ .filter(Boolean)
215
+ .map((l) => {
216
+ const [date, country, intent, model, pct] = splitCsv(l);
217
+ return { date: date, country: country, intent: intent, model: model, pct: Number(pct) };
218
+ });
219
+ }
220
+ /** `ai_visibility` from the local scans in the repo: the share of answers that name the app, averaged per scan day (0–1). */
221
+ export function localVisibilityMetricSource(project) {
222
+ const target = visibilityTarget(project);
223
+ if (!target || !existsSync(historyFile(project.root)))
224
+ return null;
225
+ const country = target.countries[0];
226
+ let history;
227
+ return {
228
+ label: "AI visibility scans in the repo",
229
+ async window(store, _appId, from, to) {
230
+ if (store !== target.store)
231
+ return null;
232
+ history ??= readHistory(project.root);
233
+ const byDay = new Map();
234
+ for (const r of await history)
235
+ if (r.country === country && r.date >= from && r.date < to && Number.isFinite(r.pct))
236
+ byDay.set(r.date, [...(byDay.get(r.date) ?? []), r.pct]);
237
+ if (byDay.size === 0)
238
+ return null;
239
+ const daily = [...byDay.values()].map((xs) => xs.reduce((a, b) => a + b, 0) / xs.length);
240
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
241
+ const values = { ai_visibility: daily.reduce((a, b) => a + b, 0) / daily.length / 100 };
242
+ return { from, to, days, values };
243
+ },
244
+ };
245
+ }
246
+ //# sourceMappingURL=visibility-local.js.map
@@ -57,6 +57,8 @@ export declare function remoteIntents(api: AppeekyApi, appId: string, country: s
57
57
  export declare function remotePrompts(api: AppeekyApi, appId: string, country: string, intentId: string): Promise<RemotePrompt[]>;
58
58
  export interface VisibilityFile {
59
59
  pulledAt: string;
60
+ /** Where the answers came from: Appeeky AI Visibility, or `visibility scan --local` on your own keys. */
61
+ source?: "appeeky" | "local";
60
62
  country: string;
61
63
  store: StoreId;
62
64
  appId: string;
@@ -96,6 +98,7 @@ export declare const visibilityPath: (root: string, country: string) => string;
96
98
  export declare function readVisibility(root: string, country: string): Promise<VisibilityFile | null>;
97
99
  /** What moved since the last pull: an intent the assistants started or stopped naming the app for. */
98
100
  export declare function visibilityChanges(prev: VisibilityFile, next: VisibilityFile): string[];
101
+ export declare function recordChanges(root: string, date: string, country: string, changes: string[]): Promise<void>;
99
102
  /** Pull every watched storefront and write its file. */
100
103
  export declare function pullVisibility(api: AppeekyApi, project: Project, countries?: string[], now?: Date): Promise<Array<{
101
104
  country: string;
@@ -91,7 +91,7 @@ export function visibilityChanges(prev, next) {
91
91
  out.push("the assistants stopped citing your own site");
92
92
  return out;
93
93
  }
94
- async function recordChanges(root, date, country, changes) {
94
+ export async function recordChanges(root, date, country, changes) {
95
95
  if (changes.length === 0)
96
96
  return;
97
97
  const file = visibilityChangesFile(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appeeky/spec",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Your app's store listing as a spec: lint it, check keyword coverage, diff it against the live store and push it to App Store Connect and Google Play.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "dist",
25
25
  "!dist/**/*.map",
26
26
  "skills",
27
+ "agents",
27
28
  ".github/assets/cover.gif",
28
29
  "LICENSE",
29
30
  "README.md"
@@ -56,14 +57,16 @@
56
57
  "node": ">=20"
57
58
  },
58
59
  "scripts": {
59
- "build": "tsc -p tsconfig.build.json",
60
+ "build": "tsc -p tsconfig.build.json && node -e \"for (const f of ['ugc-core.js','visibility-core.js']) require('fs').copyFileSync('vendor/'+f,'dist/'+f)\"",
60
61
  "dev": "tsc -p tsconfig.build.json --watch",
61
62
  "typecheck": "tsc -p tsconfig.json --noEmit",
62
63
  "test": "vitest run",
63
64
  "test:watch": "vitest",
64
65
  "prepublishOnly": "npm run build",
65
66
  "pack:check": "npm run build && npm pack --dry-run",
66
- "release:dry": "node scripts/release.mjs --dry-run"
67
+ "release:dry": "node scripts/release.mjs --dry-run",
68
+ "vendor:ugc": "node scripts/vendor-ugc-core.mjs",
69
+ "vendor:visibility": "node scripts/vendor-visibility-core.mjs"
67
70
  },
68
71
  "dependencies": {
69
72
  "commander": "^13.1.0",
@@ -72,6 +75,7 @@
72
75
  },
73
76
  "devDependencies": {
74
77
  "@types/node": "^22.15.3",
78
+ "esbuild": "^0.28.2",
75
79
  "typescript": "^5.8.3",
76
80
  "vitest": "^3.1.2"
77
81
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: appeeky-spec
3
- description: Improve and ship an app's App Store / Google Play listing with Appeeky Spec (appeeky.spec.yaml + appeeky/store). Use when asked to do ASO on a repo, change an app's name, subtitle, keywords, promotional text or descriptions, check keyword coverage, set up listing-as-code or its GitHub Action, handle App Store screenshots, or push listing changes to App Store Connect or Google Play, or run the weekly growth routine. To choose target keywords see appeeky-spec-targets; for a new language or country see appeeky-spec-localize; to judge past releases see appeeky-spec-review; for AI assistants and the category chart see appeeky-spec-visibility; for screenshots, UGC, ads, pricing, attribution and data see appeeky-spec-experiments.
3
+ description: Improve and ship an app's App Store / Google Play listing with Appeeky Spec (appeeky.spec.yaml + appeeky/store). Use when asked to do ASO on a repo, change an app's name, subtitle, keywords, promotional text or descriptions, check keyword coverage, set up listing-as-code or its GitHub Action, handle App Store screenshots, or push listing changes to App Store Connect or Google Play, or run the weekly growth routine (the co-founder review with the specialist subagents). To choose target keywords see appeeky-spec-targets; for a new language or country see appeeky-spec-localize; to judge past releases see appeeky-spec-review; for AI assistants and the category chart see appeeky-spec-visibility; for screenshots, UGC, ads, pricing, attribution and data see appeeky-spec-experiments.
4
4
  metadata:
5
5
  version: 1.3.0
6
6
  ---
@@ -20,7 +20,8 @@ The app's store listings live in files, and every push is recorded and measured.
20
20
  | `appeeky/playbook.md` | what worked and what did not for this app |
21
21
  | `appeeky/reviews/` | customer reviews per month (JSONL), `summary.md`, and `replies/` drafted for review |
22
22
  | `appeeky/competitors/<name>.yaml` | each competitor's listing, best ranks, ranks on your targets, recent low ratings |
23
- | `appeeky/visibility/<country>.yaml` | whether ChatGPT, Claude, Gemini and Perplexity name the app per intent, who they name instead, the pages they cite, suggested fixes; `changes.md` beside it |
23
+ | `appeeky/visibility/<country>.yaml` | whether ChatGPT, Claude, Gemini and Perplexity name the app per intent, who they name instead, the pages they cite, suggested fixes; `changes.md` beside it (`history.csv` and `answers/` with `--local` scans) |
24
+ | `appeeky/seo/` | Google Search Console for the app's website: `daily.csv`, `queries.csv`, `pages.csv`, `summary.md` (`seo pull`) |
24
25
  | `appeeky/category.csv` | rank in the category chart (top 100), one row a day |
25
26
  | `.appeeky/data/*.csv` (or `appeeky/data/`) | analytics, Search Ads, RevenueCat, Meta and TikTok per day; `README.md` says what each file holds |
26
27
 
@@ -46,7 +47,7 @@ In a terminal the user runs, `init` with no options is a guided setup. It finds
46
47
  | --- | --- | --- |
47
48
  | `listing-check.yml` | every pull request | comments what the listing changes do (`report`), fails on check errors |
48
49
  | `listing-push.yml` | merge to main touching `appeeky/store/**` or the spec | `push --yes`, then commits the release file |
49
- | `listing-daily.yml` | daily 06:00 UTC | `category pull`, `visibility pull`, `verify --playbook`, `reviews pull`, `competitors pull`, then commits |
50
+ | `listing-daily.yml` | daily 06:00 UTC | `category pull`, `visibility pull`, `verify --playbook`, `reviews pull`, `competitors pull`, `social pull`, `ugc pull`; `seo pull` with `GSC_CREDENTIALS` and `visibility scan --local` every `VISIBILITY_EVERY` days; then commits |
50
51
 
51
52
  The merge is the approval gate: nothing reaches the store unless a person merged it. The workflows need `APPEEKY_API_KEY` (or store keys) as repository secrets.
52
53
 
@@ -71,23 +72,18 @@ Without CI, a person runs `push`. Never take over that gate with `--yes`.
71
72
 
72
73
  Never run `pull` over uncommitted listing edits: it overwrites local files.
73
74
 
74
- ### Weekly routine
75
+ ### Weekly routine: the co-founder review
75
76
 
76
- For a scheduled or "what should we do this week" run, propose exactly one listing change. Make it for a `free` store (`status --json`), never one with a release still being measured.
77
+ For a scheduled or "what should we do this week" run, follow the co-founder review: `agents/cofounder.md` in this package, installed as the `/appeeky-cofounder` command by `agents install`.
77
78
 
78
- 1. Read the sources:
79
- - `status`, `appeeky/loops.md`, `appeeky/playbook.md`
80
- - `appeeky/competitors/changes.md`, `appeeky/reviews/summary.md`, `appeeky/visibility/changes.md`
81
- - `.appeeky/data/README.md`
82
- 2. Pick the change most likely to win:
83
- - an uncovered target,
84
- - words users write in reviews,
85
- - a search a competitor just climbed,
86
- - an intent the assistants answer with competitors.
79
+ 1. `status --json`: only the `free` stores may change. Read `appeeky/loops.md`, `appeeky/playbook.md` and the latest brief in `appeeky/briefs/`.
80
+ 2. Start the specialist subagents in parallel: `appeeky-aso`, `appeeky-reviews`, `appeeky-ads`, `appeeky-releases`, `appeeky-ugc`, `appeeky-social`. They only read, and each returns findings and up to two candidate changes. Without subagents, do their checklists yourself.
81
+ 3. Pick exactly one change for a free store, the one with the strongest evidence. Never repeat a change the playbook says lost.
82
+ 4. Edit, then `check` and `diff`.
83
+ 5. Write the brief to `appeeky/briefs/<date>.md`: where things stand, what each specialist found, the change (or why none), what is for a person (replies, ads, product requests, the next UGC experiment, posts to publish), and what was not picked.
84
+ 6. Write `.appeeky/proposal.md` for the pull request. Do not `push`. A person merges; the push runs on merge.
87
85
 
88
- Do not repeat a change the playbook says lost.
89
- 3. Edit, then `check` and `diff`. Write the pull request: what changes, why (with the numbers), which metric should move and when it is checked.
90
- 4. Do not `push`. A person merges; the push runs on merge.
86
+ `npx @appeeky/spec agents install` writes the specialists and the command into `.claude/` (`--for opencode` or `all` for `.opencode/`). Commit them so scheduled runs have them too.
91
87
 
92
88
  To run it every week without anyone starting it:
93
89
 
@@ -97,7 +93,7 @@ To run it every week without anyone starting it:
97
93
  | The user's machine | `examples/weekly/weekly.sh` with cron or launchd |
98
94
  | The user's own server | `WEEKLY_AT=0 7 * * 1` in the docker `.env`, with the `latest-agent` image |
99
95
 
100
- All of them copy `examples/weekly/propose.md` to `.github/appeeky-propose.md`.
96
+ Each uses `.github/appeeky-propose.md` when it exists, otherwise the installed `.claude/commands/appeeky-cofounder.md`. The pull request carries the brief, and the listing change when there is one.
101
97
 
102
98
  The agent needs one of two things:
103
99
  - **`CLAUDE_CODE_OAUTH_TOKEN`:** the user runs `claude setup-token` once on their computer. Runs then count against their Claude Pro or Max plan, with no API key.
@@ -19,7 +19,7 @@ Run commands as `npx @appeeky/spec <command> --json`.
19
19
  | --- | --- |
20
20
  | `APPEEKY_API_KEY` with the store connected in Appeeky | `conversion`, `page_views`, `downloads`, `proceeds`, `trials` |
21
21
  | RevenueCat connected in Appeeky (app-scoped project) | `revenue`, `rc_trials`, `trial_conversion` |
22
- | Apple Search Ads connected | `ad_installs`, `cpa` |
22
+ | Apple Search Ads connected in Appeeky, or `search-ads pull` | `ad_installs`, `cpa` |
23
23
  | Meta / TikTok campaigns under `ads:` | `meta_spend`, `meta_installs`, `meta_cpi`, `tiktok_spend`, `tiktok_conversions`, `tiktok_cpa` |
24
24
  | `attribution:` in the spec | `organic_installs`, `organic_revenue`, `organic_paid`, and with `--link`: `link_clicks`, `link_installs`, `link_revenue` |
25
25
  | reviews pulled | `rating`, `low_ratings` |
@@ -71,10 +71,20 @@ npx @appeeky/spec experiment close <id> --value 3.1 --note "Hook B: 3.1 installs
71
71
  ## 5. Ads
72
72
 
73
73
  - `ads --json` gives spend, installs and cost per install for the last `--days` (default 30). It covers Apple Search Ads and the Meta and TikTok campaigns listed under `ads:` in the spec.
74
+ - With the user's own Search Ads key, `search-ads pull` writes `appeeky/search-ads/`: `daily.csv`, `keywords.csv`, `search-terms.csv` and `summary.md`. The key is saved on the App Store Connect CLI account (`appstore accounts search-ads <account> --client-id … --team-id … --key-id … --private-key-path …`, the account in `stores.appStore.account`), or set as `ASA_*` env in CI. It reads only the campaigns for the App Store app (`stores.appStore.searchAds` sets the org and the campaigns). The summary lists search terms that installed and are not targets yet: propose them as targets, since ranking for them organically is free. `ads` and `verify` then read Search Ads from these files, without Appeeky.
74
75
  - `--save` keeps a copy in `appeeky/ads/<date>.yaml`.
75
76
  - Campaigns are listed by id. Ask the user which campaigns promote this app; never guess from names.
76
77
 
77
- ## 6. Data for analysis
78
+ ## 6. Social posts and UGC videos
79
+
80
+ With accounts and creator personas set up in Appeeky (`APPEEKY_API_KEY`):
81
+
82
+ - `social pull` writes the connected accounts, recent posts and how each did to `appeeky/social/` (read `summary.md`). `ugc pull` writes the personas (`ready` when they can make clips) and their clips to `appeeky/ugc/`.
83
+ - To post: write `appeeky/social/drafts/<date>-<slug>.yaml` (`accounts`, `text`, `when`, `media`, `why`) and run `social check`. To make a video: write `appeeky/ugc/briefs/<date>-<slug>.yaml` (`persona`, `script`, `scene`, `caption`, `durationSec`, `aspectRatio`, `why`) and run `ugc generate --dry-run` for its cost.
84
+ - Open a pull request. `social push` and `ugc generate` run after the merge (the push workflow does it). Never run them, or `ugc generate --yes`, unless the user asked: posts are public and clips use media credits.
85
+ - Give each post or video its own smart link (`experiment add --link <channel>`) so `experiment close` can say what it brought.
86
+
87
+ ## 7. Data for analysis
78
88
 
79
89
  `data pull --json` writes CSV files the agent can read: store analytics, traffic sources, sales, Apple Search Ads, RevenueCat, Meta and TikTok per day. It also writes attribution by source, keyword and smart link. `--days` defaults to 90, and `README.md` in the folder says what each file holds.
80
90
 
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: appeeky-spec-visibility
3
- description: Get an app named when people ask ChatGPT, Claude, Gemini or Perplexity for an app like it, and track its category chart rank, in an Appeeky Spec repo. Use when asked about AI visibility, AI search, LLM or GEO/AEO recommendations, "does ChatGPT recommend us", which questions to track, why a competitor is named instead, or how the app ranks in its category.
3
+ description: Get an app named when people ask ChatGPT, Claude, Gemini or Perplexity for an app like it, get its website found in Google, and track its category chart rank, in an Appeeky Spec repo. Use when asked about AI visibility, AI search, LLM or GEO/AEO recommendations, "does ChatGPT recommend us", which questions to track, why a competitor is named instead, SEO for the app's website, Search Console, llms.txt, or how the app ranks in its category.
4
4
  metadata:
5
5
  version: 1.0.0
6
6
  ---
7
7
 
8
8
  # AI visibility and category rank with Appeeky Spec
9
9
 
10
- Two ways people find an app outside a keyword search: they ask an AI assistant, or they browse the category chart. The spec records both and `verify` measures them around every push.
10
+ Three ways people find an app outside a store search: they ask an AI assistant, they search Google and land on the app's website, or they browse the category chart. The spec records all three and `verify` measures them around every push.
11
11
 
12
12
  Run commands as `npx @appeeky/spec <command> --json`.
13
13
 
@@ -19,13 +19,18 @@ Run commands as `npx @appeeky/spec <command> --json`.
19
19
  - A **prompt** is one way to ask for it: "what app plays rain sounds to help me sleep". Prompts need 15+ characters.
20
20
  - Both live in `appeeky.spec.yaml` under `aiVisibility.intents.<country>`. Appeeky AI Visibility asks each assistant every prompt on a schedule. It records whether the answer names the app, in what position, who else is named and which pages are cited.
21
21
 
22
- Needs `APPEEKY_API_KEY` on a plan with AI Visibility.
22
+ Two ways to ask the assistants, writing the same files:
23
+
24
+ - **Appeeky AI Visibility** (`APPEEKY_API_KEY` on a plan with AI Visibility): Appeeky asks on a schedule; `visibility pull` writes the results.
25
+ - **Your own keys** (`--local`): `visibility scan --local` asks from where Spec runs, with `OPENAI_API_KEY` (ChatGPT), `ANTHROPIC_API_KEY` (Claude), `GEMINI_API_KEY` (Gemini) and `PERPLEXITY_API_KEY` (Perplexity), any of them. Each key adds that assistant. The providers bill the user; no Appeeky account is needed. It also writes `history.csv` (one row per scan, intent and assistant) and `answers/<country>.jsonl` (every answer, for reading why an assistant named someone else).
26
+
27
+ Use one of them per repo: both write `appeeky/visibility/<country>.yaml`.
23
28
 
24
29
  ### Set up
25
30
 
26
- 1. `visibility init`. If Appeeky already tracks intents, they are written into the spec. If not, Appeeky writes them from the store listing in the background; run `init` again a few minutes later.
31
+ 1. `visibility init`. If Appeeky already tracks intents, they are written into the spec. If not, Appeeky writes them from the store listing in the background; run `init` again a few minutes later. With your own keys, `visibility init --local` writes them from the listing right away, on the cheapest model of the key that is set (a fraction of a cent).
27
32
  2. Read the intents with the user. Keep the ones a real person of a persona would ask (see `personas` in the spec). Drop vague ones.
28
- 3. After the user approves the edits (in a pull request), run `visibility sync`. It only adds. It lists what exists only in Appeeky and archives it only with `--prune`, which you run only when the user asks.
33
+ 3. With Appeeky: after the user approves the edits (in a pull request), run `visibility sync`. It only adds. It lists what exists only in Appeeky and archives it only with `--prune`, which you run only when the user asks.
29
34
 
30
35
  Good prompts:
31
36
 
@@ -62,7 +67,29 @@ Never write fake reviews, posts or comments, and never make claims the app does
62
67
 
63
68
  ### Measure it
64
69
 
65
- `verify` reads Appeeky's daily series as `ai_visibility`, the 0–1 share of answers that name the app. It compares the days before a push with the days after. Assistant answers change slowly and scans may be weekly, so a day-7 move is a hint and day 14 counts. `visibility scan` asks the assistants now. It uses AI Visibility credits: run it only when the user asks.
70
+ `verify` reads `ai_visibility`, the 0–1 share of answers that name the app, from Appeeky's daily series or from `appeeky/visibility/history.csv`. It compares the days before a push with the days after. Assistant answers change slowly and scans may be weekly, so a day-7 move is a hint and day 14 counts.
71
+
72
+ `visibility scan` costs money: AI Visibility credits, or with `--local` the user's own provider accounts. Run it only when the user asks. `visibility scan --local --dry-run` shows the prompts, the assistants and the cost and asks nothing; that one is always fine. On a schedule, the daily routine runs `visibility scan --local --every <days>` when the user set `VISIBILITY_EVERY`.
73
+
74
+ ## The app's website: Google and AI assistants
75
+
76
+ Set `web.url` in `appeeky.spec.yaml` (and `web.searchConsole` when the Search Console property is not the domain).
77
+
78
+ - `seo check` reads the site the way a crawler does, with no key: title, description, canonical, sitemap and noindex for Google; store links, the Smart App Banner and app structured data for the app; and for AI assistants whether robots.txt lets their crawlers in (GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended and others), whether there is an llms.txt, how much text is readable without JavaScript, and how many target keywords the page uses. Each finding has a fix.
79
+ - `seo llms --out public/llms.txt` writes llms.txt from the listing. Propose it in the website's repo when the check says it is missing.
80
+ - `seo pull` reads Google Search Console with the user's own service account (`GSC_CREDENTIALS`: the JSON key or its path; the service account's email must be a user of the property). It writes `appeeky/seo/daily.csv` (clicks and impressions per day, kept), `queries.csv` and `pages.csv` (last 28 days), and `summary.md`: the trend, top queries, queries close to the top (position 4–15) and queries with the store target keywords.
81
+
82
+ `verify` reads `search_clicks` and `search_impressions` per day from `daily.csv`. Search Console lags two days.
83
+
84
+ On the user's own DataForSEO account (`DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD`), for `web.keywords.<country>` or the store targets:
85
+
86
+ - `seo ranks` → `appeeky/seo/ranks.csv` and `ranks.md`: the site's Google position, where the app's store page ranks on Google, and whether the AI Overview shows and cites the site, per keyword. `changes.md` gets keywords entering or leaving the top 10. `verify` reads `web_rank` (lower is better) and `ai_overview_citations`.
87
+ - `seo keywords` → `appeeky/seo/keywords.csv`: volume, difficulty, CPC, intent, plus related ideas (`tracked: no`). High-volume, low-difficulty ideas are candidates for `web.keywords` and, when people would search the store for them too, for store `targets`.
88
+ - `seo domain` → `appeeky/seo/domain.md` and `ranked-keywords.csv`: what the site ranks for and which sites compete for the same searches.
89
+
90
+ They cost money. Run them with `--dry-run` freely, and without it only when the user asks; the daily routine checks ranks on its own.
91
+
92
+ Read them together: a query people search on Google is a phrase they may also ask an assistant, and a good intent or target. A page that ranks in Google is a page the assistants can cite. When `visibility` shows the assistants cite competitors' pages for an intent and `seo check` shows the site is thin or blocks AI crawlers, the website is the fix, and it is the user's website change, proposed as a list or in that repo, not a listing push.
66
93
 
67
94
  ## Category rank
68
95