Marketing Studio blueprint
internal prototype · canonical JSON + Dreamborn Forge HTML
internal generated
blueprint · supabase_json

Marketing Studio blueprint

blueprint artifact · for Marketing Studio · status approved

Planning Surface

Use this to decide what happens next.

Status

approved

Agent Handoff
Start Here

blueprint artifact · for Marketing Studio · status approved

Completion Evidence

No explicit evidence field yet. Require tests, screenshots, linked PRs, or reviewed outputs before marking complete.

Artifact Shape
  • api: endpoints: object
  • stack: backend: string, hosting: string, database: string, frontend: string
  • schema: tables: object
  • services: env_vars: object
Structured Payload

Machine-readable source fields

api
endpoints
authpathmethoddescription
Supabase anon key (VITE_SUPABASE_ANON_KEY)/rest/v1/studio_artifacts?project_id=eq.{marketing_project_id}&artifact_type=eq.campaign&order=created_at.desc&limit=1GETCampaign view (MarketingHome.vue): fetch latest CAMPAIGN.json artifact. Response content.content shape: {week_of: 'YYYY-MM-DD', theme: 'string', angles: [{id, title, channel, hook}], piece_slots: [{piece_id, angle_id, channel, scheduled_date, status}]}. Rendered as campaign card with theme headline, angle breakdown list, and piece slot grid with status badges.
Supabase anon key/rest/v1/studio_artifacts?id=eq.{artifact_id}PATCHCampaign view: Justin approves weekly campaign. Body: {status: 'approved', approved_by: 'justin', approved_at: '<iso>'}. Nova polls artifact status before beginning piece production for the week — Nova does not write pieces for an unapproved campaign.
Supabase service role key (SUPABASE_SERVICE_ROLE_KEY — VPS server-side only)/rest/v1/studio_artifacts?on_conflict=project_id,artifact_typePOSTNova agent (backend): upsert CAMPAIGN.json after weekly planning cycle. artifact_type='campaign', status='draft'. Content JSON: {week_of, theme, angles: [{id, title, channel, hook}], piece_slots: [{piece_id, angle_id, channel, scheduled_date, status: 'planned'}]}. Headers include Prefer: resolution=merge-duplicates. Nova continues writing weekly_plan.md to Drive in parallel until Campaign view is verified and Nova persona formally switched.
Supabase anon key/rest/v1/content_assets?piece_id=eq.{piece_id}&is_selected=eq.true&select=id,piece_id,channel,body,topic,angle,hook,theme,status,scheduled_date,justin_editGETPiece Editor (PieceEditor.vue): fetch selected content version for a piece. body field loaded into Tiptap editor on mount. justin_edit reflects prior approval path if piece was previously approved. Called on view mount; Supabase Realtime subscription listens for subsequent status changes.
Supabase anon key/rest/v1/content_assets?id=eq.{asset_id}PATCHPiece Editor approval — two exclusive paths. (A) Edit+Approve primary CTA: body {body: '<tiptap html output>', justin_edit: true, status: 'staged', staged_at: '<iso>'}. (B) Approve-as-is secondary action (muted styling, labelled 'Approve as written'): body {justin_edit: false, status: 'staged', staged_at: '<iso>'}. Both paths trigger Ivy via Supabase Realtime subscription on content_assets filtering on status='staged'.
Supabase anon key/rest/v1/publish_queue?client_id=eq.bezeliq&scheduled_at=gte.{week_start}&scheduled_at=lte.{week_end}&select=id,piece_id,channel,status,scheduled_at,published_at,publish_url,error_message,retry_count&order=scheduled_at.ascGETCalendar view (MarketingCalendar.vue): fetch publish jobs for a date range. Frontend groups by day then by channel. Status indicators: pending=gray dot, in_progress=amber spinner, succeeded=green check, failed=red dot with error_message tooltip on hover, retrying=amber-pulse, manual=blue dot (substack only — user clicks through to staged draft).
Supabase service role key (VPS server-side)/rest/v1/publish_queuePOSTIvy agent (backend): insert publish_queue rows when a piece's content_assets status transitions to 'staged' via Realtime trigger. One row per channel for the piece: linkedin, x, blog, youtube with status='pending'; substack with status='manual' (no API call, staged draft). scheduled_at derived from content_assets.scheduled_date + 9:00 AM ET. asset_id = staged content_assets.id.
Supabase service role key (VPS server-side)/rest/v1/publish_queue?id=eq.{queue_id}PATCHIvy agent (backend): update publish_queue row after each publish attempt. Success: {status: 'succeeded', published_at: '<iso>', publish_url: '<url>', updated_at: '<iso>'}. Retry (retry_count < 3): {status: 'retrying', error_message: '<text>', retry_count: N, attempted_at: '<iso>', updated_at: '<iso>'}. Permanent failure (retry_count = 3): {status: 'failed', error_message: '<final error>', retry_count: 3, updated_at: '<iso>'}.
Authorization: Key {FAL_API_KEY} headerhttps://fal.run/fal-ai/flux/schnellPOSTRosa agent (backend) via scripts/lib/falai.js: generate one image variant. Body: {prompt: '<style-grounded prompt built from topic + hook + style type>', image_size: 'landscape_16_9', num_inference_steps: 4}. Called 6x per piece (2 variants per style approach). Style rule — derived from channel + angle, not open judgment: photorealistic for product/lifestyle pieces, illustrated for concept pieces, typographic for quotes/stats, data-viz for metrics. Idempotency guard: if 6 graphic rows with status >= 'staging' already exist for piece_id in content_assets, skip all generation calls.
X-API-KEY: {UNIPILE_API_KEY} header (existing Unipile auth pattern from scripts/lib/unipile.js){UNIPILE_API_URL}/posts/POSTIvy agent (backend) via new createPost() in scripts/lib/unipile.js: publish LinkedIn piece. Body: {account_id: UNIPILE_ACCOUNT_ID, text: '<content_assets.body>', media_ids: ['<selected_graphic_content_asset_r2_url>'] (optional, included if Rosa produced a selected image)}. Returns LinkedIn post ID on success; Ivy writes publish_url to publish_queue. createPost() uses existing _post() helper and X-API-KEY auth — no new OAuth or account registration required.
OAuth 1.0a — X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECREThttps://api.twitter.com/2/tweetsPOSTIvy agent (backend) via scripts/lib/x-publisher.js: publish X piece. Body: {text: '<content_assets.body truncated to 280 chars>'}. OAuth 1.0a signed with X_API_KEY + X_API_SECRET + X_ACCESS_TOKEN + X_ACCESS_TOKEN_SECRET. Retry on 429: exponential backoff 15min/30min/60min (max 3 retries), publish_queue.status='retrying'. On 403: status='failed', error_message='X API tier insufficient — Basic tier required', no retry. PRE-SHIP GATE: Justin must confirm X API tier is Basic ($100/month) or above before V1 deploy — Free tier cannot POST /2/tweets. [OPEN QUESTION: X API tier not yet confirmed]
Authorization: Bearer {BLOG_REPO_GITHUB_TOKEN}https://api.github.com/repos/{BLOG_REPO_OWNER}/{BLOG_REPO_NAME}/contents/{BLOG_POSTS_PATH}/{slug}.mdPUTIvy agent (backend) via scripts/lib/blog-publisher.js — blog publish step 1 of 2: commit blog post as .md file to dreamborn.ai Eleventy repo via GitHub Contents API. File content includes Eleventy frontmatter (title, date, layout, description). Body: {message: 'publish: {title}', content: '<base64-encoded markdown with frontmatter>', branch: 'main'}. Must succeed before deploy hook is called. Failure sets publish_queue.status='failed'.
None — secret embedded in hook URL stored as env var (BLOG_DEPLOY_HOOK_URL), never hardcoded in source{BLOG_DEPLOY_HOOK_URL}POSTIvy agent (backend) via scripts/lib/blog-publisher.js — blog publish step 2 of 2: trigger Cloudflare Pages rebuild of dreamborn.ai after .md commit succeeds. No request body. Must return HTTP 200 — any non-200 response sets publish_queue.status='failed'. PRE-SHIP GATE: BLOG_DEPLOY_HOOK_URL must be set in env and confirmed returning 200 on a test POST before V1 ships — a missing or broken URL causes silent blog publish failure.
stack
backend

