SaaS for go-to-market execution: an AI content pipeline (short videos/clips), automated B2B email outreach, and AI-generated GTM strategy plans — all behind a human-in-the-loop approval workflow.
Full technical spec: see ARCHITECTURE.md.
backend/— FastAPI (Python 3.11+), SQLAlchemy 2.x, SQLite by default (Postgres-ready viaDATABASE_URL), JWT auth, Alembic migrations, APScheduler for scheduled publishing and reply polling.frontend/— Next.js 14 (App Router), TypeScript, Tailwind CSS. Talks to the API viaNEXT_PUBLIC_API_URL, JWT stored inlocalStorage.- Storage — local disk by default (
backend/storage/); swaps to Tigris/S3 automatically when S3 credentials are set. Storage is meant to be temporary: once content is published, only the platform URL needs to be kept.
Stage 2 layers agency-facing white-label branding and a client-facing approval portal on top of Stage 1's tenancy. Full contract: STAGE2_SPEC.md.
- Branding —
Organization.brandingandWorkspace.brandingare both{app_name?, logo_url?, accent_color?, from_name?, from_email?, subdomain?}JSON; workspace branding overrides org branding via one merge helper (app/services/branding_service.py) used everywhere branding is resolved — the authed app, the client portal, outbound emailfrom_name/from_email, and the branded report. Edit it at Settings → Organization (PATCH /orgs/me, agency plan required). The frontend applies branding as CSS variables via a singleBrandingProvider— no per-tenant builds. - Client approval portal — agencies invite clients per-workspace (Settings → Workspace → "Client access") via a magic link (
POST /workspaces/{id}/client-access); the client never gets a login or sees the agency's org. The link exchanges for a short-lived, portal-scoped JWT (GET /portal/auth?token=) that only works against/portal/*routes — app JWTs are rejected there and portal JWTs are rejected on every normal app route (app/tenancy.py CurrentPortalClientvsCurrentOrg/CurrentWorkspace). The portal (/portal/review,/portal/published,/portal/report) is mobile-first with no sidebar: review queue with video preview + approve/reject + comments, a read-only published list, and the same monthly report the agency sees. Approve/reject reuse the existing content service functions — the portal only adds aContentCommentauthor trail (author_kind: agency|client), never new state logic. - Monthly reports — one aggregate function (
app/services/report_service.py) powers the agency report page, the portal report, and a downloadable/printableGET /workspaces/{id}/report.html?month=— a self-contained branded HTML document (inline CSS, logo, accent color; print-to-PDF, no PDF library needed).monthisYYYY-MM, defaulting to the current month.
Connect a real YouTube account from Settings → Workspace → "Connected accounts" to publish scheduled content automatically instead of pasting URLs by hand. Requires GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and OAUTH_REDIRECT_BASE (see env vars below) — without them the connect button surfaces a clear "not configured" error and every platform, including YouTube, falls back to the publish kit flow: an approved item with no connected publisher for its platform gets a copy-paste kit (GET /content/{id}/publish-kit → video URL, caption, hashtags, checklist) shown in both the content detail page and the client portal, and the existing "mark published" action closes the loop once you've posted it manually.
Real agency sending is gated behind guardrails in app/services/deliverability_service.py, all enforced inside the outreach send path (never optional at the call site):
- Suppression — unsubscribed, bounced, or manually-suppressed emails are checked before every send and skipped, never silently dropped;
POST /campaigns/{id}/sendreports back{queued, skipped_suppressed, deferred_cap}so the agency can see what got held back. - Unsubscribe — every outbound outreach email carries a footer link (
GET /public/unsubscribe/{token}, a signed token, no login required) injected at the email-provider layer so no send path can bypass it; clicking it adds a suppression entry and shows a confirmation page. - Volume ramp — per-workspace daily send cap ramps up with days-since-first-send (20/day for days 1–3, 50/day days 4–7, 100/day week 2, 200/day after that), capped by the plan's own limit. An unverified sending domain hard-caps at 10/day regardless of ramp stage.
- Domain verification —
POST /workspaces/{id}/verify-domainchecks SPF and DMARC records for the workspace'ssettings.sending_domainviadnspythonand stores{spf_found, dmarc_found, verified, checked_at}back ontoworkspace.settings.domain_verification; the UI shows a verified/unverified badge next to the sending domain field. - Bounce handling — the existing IMAP reply-poll job also detects bounce notifications by subject/from heuristics and marks the contact bounced + suppressed.
The app is organized as Organization → Workspace. Every user's account has one Organization (created automatically at registration), and an Organization holds one or more Workspaces — isolated containers for content, campaigns, GTM plans, and schedule config. Use workspaces to separate products or client accounts within the same org; every domain request must carry an X-Workspace-Id header (the frontend attaches this automatically for the active workspace, see lib/workspace-context.tsx). See STAGE1_SPEC.md for the full tenancy model.
Plan tiers (backend/app/plans.py) gate workspace count and monthly content-generation volume:
| Plan | Max workspaces | Generations / month | Client portal | White-label |
|---|---|---|---|---|
| free (default) | 1 | 10 | – | – |
| starter | 3 | 100 | 1 workspace (a taste) | – |
| agency | 25 | 1000 | ✓ | ✓ |
Exceeding a limit returns HTTP 402 Payment Required with an upgrade message. GET /orgs/me reports current usage vs. limits; the frontend surfaces this in Settings → Organization. Client-portal invites and branding PATCH /orgs/me are gated the same way (starter's portal access is scoped to its oldest workspace only).
Billing runs in mock mode by default: POST /billing/checkout immediately upgrades the org's plan and returns {url: "/settings?upgraded=<plan>"} — no real payment happens, and it's logged clearly as mock. Set STRIPE_SECRET_KEY (plus STRIPE_WEBHOOK_SECRET and the STRIPE_PRICE_ID_* vars) to switch to real Stripe Checkout.
Run backend and frontend in two terminals.
cd backend
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # edit if you want real providers; safe to leave blank
alembic upgrade head # creates/updates the database schema
uvicorn app.main:app --reloadThe API serves on http://localhost:8000. Interactive docs at http://localhost:8000/docs. Schema changes are managed by Alembic — run alembic upgrade head after every pull that touches app/models.py or alembic/versions/. (Tables are no longer created automatically on startup.)
cd frontend
npm install
cp .env.example .env.local # NEXT_PUBLIC_API_URL defaults to http://localhost:8000
npm run devThe app serves on http://localhost:3000. Register a new account there to get started — there's no seed data.
| Variable | Purpose | Required? |
|---|---|---|
DATABASE_URL |
SQLAlchemy connection string | No — defaults to local SQLite |
JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_MINUTES |
Auth token signing | No — has dev defaults, change JWT_SECRET before any real deployment |
ANTHROPIC_API_KEY / OPENAI_API_KEY |
Real LLM for scripts, email personalization, GTM plans, reply classification | No — falls back to MockLLM |
SMTP_HOST/PORT/USER/PASS, IMAP_HOST/USER/PASS |
Real email sending + reply polling | No — falls back to MockEmail (simulated sends) |
STORAGE_DIR |
Local asset storage path | No — defaults to ./storage |
AWS_ENDPOINT_URL_S3, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, BUCKET_NAME |
Tigris/S3-compatible object storage | No — falls back to local disk |
CORS_ORIGINS |
Allowed origins, comma-separated | No — defaults to http://localhost:3000 |
FRONTEND_URL |
Used to build Stripe checkout redirect URLs | No — defaults to http://localhost:3000 |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID_STARTER, STRIPE_PRICE_ID_AGENCY |
Real billing via Stripe Checkout + webhook | No — falls back to MockBilling (checkout instantly upgrades the org, no real payment) |
SENTRY_DSN |
Error tracking | No — Sentry only initializes when set |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, OAUTH_REDIRECT_BASE |
YouTube OAuth (connect account, resumable upload, token refresh) | No — without these, "Connect YouTube" surfaces a clear "not configured" error and content falls back to the publish-kit flow |
PORTAL_JWT_EXPIRE_MINUTES |
Client-portal session length after a magic-link exchange | No — defaults to 24 hours |
| Variable | Purpose | Required? |
|---|---|---|
NEXT_PUBLIC_API_URL |
Base URL of the backend API | No — defaults to http://localhost:8000 |
Every external dependency sits behind a provider interface with a deterministic mock fallback, so the entire app — content generation, video assembly, email outreach, GTM plans — works fully offline with no keys configured:
- LLM: no
ANTHROPIC_API_KEY/OPENAI_API_KEY→MockLLMreturns deterministic templated scripts, email copy, GTM plans, and reply classifications. - TTS: no network/
gTTS→MockTTSwrites a silent WAV of the correct duration. - Video: no
moviepy/ffmpeg →MockAssemblerwrites a placeholder.mp4. - Email: no SMTP credentials →
MockEmailrecords sends in the database with statussimulated(nothing actually goes out; reply polling is disabled). - Publishing: the
manualpublisher is always available — you post the content yourself and paste the live URL back in in the UI. OAuth publishers (YouTube/TikTok/Meta) are stubs that raise a clear "not configured" error until built out. - Storage: no S3/Tigris credentials → local disk under
backend/storage/.
Check what's mocked vs. live at any time via GET /health/providers, surfaced on the /settings page.
Nothing publishes automatically — every content item requires explicit approval (ScheduleConfig.require_approval, default true) before the scheduler will touch it.
See ARCHITECTURE.md for the full spec: data models, API contract, provider pattern, GTM strategy engine, and scheduler behavior.
High-level shape:
backend/app/routers/— one router per resource (auth, orgs, workspaces, billing, assets, content, schedule-config, campaigns, gtm, health, portal, public, social).backend/app/services/— query/mutation logic behind the routers (routers stay thin: parse → call service → return schema). Stage 2 addsbranding_service.py(the one merge helper),report_service.py(the one aggregate, three surfaces),deliverability_service.py(suppression/ramp/domain checks), andsocial_service.py(YouTube OAuth flow).backend/app/tenancy.py—CurrentOrg/CurrentWorkspacerequest-scoping dependencies used by every domain router;CurrentPortalClientis the equivalent for portal-scoped JWTs, kept fully separate so app and portal auth can never cross.get_workspace_for_app_or_portalcovers the handful of endpoints (asset file serving) rendered as plain<img>/<video>src that need to accept either token type via a?token=query-param fallback.backend/app/providers/— swappable provider implementations (LLM, TTS, video assembly, email, billing, publishing) with mock fallbacks.providers/publisher.pyholds the manual/YouTube/TikTok/Meta publishers behind one interface.backend/app/gtm/— static playbook library + engine that tailors playbooks into a plan via the LLM provider.backend/app/scheduler.py— APScheduler jobs: publish approved+due content every minute (falls back to the publish-kit flag when no publisher is connected), poll IMAP for replies + bounces every 5 minutes (only when configured).backend/alembic/— schema migrations;alembic upgrade headis required before first run (see Quickstart). Three revisions so far:0001_initial_schema(Stage 1 tenancy),0002_stage2(branding, client portal, deliverability), and0003_stage3(portfolio metrics, audience graph, GTM-audit lead magnet, self-serve hardening).frontend/lib/api.ts— single HTTP client module the authed app goes through (attaches JWT +X-Workspace-Id);frontend/lib/portal-api.tsis the equivalent for the client portal (separate token store, never mixed with the app token);frontend/lib/types.tsmirrors the backend models/schemas for both.
Nothing is deployed by default — see deploy.md for the scaffolding to ship the backend to Fly.io (+ Fly Postgres, Tigris object storage) and the frontend to Vercel.
- TikTok/Meta OAuth publishers — YouTube is the only platform with a real OAuth publisher so far; TikTok and Meta still raise a clear "not configured" error and fall back to the publish-kit flow (
SocialAccount.oauth_token/refresh_tokenfields already support them). - Tigris/S3 asset lifecycle — automatic expiry/deletion of temporary generated assets once a
ContentItemis published and only the platform URL is needed (Asset.expires_atis modeled but not yet enforced by a cleanup job). - Real Stripe billing in production — mock billing is the default; wire up live
STRIPE_SECRET_KEY/webhook secrets perdeploy.mdwhen ready to charge real customers. - Reply-poll hardening — the IMAP reply poller does best-effort matching by sender email and subject/from bounce heuristics; needs threading (In-Reply-To/References), retry/backoff, and dedup before it's production-grade.
- Team members — orgs are still single-owner; member invites/roles beyond
ownerare modeled (Membership.role) but there's no UI or endpoint to manage them yet. - PDF export —
report.htmlis designed to be printed to PDF from the browser; a server-side PDF renderer is deliberately out of scope for now.
Stage 3 layers portfolio-level intelligence and self-serve growth on top of Stages 1-2. Full contract: STAGE3_SPEC.md.
- Org metrics —
GET /orgs/metricsrolls up weekly content/outreach/approval stats per workspace plus org-wide totals (app/services/metrics_service.py), with week-over-week deltas. Powers the dashboard's portfolio view across every workspace in an org at once. - Audience management + cross-promo —
AudienceContactis an org-wide contact graph (GET /audience,POST /audience/importfor CSV upload,GET /audience/export.csvfor download) fed by every workspace's outreach activity.POST /crosspromodrafts a content item + campaign to introduce one workspace's audience to another workspace in the same org — never sends anything automatically, always lands in the review queue first. - GTM audit tool — a public, unauthenticated lead magnet at
POST /public/gtm-audit(rate-limited 3/day/IP) that generates a 90-day GTM plan from a product URL + category + channels, and returns a shareable link (GET /public/gtm-audit/{token}) rendering a self-contained HTML report — no login required to view. Submitting the audit form also captures aLeadrecord that converts into a real org on signup. - Onboarding templates — new users hit a first-run wizard (
frontend/app/onboarding) that seeds a workspace from one of three templates (saas_product,client_smb,newsletter, defined inbackend/app/gtm/templates.py) — each seeds a posting cadence, three starter content prompts, and a campaign skeleton in onePOST /workspaces {template}call. - Self-serve auth —
GET /auth/verify-email?token=confirms the signed link sent at registration; unverified orgs are capped at free-plan limits regardless of their actual billing plan (app/plans.py effective_plan_key) as an anti-abuse guard.POST /auth/forgot-password/POST /auth/reset-passwordprovide a full self-serve password reset flow, non-leaking on unknown emails.
| Doc | Covers |
|---|---|
ARCHITECTURE.md |
Full technical spec — data models, API contract, provider pattern, GTM strategy engine, scheduler behavior |
PRODUCT_ROADMAP.md |
Product-level roadmap and stage sequencing |
STAGE1_SPEC.md |
Multi-workspace tenancy model (Organization → Workspace) |
STAGE2_SPEC.md |
White-label branding, client approval portal, deliverability guardrails |
STAGE3_SPEC.md |
Portfolio metrics, audience graph/cross-promo, GTM audit lead magnet, self-serve hardening |
deploy.md |
Fly.io + Vercel deployment scaffolding, plus a pre-launch Production Checklist |
Beyond the Roadmap items above, these are explicitly out of scope for the current build and worth flagging before relying on them:
- No resend-verify-email endpoint — if a user misses/loses the original signup verification email, there is currently no
POST /auth/resend-verify-email(or equivalent) to trigger a fresh one; the only way to re-trigger it today is viaPOST /auth/forgot-passwordif the account also needs a password reset, or a manual DB/admin action. - No public JSON endpoint for shared audit plans —
GET /public/gtm-audit/{token}only renders HTML; there is noAccept: application/json-aware or separate JSON variant, so nothing (e.g. a future embeddable widget) can consume the shared audit plan as structured data without scraping the HTML. - TikTok/Meta OAuth deferred — see Roadmap; only YouTube has a working OAuth publisher today.
- Custom domains deferred — client portal / branded reports use the app's own domain; there's no support yet for agencies to point a vanity/custom domain at their branded surfaces.
- Stray/scratch artifacts to remove before
git init— the working tree currently contains local run artifacts from development/testing that should not be committed (most are already covered by.gitignore, but confirm they're gone before the first commit rather than relying solely on the ignore rules):backend/smoke_test.dbbackend/smoke_test.db-journalbackend/verify.dbbackend/verify.db-journalbackend/test_write.txtbackend/storage/(local dev asset scratch dir — empty)backend/storage_smoke_test/backend/venv/backend/**/__pycache__/(several, underapp/andalembic/)frontend/testfile.txtfrontend/tsconfig.tsbuildinfofrontend/.next/frontend/node_modules/