# Hundrads API — agent reference (v1)

Base URL: `$HUNDRADS_BASE_URL` (default `http://localhost:7007`).
Auth: every `/v1/*` request needs `Authorization: Bearer $HUNDRADS_API_KEY`
(or `X-API-Key`) — a per-workspace `hnd_live_…` key minted in the dashboard
Keys tab (or via the setup skill's device-auth flow). Interactive OpenAPI
docs: `$HUNDRADS_BASE_URL/docs`.

The contract in one line: **agents create drafts/proposals and read data;
humans approve in the dashboard; only approval touches a platform (Meta /
Google ad push = PAUSED; social content = auto-posted at its approved slot;
newsletter = created and scheduled in Kit immediately for preview;
proposals = executed on the live object, only on approve or a standing
policy rule).**

## All endpoints at a glance

Three draft families. **Ads** (`meta_ad` / `google_search_ad` drafts →
review queue → approve pushes PAUSED to Meta / Google Ads), **Content**
(`threads_post` / `facebook_post` / `instagram_post` / `linkedin_post` / `telegram_post` /
`newsletter` drafts → Schedule tab → approve schedules it in Kit immediately;
other content approval arms an auto-post at the slot), and **Proposals** (changes to LIVE
objects → review queue → approve EXECUTES) — plus supporting reads/writes:
brand knowledge, AI completions, sales ground truth, and run reports.

### Ads — reads (ungated; study before drafting or proposing)

Ads endpoints default to Meta; the ones marked `platform=` also serve
Google Ads (Search) with `platform=google`.

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/accounts` | Connected brands / ad accounts; `can_push` (Meta) / `google_can_push` (Google) `false` = research/draft only |
| GET | `/v1/ads?brand=&platform=` | LIVE ads in the account: status, spend, budget + the platform ids to propose against |
| GET | `/v1/ads/{ad_id}?brand=` | One live ad, full detail (Meta only) |
| GET | `/v1/ads/pending-activation?brand=` | Approved-but-still-PAUSED staged ads — the backlog the no-pile-up guard checks (Meta only) |
| GET | `/v1/library/ads?platform=` | The user's own past ads with real performance — the voice + structure source |
| GET | `/v1/insights?platform=` | Live platform numbers; `breakdown=` (age/gender/placement/country/..., Meta only) and `time_increment=` (1/7/28/monthly; Google: 1 only) for splits + time series |
| GET | `/v1/insights/compare?object_ids=a,b` | Side-by-side insights for 2-5 objects — the A/B verdict in one call (Meta only) |
| GET | `/v1/search-terms?brand=` | What people actually typed before clicking a Google Search ad — the negative-keyword feedstock (Google only) |
| GET | `/v1/budget/pacing?brand=` | Today's spend vs daily budget per ad set; pace > 1.0 = overshooting (Meta only) |
| POST | `/v1/media/poster` | Generate an ad image; returns `image_hash`/`image_url` for a Meta ad draft |
| POST | `/v1/media/upload` | Upload your own image **or video** as a Meta ad creative; image → `image_hash`/`image_url`, video → `video_id`/`image_url`/`video_url` for an ad draft |
| POST | `/v1/drafts` | kinds `meta_ad`, `google_search_ad` — submit an ad draft to the review queue |

### Engagement (reads; act via proposal kinds)

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/comments?brand=&unanswered=true` | Comment inbox on the page's recent posts, `answered` flags included |
| GET | `/v1/audiences?brand=` | Custom/lookalike audiences with sizes |
| GET | `/v1/leads?brand=` (`&form_id=` for submissions) | Lead-gen forms + recent leads |

### Intel (reads; the layer a raw platform API can't give you)

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/benchmarks?brand=&platform=` | The account's OWN norms (spend-weighted CTR/CPC/CPM/ROAS + top-quartile) — what "good" means HERE |
| GET | `/v1/decisions` | The user's approve/reject history WITH reasons — learn their taste before proposing |
| GET | `/v1/outcomes` | Before/after for executed proposals — argue the next one with evidence |
| GET | `/v1/alerts?brand=` | Ranked attention feed: delivery issues, fatigue, weak CTR, negative ROAS, pacing blowouts |
| GET | `/v1/performance/unified` | Every channel in one view + the draft pipeline |
| GET | `/v1/policies?brand=` | Guardrails + standing auto-approve rules. READ BEFORE PROPOSING |

### Agent ops (the autopilot's memory + derived working state)

| Method | Path | What it does |
|--------|------|--------------|
| POST | `/v1/agent/notes` | Save a durable operational note — judgment/intent, never a fact a query can answer |
| GET | `/v1/agent/notes` | Your notes for this workspace, newest first — read at the start of a pass |
| DELETE | `/v1/agent/notes/{note_id}` | Delete a note once it's no longer true |
| GET | `/v1/agent/ops` | Derived-live working state: pending-activation backlog, pending drafts, today's counters |
| POST | `/v1/agent/notify-check` | Server-owned notification dedup — ask before sending, `{send, reason}` back |

### Proposals (changes to live objects — approve executes)

| Method | Path | What it does |
|--------|------|--------------|
| POST | `/v1/drafts` | Meta kinds `budget_change`, `status_change`, `ad_edit`, `comment_reply`, `comment_hide`; Google kinds `google_budget_change`, `google_status_change`, `google_negative_keyword` |

### Content

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/schedule` | Content slots in flight — call BEFORE proposing a `scheduled_at` |
| GET | `/v1/library/posts` | Past Telegram channel posts with reactions/views — the Telegram voice library |
| GET | `/v1/library/threads` | Our own past Threads posts (root + chain) with views/likes |
| GET | `/v1/library/broadcasts` | Sent Kit newsletters with open/click rates |
| POST | `/v1/library/refresh` | Pull fresh platform data into a library (`target`: ads/posts/threads/broadcasts; `platform: google` syncs the Google ads archive) |
| POST | `/v1/drafts` | kinds `threads_post`, `facebook_post`, `instagram_post`, `linkedin_post`, `telegram_post`, `newsletter` — submit a post draft to the Schedule tab |

### Brand knowledge

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/brand/brief?brand=` | The compiled brand brief (voice, USP, audience, offers) — read BEFORE drafting |
| POST | `/v1/brand/notes` | Save a durable text note into the brand's knowledge base |

### AI

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/models` | The AI model registry — ALWAYS read before picking a model; training data is stale |
| POST | `/v1/complete` | Run a prompt through a registered provider/model; every call is cost-attributed |

### Sales (ground truth — pixel ROAS undercounts)

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/sales/brands` | Sales-tracker brand registry — which `brand` values summary/daily accept |
| GET | `/v1/sales/summary?brand=&start_date=&end_date=` | Sales + ad-spend KPIs with prior-period comparison |
| GET | `/v1/sales/daily?brand=&start_date=&end_date=` | Per-day sales + ad spend breakdown |
| GET | `/v1/sales?brand=` | Recent pushed sales events + trailing-window total |
| POST | `/v1/sales` | Batch-push the brand's own sales events (idempotent) |

### Analytics (GA4 — what ad traffic does on-site)

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/analytics/summary?brand=&start_date=&end_date=` | GA4 KPI totals with prior-period comparison |
| GET | `/v1/analytics/daily?brand=&start_date=&end_date=` | Per-day GA4 KPI breakdown |
| GET | `/v1/analytics/channels?brand=&start_date=&end_date=` | Traffic by channel group (Paid Social, Organic, …) |
| GET | `/v1/analytics/pages?brand=&start_date=&end_date=&limit=` | Top pages by views |

### Reports

| Method | Path | What it does |
|--------|------|--------------|
| POST | `/v1/reports/ads` | Send the end-of-run ads report to the user's channel — EXACTLY ONCE per brand per run |
| POST | `/v1/reports/ad-preview` | Send ONE feed-mockup preview of a single staged test ad — once per staged ad |

### All families

| Method | Path | What it does |
|--------|------|--------------|
| GET | `/v1/drafts` | List drafts + verdicts (filter by `status`, `kind`, `brand`) |
| GET | `/v1/drafts/{id}` | One draft, full detail (status, `review_note`, `published_refs`, `before`) |
| PATCH | `/v1/drafts/{id}` | Revise payload fields or `agent_note` while the draft is still PENDING |
| DELETE | `/v1/drafts/{id}` | Withdraw a PENDING draft you no longer stand behind |

There is still no agent-facing approve / send. By design — approval is the
user's (or their standing policy's), never yours.