Node.js agent scripts on VPS (/opt/redk3y/scripts/). Three net-new library files: (1) scripts/lib/falai.js — Fal.ai FLUX.1-schnell client; exports generateImage(prompt, styleType) returning imageUrl; POSTs to https://fal.run/fal-ai/flux/schnell with Authorization: Key {FAL_API_KEY} header; body: {prompt, image_size: 'landscape_16_9', num_inference_steps: 4}; style-per-type mapping is rule-based (not open judgment): photorealistic=product/lifestyle, illustrated=concepts, typographic=quotes/stats, data-viz=metrics; mapping derived from content_assets.channel + content_assets.angle pattern; idempotency guard: skip all 6 calls if 6 graphic rows with status >= 'staging' already exist for piece_id in content_assets. (2) scripts/lib/x-publisher.js — X API v2 client; exports postTweet(text); OAuth 1.0a signed using X_API_KEY + X_API_SECRET + X_ACCESS_TOKEN + X_ACCESS_TOKEN_SECRET; 429 response triggers exponential backoff: 15min, 30min, 60min (max 3 retries), publish_queue.status='retrying'; 403 response sets status='failed', error_message='X API tier insufficient — Basic tier required', no retry. (3) scripts/lib/blog-publisher.js — GitHub Contents API + Cloudflare Pages deploy hook; expor

