Agentic CMS that turns a free-text brief into luxury-grade product imagery or video, then routes the artefacts through an approval + project-review workflow.
Pipeline: Brief β OpenAI Intent β OpenAI Planner (registry-constrained) β OpenRouter execution (Vercel AI Gateway image fallback) β asset uploaded to Supabase Storage β approval workflow β projects β reviewer sign-off. Deployed end-to-end on Render as two services from one render.yaml Blueprint.
| Layer | Tech |
|---|---|
| Backend | Python 3.11+, FastAPI, OpenAI SDK, httpx (OpenRouter + Vercel AI Gateway over OpenAI-compatible REST), Pydantic v2 β runs as a Docker web service on Render (or any Docker host) |
| Database | Supabase Postgres (via PostgREST + direct pooler for migrations) |
| Asset storage | Supabase Storage (public bucket) |
| Frontend | Next.js 15 (app router), Tailwind, TypeScript, framer-motion β Render (Node web service) |
The generation pipeline is a streaming state machine: POST /generate is a Server-Sent Events endpoint that yields a full JobStatus JSON after every transition (queued β intent β brand_context β planning β awaiting_confirm | executing β done | error). The frontend renders each frame as it arrives, so the chat journey fills in step by step in real time instead of waiting 30β50s for a single response. Video jobs pause at awaiting_confirm for a cost-gate confirmation, then GET /jobs/{id} polling drives the provider's queue to completion. No background workers, no Redis, no long-lived sockets β just streamed HTTP and short polls, both of which work cleanly behind Render's edge.
Provider routing: Each registry entry has a primary slot (OpenRouter) and a fallback slot (Vercel AI Gateway, for images only β the gateway's video API is AI-SDK-only and not addressable over plain REST). The executor in backend/provider_executor.py transparently retries the fallback on any primary error.
# 1. backend
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
cp .env.example .env # fill in the secrets below
python -m backend.migrate # one-time: apply CMS schema to Supabase
uvicorn backend.main:app --reload --port 8000
# 2. frontend (new terminal)
cd frontend
cp .env.example .env.local # set BACKEND_URL=http://localhost:8000
npm install
npm run dev # http://localhost:3000| Key | Where | Notes |
|---|---|---|
OPENAI_API_KEY |
backend | gpt-4.1 (intent + planner), gpt-4o (vision) |
OPENROUTER_API_KEY |
backend | primary image + video provider (sk-or-...) |
AI_GATEWAY_API_KEY |
backend | fallback for images. Optional in dev β without it, an OpenRouter error surfaces directly to the user instead of falling back |
AUTH_USERNAME / AUTH_PASSWORD |
backend | single admin login; defaults to admin / 1601admin |
AUTH_SESSION_SECRET |
backend | signs the session cookie. Generate one with python -c "import secrets; print(secrets.token_urlsafe(48))"; without it sessions reset on every restart |
AUTH_COOKIE_HTTPS_ONLY |
backend (prod) | set to 1 behind HTTPS so the session cookie is Secure |
SUPABASE_URL |
backend | https://<ref>.supabase.co |
SUPABASE_SERVICE_ROLE_KEY |
backend | service-role JWT, never expose to browser |
SUPABASE_ASSETS_BUCKET |
backend | public bucket name (e.g. luxury) |
DATABASE_URL |
backend (migrations only) | Supabase session-pooler URL; percent-encode @ in password as %40 |
FRONTEND_ORIGINS |
backend (prod) | comma-separated allowed origins for CORS |
BACKEND_URL |
frontend | server-only; used by the proxy route |
Upstash Redis is no longer required. The confirm-gate that used to block on Redis is now a direct DB state transition driven by the client (POST /jobs/{id}/confirm).
The planner picks model_id from the primary slot of each entry in backend/registry.py β it cannot invent IDs. Fallbacks are executor-internal and hidden from the planner prompt.
| Job | OpenRouter primary | Vercel AI Gateway fallback | Why |
|---|---|---|---|
| Hero still (no ref) | black-forest-labs/flux.2-max |
bfl/flux-2-max |
Most photoreal Flux on both gateways |
| Hero still (with product ref) | google/gemini-3-pro-image-preview (Nano Banana Pro) |
google/gemini-3-pro-image |
Preserves product identity for catalog |
| Branded composition | openai/gpt-5.4-image-2 |
recraft/recraft-v4.1-pro |
Real typography + composition control (gateway fallback restores Recraft parity) |
| Image β video | kwaivgi/kling-v3.0-pro |
bytedance/seedance-2.0 (OpenRouter, same provider) |
Best fabric/metal/liquid motion |
| Text β video | google/veo-3.1-fast |
openai/sora-2-pro (OpenRouter, same provider) |
Native audio + strongest narrative |
Video has no cross-provider fallback β Vercel AI Gateway only exposes video through its AI SDK v6 (Node.js); there's no REST endpoint for video that we can call from Python. If OpenRouter video fails, the job ends in status=error with a clear message rather than hanging. Image fallback to the gateway still works because images go through /v1/images/generations (REST).
brief β generation β done β β Approve β Approved sheet β bundle into Project β assign β reviewer approves
Five personas (Souvik, Sara, Marco, LΓ©a, Devon) are seeded on first boot; the active persona is stored in app_state.current_user and switched via the topbar avatar.
Generation
POST /generateβ multipart form (brief, optionalproduct_image,brand_guidelines,mood_board[])GET /jobs/{id}/GET /jobs?limit=NPOST /jobs/{id}/confirmβ required before video jobs run (cost guard)
Catalogues (filtered view over jobs)
GET /catalogues?status=approved|pending|rejected|allPOST /catalogues/{id}/approve|reject|unapprove
Projects
POST /projects(empty or withcatalogue_ids[])GET /projects/GET /projects/{id}PATCH /projects/{id}β name, description, assignee, due_datePOST /projects/{id}/catalogues(bulk add) /DELETE /projects/{id}/catalogues/{cid}POST /projects/{id}/assign|approve|reject|reopen|commentsDELETE /projects/{id}
Users
GET /users/GET /users/me/POST /users/me
System
GET /health/GET /registry/GET /assets/{filename}(local-fallback)
Both halves of the app deploy as two services from a single render.yaml Blueprint: the FastAPI backend on Docker, the Next.js frontend on Node. The frontend's BACKEND_URL is auto-wired to the backend service's hostname via Render's fromService syntax, so there's no manual copy-paste between deploys.
- Create a project, copy
Project URL+service_roleJWT (Settings β API). - Create a public Storage bucket (e.g.
luxury). - Note the session pooler URL from Settings β Database β "Session pooler (IPv4 compatible)". Percent-encode any
@in the password as%40. This isDATABASE_URLβ used only by the migration runner.
- Push the repo to GitHub.
- Render β New β Blueprint β connect the repo. Render reads
render.yamland provisions both services. - When prompted, paste every
sync: falsesecret listed inrender.yaml:OPENAI_API_KEYOPENROUTER_API_KEY(primary image + video provider)AI_GATEWAY_API_KEY(image fallback only; optional but recommended)SUPABASE_URLSUPABASE_SERVICE_ROLE_KEYSUPABASE_ASSETS_BUCKETDATABASE_URL(migration runner only)FRONTEND_ORIGINSβ leave blank for first deploy; set after step 3 once Render hands you the frontend URL.
- Render builds Dockerfile for the backend, runs
python -m backend.migrateas the pre-deploy step (idempotent βschema_migrationstable tracks applied files), then starts uvicorn on$PORT. It separately runsnpm run build+npm startfor the frontend out of frontend/.
After the first deploy, copy the frontend's Render URL (e.g. https://1ai-x-cms-web.onrender.com) and set it as FRONTEND_ORIGINS on the backend service. Trigger one redeploy to pick it up.
curl https://<backend>.onrender.com/health
# { "ok": true, "db": "supabase", "asset_storage": "supabase" }
curl https://<frontend>.onrender.com/api/proxy/health
# same β confirms the Next.js proxy can reach the backend
curl https://<frontend>.onrender.com/api/proxy/users | jq length # β 5Open the frontend URL, submit a brief, watch the streamed pipeline fill in step by step β intent β brand β plan β result β with each frame arriving over Server-Sent Events.
The streaming /generate endpoint and the FAL-queue-style video polling both run longer than typical serverless function caps. Render's Web Service has no per-request cap, which keeps the architecture simple β no background queue, no SSE-disconnect-then-reconnect dance. If you ever want to deploy elsewhere, the same Dockerfile runs on Fly, Cloud Run, ECS, Railway, etc. β point the frontend's BACKEND_URL at the new host and the proxy route handles the rest.
Real auth (Clerk / Supabase Auth), notifications, per-asset comments, asset versioning, multi-assignee review, project export.
.envis gitignored. Never commit it.- Service-role keys and OpenRouter / OpenAI / Vercel AI Gateway keys authenticate as your account β treat them like credit-card numbers. Rotate any key that's been shared in chat.
- The
service_roleJWT bypasses Supabase row-level security; that's why it stays server-side.