## Skills that use this API

Each skill is a self-contained playbook — install with
`npx skills add pandaitech/hundrads-skills`; all of them speak to these
endpoints and nothing else.

### Ads

| Skill | Drafts kind | What it's for |
|-------|------------|----------------|
| `meta-ads` | `meta_ad` + proposal kinds | Study past ad performance, draft ad variants, propose budget/status changes, iterate on verdicts + insights |
| `google-ads` | `google_search_ad` + google proposal kinds | Study Search performance, plan keywords, draft RSAs, mine search terms into negatives, propose budget/status changes |
| `manage-ads` | proposal kinds | The daily marketer loop on live ads: kill losers, scale winners, fix fatigue, handle comments |

### Content

| Skill | Drafts kind | What it's for |
|-------|------------|----------------|
| `threads-post` | `threads_post` | Conversation-starting Threads posts (single by default; chains rare) |
| `facebook-post` | `facebook_post` | Organic Facebook Page posts with optional image or link |
| `instagram-post` | `instagram_post` | Instagram single-image or carousel feed posts with captions |
| `linkedin-post` | `linkedin_post` | LinkedIn profile or organization Page posts with optional image or article link |
| `telegram-post` | `telegram_post` | Telegram channel posts, optional image |
| `newsletter` | `newsletter` | Email broadcasts (markdown body; Hundrads converts + sends via Kit) |
| `product-launch` | all content kinds | 10-post launch campaign (teaser → launch → last call) across the three content channels, after a detailed intake interview |
| `seed-page` | `threads_post`, `telegram_post` | Pre-ads warm-up: 5–7 organic-looking seed posts (no CTA) scheduled before the ad launch date so the page passes the "is this account real?" check |

