Project One

llms.txt

# Project One API — AI agent entry point

> AI marketing platform for coaching businesses: signup → positioning → creative → launch (human-approved) → diagnostics. Same-origin HTTP API, JSON-first. This file maps the surface; details live in the linked pages.

## Critical context (read before calling anything)

- Two modes decided by env config: keyless DEMO (default; responses tagged `"mode":"demo"`, nothing persisted) and LIVE. Always check success bodies for `mode:"demo"` — it means the call did not persist.
- Send Content-Length on every POST with a body; guarded routes return 411 without it.
- Error envelope is usually `{"ok":false, "error"|"missing"|"reason": ...}`; auth uses 303 redirects; unsubscribe/booking-manage return HTML; cron errors are plain text.
- Real-world actions (publish/launch/activate) are never direct: decide a pending approval via POST /api/approvals; a server-side one-time token executes it. Decisions fail closed (exact lowercase "approve"/"reject").
- Rate limits are per-IP sliding windows (429 + retry-after: 60). See docs/api/conventions.md for the full table.

## Docs

- docs/api/README.md: index + update contract
- docs/api/conventions.md: modes, auth tiers, rate/body limits, envelopes, approval gate, env vars, idempotency table
- docs/api/openapi.yaml: OpenAPI 3.1 spec (all 38 endpoints, incl. /api/v1)
- docs/api/resources/*.md: per-group deep dives with caller gotchas
- docs/api/resources/platform-api-v1.md: START HERE if you are a machine — the /api/v1 surface is built for you
- docs/api/resources/video-generation-provider.md: the Gemini/Veo ("Nano Banana") video backend — external API contract, config env vars, async two-phase lifecycle, VERIFY-AT-LIVE (built keyless, not yet run against a live key)

## Endpoints (method path — auth — one-liner)

- POST /api/leads — public — capture a lead into a tenant CRM (NOT idempotent; live failure → honest 500, demo ack only when keyless)
- POST /api/interest — public — tour-gate interest registration (idempotent by email; sets p1_tour cookie)
- GET|POST /api/unsubscribe?token= — HMAC token — CAN-SPAM/RFC 8058 unsubscribe (GET=HTML always 200; POST=204 always)
- GET /api/bookings?instanceId= — public — open slot set (CDN-cached 60s live/300s demo)
- POST /api/bookings — public — book a slot (409 slot_unavailable carries fresh slots; race-safe)
- GET|POST /api/bookings/manage?token= — HMAC capability URL — lead's reschedule/cancel page (HTML, always 200)
- POST /api/bookings/mark — session — host marks showed|no_show (idempotent; no_show triggers recovery nurture)
- POST /api/conductor — session when live — chat; {reply, mode}; 6000-char cap; demo fallback on any live failure
- POST /api/approvals — session inside live handler — decide {approvalId, decision:"approve"|"reject"}; single-use; second decide → 500 live / 404 demo; expired approvals rejected at decision time
- POST /api/media {action:"photos"} — session (live) — base64 photo library add (≤8 files, ≤4MB each, silent drops)
- POST /api/media {action:"vsl_ticket"} — session (live) — mint signed-upload slot (video/mp4|quicktime|webm, ≤512MiB declared)
- POST /api/media {action:"vsl_confirm"} — session (live) — verify REAL object, register single active VSL (idempotent)
- GET /api/vsl?i=<uuid> — public — 302 to fresh 1h signed URL; all failures = 404 "Not found"; never cache the target
- POST /api/creatives — session (live) — approve|reject a creative (statics AND generated videos; already_live → 404)
- POST /api/videos — session (live) — animate an approved static into a draft video (one per source; already:true on dup)
- POST /api/onboarding — public — full tenant provisioning (email collision = neutral 200 with instanceId:"")
- POST /api/brand-assets — session (live effect) — add logo/photos/URLs (live failure → honest 500; demo ack only when keyless)
- POST /api/auth/login — public — 303s: /home (success+cookies) | /login?error=credentials|email|demo | ?sent=1|demo
- POST /api/auth/logout — none — always 303 / (idempotent)
- GET /auth/confirm?token_hash= — token IS credential — magic-link landing; token single-use (prefetch burns it)
- GET /p/{instanceId} and /thanks — public — served funnel HTML (newest row wins; ambiguous 404s by design)
- POST /api/cron — Bearer CRON_SECRET — one platform tick (idempotent; 200 body carries per-area error counts)

## /api/v1 — the programmatic surface (prefer this for integrations)

Different contract from the console routes above: success = resource JSON directly (no {ok} wrapper); errors = RFC 9457 application/problem+json — branch on the stable `code` field, never on title/detail. Demo (keyless, credential-less) GETs serve labeled fixtures ("mode":"demo" + x-p1-mode:demo header); ANY presented credential and EVERY mutation → honest 503 platform_not_configured. Bearer API keys (p1_live_…) are not verifiable yet → 501 api_keys_not_available; the only live auth today is the session cookie. Tenancy derives from the credential (oldest instance) — never send instanceId; id-carrying mutations refuse rows from your OTHER instances (not-found dialect). No CORS by design. Limits: 60/min/IP/path + 120/min/instance; mutations 1 MB cap + same-origin Origin check (absent Origin passes).

- GET /api/v1/status — pipeline position {mode, stage, completed[], stages[]}
- GET /api/v1/approvals — pending approvals [{id, action_type, summary}] — poll this, then decide (the core loop)
- GET /api/v1/creatives — [{id, hook, status: draft|approved|rejected|retired, preview_url?}] (preview links live 1h — refetch, never store)
- GET /api/v1/videos — {videos:[{id, hook, state: generating|needs_okay|approved|failed, video_url?, duration_seconds}], candidates:[{id, hook}]}
- GET /api/v1/performance — {funnel:{leads,calls_booked,shows,sales}|null, history:[...last 6]} (strict reads: DB blip → 500, empty really means empty)
- GET /api/v1/positioning — active/draft positioning document {mode, positioning:{status,version,niche_statement,core_message,ideal_client,category_of_one|null,...}|null}; null = not drafted yet; problem_archetype/confidence_notes never on the wire
- GET /api/v1/angles — the full angle bank {mode, angles:[{id,name,moment,one_line,archetype,predicted_fit|null,status,positioning_version}]}; raw status vocab draft|live|in_bank|retired; archetype is the raw snake_case key (open vocabulary)
- GET /api/v1/scripts — {mode, chosen_angle_id|null, vsl_video_live, scripts:[{id,angle_id,type:ad_script|vsl|ad_copy,structure|null,format|null,status,length_seconds|null,hook,body|null,cta|null,compliance_checked,sections?}]}; filtered to the live angle; ad_copy rows carry null structure/format/cta BY DESIGN — handle nulls
- GET /api/v1/landing-page — newest lead page {mode, page:{id,status,version,url|null,angle_id,message_match|null,tracking_installed,form_targets|null,published_at|null,copy{...}}|null}; url null until published; message_match null = check not yet run
- GET /api/v1/brand — brand board {mode, brand:{status,version,rationale,palette{primary..ground},typography,logo{present,clear_space_note},imagery,motifs,never_look_like,provenance}|null, assets:[{kind,label}]}; NO storage paths or signed URLs on the wire
- POST /api/v1/approvals/{id}/decision — {decision:"approve"|"reject"} → {id, status}; NOT idempotent (repeat → 500 — verify via GET, don't retry-loop); expired approvals rejected at decision time
- POST /api/v1/creatives/{id}/decision — same body → {id, status}; same-decision repeat = no-op success, flips allowed; live creative → 404 already_live
- POST /api/v1/creatives/{id}/animate — NO body but Content-Length: 0 still required (411 otherwise) → {id, animation:"queued"|"already_queued"}; idempotent per source; 10/min/instance; poll /api/v1/videos for progress
- PATCH /api/v1/angles/{id} — bank housekeeping {status:"retired"|"in_bank"} → {id,status}; live angle → 409 conflict; state moved mid-request → 409 conflict (refetch, retry); going live stays with the angle_choice approval; idempotent repeat
- POST /api/v1/approvals/pause-request — REQUEST-ONLY campaign pause → 201 {id,action_type:"meta_pause",status:"pending",summary,expires_at} (200 = deduped repeat, same row); NO body but Content-Length: 0 required; nothing launched → 409; decided ≠ executed until Graph adapters land
- POST /api/v1/approvals/budget-request — REQUEST-ONLY {daily_budget_cents:int 100..1000000} → 201/200 same contract; changed amount SUPERSEDES the pending request (old row → expired; one pending per instance); no ad set → 409