tellatribe

Enter the access code to read the project documents.

tellatribe Docs Status: All modules built and live · Oct 19: Creator Campaigns Part B shipped (briefs, applications, deals, verification → bounties, brand balance + Stripe funding, disputes, reports) on top of the tax-identity gate and annual statements · awaiting owner inputs: tellatribe Stripe account (payments + Connect + funding webhook), HealthyApp webhook secret, Ayrshare key

Build tracks · build-plan/02-collateral-publishing-teleprompter.md

Build Plan §02 — Collateral Vault: Take it · We post it · Record it

Scope: PRD §9.3 (collateral vault, three delivery modes) and the admin collateral screen in §10. Date: October 3, 2026. Platform facts below were checked against primary docs on this date; anything not confirmable is marked. Reads with: PRD.md (authoritative), PRODUCT.md (authoritative: affiliate is on a phone, in a mobile browser, no native app in v1). PRD-Activation-Platform.md §7.3 is used for ideas only.


0. The short version

  • Mode 1 (Take it) is pure product work with no external dependency. It ships first and alone, and it is where most affiliate activity will land for the first year regardless of what else exists. Build it to be excellent.
  • Mode 2 (We post it) is gated by platform approvals, not code. Every network worth posting to requires an app review (Meta, TikTok) or has structural limits (LinkedIn self-serve is fine; X is pay-per-use and marginal; YouTube is now cheap). Recommendation: go through an aggregator (Ayrshare Business plan) for v1, start Meta and TikTok reviews in parallel on our own app anyway, and migrate network-by-network only if volume or cost justifies it.
  • Mode 3 (Record it) is buildable as pure web on today's iOS Safari and Android Chrome, with one hard trap (in-app browsers block the camera) and one hard requirement (server-side remux/transcode of every recording). It is v1.5: it depends on Mode 2's publish step for the hand-off and on the upload/transcode pipeline, and it needs real-device QA that Mode 1 does not. A Capacitor wrapper is not needed to ship it; it becomes worth considering only if recording quality or in-app-browser arrivals turn out to matter more than expected.

1. Asset model

1.1 Entities

collateral_assets            the logical thing ("Launch carousel", "Why I use HealthyApp script")
  └── asset_versions         immutable; a new upload or edit = new version
        └── asset_renditions per-platform crops/encodes, generated from a version
scripts                      a kind of asset_version body (text), with read-time
asset_disclosures            which disclosure block attaches, and how (caption / spoken / on-screen)
asset_restrictions           program / tier gating
affiliate_asset_marks        per-affiliate: used / skipped / posted / seen, plus per-rendition download+copy events

1.2 Tables

collateral_assets

Column Type Notes
id uuid
product_id uuid → products Assets are per-product (PRD §9.3)
category enum email_swipe, social_caption, image, video, script, one_pager, deck, faq
title text
recommended_use text null "optional recommended-use note"
status enum draft, published, retired
retired_behavior enum null revoke_now, grace_until (+ retire_at) — borrowed from Activation PRD ENB-12
current_version_id uuid → asset_versions denormalised pointer
usage_count int maintained by trigger from affiliate_asset_marks
created_by, created_at, updated_at

asset_versions (immutable rows)

Column Type Notes
id uuid
asset_id uuid
version_no int monotonic per asset
body_text text null caption / email / script body, with tokens unresolved
source_storage_path text null original upload (Supabase Storage, private bucket)
source_mime, source_width, source_height, source_duration_ms, source_bytes from ffprobe on ingest
caption_text text null the caption that ships with an image/video/script (tokens unresolved)
disclosure_id uuid → disclosure_blocks required for published; cannot be null
disclosure_placement enum[] subset of caption_start, caption_end, spoken_in_script, on_screen_overlay
claims_checked_by, claims_checked_at admin attestation that the copy passed the "may/may not claim" page; required to publish
change_note text
created_at

A version that is referenced by any publish_jobs or recordings row is never deleted. Retiring an asset marks it retired; the versions remain so old posts and recordings resolve.

asset_renditions

Column Type Notes
id uuid
version_id uuid
platform enum generic, instagram, facebook, tiktok, linkedin, x, youtube
placement enum feed, story, reel, carousel_slide, cover, short
aspect text 1:1, 4:5, 9:16, 16:9, 1.91:1
width, height int
mime text image/jpeg for anything bound for Instagram (JPEG is the only image type the IG API accepts)
codec, bitrate_kbps, duration_ms video only
storage_path text
bytes int
status enum queued, ready, failed
generated_by enum auto, admin_upload (admin can override a crop)

scripts — not a separate table; a script is an asset_versions row whose asset category = 'script'. It carries three extra columns on asset_versions that are null for other kinds:

Column Notes
script_word_count computed on save, tokens counted at their resolved length using a sample affiliate
script_read_time_s ceil(word_count / (DEFAULT_WPM / 60)); DEFAULT_WPM = 150 (conversational on-camera pace; 130–160 is the normal spoken range)
script_target_s admin-entered intent (e.g. 45) so the editor can warn when read-time exceeds target

A script version always has a caption_text (the caption that goes out with the recorded video) and disclosure_placement must include spoken_in_script and on_screen_overlay (see §7, FTC video disclosure).

disclosure_blocks

Column Notes
id, product_id per-product because HealthyApp's health-claims language differs from WhoHire's
short_text ≤ 60 chars, for captions and on-screen overlay: e.g. #ad — I earn a commission if you sign up through my link.
spoken_text one sentence the affiliate reads aloud
long_text for email swipes / one-pagers
version_no, effective_from versioned; an asset version pins a disclosure version

asset_restrictions

Column Notes
asset_id
program_id null restrict to enrollees of a program
min_tier enum null
enforcement RLS policy on collateral_assets joins through affiliate_programs and a affiliate_performance view

affiliate_asset_marks

Column Notes
affiliate_id, asset_id PK
state enum none, used, skipped, posted — affiliate-settable; posted is also set automatically by a successful Mode 2 publish
first_seen_version_id drives the "new since your last visit" badge: badge shows when current_version_id ≠ first_seen_version_id or row absent
last_seen_at
notes text null

asset_events (append-only, high-write)

Column Notes
affiliate_id, asset_id, version_id, rendition_id null
kind enum view, copy_caption, copy_link, download, publish_submitted, publish_succeeded, record_started, record_completed
platform null, campaign_label null
created_at partition by month like clicks