Shared loop for every skill: **read the library/brief → write in the user's
voice → show the user → check `/v1/schedule` (content) → submit draft →
read the verdict → iterate.**

For proposals the loop is: **alerts/pacing/live ads → benchmarks + decisions
+ outcomes (what's normal, what does the user like, what worked) → policies
(the caps) → propose with the numbers in `reason` → read the verdict.**

## Drafts

### POST /v1/drafts
Submit work for human review. All kinds accept an optional top-level
`batch_id` (≤64 chars): give every draft from one run the SAME value (e.g.
`"bat_" + a random hex string, generated once per run`) and the review
dashboard groups them into a single block — easier for the user to read a
multi-draft submission as one piece of work. Decisions stay per-draft.
Body for an ad:

```json
{
  "kind": "meta_ad",
  "agent_note": "why you made this — data studied, what the variant tests",
  "payload": {
    "brand": "string (must be a registered brand — see /v1/accounts)",
    "campaign_name": "string 3..200 (share across variants of one test)",
    "ad_name": "string 3..200",
    "objective": "OUTCOME_TRAFFIC | OUTCOME_SALES | OUTCOME_ENGAGEMENT | OUTCOME_LEADS | OUTCOME_AWARENESS | OUTCOME_APP_PROMOTION",
    "primary_text": "string 10..3000",
    "headline": "string 2..255 (~40 chars display fully)",
    "description": "string 0..255, optional",
    "call_to_action": "LEARN_MORE | SHOP_NOW | SIGN_UP | SUBSCRIBE | GET_OFFER | CONTACT_US | DOWNLOAD | APPLY_NOW | BOOK_NOW | MESSAGE_PAGE | WHATSAPP_MESSAGE (whatsapp destination only)",
    "destination": "website (default) | whatsapp. 'whatsapp' = a click-to-WhatsApp ad: the click opens a WhatsApp chat with the brand Page's connected WhatsApp Business number — no landing page. link_url is ignored, call_to_action becomes WHATSAPP_MESSAGE (auto-set if left default), and no pixel is needed even for OUTCOME_SALES. The Page must have a WhatsApp number connected or approve fails. Multi-ratio images[] not supported with whatsapp yet.",
    "whatsapp_prefill": "string 0..500, optional — destination 'whatsapp' only (422 on 'website'). The initial message pre-filled in the customer's WhatsApp composer when the chat opens; write it as the customer's first message and name the ad's offer (e.g. 'Hi! I saw the RM49 promo — is it still on?') so the conversation starts with context. Empty = Meta's stock 'Hello! Can I get more info on this?' (warned).",
    "link_url": "http(s) URL — required for destination 'website'; ignored for 'whatsapp'",
    "daily_budget_cents": "int 100..100000 (minor currency unit; RM10/day = 1000)",
    "image_hash": "optional, from POST /v1/media/poster or upload (also the thumbnail for a video ad)",
    "image_url": "optional preview URL shown in the review queue (also the video ad's poster frame)",
    "images": "optional list of ratio variants [{image_hash, image_url, ratio}], ratio in 1:1|4:5|9:16|16:9 (must be distinct). 2+ → one placement-customized creative (feed gets the square/portrait, Stories/Reels the 9:16); 1 → use image_hash instead. Generate via POST /v1/media/poster once per ratio. Image-only — can't combine with video_id.",
    "video_id": "optional — for a VIDEO ad. Upload an MP4/MOV to POST /v1/media/upload, which returns video_id + an image_url thumbnail; put both here. Set image_url (or image_hash) as the poster frame. Mutually exclusive with images[].",
    "video_url": "optional playable URL the review card streams (from the video upload); not pushed to Meta — only video_id is.",
    "geo_countries": "optional list of ISO 3166-1 alpha-2 codes (e.g. [\"MY\",\"SG\"]); empty = account default (MY). Set ONLY for geo-bound offers — targeting is creative-driven, country is the only audience lever."
  }
}
```

Body for a Google Search ad (text-only — no image; the brand needs a
`google_customer_id` in the registry or the draft 422s). Approve pushes the
whole chain PAUSED: budget → campaign → ad group → RSA + keywords + negatives.
Campaign and ad group are get-or-create by exact name, so variants sharing a
`campaign_name` consolidate under one campaign. Landing in an existing
already-ENABLED campaign + ad group creates the ad itself PAUSED instead —
either way nothing you submit can start spend:

```json
{
  "kind": "google_search_ad",
  "agent_note": "why you made this — data studied, what the variant tests",
  "payload": {
    "brand": "string (registered brand WITH a google_customer_id — see /v1/accounts google_can_push)",
    "campaign_name": "string 3..200 (share across variants of one test)",
    "ad_group_name": "string 3..200",
    "headlines": "3..15 strings, each 1..30 chars. Duplicates are dropped case-insensitively (warned, error if <3 survive); aim for 8-15 distinct",
    "descriptions": "2..4 strings, each 1..90 chars",
    "path1": "optional display-URL path, ≤15 chars (cosmetic: domain.com/path1/path2)",
    "path2": "optional ≤15 chars — requires path1",
    "final_url": "http(s) landing page URL",
    "keywords": "1..50 of {text: 1..80 chars, match_type: BROAD | PHRASE | EXACT (default PHRASE)}",
    "negative_keywords": "optional, same shape — campaign-level negatives pushed with the ad",
    "daily_budget_cents": "int 100..100000 (minor currency unit; RM10/day = 1000)",
    "geo_countries": "optional list of ISO 3166-1 alpha-2 codes; empty = account default (MY). Applied only when the campaign is newly created",
    "bidding_strategy": "MAXIMIZE_CLICKS (default — needs no conversion data) | MAXIMIZE_CONVERSIONS (only once conversions flow)"
  }
}
```

Content payloads (all REQUIRE `scheduled_at`, naive local ISO 8601 interpreted
using the server's `HUNDRADS_TIMEZONE` — check
`GET /v1/schedule` first and pick a free slot):

```json
{"kind": "threads_post", "agent_note": "...", "payload": {
  "text": "string 1..500", "chain": ["≤9 self-replies, each ≤500"],
  "account": "optional handle", "scheduled_at": "2026-06-12T09:00"}}

{"kind": "facebook_post", "agent_note": "...", "payload": {
  "brand": "registered brand", "text": "string 1..63206",
  "image_url": "optional public http(s) image",
  "link_url": "optional http(s) link; cannot combine with image_url",
  "scheduled_at": "2026-06-12T10:00"}}

{"kind": "instagram_post", "agent_note": "...", "payload": {
  "brand": "registered brand", "caption": "string 1..2200",
  "image_url": "public http(s) image for a single post; omit for carousel",
  "image_urls": ["2..10 ordered public image URLs for a carousel; omit for single"],
  "scheduled_at": "2026-06-12T11:00"}}

{"kind": "linkedin_post", "agent_note": "...", "payload": {
  "brand": "registered brand", "text": "string 1..3000",
  "image_url": "optional public http(s) image",
  "link_url": "optional article URL; cannot combine with image_url",
  "link_title": "required with link_url", "link_description": "optional, ≤256",
  "scheduled_at": "2026-06-12T14:00"}}

{"kind": "telegram_post", "agent_note": "...", "payload": {
  "brand": "registered brand", "text": "string 1..4096",
  "image_url": "optional", "scheduled_at": "2026-06-12T09:00"}}

{"kind": "newsletter", "agent_note": "...", "payload": {
  "subject": "string 3..200", "body": "markdown 50..50000",
  "preview_text": "optional ≤300", "scheduled_at": "2026-06-13T09:00"}}
```

Proposal payloads (every one needs `brand` + a `reason` with the NUMBERS that
justify it — the user decides on that line; ids come from `GET /v1/ads` and
`GET /v1/comments`):

```json
{"kind": "budget_change", "payload": {
  "brand": "...", "adset_id": "...", "adset_name": "optional",
  "current_daily_budget_cents": 2000, "new_daily_budget_cents": 3000,
  "reason": "ROAS 3.2 over 7d at RM20/day; pacing 0.9 — room to scale",
  "expected_impact": "optional"}}

{"kind": "status_change", "payload": {
  "brand": "...", "object_type": "ad | adset | campaign", "object_id": "...",
  "object_name": "optional", "action": "pause | resume",
  "reason": "ROAS 0.4 after RM180 — losing money"}}

{"kind": "ad_edit", "payload": {
  "brand": "...", "ad_id": "...", "ad_name": "optional",
  "primary_text": "10..3000", "headline": "2..255", "description": "optional",
  "link_url": "http(s)", "call_to_action": "optional", "image_hash": "optional",
  "reason": "CTR 0.3% vs account avg 1.1%; new angle from top performer"}}

{"kind": "comment_reply", "payload": {
  "brand": "...", "comment_id": "...", "message": "1..2000",
  "comment_text": "context shown to the user", "commenter": "optional",
  "post_id": "optional", "reason": "optional"}}

{"kind": "comment_hide", "payload": {
  "brand": "...", "comment_id": "...", "comment_text": "context",
  "commenter": "optional", "reason": "spam: crypto link drop"}}
```

Google proposal payloads (same contract; ids come from
`GET /v1/ads?platform=google`, negative-keyword evidence from
`GET /v1/search-terms`):

```json
{"kind": "google_budget_change", "payload": {
  "brand": "...", "campaign_id": "...", "campaign_name": "optional",
  "current_daily_budget_cents": 2000, "new_daily_budget_cents": 3000,
  "reason": "ROAS 2.8 over 30d at RM20/day, search terms clean — room to scale",
  "expected_impact": "optional"}}

{"kind": "google_status_change", "payload": {
  "brand": "...", "object_type": "campaign | ad_group | ad", "object_id": "...",
  "object_name": "optional", "action": "pause | resume",
  "reason": "RM140 spend, 0 conversions, CTR 1.1% vs account avg 4.2%"}}

{"kind": "google_negative_keyword", "payload": {
  "brand": "...", "campaign_id": "...", "campaign_name": "optional",
  "keywords": [{"text": "free", "match_type": "PHRASE"}],
  "reason": "search-terms report: 'free' queries took 312 clicks / RM64 with 0 conversions"}}
```

`keywords` on `google_negative_keyword` takes 1..100 entries; adding an
already-present negative is a no-op (idempotent). Shared-budget campaigns
refuse `google_budget_change` at execution — the error names the reason.

`201` → `{draft: {id, status: "pending", ...}, warnings: [...], review_url}`.
`422` → validation error with a field-level message; fix and resubmit.

Proposal lifecycle: `pending` → (user Approve or policy auto-approve) →
`publishing` → `published` (executed; `published_refs` has the result,
`before` has the pre-change snapshot) | `failed` (read `error`) | `rejected`
(read `review_note`) | `withdrawn` (you pulled it via DELETE).

### GET /v1/drafts?status=&kind=&brand=&limit=&skip=
Newest first. `status`: `pending | scheduled | publishing | published |
rejected | failed | withdrawn`. Rejected drafts carry `review_note` — the
user's reason. Read it before drafting replacements. Published drafts carry
`published_refs` (PAUSED Meta ids for ads; post/broadcast ids for content;
execution result for proposals).

### GET /v1/drafts/{id}
One draft, full detail.

### PATCH /v1/drafts/{id}
Revise a draft while it is still `pending`, without creating a duplicate
review item. Send a partial `payload`, an updated `agent_note`, or both:

```json
{
  "payload": {
    "text": "Corrected post copy",
    "scheduled_at": "2026-06-12T11:00"
  },
  "agent_note": "Moved one hour later and tightened the opening."
}
```

Payload fields are merged into the stored payload, then the complete result is
revalidated with the same kind-specific rules as `POST /v1/drafts`. The
draft's `kind` cannot change. `409` means the user has already acted; read the
latest draft instead of overwriting their decision.

### DELETE /v1/drafts/{id}
Withdraw a pending draft (stale numbers, better idea). `409` once the user
has acted on it.

### GET /v1/schedule
`{slots: [{id, kind, status, scheduled_at, title}]}` — every content draft
still in flight (pending, scheduled, mid-send), oldest slot first. Call this
BEFORE proposing a `scheduled_at`; don't collide with booked slots and leave
hours between posts on the same channel.

## Ads — research

### GET /v1/accounts
The user's connected brands. `telegram_channel` is the destination Hundrads
resolves and freezes onto Telegram drafts for that brand.
`can_push: false` = draft/research only (Meta).
`google_can_push` is the Google mirror: true when the row has a
`google_customer_id` AND the workspace has usable Google credentials — a
`google_search_ad` draft for a brand without one is rejected at validation.

### GET /v1/accounts/health?brand=
LIVE delivery health for one brand's ad account: `account_status`,
`can_spend`, `disable_reason` (names a card/billing problem when that's the
cause) and today's account spend. The "is my ad actually running?" check —
use it before assuming ads are delivering.

### GET /v1/ads?brand=&status=running|paused|other&q=&limit=&refresh=&platform=meta|google
The LIVE account: every ad with effective_status, lifetime spend/CTR/ROAS,
daily budget, frequency, copy — and the `id` / `adset_id` / `campaign_id`
you need to file proposals. Served from a <15-min snapshot; `refresh=true`
forces a fresh Meta sweep.

`platform=google` reads live from the Google Ads API on every call (no
snapshot; `refresh` is a no-op) and returns RSA rows: `ad_id`, `ad_name`,
`status`, `campaign_id`/`campaign_name`/`campaign_status`,
`ad_group_id`/`ad_group_name`/`ad_group_status`, `headlines[]`,
`descriptions[]`, `final_url` + lifetime metrics (spend, impressions,
clicks, ctr, cpc, results, conversions_value, roas) and a derived `bucket`
(`running` only when campaign + ad group + ad are ALL enabled; `paused` when
any link in the chain is paused). Google rows carry lifetime metrics only —
keep `date_preset=maximum` (anything else 422s). Use the returned ids for
the `google_*` proposal kinds.

### GET /v1/ads/pending-activation?brand=
Ads you staged (published `meta_ad` drafts) whose live ad is still PAUSED —
the approved-but-unactivated backlog, derived fresh from drafts ⋈ the live
snapshot. Non-empty = the human hasn't turned them on yet; don't stage more.
Meta-only in v1 (`platform=google` 422s — check
`GET /v1/ads?platform=google` directly instead).

### GET /v1/library/ads?brand=&q=&sort=spend|ctr|roas|clicks|recent&limit=&min_spend=&min_ctr=&min_roas=&active_only=&platform=meta|google
The user's own past ads with real performance (spend, ctr, cpc, roas,
results). The voice + structure source — study before drafting.
`platform=google` searches the synced Google Ads archive (RSA rows with
`headlines`/`descriptions`, same filters and sort keys); sync it via
`POST /v1/library/refresh` with `platform: "google"`.

