Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "apple-ads",
"version": "0.2.0",
"version": "0.2.1",
"description": "Run and audit Apple Ads from your agent — cohort bid reviews, Market Intelligence keyword opportunities, search-term harvesting, negatives, CPP routing, and read-only account diagnostics through the Adapty CLI.",
"author": { "name": "Adapty", "email": "support@adapty.io" },
"homepage": "https://adapty.io/docs/developer-cli-ads-manager-skill",
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
run: node scripts/lint-playbooks.mjs
- name: Flagship workflow behavior contracts
run: node scripts/lint-workflows.mjs
- name: Quiet Cowork preflight fixtures
run: node scripts/test-cowork-preflight.mjs
- name: Translations are not stale
run: node scripts/lint-i18n.mjs
- name: Plugin manifests are valid JSON
Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ The most useful contribution is a **vertical guide** for a category that has non
## Adding a vertical guide

1. Copy `skills/apple-ads-strategy/references/verticals/_TEMPLATE.md`.
2. Fill **every** section. A section you cannot fill gets `> TODO(owner): ...`, never a deletion —
missing sections are what make a guide library feel unreliable.
3. Every number is sourced, given as a range to calibrate, or marked TODO. A fabricated CPI
benchmark is worse than none, because the agent will act on it.
2. Fill **every** section. Do not add the guide until no template slots, checklists, stubs, or
`TODO(owner)` markers remain.
3. Every number is sourced or given as a range to calibrate. When evidence is unavailable, state
which first-party input must be measured and keep its value explicitly unknown. A fabricated
CPI benchmark is worse than none, because the agent will act on it.
4. Add a row to `skills/apple-ads-strategy/references/INDEX.md`. A guide with no INDEX row is never
opened.
5. `node scripts/lint-playbooks.mjs` and `node scripts/lint-workflows.mjs` must pass.
Expand Down
9 changes: 0 additions & 9 deletions commands/asa-budget.md

This file was deleted.

5 changes: 3 additions & 2 deletions commands/asa-launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ Help me launch a new Apple Search Ads campaign.

If I have not connected an account yet, or I am still deciding the structure, use the
`apple-ads-strategy` skill and give me a plan first — it needs no account. Once the plan is agreed,
switch to `apple-ads` and follow `references/playbooks/campaign-launch.md`. Create everything
`PAUSED`, show me the structure, and enable only after I confirm.
switch to `apple-ads`, use workflow 3 in `SKILL.md`, and read `references/asa-management.md` for
the current command syntax. Create everything `PAUSED`, show me the structure, and enable only
after I confirm.
18 changes: 14 additions & 4 deletions commands/asa-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ Run the weekly check-in for my Apple Ads account.

Use the `apple-ads` skill. Start with `references/playbooks/preflight.md`, then follow
`references/playbooks/weekly-review.md` exactly — including its call budget. Ask me for the date
range if I did not give one, whether I want a comparison, and the metric and cohort window before
ranking anything. Include the same-window Apple total installs versus Adapty installs comparison,
up to two positive changes, up to two concerns, and one primary action. Do not investigate
attribution and do not write.
range if I did not give one and whether I want a comparison. If I did not name a success metric,
offer these choices in this exact order:

1. Cost per paid
2. Cost per trial
3. Net ROAS at day X

Ask for my numeric target or guardrail after the metric is selected. Always use net values for
revenue-family metrics; never offer gross or proceeds. Ask for a cohort day only when the selected
metric is `revenue`, `roas`, `arpu`, `arppu`, `arpas`, or `roi`. Never ask for a cohort window, pass
`--by-days` / `--order-by-day`, or label a result `day-X` for `cost_per_paid`, `cost_per_trial`, or
any other non-cohort metric. Include the same-window Apple total installs versus Adapty installs
comparison, up to two positive changes, up to two concerns, and one primary action. Do not
investigate attribution and do not write.
74 changes: 0 additions & 74 deletions docs/IMPROVEMENT-IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,69 +60,6 @@ Acceptance criteria:
- Dependent writes stop when a prerequisite fails.
- Tests fail when a safety boundary is removed from a workflow.

## P0 — complete the existing operator chain