hosting

Cloudflare Pages (existing — studio.bezeliq.ai). No new hosting. VPS (existing — /opt/redk3y, daemons under ccos user). No new servers or containers. dreamborn.ai blog auto-publish uses the Cloudflare Pages deploy hook for that separate repo — BLOG_DEPLOY_HOOK_URL must be confirmed set and returning HTTP 200 on test POST before V1 ships.

database

Supabase (existing bezeliq project). Migration sequence — run in order, do not parallelize: (1) Additive columns on content_assets: ALTER TABLE content_assets ADD COLUMN IF NOT EXISTS body text, ADD COLUMN IF NOT EXISTS justin_edit boolean NOT NULL DEFAULT false. Run before UI build. No existing agent reads these columns — confirmed from Nova, Harper, Jade, Rosa, Ivy persona inspection. Rollback: DROP COLUMN on both (no data at risk). (2) CREATE TABLE publish_queue (see schema section below). (3) Enable Supabase Realtime on content_assets and publish_queue tables — Piece Editor and Calendar receive live status updates without polling. studio_artifacts table requires no DDL: artifact_type values 'campaign' (Nova's weekly plan, one row per week per project_id) and 'content' (individual piece body, one row per piece_id) are data-layer additions to existing schema.

frontend

Vue 3 SPA (apps/bezeliq-studio, deployed to studio.bezeliq.ai via Cloudflare Pages). Three net-new route-level views follow the DevStudioHome.vue pattern: (1) MarketingHome.vue at /marketing — Campaign view; reads latest studio_artifacts row with artifact_type='campaign' ordered by created_at desc; displays week_of, theme, angles list with channel tags, piece slots with status badges (planned/draft/staged/published); Approve Campaign CTA PATCHes artifact to status='approved'. Piece slot cards deep-link to Piece Editor. (2) PieceEditor.vue at /marketing/piece/:pieceId — Voice editing view; fetches content_assets where piece_id matches and is_selected=true; renders body field in @tiptap/vue-3 + @tiptap/starter-kit Tiptap editor (paragraph/bold/italic only — minimal toolbar); saves to content_assets.body on blur. Two approval CTAs: Edit+Approve (primary, full-size button) sets body + justin_edit=true + status='staged' + staged_at; Approve-as-is (secondary, muted styling, explicit label 'Approve as written') sets justin_edit=false + status='staged' + staged_at. Both paths trigger Ivy via Supabase Realtime subscription on content_assets. Image pane shows Rosa's generated variants; Justi

schema
tables
namecolumns
content_assets- id: name: string, type: string, constraints: string - piece_id: name: string, type: string, constraints: string - version: name: string, type: string, constraints: string - is_selected: name: string, type: string, constraints: string - client_id: name: string, type: string, constraints: string - scheduled_date: name: string, type: string, constraints: string - channel: name: string, type: string, constraints: string - topic: name: string, type: string, constraints: string - angle: name: string, type: string, constraints: string - hook: name: string, type: string, constraints: string - theme:
publish_queue- id: name: string, type: string, constraints: string - piece_id: name: string, type: string, constraints: string - asset_id: name: string, type: string, constraints: string - channel: name: string, type: string, constraints: string - client_id: name: string, type: string, constraints: string - status: name: string, type: string, constraints: string - scheduled_at: name: string, type: string, constraints: string - attempted_at: name: string, type: string, constraints: string - published_at: name: string, type: string, constraints: string - publish_url: name: string, type: string, constraints:
services
env vars
namerequireddescription
FAL_API_KEYtrueFal.ai API key for FLUX.1-schnell image generation. Used in scripts/lib/falai.js as Authorization: Key header. Rosa calls 6 variants/piece × ~14 pieces/week at ~$0.003/image = ~$0.25/week expected cost. Add $5/week spend alert on Fal.ai account dashboard as cost overrun guard.
X_API_KEYtrueX/Twitter OAuth 1.0a consumer key for X API v2. Used in scripts/lib/x-publisher.js. PRE-SHIP GATE: requires Basic tier ($100/month) minimum — Free tier cannot POST /2/tweets. Confirm Justin's current X API tier before V1 deploy. [OPEN QUESTION: tier not yet confirmed]
X_API_SECRETtrueX/Twitter OAuth 1.0a consumer secret. Paired with X_API_KEY. Generated in X developer portal under Justin's developer account.
X_ACCESS_TOKENtrueX/Twitter user-level OAuth 1.0a access token for Justin's personal X account. Authorizes posting to Justin's X profile. Generated via X developer portal.
X_ACCESS_TOKEN_SECRETtrueX/Twitter user-level OAuth 1.0a access token secret. Paired with X_ACCESS_TOKEN for request signing in scripts/lib/x-publisher.js.
BLOG_REPO_GITHUB_TOKENtrueGitHub personal access token with 'repo' write scope. Used in scripts/lib/blog-publisher.js by Ivy to commit .md files to the dreamborn.ai Eleventy repo via GitHub Contents API.
BLOG_REPO_OWNERtrueGitHub username or org owning the dreamborn.ai Eleventy repo (e.g. 'justindressel'). Used in GitHub Contents API URL construction in scripts/lib/blog-publisher.js.
BLOG_REPO_NAMEtrueGitHub repository name for the dreamborn.ai Eleventy site (e.g. 'dreamborn-ai'). Used in GitHub Contents API URL construction in scripts/lib/blog-publisher.js.
BLOG_POSTS_PATHtrueRepo-relative directory path where Eleventy blog post .md files live (e.g. 'src/posts'). Ivy constructs the full commit path as BLOG_POSTS_PATH/{slug}.md for the GitHub Contents API PUT.
BLOG_DEPLOY_HOOK_URLtrueCloudflare Pages deploy hook URL for dreamborn.ai. Ivy POSTs here (no body) after a successful blog .md commit to trigger a site rebuild. PRE-SHIP GATE: must be set in env and confirmed returning HTTP 200 on a test POST before V1 ships — missing or broken URL causes silent blog publish failure with no user-facing error.
UNIPILE_API_URLtrueUnipile API base URL (e.g. https://api2.unipile.com:13465/api/v1). Already configured in scripts/lib/unipile.js as BASE constant. No change required — createPost() uses the same base URL.
UNIPILE_ACCOUNT_IDtrueJustin's LinkedIn account ID in Unipile. Used by new createPost() export and all existing Unipile functions (sendConnectionRequest, sendDm, getConnectionUpdates). Already configured. No change.
UNIPILE_API_KEYtrueUnipile API key sent as X-API-KEY header. Already configured in scripts/lib/unipile.js as KEY constant via _headers() helper. No change — createPost() uses the same _post() helper which calls _headers().
VITE_SUPABASE_URLtrueSupabase project URL for Studio frontend (apps/bezeliq-studio). Already configured. No change — new Marketing Studio views use the same Supabase client instance.
VITE_SUPABASE_ANON_KEYtrueSupabase anon/public key for Studio frontend client. Already configured. No change — all Marketing Studio Supabase reads/writes from the frontend use this key.
SUPABASE_SERVICE_ROLE_KEYtrueSupabase service role key for server-side agent operations (bypasses RLS). Used by Nova, Rosa, and Ivy on VPS when writing to content_assets, studio_artifacts, and publish_queue. Already configured in VPS agent env. No change.