## Library — content voice sources

### GET /v1/library/posts?q=&limit=&min_reactions=&min_views=&min_replies=&min_engagement=&exclude=
The Telegram channel post archive (newest first). Match real tone + study
what got reactions before drafting. `exclude` is a regex; matching posts
are dropped.

### GET /v1/library/threads?q=&limit=&roots_only=true&min_views=&min_likes=&min_engagement=
Our own Threads posts (newest first). `roots_only=true` returns one row per
logical post with its self-reply chain under `chain`; `false` browses every
individual post flat.

### GET /v1/library/broadcasts?q=&limit=&min_open_rate=&min_click_rate=&with_content=
Sent email newsletters (Kit broadcasts), newest first. Open/click rates are
the performance signal. `with_content=true` includes raw HTML (the stripped
`text` is usually what you want).

### POST /v1/library/refresh
`{target: "ads" | "posts" | "threads" | "broadcasts", brand: "ads only,
empty = all", platform: "ads only — meta (default) | google", full: false}`
— pull fresh platform data into the library (incremental by default). Slow
(platform round-trips); call once at the start of a session, not per query.
`platform: "google"` (target=ads only, else 422) syncs the Google Ads
archive that `GET /v1/library/ads?platform=google` and
`GET /v1/benchmarks?platform=google` read.