asset_events is what the "collateral usage vs. revenue" report in PRD §10 is built from: join asset_events.campaign_label to clicks.campaign (every Mode 1 copy and every Mode 2 post stamps a campaign label of the form col-<asset_short_id>-<platform>), so revenue can be traced to an asset without the affiliate doing anything.

1.3 Token substitution rules

Tokens are resolved at read time, per viewing affiliate, never stored resolved. Supported set (PRD §9.3 plus two needed by Mode 2):

Token Resolves to Notes
{{ref_link}} https://<product-domain>/r/<CODE>?c=<campaign> campaign label auto-stamped (see above)
{{short_link}} https://go.tellatribe.com/<CODE>?c=<campaign> preferred in anything character-limited or spoken
{{affiliate_name}} display name
{{product_name}}
{{disclosure}} disclosure_blocks.short_text for the pinned version cannot be removed: if the admin omits the token from a caption, the renderer appends the disclosure per disclosure_placement anyway
{{spoken_disclosure}} disclosure_blocks.spoken_text scripts only; renderer refuses to publish a script version lacking it

Rules:

  1. Unknown tokens fail validation at admin save time; a version with an unknown token cannot be published.
  2. Resolution is a pure function (body, affiliate, product, campaign, disclosure) → string in packages/core, unit-tested, shared by portal, publish worker, and teleprompter.
  3. For spoken scripts, {{short_link}} is rendered as a speakable form: go dot affiliate dot coach slash J-WHIST with the code letter-grouped, and the printed caption carries the real URL. (Novices reading "https://" aloud is a real failure mode.)
  4. Per-platform link placement overrides apply after substitution (§3.5): on Instagram the caption's {{ref_link}} is replaced with "link in my bio" text and the link is pushed to the bio-link step.

1.4 Program/tier restriction evaluation

Evaluated in one RLS policy so the vault list query is a single select. The affiliate never sees an asset they are not eligible for; there is no "locked" teaser in v1 (a locked teaser is a decision for later, see §8).


2. Mode 1 — Take it

2.1 Behaviours (exact)

Surface Behaviour
Asset card (vault list) Thumbnail of generic rendition, title, category chip, "New" badge, usage count ("used by 14 affiliates"), the affiliate's own mark (used/skipped/posted). Tap → detail.
Detail: caption/email Rendered with tokens resolved. One Copy button. On tap: navigator.clipboard.writeText(resolved); button label becomes "Copied" for 2 s; writes asset_events(copy_caption) and sets mark to used if it was none. Disclosure is visibly part of the text, styled no differently (it must not look optional).
Detail: link only "Copy link" and "Copy short link" as separate buttons; both stamp the campaign label.
Detail: image Platform selector (Instagram / Facebook / TikTok / LinkedIn / X / Generic). Shows the rendition for that platform at correct aspect. Download triggers a signed-URL GET with Content-Disposition: attachment; filename="<product>-<asset>-<platform>.jpg". On iOS Safari this opens the share sheet / saves to Files; copy says "Save to Photos, then post." Writes download.
Detail: video Same, plus duration and size shown before download ("0:38 · 24 MB").
Detail: carousel One "Download all (zip)" plus per-slide download. Zip is pre-built per version at rendition time, not on demand.
Detail: email swipe Copy as plain text and Copy as HTML (two buttons; ClipboardItem with both text/plain and text/html).
Mark controls Three-state segmented control: Used / Skipped / Posted. Persisted immediately.
Post-copy nudge After the first copy on an asset, a one-line prompt: "Posted it? Mark as posted so you can find your best-performing assets later." Dismissible, shown once per asset.

Everything above works with zero third-party dependencies and zero platform approvals.

2.2 Rendition matrix

Generated automatically on admin upload (§5). Admin can override any cell by uploading a hand-cropped file.