Four current operator playbooks remain stubs. Complete them in this order.

### 1. Keyword load

This closes the path from Market Intelligence or strategy output to a verified live keyword list:

```text
keyword opportunities
→ approved list
→ safe keyword load
→ read-back verification
→ separate bid review
```

Required behavior:

- resolve destination ownership before loading;
- compare active, paused, and negative inventory;
- require explicit match type and bid;
- keep additions at 15 keywords or fewer per call;
- use a fresh idempotency key per batch;
- report applied, skipped, duplicate, rejected, and unverifiable rows;
- never combine loading and later bid optimization under one confirmation.

### 2. Budget reallocation

This closes the main execution path from a weekly check-in.

Required behavior:

- use a user-approved value metric and cohort window;
- protect campaign minimums and account budget totals;
- separate budget and bid decisions;
- show before and after budgets for every campaign;
- require confirmation for the complete allocation plan;
- read every changed campaign back.

### 3. Campaign launch

Required behavior:

- consume a completed strategy plan;
- resolve organization and app ids from reads;
- create the full hierarchy in dependency order;
- create campaigns paused;
- use pinned, distinct idempotency keys for every write;
- verify each parent before creating a child;
- stop the chain after an ambiguous or failed prerequisite;
- enable only through a second explicit confirmation.

### 4. Automation rules

Required behavior:

- validate the rule schema before creation;
- dry-run every rule that can change bids or budgets;
- show affected entities and proposed changes;
- start a rule only after the user reviews the dry-run;
- preserve run history and explain partial failures;
- never use an automation to bypass workflow confirmation rules.

## P1 — standardize mutation plans

### Introduce one Action Plan contract
Expand Down Expand Up @@ -355,17 +292,6 @@ Remove it only in a breaking release after:
- documentation and examples no longer link to the old path;
- routing evaluations pass without the redirect.

## P3 — expand strategy coverage

After the operator chain is complete, finish the existing strategy vertical stubs:

- utility cleaner;
- utility scanner;
- VPN.

Execution playbooks come first. A detailed strategy is less useful when campaign launch and keyword
load remain incomplete.

## Deliberate non-goals

Do not add these until a concrete product need changes the decision:
Expand Down
7 changes: 4 additions & 3 deletions examples/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ Which Discovery ad groups are missing cross-negatives for verified Exact owners?
```
Which of my ad groups lost money last month at day-30 cohort ROAS?
How did last week compare to the week before?
Show me the 20 worst keywords by day-30 gross ROAS.
Show me the 20 worst keywords by day-30 net ROAS.
Which US terms appear for these three competitor App Store ids but not in my active keywords?
Which competitor terms are blocked by my current negatives?
Which keyword bids should increase under my day-30 proceeds ROAS target?
Which keyword bids should increase under my day-30 net ROAS target?
How many active campaigns do I have?
Why isn't this ad running?
```
Expand All @@ -53,7 +53,8 @@ Set up a rule that pauses any keyword spending over $50 with no conversion, and
For "which keywords lost money last month", a correct answer:

1. runs preflight,
2. asks which cohort window matches the subscription period before ranking anything,
2. offers cost per paid, cost per trial, then net ROAS in that order, and asks for a cohort window
only if the selected metric is cohort-aware,
3. answers in **one** metrics call, sorted server-side,
4. splits the result into pays-back / does-not / not-enough-data,
5. recommends an action and its playbook — and does not execute it.
Expand Down
11 changes: 5 additions & 6 deletions scripts/lint-playbooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ import { join, relative, basename, extname } from 'node:path'

const ROOT = new URL('..', import.meta.url).pathname
const errors = []
const warnings = []
const fail = (file, msg) => errors.push(`${relative(ROOT, file)}: ${msg}`)
const warn = (file, msg) => warnings.push(`${relative(ROOT, file)}: ${msg}`)

// Fields the Agent Skills spec accepts outside Claude Code. Anything else fails validation
// when a skill is packaged for claude.ai or the Skills API.
Expand Down Expand Up @@ -114,8 +112,10 @@ for (const { dir, kind: expectedKind, index } of targets) {
fail(path, `not listed in ${relative(ROOT, index)} — an unreachable playbook is a dead playbook`)
}