## Brand knowledge

### GET /v1/brand/brief?brand=&per_asset_chars=8000
The brand's full knowledge brief — voice, USP, audience, offers, channel
playbooks — compiled from everything the user collected (notes, files,
URLs, repos). ALWAYS read before drafting; pair with the library endpoints
to ground both WHAT the brand is and WHAT worked. `404` = unknown brand.

### POST /v1/brand/notes
`{brand, title?: ≤200, content}` — save a durable text note into the
brand's knowledge base (shows up in the brief). Use when you learn
something mid-task — a positioning insight, an audience fact, an offer
detail the user confirmed — so the next session starts smarter.

## AI

### GET /v1/models
The model registry: name, provider, model_id, what each is good at, and
status (`current` / `verify` / `legacy`). ALWAYS read this before picking a
model — training data is stale, the registry is maintained. Prefer
`current`; never use `legacy`. Each model also carries a `providers` list —
every provider that serves it (its primary plus any per-provider id
overrides). A model may be reachable through several providers (e.g. native
`gemini`, `openrouter`, `pareto`), each addressing it by a different id
string under the hood.

### POST /v1/complete
`{prompt, system?, provider: "claude", model, n: 1..10, temperature: 0..2,
skill?, channel?, brand?}` → `{results: [...], tokens_in, tokens_out,
cost_usd, latency_ms}`. Pass `model` = the registry `model_id` and `provider`
= any provider in that model's `providers` list; Hundrads maps it to the
provider-specific id (`422` if that provider doesn't serve the model). Pass
`skill` / `channel` / `brand` so the call's cost is attributed. Route ALL AI
calls through here — never call a provider directly from a skill.

## Sales — ground truth revenue

Meta pixel ROAS undercounts (iOS signal loss, WhatsApp/COD checkouts that
never fire the pixel). True ROAS comes from here.

### GET /v1/sales/brands
The sales-tracker brand registry — which `brand` values `/sales/summary`
and `/sales/daily` accept (base brands + product views).

### GET /v1/sales/summary?brand=&start_date=&end_date=
Sales + ad-spend KPIs over [start_date, end_date] (YYYY-MM-DD) with a
prior-period comparison: sales, orders, ad_cost, profit, roas, aov, cac,
margin.

### GET /v1/sales/daily?brand=&start_date=&end_date=
Per-day sales + ad spend breakdown (newest day first), including
per-package order counts.

### GET /v1/sales?brand=&limit=60&window_days=7
Recent pushed sales events + an inclusive trailing-window total — quote
true revenue next to pixel numbers.

### POST /v1/sales
`{brand, source: "api", events: [{external_id, date: "YYYY-MM-DD",
amount: RM major unit, currency: "MYR", orders: 1, note?}]}` — upsert
ground-truth sales events, idempotent on (brand, source, external_id);
re-push freely. `source: "manual"` is reserved for the dashboard.

## Analytics — GA4 traffic & on-site conversions

Google Analytics reads for brands with a GA4 property wired in the
dashboard Brands tab (`ga4_property_id`). Use next to `/v1/insights`
(Meta's view) and `/v1/sales` (ground truth) to see what ad traffic
actually does on-site. All dates are YYYY-MM-DD. A brand with no GA4
property (or no GA service account configured) returns **502** with the
reason.

### GET /v1/analytics/summary?brand=&start_date=&end_date=
KPI totals over the window with a prior same-length-period comparison:
sessions, totalUsers, newUsers, screenPageViews, keyEvents (GA4
conversions), totalRevenue, engagementRate, averageSessionDuration.

### GET /v1/analytics/daily?brand=&start_date=&end_date=
Per-day breakdown of the same KPIs (newest day first).

### GET /v1/analytics/channels?brand=&start_date=&end_date=
Sessions/users/key events/revenue by default channel group (Paid Social,
Organic Search, Direct, …), busiest first.

### GET /v1/analytics/pages?brand=&start_date=&end_date=&limit=20
Top pages by views: screenPageViews, totalUsers, keyEvents per pagePath.

## Reports

### POST /v1/reports/ads
`{brand, headline, health?, actions?, watching?, outcomes?, blocked?,
next_step?, creative_url?}` — send the end-of-run ads report to the user's
channel (Discord/Telegram/email, per their settings). Call EXACTLY ONCE per
brand at the end of a successful management run. It's a marketer's
stand-up, not a data dump: `headline` = the outcome in one sentence;
`actions` = one entry per proposal filed this run ({kind, object_name,
summary, status}). The server renders the account-health dashboard from
LIVE Meta data — don't restate those numbers. Never mention the approval
flow, internal ids, or tool names.

### GET /v1/insights?brand=&date_preset=last_30d&level=campaign|adset|ad|account&breakdown=&time_increment=&platform=meta|google
Live numbers from the platform. Presets (both platforms): today, yesterday,
last_3d/7d/14d/28d/30d/90d, this_month, last_month, maximum. `breakdown`
(Meta only): age | gender | country | region | publisher_platform |
platform_position | device_platform |
hourly_stats_aggregated_by_advertiser_time_zone. `time_increment`: 1 (daily) |
7 | 28 | monthly. Combine: "which placement is dying this week" in one call.

`platform=google` levels are account | campaign | ad_group | ad; breakdowns
422 ("not supported for platform=google yet") and `time_increment` accepts 1
only (each row then gains a `date` key). Returns `{brand, platform, level,
date_preset, time_increment, rows, totals}` — rows carry the level's
ids/names/status plus the standard metric keys (spend, impressions, clicks,
ctr as a percent, cpc, results, conversions_value, roas), `totals` sums
spend/clicks/impressions/ctr across them.

### GET /v1/insights/compare?brand=&object_ids=id1,id2&date_preset=last_7d
2-5 ids side by side with name + status — the A/B verdict in one call.
Meta only.

### GET /v1/search-terms?brand=&date_preset=last_30d&limit=200
Google-only (Meta has no equivalent): what people actually TYPED before a
Search ad served, impression-ranked. Returns `{brand, date_preset, total,
terms}`; each term row is `{search_term, campaign_id, campaign_name,
ad_group_id, ad_group_name, impressions, clicks, spend, results,
conversions_value}`. `limit` caps at 1000. This is the feedstock for
`google_negative_keyword` proposals — scan for irrelevant queries eating
spend (clicks with zero results) and propose them as negatives with the
numbers in `reason`.

### GET /v1/budget/pacing?brand=
`{rows: [{adset_id, daily_budget_cents, spent_today_cents,
projected_spend_cents, pace}]}` — pace > 1.0 means on track to exceed the
daily budget. Check before proposing increases.

## Engagement

### GET /v1/comments?brand=&unanswered=true&limit=
The comment inbox. `answered: false` = nobody from the page replied yet. Act
via `comment_reply` / `comment_hide` proposals, never anywhere else.

### GET /v1/audiences?brand=
Custom/lookalike audiences with approximate sizes and delivery status.

### GET /v1/leads?brand=&form_id=&limit=
Without `form_id`: the page's lead forms + lead counts. With it: recent
submissions (field_data).

## Intel

### GET /v1/benchmarks?brand=&platform=meta|google
Spend-weighted avg CTR/CPC/CPM/ROAS + top-quartile thresholds over the
account's own synced library. THIS is the bar — not industry numbers.
`platform=google` runs the same math over the synced Google Ads archive
(ads with ≥500 impressions; response carries `platform` + `ads_measured`) —
sync it first via `POST /v1/library/refresh` with `platform: "google"`.

### GET /v1/decisions?brand=&kind=&limit=
Approve/reject history with `review_note` (rejections), the proposal's
`reason`, and `auto_rule` (policy auto-approvals). The fastest way to learn
the user's taste.

### GET /v1/outcomes?brand=&limit=
Executed proposals with the `before` snapshot (budget/status + last-7d
insights at execution time) and `after_last_7d` (live now). Use as evidence
in the next proposal's reason. Limit capped at 10.

### GET /v1/alerts?brand=
Ranked `{severity, type, message, suggestion}`: delivery issues, fatigue
(frequency > 3.5), CTR under half the account average, negative ROAS with
real spend, pacing > 1.3x. Poll at the start of an ads session.

### GET /v1/performance/unified
All channels in one view (ads / threads / telegram / newsletter rollups) +
the draft pipeline by kind and status.

### GET /v1/policies?brand=
`{max_daily_budget_cents, max_budget_increase_pct, auto_approve, notes}`.
Stay under the caps or the proposal is wasted. Kinds/rules in `auto_approve`
execute without a per-item Approve — the user's standing consent, set in the
dashboard (you can read it, never write it).

## Ads — media

### POST /v1/media/poster
`{prompt, complexity: "simple"|"complex", aspect: "1:1"|"4:5"|"9:16"|"16:9",
brand, quality, include_b64, reference_images}` → `{model, image_hash?,
image_url?, bytes}`. Pass `brand` to upload to the ad account (that's what
produces `image_hash`). `reference_images`: up to 4 base64 PNG/JPEG (data-URI
prefix optional) the model conditions on — e.g. an existing brand logo so the
generated pfp/banner matches the real mark. Works on both backends (Nano
Banana / gpt-image).

### POST /v1/media/upload
Multipart form: `brand` (field) + `file` (an image OR a video). The
bring-your-own-creative counterpart of /v1/media/poster: uploads your own file
to `brand`'s Meta ad account and returns a creative ref that plugs straight into
a meta_ad draft. File type is auto-detected. No AI provider key needed — this
path never calls a model; the original is archived to durable storage
(DigitalOcean Spaces).

- **Image** — PNG/JPEG/GIF/WEBP, ≤30MB → `{image_hash, image_url, archive_key,
  archive_url, bytes, mime}`. Put `image_hash` on the draft.
- **Video** — MP4/MOV, ≤200MB → `{video_id, image_url, video_url, archive_key,
  archive_url, bytes, mime}`. The call **waits for Meta to finish processing the
  video** (a few seconds to ~a minute — keep the client timeout generous), then
  returns `video_id` plus an auto-generated `image_url` thumbnail (the poster
  frame) and a playable `video_url`. On the draft set `video_id` **and** the
  thumbnail `image_url` (or your own `image_hash`). Don't also send `images[]` —
  video and multi-ratio image creatives are mutually exclusive.

## Errors

- `401` bad/missing key · `409` state conflict (e.g. withdrawing a decided
  draft) · `422` validation (message says exactly what) · `502` upstream
  platform error (relay it; don't retry blindly).
- Validation messages are written for you to self-correct from. One retry
  after fixing is fine; repeated identical failures should be surfaced to
  the user instead.

## Blog — systems and receipts from running AI ad automation

- [We replaced our freelance ad manager with an AI agent that runs every 4 hours](https://hundrads.com/blog/replaced-ad-manager-with-ai-agent): The full architecture of an AI ad manager running real Meta ad accounts on a cron — what it decides each pass, what it's allowed to touch, and what we learned running it on our own money.