Platform Placement Aspect Pixels Image Video constraints
Generic — source source source source, remuxed to MP4/H.264/AAC
Instagram Feed 4:5 1080×1350 JPEG only MP4 H.264, ≤ 60 s for feed video (feed video is published as a Reel by the API anyway)
Instagram Feed square 1:1 1080×1080 JPEG —
Instagram Reel 9:16 1080×1920 — MP4 H.264/HEVC, 5–90 s for Reels-tab eligibility; keep ≤ 90 s
Instagram Story 9:16 1080×1920 JPEG ≤ 60 s
Instagram Carousel slide 4:5 or 1:1 (uniform across slides — the API crops all slides to the first slide's aspect) 1080×1350 JPEG ≤ 10 slides
Facebook Feed 4:5 or 1.91:1 1080×1350 / 1200×628 JPEG/PNG MP4
Facebook Reel/Story 9:16 1080×1920 — MP4
TikTok Video 9:16 1080×1920 — MP4 H.264; TikTok photo posts also exist (JPEG/WebP)
LinkedIn Feed 1.91:1 or 1:1 1200×627 / 1080×1080 JPEG/PNG MP4
LinkedIn Vertical video 4:5 1080×1350 — MP4
X Feed 16:9 or 1:1 1600×900 / 1080×1080 JPEG/PNG MP4 H.264, ≤ 2:20
YouTube Short 9:16 1080×1920 — MP4, ≤ 3 min (Shorts)

Rule: every rendition's video is MP4, H.264 High, AAC, +faststart, ≤ 30 fps, because that intersection is accepted everywhere. We never deliver WebM to an affiliate.

This is the part Mode 1 has to be honest about, because the affiliate will blame the link when the real problem is platform mechanics.

Platform Reality (checked Oct 2026) What the vault does
Instagram URLs in captions and comments are not clickable for ordinary accounts; this has not changed. Clickable surfaces are the bio (up to 5 links), the Story link sticker, and DMs. Instagram caption renditions substitute {{ref_link}} with "Link in my bio 👆" and the detail screen shows a separate "Copy bio link" step with a one-line instruction. Story renditions keep the URL in the copy with "add this as a link sticker". Default campaign for bio copies is col-<asset>-igbio so bio traffic is attributable.
Facebook Links in post text are clickable and generate a preview card. {{ref_link}} stays inline.
TikTok Caption links are not clickable; bio link requires Business account or 1k followers (varies). Same treatment as Instagram; caption says "link in bio".
LinkedIn Clickable; LinkedIn's feed de-ranks posts with external links. Common practice is link in first comment. Caption copy includes the link inline by default; a toggle "put link in first comment instead" produces two copy blocks (post + comment).
X Clickable; shortened by t.co regardless. Inline; always {{short_link}} for character budget.
Email Clickable. Inline, full {{ref_link}}; HTML copy carries the link as an <a>.
YouTube Clickable in description. Description copy with link inline plus the disclosure.

2.4 Disclosure baked in

{{disclosure}} is forced into every caption at caption_start for Instagram/TikTok/X (short formats where a reader may not expand) and caption_end for Facebook/LinkedIn/email unless the admin pins otherwise. Images and videos destined for Instagram/TikTok/Reels carry an on-screen disclosure burn-in as a rendition option (admin toggle per asset, default on for anything that is itself an endorsement). See §7.


3. Mode 2 — We post it

3.1 Per-network findings (as of October 3, 2026)

Network What is postable via API Account type Review / approval required Limits Facebook Page needed? Verdict
Instagram Single image (JPEG only), carousel (≤ 10), Reels, Stories (video or image; no link/poll stickers), video (published as Reel) Professional (Business or Creator). One vendor claims Creator cannot publish Reels; Meta's own doc says "professional accounts" — treat Creator as supported, verify in sandbox. Yes. instagram_business_content_publish needs Advanced Access, which needs Meta Business Verification first (5–15 business days per cycle), then App Review (screencasts, privacy policy). Field reports: 2–4 weeks end-to-end, longer with a rejection. 100 API-published posts per account per rolling 24 h. Media must be at a public URL at publish time. No, if we use "Instagram API with Instagram Login" (graph.instagram.com). Yes if we use the Facebook-Login path. Use the Instagram-Login path. Viable; gated by review.
Facebook Pages Text, link, photo, video, Reels, Stories to a Page (never a personal profile) Page admin Yes. pages_manage_posts, pages_read_engagement, publish_video all need App Review + Business Verification. Standard Graph rate limits. — Viable for affiliates who have a Page; many novices don't. Lower priority than IG.
TikTok Video (Direct Post or upload-to-inbox as draft), photo posts Any Yes. Unaudited apps can only post private (SELF_ONLY) content and are limited to a handful of test users. Must pass the Content Posting audit (demo video of full UX, URL-ownership verification, compliance with their UX rules: user must see/choose privacy level from creator_info, must be able to toggle commercial-content disclosure). 2–4 weeks, often multiple rounds. 6 init requests/min per user token. Caption ≤ 2,200 chars. — Viable; gated by audit. Upload-to-inbox (draft) is the fallback: affiliate finishes in the TikTok app.
LinkedIn (personal profile) Text, article/link, image, video to the member's own profile via w_member_social Any member No review for "Share on LinkedIn" (self-serve product in the Developer Portal). Community Management API (for org pages, analytics, reactions) is vetted; we don't need it for v1. 150 requests/member/day, 100k/app/day. — Viable now. Only network we can ship on our own app with zero approval wait.
X Tweets with media Any No review, but the tiered plans are gone: pay-per-use since Feb 2026, Free/Basic/Pro retired for new sign-ups by Sept 2026. $0.015 per post, $0.20 per post containing a link; reads $0.005 each; Enterprise ≈ $42k/mo. — Marginal. Every affiliate post has a link → $0.20/post. At 2,000 posts/month that's $400 — tolerable, but the platform is unstable and the audience fit for a wellness affiliate is weak. Ship via aggregator pass-through only; do not build direct.
YouTube Shorts Video upload via videos.insert Any channel OAuth consent-screen verification for sensitive scopes (youtube.upload); typical Google verification, not a content audit. videos.insert dropped from 1,600 to ~100 quota units (Dec 2025); default 10,000 units/day → ~100 uploads/day/project before requesting more. — Viable and now cheap. Low priority: few wellness affiliates publish Shorts first. v1.5.

Two cross-cutting facts: (1) every Meta and TikTok approval requires a working UX to record a demo video, so Mode 2 cannot be approved before it is built; (2) all of them require the media to be fetchable at a public HTTPS URL at publish time — renditions are served from a short-lived signed URL (Supabase Storage) generated inside the publish job.

3.2 Build vs. aggregator

Options compared

Build direct (6 networks) Ayrshare (Business plan) Zernio (ex-Late) Post Bridge
Model Our app IDs, our reviews White-label connect page per user profile; one profile = one of our affiliates; all their networks under it Per connected social account Per connected social account
Price Engineering time + Meta/TikTok review cycles; X $0.20/link-post $599/mo for 30 profiles, then $8.99/profile (31–100), $3.49 (101–500), $2.49 (500+). Reviews already done by them. First 2 free, $6/acct (3–10), $3 (11–100), $1 (101+). X usage passed through. API included. $39/mo for 15 accounts; $99/mo for 99 then $1/acct. "Built for one person doing the marketing" — team/multi-user is thin.
Networks We choose 14+ incl. IG, FB, TikTok, LinkedIn, X, YouTube, Threads, Pinterest, Bluesky 15 incl. all we need 10 incl. all we need
Multi-tenant (our affiliates' accounts, not ours) Yes, natively Yes — JWT-signed white-label linking page, profile-scoped posting; built for exactly this Yes (API supports per-account), less mature SaaS-embedding story Weak; designed for one marketer's accounts
What you give up Nothing Their preview is their rendering, not ours (we must build our own preview and treat their result as the truth); vendor dependency; per-profile cost scales with affiliate count, not with posting Younger company; pricing just changed with a rename — treat as flight risk Not built for embedding

Cost model (Ayrshare Business), affiliate connected-count scenarios

Affiliates with ≥1 connected account Monthly
≤ 30 $599
100 $599 + 70 × $8.99 ≈ $1,228
300 $1,228 + 200 × $3.49 ≈ $1,926
1,000 $1,926 + 500 × $2.49 ≈ $3,171

Versus direct: zero marginal cost, but ~6–8 weeks of engineering across six OAuth flows + token refresh + six media upload protocols + per-network error taxonomies, plus 4–8 weeks of calendar time for Meta and TikTok approvals that cannot start until the UX exists, plus the ongoing cost of chasing API deprecations (Meta deprecated graph-video.facebook.com this year; X repriced twice).

Recommendation

  1. v1: Ayrshare Business. It is the only option whose core abstraction (a user profile that our customer connects through a white-labelled page we open with a JWT) matches our shape without adaptation. $599–1,200/month is less than one engineer-week, and it unblocks IG + FB + TikTok + LinkedIn + YouTube on day one with approvals already held.
  2. Only connected affiliates cost money. Profiles are created lazily at the moment an affiliate first taps "Connect an account", and deleted after 90 days with no connected networks, so the bill tracks real Mode 2 users, not sign-ups.
  3. In parallel, register our own Meta app and TikTok app and start Business Verification now (it is a prerequisite for everything Meta and takes weeks of calendar time, little engineering time). We don't build against them yet.
  4. Migration trigger: when monthly aggregator spend exceeds ~$2,500 for three consecutive months or a network we depend on breaks through the aggregator twice in a quarter, migrate that one network to direct behind the same publish_adapter interface. The adapter interface (§3.4) is designed so each network is independently swappable.
  5. X ships only as aggregator pass-through, off by default, with the per-post cost surfaced to the admin.

3.3 OAuth connection manager

Affiliate-facing surface: Settings → Connected accounts.

Element Spec
Network rows IG, Facebook Page, TikTok, LinkedIn, YouTube, X (hidden unless admin enables). Each: logo, state (not_connected, connected with handle + avatar, needs_reconnect, disabled_by_admin), last successful post time.
Connect Opens aggregator's white-labelled linking page (JWT URL, 5-minute expiry) in the same tab (not a popup — popups are unreliable on iOS Safari). Return URL lands on this screen with a toast. We then call the aggregator's profile endpoint and store the connection facts in social_connections.
Disconnect One tap, confirm sheet, revokes at aggregator and deletes our row. Scheduled jobs referencing the connection move to cancelled_connection_removed and the affiliate is told how many.
Token health Nightly job checks each connection; tokens that fail or will expire within 7 days flip to needs_reconnect and trigger an email (PRD §9.6 notification, switchable). Meta long-lived tokens expire at 60 days if unused; TikTok refresh tokens at 365 days; LinkedIn member tokens at 60 days — the aggregator handles refresh but we still surface the state.
social_connections table affiliate_id, network, external_profile_ref (aggregator profile key + network account id), handle, avatar_url, scopes[], account_kind (business/creator/personal/page), state, connected_at, last_ok_at, last_error

3.4 Publish queue

publish_jobs

Column Notes
id, affiliate_id
asset_version_id, rendition_id pinned at creation so a later asset edit never changes a scheduled post
recording_id null set when the source is a Mode 3 recording
network, connection_id one job per network (a multi-select creates N jobs sharing a batch_id)
batch_id
caption_resolved the exact text we will send, frozen at confirmation
link_placement inline, bio_instruction, first_comment, description
disclosure_version_id audit
scheduled_for null = now
state see below
attempts, next_attempt_at, last_error_code, last_error_message
external_post_id, external_url on success
consent_snapshot_id → publish_consents row the affiliate accepted for this batch
created_at, updated_at

States

draft → confirmed → queued → sending → posted
                      │          │
                      │          ├→ retrying (transient: 5xx, rate limit, token refresh)  → sending
                      │          └→ failed   (permanent: media rejected, policy, revoked token, review-gated)
                      └→ cancelled (affiliate, or connection removed, or asset retired with revoke_now)
Rule Spec
Worker A cron-triggered Vercel function (every minute) claims jobs with state='queued' and scheduled_for <= now() using select … for update skip locked, batches of 20, calls the adapter, writes results. Each job attempt completes well inside 60 s because media is referenced by URL, not uploaded through the function.
Retry Exponential backoff: 1 min, 5 min, 30 min, 2 h, 6 h; max 5 attempts; then failed. Rate-limit responses honour Retry-After.
Idempotency Idempotency-Key = job.id on every outbound call; the adapter checks for an existing external_post_id before any resend.
Pre-flight Before queued: rendition exists and is ready; connection state connected; caption length within network limit (IG 2,200, TikTok 2,200, LinkedIn 3,000, X 280 incl. t.co 23-char link); video duration within the network's limit; for TikTok, privacy_level chosen from the live creator_info options and commercial-content toggle set. Failures here block confirmation with a plain-English reason rather than failing later.
Failure surfacing The affiliate's Posts screen lists every job: thumbnail, network, state chip (never colour-only), time. failed rows show a one-line human reason mapped from last_error_code (e.g. "Instagram rejected the video: it's longer than 90 seconds" / "Your TikTok connection expired — reconnect and tap Retry"). Buttons: Retry, Download instead (drops to Mode 1 with the same rendition and caption), Delete. Failures also email (switchable).
Admin surfacing Failure rate by network over 24 h goes into the exceptions queue only when it exceeds a threshold (e.g. > 20% of attempts) — one row, not one per job.
Retired asset revoke_now cancels queued jobs for that asset and notifies; grace_until lets them run. Already-posted jobs are untouched (we don't delete from people's accounts).

Adapter interface (packages/core/publish/adapter.ts): preflight(job) → PreflightResult, send(job, signedMediaUrl) → {externalId, url}, status(externalId), mapError(e) → {code, retryable, userMessage}. One implementation per network over the aggregator; a direct implementation can replace any one later.

3.5 The "exactly what we will post" preview

PRD §9.3 calls this the thing that makes the feature usable. Requirements:

  1. Rendered per network, in that network's frame. A faithful (not pixel-perfect) mock of the IG feed card / Reel / TikTok / LinkedIn / Facebook post, built from our own React components, showing: the exact rendition image or the first frame of the exact video, the handle and avatar from social_connections, and caption_resolved with the disclosure visible and the link shown where it will actually be (for Instagram: "Link in my bio" text in the caption plus a second card: "We'll also copy your tracked link so you can paste it in your bio — Instagram doesn't allow clickable caption links").
  2. Caption is editable in place, except the disclosure span, which is rendered as a locked chip. Edits change caption_resolved on the job; they do not touch the asset.
  3. Shows the literal API payload truthfully in plain words: "Posting as @handle · Instagram Reel · 0:38 · caption 412 characters · scheduled Tue 9:00 AM your time."
  4. Scroll-to-confirm. The Confirm button sits below the full preview; for N networks, the affiliate swipes through N previews and confirms once. Confirmation freezes caption_resolved and writes the consent snapshot.
  5. What we show is what we send. The preview and the adapter consume the same caption_resolved and rendition_id. There is no second rendering path.

Shown once at first connection and re-shown whenever the text version changes; the specific version accepted is stored in publish_consents and referenced by every job:

  • We will post to the accounts you connect only when you tap Confirm on a specific post; we never post automatically.
  • Posts will carry the required disclosure, and you may not remove it.
  • You are the publisher: you are responsible for the content on your account; you can delete any post from the network at any time.
  • We store the text and media we post, and the post's URL, for your records and ours.
  • Disconnecting removes our access immediately and cancels anything scheduled.
  • Per-network consent is additionally captured by the network's own OAuth screen; for TikTok, the affiliate chooses the privacy level and commercial-content disclosure on each post (TikTok requires this in-UX).

4. Mode 3 — Record it

4.1 Platform evidence

Question Finding (checked Oct 2026)
getUserMedia + MediaRecorder on iOS Safari Supported since iOS 14.5; caniuse shows support through iOS 26.x/27.x. Safari records video/mp4 with H.264 + AAC natively. Safari 18.4 (Mar 2025) added WebM (VP8/VP9 + Opus) as well. So on iOS we record MP4 directly — the format every platform accepts.
iOS quirks Three documented and still-live issues: (a) MP4s from Safari's MediaRecorder frequently have broken duration/metadata (ffprobe cannot read duration; players show huge or zero duration); (b) Safari 26 fires empty dataavailable blobs for timeslices ≤ 100 ms — use ≥ 1,000 ms or no timeslice; (c) an 18.4-beta regression broke video/mp4 recording temporarily — codec behaviour drifts between point releases. Conclusion: never trust the raw file; always remux server-side.
Android Chrome MediaRecorder supported; default output is WebM (VP8/VP9/Opus). Chromium has shipped H.264/HEVC MP4 muxing in MediaRecorder on Windows/macOS/Android where the platform encoder exists, so video/mp4;codecs=avc1 may be available on many Android devices but not guaranteed — feature-detect with isTypeSupported and fall back to WebM. WebM is not accepted by Instagram or TikTok, so Android recordings always need transcoding.
In-app browsers (Instagram, Facebook, TikTok, LinkedIn) Confirmed trap: these WebViews do not implement the camera-permission delegate, so getUserMedia rejects with NotAllowedError even when the OS permission is granted. On iOS, only SFSafariViewController (used by some apps, not Meta's) passes camera through. There is no web-side fix; the only remedy is to get the user into Safari/Chrome.
Screen Wake Lock navigator.wakeLock.request('screen') works in iOS Safari 16.4+ in the browser; it was broken in installed PWAs until iOS 18.4. Android Chrome: supported. Must re-request on visibilitychange.
Orientation lock screen.orientation.lock() is not supported in iOS Safari (any version through 26.x). Android Chrome supports it but only in fullscreen. Portrait default must therefore be enforced by UI (a "rotate back" overlay) rather than by API.
Front-camera mirroring Preview should be mirrored (transform: scaleX(-1) on the <video>) so the user sees a mirror; the recorded stream is not mirrored, which is the right output (text in-frame reads correctly). The script overlay must not be inside the mirrored element.
Record while compositing text The script is DOM text layered over the <video> element; the recorder consumes the raw MediaStream, so the overlay never appears in the recording. No canvas compositing needed. If we ever want to burn the disclosure into the video, that happens server-side in the transcode step, not in the browser.
File size At Safari's default encoder bitrate (roughly 8–12 Mbps at 1080p) a 45-second clip is ~45–65 MB. Setting videoBitsPerSecond: 6_000_000 (honoured by Safari and Chrome) yields ~30–35 MB at 1080p30, visually indistinguishable on a phone and well above what platforms re-encode to. Estimate, to be measured on devices.
Upload from cellular Vercel functions reject bodies > 4.5 MB, so the browser never uploads through our API. Use Supabase Storage resumable uploads (TUS) direct from the browser: 6 MB chunks, resumes after network drops, progress events. 30 MB on LTE at ~10 Mbps up ≈ 25–40 s.
Transcode location A Vercel function (Fluid compute, Node, 4 GB / 800 s max on Pro) can run a static ffmpeg binary on a 35 MB input in well under a minute — but the function bundle must carry the binary (large-function beta or ffmpeg-static, ~80 MB uncompressed) and it is not where we want unbounded CPU. Decision: start on a Vercel function with maxDuration: 300, because it keeps the stack uniform and one 45-s clip is small; move to a dedicated worker (Fly machine or a Supabase-triggered container) when p95 queue wait exceeds 60 s or monthly transcode CPU exceeds a few hundred dollars. The job interface is the same either way.

4.2 Architecture decision

Pure web, with an in-app-browser fallback. No native wrapper for v1.5.

Reasons:

  • Every required primitive — camera preview, MP4/WebM recording, overlay text, wake lock, resumable upload — works in iOS Safari and Android Chrome today. The things that do not work (orientation lock on iOS, camera in in-app WebViews) are not fixed by a PWA either; only a native app fixes them, and PRODUCT.md rules out a native app in v1.
  • Quality: the browser gets the same camera and hardware encoder a native app does. The gaps (no manual exposure/focus, no 4K, slightly less control over bitrate) do not matter for a 45-second selfie clip that Instagram will re-encode.
  • Capacitor is a real, already-exercised option for this owner. When to revisit: (a) analytics show > 25% of record-flow arrivals are in in-app browsers and the "Open in Safari" fallback converts poorly; (b) iOS codec drift produces a release where MP4 recording is broken for weeks (as the 18.4 beta briefly did) and we need a stable path; (c) affiliates ask for a camera-roll import + trim flow that a native picker does much better. Capgo's camera-preview plugin provides a native preview with HTML overlays, so the teleprompter UI would port unchanged. Until one of those triggers fires, a wrapper adds App Store review, update latency, and a second deploy target for no measurable gain.

In-app-browser fallback (required): detect the WebView by user agent (Instagram, FBAN|FBAV, musical_ly|BytedanceWebview, LinkedInApp) and by probing navigator.mediaDevices?.getUserMedia presence; show a full-screen card: "Your camera isn't available inside Instagram's browser. Tap ⋯ → Open in Safari/Chrome." with a Copy link button and, on Android, an intent:// link that opens Chrome directly. Do not try to start the camera in that state.

4.3 Teleprompter — PRD requirements → technical requirements

PRD §9.3 note Technical requirement
Scroll speed adjustable during recording Scroll is requestAnimationFrame-driven, translateY on a container, speed in px/s derived from WPM. A vertical slider (right edge, thumb-reachable one-handed) and two large ± buttons change WPM live; speed and position are decoupled so changing speed never jumps the text. Range 90–220 WPM, default = script's DEFAULT_WPM 150. Last-used WPM is persisted per affiliate.
Script over the lens, not beside it Overlay occupies the top 35–45% of the viewport (eye-line near the front camera), semi-transparent dark scrim, 22–26 px text, 1.4 line-height, high-contrast, current line highlighted, ~3 lines visible either side. Not inside the mirrored <video> element.
Retake is one tap, previous take safe Each take is a separate Blob kept in an in-memory array and written to IndexedDB immediately on stop (so a tab crash/refresh does not lose it). "Retake" starts a new take; the prior take stays selectable in Review until the affiliate explicitly deletes or publishes. Max 5 takes in memory, oldest evicted with a warning.
Front and rear camera, portrait default Camera toggle via facingMode: {exact:'user'} / 'environment', stream renegotiated without leaving the screen. Portrait enforced by UI: on orientationchange to landscape, show a blocking "Turn your phone upright" overlay and pause recording if it was active. Record constraints: width: {ideal: 1080}, height: {ideal: 1920}, frameRate: {ideal: 30}.
Safe area for captions marked Faint guides drawn at the bottom ~20% and right ~15% of the frame (where TikTok/Reels place caption, buttons, and username). The script overlay never enters the bottom zone. A "where the platform puts its UI" toggle explains the guides once.
Countdown before record 3-2-1 with large numerals and haptic (navigator.vibrate on Android; no API on iOS). Script begins scrolling at 0, with a 1-second lead-in before the first line reaches the highlight band.
Visible recording state that survives a glance away Red pill with elapsed time and a pulsing dot at top-centre; a thin red border around the whole viewport; screen kept awake via Wake Lock; if the tab is backgrounded, recording pauses (iOS stops the camera anyway) and the screen shows "Paused — tap to resume or keep this take".
Tokens in scripts Script body resolved via the same packages/core function, with the speakable short-link form (§1.3 rule 3).
Required disclosure part of the script {{spoken_disclosure}} is rendered as a highlighted line that the scroll pauses on for 1.5 s the first time it is reached, so it cannot be skimmed past. The caption that ships with the video includes {{disclosure}}; the transcode step burns short_text into the first 3 seconds (§4.6).

Additional requirements not in the PRD but forced by the platform:

  • Permission pre-prompt. A friendly card before the real getUserMedia call ("We'll ask for camera and microphone. Nothing is saved until you tap Keep."). iOS asks per site, remembers for the session; denied permission is detectable via NotAllowedError and gets a "how to re-enable in Settings" card.
  • Audio. echoCancellation: true, noiseSuppression: true; mono is fine. Bluetooth headsets are honoured automatically.
  • Mimetype selection. Prefer in order: video/mp4;codecs=avc1.42E01E,mp4a.40.2, video/mp4, video/webm;codecs=vp9,opus, video/webm;codecs=vp8,opus, video/webm. Record with videoBitsPerSecond: 6_000_000, no timeslice (single blob on stop) to avoid the Safari empty-chunk bug.
  • Max take length 3 minutes hard, with the script's read_time_s × 1.5 shown as a soft target bar.

4.4 Review / retake / trim

Step Spec
Review Full-screen playback of the selected take (<video playsinline> of the local blob URL), scrub bar, mute toggle. Takes appear as a horizontal strip of thumbnails (first frame captured via canvas at stop time).
Trim Head/tail trim only in v1.5: two handles on the scrub bar. Trim points are stored as trim_start_ms / trim_end_ms and applied server-side in the transcode (-ss/-to); the browser does not re-encode. This gives the two cuts people actually need (cut the reach-for-the-button at the start, the awkward pause at the end) without a browser-side editor.
Keep "Keep this take" starts the upload immediately (§4.5) while the affiliate writes/edits the caption, so by the time they reach Publish the file is usually up.
Discard Deletes the blob and IndexedDB entry.

4.5 Upload pipeline

browser ── TUS resumable (6 MB chunks) ──► Supabase Storage  bucket: recordings-raw/{affiliate}/{recording_id}.{ext}
                                                │
                                                ▼  storage webhook → enqueue
                                   transcode job (Vercel fn, maxDuration 300, ffmpeg-static)
                                                │  ffprobe → -ss/-to trim → scale/pad to 1080x1920 →
                                                │  libx264 High, crf 20, 30 fps, aac 128k, +faststart,
                                                │  optional disclosure burn-in (drawtext, 0–3 s), loudnorm
                                                ▼
                                   recordings-ready/{recording_id}.mp4  + poster.jpg
                                                │
                                                ▼
                                   recordings.state = ready → portal polls / Realtime → "Publish" enabled

recordings

Column Notes
id, affiliate_id, script_version_id
raw_path, raw_mime, raw_bytes, raw_duration_ms (null until ffprobe)
trim_start_ms, trim_end_ms
ready_path, poster_path, ready_bytes, ready_duration_ms
state uploading, uploaded, transcoding, ready, failed
wpm_used, take_number, camera (user/environment) for product analytics
error
created_at, ready_at

Rules: signed TUS upload URL minted by our API per recording (24 h validity); raw files auto-deleted 7 days after ready; ready files retained 90 days or until the affiliate deletes; transcode failure returns the affiliate to Review with "We couldn't process that take — try recording again" and keeps the local blob so nothing is lost.

4.6 Disclosure burn-in

Default on for every Mode 3 recording: drawtext of disclosure_blocks.short_text in a high-contrast band, top-left (outside the platform UI safe zones), seconds 0–3, plus the spoken line already in the script. This satisfies the FTC's position that a disclosure in a Reel/TikTok text description alone is not clear and conspicuous (§7). Admin can disable per asset for non-endorsement scripts (e.g. a pure "how to" clip) — recorded in the version's disclosure_placement.

4.7 Hand-off to Mode 2

When recordings.state = ready, the Review screen's primary button becomes Publish, which opens the Mode 2 composer pre-filled: recording_id, the script version's caption_text resolved, networks limited to those that accept 9:16 video (IG Reel, FB Reel, TikTok, LinkedIn, YouTube Short, X). The preview uses poster.jpg plus the ready MP4. Secondary button: Download (Mode 1 behaviour on the ready file; on iOS this saves to Photos via the share sheet). If the affiliate has no connected accounts, Publish shows the connect flow inline and then returns to the composer with the recording intact.


5. Admin authoring

Scripts

  • Editor: plain textarea with a token palette (tap to insert), live word count and read time at 150 WPM in the header, a target-seconds field, and a warning banner when read time > target by more than 20%. Read time recomputes on every keystroke using a sample affiliate ("Jamie Rivera", code JRIVERA) so token lengths are realistic.
  • A "Preview on phone" QR opens the actual teleprompter screen in draft mode on the admin's phone, so they can read it at speed before publishing.
  • Required before publish: {{spoken_disclosure}} present, caption text present with {{disclosure}}, claims checkbox ("I checked this against the claims page") ticked; the claims page link sits beside the checkbox.
  • Versioning: Save creates a draft version; Publish makes it current; prior versions are listed with diffs (word-level) so a compliance edit is auditable.

Assets (image / video)

  • Upload once (any size ≤ 500 MB, direct-to-storage TUS from the admin browser). On arrival, the rendition job runs ffprobe/sharp and generates every row in the §2.2 matrix that the asset's category calls for, using smart crop (sharp attention strategy for images; centre-crop with letterbox-avoidance for video, with an admin-adjustable focal point overlay if the auto crop is wrong).
  • Each rendition cell shows a thumbnail, a "Replace" control to upload a hand-made crop, and the derived file size. A video asset additionally gets a poster frame picker.
  • Disclosure: choose block (default: product's current), placement checkboxes, "burn into video" toggle.
  • Restrictions: program multi-select, min tier.
  • Publish: the asset becomes visible; "New" badges appear for affiliates. Retire: choose revoke_now or grace_until, see count of scheduled jobs affected.
  • Usage panel: views, copies, downloads, posts, and attributed clicks/conversions/revenue via the col- campaign stamp — the "collateral usage vs. revenue" report, one asset at a time.

6. Build sequence

Sizes: S ≤ 2 days, M ≤ 1 week, L 2–3 weeks. Dependencies in brackets.

Phase 6 — Mode 1 (ships first, must be excellent alone)

# Item Size Depends on
6.1 Schema: collateral_assets, asset_versions, asset_renditions, disclosure_blocks, asset_restrictions, affiliate_asset_marks, asset_events (partitioned), RLS M Phase 1 ledger schema conventions
6.2 packages/core token resolver (incl. speakable link form) + per-platform link-placement transform, fully unit-tested S 6.1
6.3 Admin: asset upload (TUS) → rendition job (sharp + ffmpeg-static on Vercel fn) → matrix UI with replace/focal point L 6.1
6.4 Admin: script editor with read time, token palette, claims gate, versioning, phone-preview QR (preview can be read-only text scroll until 6c) M 6.1, 6.2
6.5 Admin: disclosure blocks, restrictions, publish/retire, usage panel M 6.1
6.6 Portal vault list + detail: copy (text/HTML), platform selector, download, marks, "New" badge, nudge, event logging L 6.1–6.3
6.7 Campaign-stamp join into reports ("collateral vs. revenue") S Phase 2 clicks, 6.6
6.8 "What you may and may not claim" page (HealthyApp first), linked from vault and admin S content from Jonathan
6.9 Load the first HealthyApp collateral set (PRD open question 6) — content

Exit: an affiliate on a phone copies a disclosed, link-substituted caption and saves a correctly cropped JPEG for Instagram in under 30 seconds.

Phase 6b — Mode 2

# Item Size Depends on
6b.0 Start now, in parallel with Phase 6: Meta Business Verification; register Meta app + TikTok app; privacy policy + terms URLs live on tellatribe S (calendar: weeks) domain live
6b.1 Ayrshare Business account; social_connections schema; profile-per-affiliate lazy creation; JWT linking page; disconnect; nightly token-health job M 6.1
6b.2 publish_jobs schema + worker (cron, skip-locked claim, backoff, idempotency) + adapter interface M 6b.1
6b.3 Adapters over Ayrshare: LinkedIn, Instagram, Facebook Page, TikTok (TikTok incl. creator_info privacy options + commercial toggle in UI) M 6b.2
6b.4 Composer + per-network preview components + consent capture + scheduling (affiliate timezone) L 6b.2, 6.6
6b.5 Posts screen: states, human error messages, Retry / Download instead; failure-rate exception for admin M 6b.2
6b.6 Adapters: YouTube Shorts, X (X off by default, cost surfaced to admin) S 6b.3
6b.7 Notifications: post succeeded/failed, reconnect needed (switchable) S Phase 3 notification plumbing

v1 Mode 2 networks: LinkedIn, Instagram, Facebook Page, TikTok — all available on day one through the aggregator, whose approvals we inherit. Wait on our own approval: nothing blocks v1, but our own Meta and TikTok apps (6b.0) are the migration path and should be in review during 6b so the option exists. YouTube and X ship in v1 as low-priority adapters, X disabled by default.

Phase 6c — Mode 3 (v1.5)

# Item Size Depends on
6c.1 recordings schema; signed TUS upload; storage webhook → transcode fn (ffprobe, trim, scale/pad, x264, loudnorm, burn-in, poster); 7-day raw purge M 6.3 (shares ffmpeg packaging)
6c.2 Capture screen: permission pre-prompt, in-app-browser detection + fallback card, camera/mic acquisition, mimetype selection, mirrored preview, portrait enforcement, wake lock, countdown, record state, takes → IndexedDB L 6.4 scripts
6c.3 Teleprompter overlay: rAF scroll, live WPM control, highlight band, disclosure pause, safe-area guides M 6c.2
6c.4 Review: take strip, playback, head/tail trim, keep/discard, upload-while-captioning M 6c.1, 6c.2
6c.5 Hand-off to Mode 2 composer with recording_id; Download path S 6b.4, 6c.4
6c.6 Device QA matrix: iPhone (current iOS and previous major), Pixel + Samsung (Chrome), iPad; in-app browsers for IG/FB/TikTok/LinkedIn M all above
6c.7 Analytics: in-app-browser arrival rate, record start→complete funnel, transcode p95 — the data that decides the Capacitor question S 6c.2

Why v1.5, not v1: (1) its publish hand-off needs Mode 2 to exist; (2) its upload/transcode pipeline is the only part of the whole product that needs ffmpeg at runtime on user content, which deserves its own hardening pass; (3) it is the one surface that needs a physical-device QA matrix rather than browser tests; (4) PRD §14's success criterion — a first-time video maker gets from script to posted without leaving the portal — is only meaningful once Modes 1 and 2 are proven and there is a real affiliate population to test it on. Shipping it two to four weeks after v1 costs nothing in the plan; shipping a half-tested camera surface in v1 costs trust.


7. Risks

Risk Detail Mitigation
Platform approval timelines Meta: Business Verification 5–15 business days per cycle, then App Review; reports of 2–4+ weeks total, resets on rejection. TikTok: audit 2–4 weeks, multiple rounds, requires demo video of a compliant UX. Neither can start before the UX exists. Aggregator for v1 so approvals are not on the critical path; start our own verifications at 6b.0 anyway.
Aggregator dependency Ayrshare outage or policy change breaks all networks at once; pricing is per connected affiliate. Adapter interface per network; migration trigger defined (§3.2); "Download instead" on every failed job keeps the affiliate productive.
Health claims (HealthyApp) Affiliate-authored captions and ad-libbed video are where disease/weight-loss claims leak in. Meta ad-policy sensibilities (personal attributes, health) also apply to organic content reports. Admin-authored scripts and captions only in v1 — affiliates can edit caption text in the composer but not the disclosure; the claims page is one tap from every edit field; an admin review queue for edited captions is a v1.5 option (§8).
FTC disclosure in video FTC's 2023 guidance: a disclosure only in a TikTok/Reel text description is not clear and conspicuous; it should be in the video itself, and if the endorsement is both visual and audible the disclosure should be too. Brand liability attaches to the merchant, not just the affiliate. Spoken disclosure line mandatory in scripts; burn-in on by default; caption disclosure locked.
In-app-browser trap A large share of affiliates will tap the portal link from Instagram/TikTok and arrive in a WebView where the camera is blocked. Detection + "Open in Safari/Chrome" card with copy-link and Android intent; measure the rate (6c.7). This is the primary data point for the Capacitor decision.
iOS codec drift Safari's MediaRecorder has shipped regressions (18.4 beta broke MP4 recording; 26 ships empty blobs on small timeslices; chronic bad duration metadata). Never trust the raw file — always remux server-side; feature-detect mimetypes at runtime; no small timeslices; keep a WebM fallback path that always transcodes; device QA on each iOS beta.
Instagram link reality Affiliates expect a clickable caption link; Instagram will not give them one. Honest UI (§2.3); bio-link copy step; attribute via campaign stamp so they can see the bio link working.
X volatility Repriced twice in 2026; per-link-post cost; could change again. Off by default; aggregator pass-through only; admin sees cost.
Vercel body limit 4.5 MB request cap would silently break any naive upload. All media goes browser → Supabase Storage directly via signed TUS.
Transcode CPU on a function Unbounded user video + ffmpeg on serverless can get expensive or time out. 3-minute hard cap on takes; maxDuration 300; move to a worker at defined thresholds (§4.1).

Resolved Oct 3, 2026 — see BUILD-PLAN.md §5. Q1 Ayrshare. Q2 X is enabled but gated: only affiliates with an open balance (pending + approved > $0) may post to X, and the per-post link fee is deducted from their payout as a fee ledger row referencing the publish_job; the composer states the cost before posting. Q3 affiliates may edit anything in the composer, including the disclosure — no locked span; warn once, log the removal, post. Q4 no review queue in v1. Q5 burn-in OFF by default, affiliate opt-in per take; the spoken disclosure in scripts stays mandatory. Q9 v1.5 stands.

  1. Aggregator choice — Ayrshare Business at $599/mo from day one, or Zernio's per-account pricing to start cheaper? Default: Ayrshare. Its user-profile + white-label JWT linking model is the exact shape we need and it is the incumbent; Zernio is cheaper at low counts but just rebranded and its pricing changed with it. Revisit if Ayrshare spend passes ~$2,500/mo.
  2. Enable X at all in v1? Default: adapter built, disabled; admin can enable per program and sees $0.20/post.
  3. Can affiliates edit captions in the composer? Default: yes, except the locked disclosure span; log the diff. Alternative: locked captions in v1 for HealthyApp because of health-claims exposure. If Jonathan wants zero claim risk, lock them for HealthyApp only.
  4. Admin review queue for edited captions / recorded videos before publish? Default: no in v1 (it would reintroduce the daily admin login the product is designed to eliminate); build as a v1.5 option gated per program.
  5. Disclosure burn-in default on for all Mode 3 recordings? Default: on. Admin can turn it off per script.
  6. Show locked/ineligible assets as teasers to motivate tier progress? Default: hidden entirely in v1. Teasers imply earnings-based incentives that start to look like rank mechanics; keep it plain.
  7. Default scroll speed 150 WPM? Default: yes, persisted per affiliate after first adjustment.
  8. Recording retention? Default: raw 7 days after ready; ready MP4 90 days or until deleted; posted videos' URLs kept indefinitely for audit.
  9. Mode 3 timing — ship with v1 or 2–4 weeks after? Default: v1.5, as argued in §6. If the first HealthyApp cohort is heavily novice and has literally nothing to post, pull 6c.1–6c.4 forward and ship the Download hand-off before the Publish hand-off.
  10. Capacitor wrapper? Default: no. Revisit only when 6c.7 shows > 25% in-app-browser arrivals with poor fallback conversion, or an iOS release breaks MP4 recording for more than one point release.
  11. Instagram login path — Instagram Login (no Facebook Page) vs Facebook Login? Default: Instagram Login for our own app registration (6b.0); it removes the Page requirement that would exclude many Creator-account affiliates. Through the aggregator this is handled for us.

Sources

Instagram / Facebook (Meta)

TikTok

LinkedIn

X

YouTube

Aggregators

Instagram link mechanics

FTC disclosure in video

Mobile browser capture

Upload and transcode infrastructure

Rendered from build-plan/02-collateral-publishing-teleprompter.md. Edit the source, not this page.