if (/^>\s*\*\*Stub\.\*\*/m.test(text)) warn(path, 'stub — contract only, body not written')
if (text.includes('TODO(owner)')) warn(path, 'has unfilled TODO(owner) slots')
if (/^>\s*\*\*Stub\.\*\*/m.test(text)) fail(path, 'stub content is not shippable; remove the route or finish the file')
if (f !== '_TEMPLATE.md' && text.includes('TODO(owner)')) {
fail(path, 'has unfilled TODO(owner) slots')
}
}

// Every INDEX row must resolve to a file that exists.
Expand Down Expand Up @@ -175,7 +175,6 @@ for (const file of publicTextFiles) {
}

// ---- report ---------------------------------------------------------------------------
for (const w of warnings) console.log(`warn ${w}`)
for (const e of errors) console.error(`ERROR ${e}`)
console.log(`\n${errors.length} error(s), ${warnings.length} warning(s)`)
console.log(`\n${errors.length} error(s)`)
process.exit(errors.length ? 1 : 0)
42 changes: 42 additions & 0 deletions scripts/lint-workflows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const read = (path) => {
const requireText = (path, text, label = text) => {
if (!read(path).includes(text)) fail(path, `missing workflow contract: ${label}`)
}
const forbidText = (path, text, label = text) => {
if (read(path).includes(text)) fail(path, `forbidden workflow behavior: ${label}`)
}
const requireOrder = (path, first, second) => {
const text = read(path)
const firstAt = text.indexOf(first)
Expand All @@ -39,15 +42,34 @@ const auditSkill = join(auditDir, 'SKILL.md')
const accountHealth = join(auditDir, 'references/playbooks/account-health.md')
const structureAudit = join(auditDir, 'references/playbooks/structure-audit.md')
const operatorPlaybooks = join(ROOT, 'skills/apple-ads/references/playbooks')
const operatorSkill = join(ROOT, 'skills/apple-ads/SKILL.md')
const setupSkill = join(ROOT, 'skills/adapty-cli-setup/SKILL.md')
const weekly = join(operatorPlaybooks, 'weekly-review.md')
const opportunities = join(operatorPlaybooks, 'keyword-opportunity.md')
const legacyCompetitors = join(operatorPlaybooks, 'competitor-check.md')
const bids = join(operatorPlaybooks, 'bid-optimization.md')
const cohortRoas = join(operatorPlaybooks, 'cohort-roas.md')
const reviewCommand = join(ROOT, 'commands/asa-review.md')
const metricsReference = join(ROOT, 'skills/apple-ads/references/asa-metrics.md')
const harvest = join(operatorPlaybooks, 'search-term-harvesting.md')
const negatives = join(operatorPlaybooks, 'negative-keyword-hygiene.md')
const cpp = join(operatorPlaybooks, 'creative-setup.md')
const operatorIndex = join(ROOT, 'skills/apple-ads/references/INDEX.md')

for (const path of [operatorSkill, setupSkill]) {
requireText(path, 'NetworkError', 'NetworkError is distinct from authentication')
requireText(path, 'failed to copy trust settings of system certificate-25291', 'Cowork certificate-noise signature')
requireText(path, 'NODE_USE_SYSTEM_CA=0', 'system CA fallback')
requireText(path, 'Do not install or log in', 'network failures never start setup')
}
requireText(operatorSkill, 'RETRY_OUTSIDE_SANDBOX', 'single host retry handoff')
requireText(operatorSkill, 'NETWORK_BLOCKED', 'concise terminal network state')
requireOrder(operatorSkill, '# agent-preflight:start', '| What it does | What that means |')
const setupDescription = frontmatterValue(read(setupSkill), 'description')
if (setupDescription.includes('402') || setupDescription.includes('ads_manager_subscription_required')) {
fail(setupSkill, '402 must not trigger the setup skill')
}

requireText(auditSkill, 'This skill has no write', 'explicit read-only audit boundary')
requireText(auditSkill, 'relative_gap = absolute_gap / apple_installs', 'install-gap formula')
requireText(auditSkill, 'only when `apple_installs > 0`', 'zero-denominator guard')
Expand Down Expand Up @@ -85,6 +107,26 @@ for (const category of ['increase', 'keep', 'decrease', 'pause_candidate', 'insu
requireText(bids, 'Do not invent a target', 'no universal bid threshold')
requireText(bids, 'Read the scoped keywords back', 'bid read-back verification')

for (const path of [weekly, bids]) {
requireOrder(path, 'cost_per_paid', 'cost_per_trial')
requireOrder(path, 'cost_per_trial', 'net `roas` at day X')
requireText(path, 'only', 'cohort-window applicability is conditional')
}
requireOrder(reviewCommand, '1. Cost per paid', '2. Cost per trial')
requireOrder(reviewCommand, '2. Cost per trial', '3. Net ROAS at day X')
requireText(reviewCommand, 'Never ask for a cohort window', 'non-cohort metrics skip the cohort question')
requireText(reviewCommand, 'never offer gross or proceeds', 'review offers net only')
forbidText(reviewCommand, 'Proceeds ROAS', 'proceeds ROAS choice')
forbidText(reviewCommand, 'Gross ROAS', 'gross ROAS choice')
requireText(weekly, 'every other non-cohort metric', 'non-cohort date-window semantics')
requireText(cohortRoas, 'agent workflows always use and offer `net_`', 'net cohort workflow variant')
for (const path of [weekly, bids, cohortRoas]) {
forbidText(path, 'day-30 proceeds ROAS', 'proceeds ROAS example')
forbidText(path, 'day-30 gross ROAS', 'gross ROAS example')
}
requireText(metricsReference, '`cost_per_paid` and `cost_per_trial` are values for the requested date window', 'cost metric date-window semantics')
requireText(metricsReference, '`--by-days` does not turn either into a day-X metric', 'non-cohort day-X prohibition')

for (const category of ['promote', 'block', 'keep_observing', 'already_owned', 'ownership_conflict', 'insufficient_evidence']) {
requireText(harvest, `\`${category}\``, `harvest category ${category}`)
}
Expand Down
39 changes: 28 additions & 11 deletions scripts/sync-agent-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
// generated upstream, next to the commands, where CI can check them against the real
// oclif manifest. This script copies the result here and stamps the version it came from.
//
// Usage: node scripts/sync-agent-docs.mjs [--ref v0.4.0]
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
// Usage: node scripts/sync-agent-docs.mjs [--ref=v0.4.0]
// node scripts/sync-agent-docs.mjs --source-dir=../adapty-cli [--only=<upstream path>]
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'

const ROOT = new URL('..', import.meta.url).pathname
const ref = (process.argv.find((a) => a.startsWith('--ref='))?.split('=')[1]) ?? 'main'
const sourceDirArg = process.argv.find((a) => a.startsWith('--source-dir='))
const sourceDir = sourceDirArg ? resolve(sourceDirArg.slice('--source-dir='.length)) : null
const only = process.argv.find((a) => a.startsWith('--only='))?.slice('--only='.length) ?? null
const REPO = 'adaptyteam/adapty-cli'

// upstream path -> path in this repo
Expand All @@ -26,15 +30,28 @@ const header = (src) =>

let failed = 0
for (const [src, dest] of Object.entries(FILES)) {
const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${src}`
const res = await fetch(url)
if (!res.ok) {
console.error(`ERROR ${src}: ${res.status} ${res.statusText}`)
console.error(` upstream has not published this file yet — leaving the local copy alone.`)
failed++
continue
if (only && src !== only) continue

let body
if (sourceDir) {
try {
body = readFileSync(join(sourceDir, src), 'utf8')
} catch (error) {
console.error(`ERROR ${src}: ${error instanceof Error ? error.message : 'local read failed'}`)
failed++
continue
}
} else {
const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${src}`
const res = await fetch(url)
if (!res.ok) {
console.error(`ERROR ${src}: ${res.status} ${res.statusText}`)
console.error(` upstream has not published this file yet — leaving the local copy alone.`)
failed++
continue
}
body = await res.text()
}
let body = await res.text()
// a synced SKILL.md keeps its frontmatter first; the header goes after it
if (body.startsWith('---\n')) {
const end = body.indexOf('\n---', 3) + 4
Expand Down
Loading
Loading