Get Heard

Get Heard - Documentation

A local tool for getting your music heard: it discovers music blogs, radio stations, magazines, YouTube channels, and playlist curators; verifies their contact addresses; composes personalized outreach emails with AI; sends them through your own mailbox; tracks opens, clicks, replies, and coverage; and helps you land the pitches that actually convert (like Spotify editorial via Spotify for Artists).

Everything runs on your machine. The external services are the AI provider you choose, your own mailbox (sending and reading replies), and - only once you publish or go looking for curators - Netlify for the published pages and Spotify for playlist discovery. Nothing else leaves the machine.

Contents

  1. Getting started
  2. AI setup: tokens, providers, and models
  3. Mail setup
  4. Spotify setup (optional)
  5. Core workflow
  6. Feature guide, page by page
  7. Email templates & template variables
  8. AI features reference
  9. Background automation
  10. Environment variable reference
  11. API quick reference
  12. Data, backups & migrations
  13. Troubleshooting

1. Getting started

Requirements

Install & run

npm install
cp .env.example .env   # every variable, commented; all of them optional
npm run dev            # dev mode: server + frontend watcher

Open http://localhost:3000. For a production-ish run: npm run build && npm start.

Nothing in .env is required - every credential that matters is settable in Settings, and the app boots without any of them and tells you what is missing. .env is for the ones you would rather not retype.

The server binds to 127.0.0.1 only (localhost) by design - nothing is exposed to your network. The SQLite database is created at ./data/getheard.db on first boot; schema migrations run automatically on startup.

First-run checklist

  1. Put AI credentials in .env (section 2 - this is the "how to inject tokens" part).
  2. Connect a mailbox in Settings → Connections (section 3).
  3. Create an Artist, then a Release (add lyrics, streaming links, blurbs - the more metadata, the better every AI feature works).
  4. Run Discover to find outlets, then Compose to draft outreach.

1b. Editions: selfhost and saas

The app builds as one of two editions. If you have never set APP_EDITION, you are running selfhost and everything else in this document applies to you unchanged - you can skip the rest of this section.

The fork lives in exactly one named place, config.edition (src/config.ts, set by APP_EDITION), never by sniffing whether some credential happens to be present. The iron rule is that anything reading it keeps selfhost byte-identical: the saas tables simply sit empty there.

selfhost (default) saas (APP_EDITION=saas)
Who uses it one owner, one laptop many tenants, one hosted install
Login none required (magic link or Google)
Tenancy one workspace, never surfaced a workspace per tenant, scoped through artists.workspace_id
AI cost your own keys, never metered metered per workspace with an enforced monthly budget; more AI means a higher plan, and there is no way to opt out of the meter
Billing none Stripe (plans in shared/plans.ts), a no-card trial of the whole app, then a free tier that keeps the link-in-bio page and the smart link live (with a small "Made with Get Heard" link on them); the press kit, fan kit, AI, pitching, clips and posting need a plan
Outlet discovery yours to run hidden (the priciest AI lane)
Outlets page your own catalog: add, edit, remove, CSV import, bulk email verification, guideline re-scrape a shared catalog the operator curates: add your own contacts and edit or remove those; any other outlet you can only switch off for yourself, and the bulk tools are not offered
Published pages a hub site on your own Netlify account, under your own domain served by the service itself at getheard.now/a/<artist>/…, or the artist's own domain once it resolves
Terms / privacy your hub's two pages, in your own voice the operator's documents at /terms and /privacy, plus a hub page that links to them

Running saas locally

npm run dev:saas              # its own folder, its own port
npm run dev:saas -- --reset   # empty that folder first, for a clean signup

Defaults: everything under data/saas-dev/, port 3998, BASE_URL derived from the port. Override with SAAS_DB_PATH / SAAS_PORT.

--reset starts you over at a blank signup - it removes data/saas-dev/ whole, which is the database and everything a run left beside it: uploaded audio, rendered video, cover art, fonts. Your own data/ is not touched, and cannot be: the reset refuses any target that is not a direct child of data/, and refuses data/ itself.

A dev edition gets a folder, not a file in data/, and that is a rule with teeth. Every on-disk path in this app derives from the database's own directory, and row ids restart at 1 in a fresh database - so a flat data/saas-dev.db would put a dev run's first upload at data/audio/1.mp3, on top of your own first master, silently. If you set SAAS_DB_PATH yourself, point it at a folder of its own; the launcher refuses anything that lands beside your files. (A data/saas-dev.db from before this rule is moved into the folder on the next start rather than deleted.)

It refuses to start against data/getheard.db. Not politeness - booting saas on the selfhost database does not corrupt anything, which is precisely what makes it dangerous: a login gate goes up in front of your own data, signing up mints a new workspace, and your artist (in workspace 1) vanishes from the UI. That reads as "the app lost my work", not as "wrong edition".

Signing in. There is no password to guess. Sign up, then take the magic link out of the server log - with no RESEND_API_KEY the auth mail transport is the console, deliberately, so a local run needs no mail vendor at all. Verify the edition took with curl localhost:3998/api/meta"edition":"saas", "emailDelivery":"console".

You will land in a new workspace, not workspace 1. Workspace 1 is the operator's - it is unmetered and it is what requireOperator gates the installation-level settings on. To exercise those surfaces:

UPDATE workspace_members SET workspace_id = 1 WHERE user_id = 1;   -- then sign in again

What the script disarms, and why it still matters on an empty database. Publishing (NETLIFY_AUTH_TOKEN blanked - the env is a fallback when the settings row is absent, which has bitten before), the release autopilot, fan release-day mail, tenant lifecycle mail, and the curator re-audit sweep. A fresh database has nothing to publish or send, so none of it costs anything today; it stops a row you seed tomorrow from reaching a real Netlify site or a stranger's inbox. Stripe is left alone only if you have pointed STRIPE_API_BASE at a stub.

This is not the same thing as the verify skill. That drives a copy of the real database, which arrives armed - live tokens, possibly an armed autopilot, a drip queue - and its disarm list is correspondingly long. This starts empty.

Deploying saas for real

See deploy/DEPLOY.md. Beyond the runbook, three things gate a real launch: the operator identity variables (OPERATOR_*, section 10) so the legal pages name somebody, a lawyer's review of those generated documents, and the platform app reviews (Meta, TikTok, Google) which take weeks.


2. AI setup: tokens, providers, and models

The app supports four AI providers - two API lanes billed per token, and two subscription lanes that drive a local agent CLI and are billed to a plan you already pay for. You pick one (or let auto decide), and every AI feature - discovery, personalization, blurbs, pitches, scoring, song analysis - runs through it.

Credentials come from the environment, not from Settings. Put them in .env (or export them); the Settings page picks the provider and the models and shows you which credentials it can see. There is deliberately no field to paste an API key into: a key typed into a web form is a key in a database backup.

Provider Credential Billing
claude-code Claude subscription (Pro/Max) via Claude Code Included in your subscription - usage draws from your plan's 5-hour/weekly limits, no per-token charges
codex ChatGPT subscription via the Codex CLI (codex login) Included in your ChatGPT plan. The only lane that generates images without an OpenAI API key - and it needs a paid plan
anthropic ANTHROPIC_API_KEY from console.anthropic.com Pay-as-you-go API billing (separate from any subscription)
openai OPENAI_API_KEY from platform.openai.com Pay-as-you-go API billing

This routes all AI calls through the Claude Agent SDK, authenticated by your Claude Pro/Max plan.

  1. Install Claude Code and log in (npm install -g @anthropic-ai/claude-code, then claude and follow the login).
  2. Mint a long-lived token from your subscription:
    claude setup-token
    
  3. Put it in .env:
    CLAUDE_CODE_OAUTH_TOKEN=<the token>
    AI_PROVIDER=claude-code   # optional - auto picks claude-code when the token is set
    

If the app runs on the same machine where Claude Code is logged in, AI_PROVIDER=claude-code alone also works (it uses the CLI's keychain login) - but the token survives logouts, so set it for anything long-running.

Important: never set ANTHROPIC_API_KEY at the same time you intend to use the subscription. The auth chain checks the API key first, and you'd silently pay per token. The app defensively strips ANTHROPIC_API_KEY from the subscription code path, but don't rely on that elsewhere.

Heads-up on quota: big batch jobs (scraping 50 blogs, composing 30 emails) share the same 5-hour window as your interactive Claude Code sessions.

Option B: Anthropic API key

Console → Settings → API Keys → create key → .env:

ANTHROPIC_API_KEY=sk-ant-...

Billed per token to your Anthropic console account. Default model: claude-opus-4-8 (override with ANTHROPIC_MODEL or the Settings picker).

Option C: OpenAI API key

OPENAI_API_KEY=sk-...

Default model: gpt-5-mini. An OpenAI key is also the only way to use audio-based song analysis (section 8) - Claude models don't take audio input. You can run Claude as your main provider and still keep an OpenAI key around just for audio analysis; the audio feature uses OpenAI directly regardless of the active provider.

Option D: OpenAI Codex subscription (no API billing)

npm i -g @openai/codex   # or use the copy vendored with @openai/codex-sdk
codex login              # sign in with your ChatGPT account

Then set the provider to Codex in Settings (or AI_PROVIDER=codex). Work is billed against your ChatGPT plan's limits rather than per token - the same deal as the Claude Code lane, and the Settings card shows signed in once the login exists.

Codex is the only way to generate images without an OpenAI API key - but it needs a paid ChatGPT plan. The Codex CLI carries a native image tool that runs on the same subscription auth, which is what the ✨ AI cover art pool (section 9g) uses. On the free plan that tool answers 403: text and web search work perfectly, and image generation does not. The app reads your plan from the stored login and grays the ✨ buttons with that reason rather than letting them fail at click time, so if they are dark while you are signed in, the plan is why. Leave the model empty to use whatever the CLI is configured with - unlike the API lanes there is no key to list models with, so pinning one is opt-in.

Provider resolution order

AI_PROVIDER=auto (the default) picks, in order:

  1. claude-code if CLAUDE_CODE_OAUTH_TOKEN is set
  2. anthropic if ANTHROPIC_API_KEY is set
  3. openai otherwise

codex is never picked by Auto - its credential is a login file rather than an env var, so selecting it automatically would hijack every AI call on a machine that merely has Codex installed for other work. Choose it explicitly.

The startup log always tells you what's active: AI provider: Claude Code subscription (…).

Which lane generates images

Image generation does not follow your text provider, because only OpenAI models make images - Claude and the Claude Code subscription cannot. The app resolves it separately, and the Settings card states the result in plain words:

  1. the OpenAI API if you explicitly chose the OpenAI provider and have a key (you picked billing);
  2. otherwise Codex if you are signed in (free at the margin - it uses your ChatGPT plan);
  3. otherwise the OpenAI API if a key exists;
  4. otherwise nothing, and every image button is disabled with that reason rather than failing when pressed.

Picking models (Settings → AI Provider & Models)

The Settings page has a live picker that overrides the env values without a restart:

Web search (used by Discover) works on all three providers: Anthropic's server-side search tool, Claude Code's WebSearch tool, or OpenAI's Responses API.


3. Mail setup

Your pitches go out of your own mailbox, so an editor sees a person rather than a tool. There are two lanes, and Settings → Connections → Email provider is where both live and where the app says which one is actually sending, with the address a reply will go to.

"Is mail connected?" is two questions. Sending and reading resolve separately, because a lane that can send and cannot read must not make Check replies answer "0 new replies" - a sentence about the outlets - when it means I did not look. The card states both.

The lane is derived, never picked. A mail server takes over whenever one is connected, and disconnecting it hands sending back to Gmail. There is no "which mailbox?" selector, so the answer can never disagree with what actually happens.

Option A: your own mailbox (SMTP + IMAP)

The simplest path, and the one hosted artists use. Type the address you send from and an app password; the app looks up your provider from the domain, fills in the hosts and ports, and verifies both halves against the real server before storing anything. A wrong host cannot survive the attempt - it comes back with the server's own words in it.

Option B: Gmail over OAuth, with your own Google Cloud project

Free, and it is the lane to pick if you would rather not hold an app password. Settings → Connections carries the same walkthrough with live links:

  1. Google Cloud Console → create a project (any name).
  2. APIs & Services → Library → enable Gmail API.
  3. APIs & Services → OAuth consent screenExternal, fill in a name and your email, and add your own Gmail address under Test users. It can stay in Testing mode forever - you are the only user.
  4. Credentials → Create Credentials → OAuth client ID → Web application, and add the redirect URI exactly as Settings → Connections prints it. ⚠ Do not type it from memory: it derives from BASE_URL, so it carries the port you are actually on, and Google matches it byte for byte.
  5. Paste the client ID and secret into Settings (or set GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_SENDER_EMAIL in .env), save, then Authorize Gmail. Google will warn that the app is unverified - it is your own app; choose Advancedcontinue. Scopes: send + readonly, and the readonly half is what powers reply detection.

Deliverability

Whichever lane you use, replies thread correctly because every email carries an RFC 2822 Message-ID that follow-ups reference.

A six-hourly check asks DNS whether the domain your pitches leave from publishes SPF and DMARC, and Settings → Connections reports the answer. It matters most on Option A: Gmail's own lane borrows Google's alignment, while a mail server borrows nothing, so a cold pitch from a domain with no policy is filed as spam and the app still reports it as sent.


4. Spotify setup (optional)

Playlist-curator discovery and the release-day watcher (section 9) use the free Spotify Web API:

  1. developer.spotify.com → create an app (any name, no redirect needed).
  2. .env: SPOTIFY_CLIENT_ID + SPOTIFY_CLIENT_SECRET.

Read the caveat in section 6 (Discover) before pinning hopes on curator cold-outreach.

This lane is selfhost only. Spotify's developer terms license the Web API for private personal use, which one artist on their own credentials is squarely inside and a hosted multi-tenant service is not - so the saas edition does not call it at all. Nothing about a selfhost install changes.

The default tier no longer returns popularity, followers or genres on artist, album or search responses (measured 2026-08-21), and it withdrew them without changing a status code. Where the app needed one of those it now says so rather than showing a confident zero. Playlist follower counts still come back, so curator discovery is unaffected.


5. Core workflow

Artist → Release → Discover outlets → Compose (AI-personalized) → Approve → Send
                                                                      ↓
        Coverage tracking ← Replies (AI triage) ← Opens/clicks ← Sent
  1. Create your artist and release with as much metadata as you have - lyrics, bio, press highlights, streaming links, cover art. Every AI feature feeds on this.
  2. Discover: AI web search + scraping finds outlets that accept free submissions, extracts their emails and submission guidelines, and de-duplicates against what you already have.
  3. Compose: pick a release + outlets; the AI writes one personalized email per outlet from your templates (site-type-aware, language-aware, guideline-aware). Nothing sends without your approval.
  4. Send: immediately, throttled batch, or drip mode (a few per hour, recipient-local Tue–Thu mornings if enabled).
  5. Track: opens, streaming-link clicks, replies (AI-classified as interested/declined/etc.), and published coverage (harvested automatically from accepted outlets).

6. Feature guide, page by page

Dashboard

Content

Insights

Four questions the app can answer that nothing else can, each with the reader's own numbers and a way through to where the work happens. ⚠ A card with no evidence yet says what would fill it and keeps its link rather than being hidden or grayed - on day three that is most of the page, and "here is how to switch this on" is a different page from "four empty boxes". Where a platform will not tell us a number, the page says so rather than guessing.

Discover

Outlets

(Called Blogs in older docs and in the /api/blogs endpoints; the page itself is Outlets, because radio stations, magazines, channels, curators and sync supervisors all live in it.)

Compose

Posts

Ads

Sent

Releases

Artists

Clicking a row (or Open) opens the artist's workspace - an inner sidebar like the release workspace, with one subpage per feature:

Settings

Settings has an inner sidebar like the release workspace - one subpage per concern. Subpages are deep-linkable and survive refreshes: the URL carries the location as a real path (/settings/connections, and likewise /releases/<id>/<tab> and /artists/<id>/<tab> for the workspaces; Back returns from a workspace to its list). Older #settings-style links still work - the app translates one into its path on arrival.


7. Email templates & template variables

Templates are Handlebars: {{variable}} placeholders are filled per-recipient at compose time. The template modal lists every variable; highlights:

Variable Content
{{outlet_name}}, {{outlet_url}}, {{site_type}} The outlet
{{personalized_intro}} AI-written opener referencing the outlet's recent coverage - put it near the top
{{artist_name}}, {{release_title}}, {{genre}}, {{release_date}} The basics
{{primary_genre}}, {{primary_mood}}, {{primary_instrument}} The first genre/mood/instrument on the release - use when the full comma-separated list is too much
{{moods}}, {{instruments}} The release's mood/trait keywords and instrumentation as you entered them on the release
{{related_links}}, {{live_session_url}}, {{music_video_url}}, … Related content attached to the release (live sessions, music videos, acoustic/lyric videos, interviews); each type also has a _title variable
{{promo_blurb_long}} / {{promo_blurb_short}} Your press blurbs (localized automatically)
{{spotify}}, {{apple_music}}, {{soundcloud}}, {{streaming_links}} Listen links
{{epk_url}} Hosted press-kit page
{{submission_guidelines}}, {{subject_format}} What the outlet asked for
{{song_genre}}, {{song_subgenres}}, {{song_moods}}, {{song_instruments}}, {{song_vocals}}, {{song_tempo}}, {{song_energy}}, {{song_sound}} AI song analysis (section 8) - empty until you run the analysis
{{explicit_status}} "Clean" / "Explicit" from the AI lyrics check (falls back to the manual checkbox)
{{rights_one_stop}}, {{publisher}}, {{writer_splits}}, {{instrumental_url}} The clearance answers a music supervisor asks for first (release editor → Credits). Empty until you answer them, so the line disappears rather than claiming anything on your behalf

Template selection at compose time: an explicitly chosen template wins; otherwise the template whose site type matches the outlet; otherwise the generic default.

Two of the seeded templates are for the sync lane - Sync Pitch (music supervisors) and Library Pitch (music libraries). They are short and factual on purpose: the metadata block is the pitch, the clearance line is why it gets read, and there is no "out now" variant because a placement is not news. Neither carries {{writer_splits}}, since those name your co-writers and their shares - add it yourself the moment a supervisor asks for it, rather than mailing it to everyone.

When an email uses a non-English template variant, genre/mood/instrument variable values are AI-translated into that language too (e.g. "energetic" → "energisk" in a Danish email). Translations are cached per term+language, so each term costs at most one AI call ever; if the AI is unavailable the English terms are used.


8. AI features reference

Everything below uses the active provider from section 2, except audio analysis (OpenAI only).

Template scoring (Settings → template modal → 🧪 Score with AI)

Scores the open template 0–100 as a cold-outreach email to your selected site type, with subscores (subject, opening, personalization, brevity, call-to-action, deliverability), what already works, concrete rewrite suggestions with example phrasing, a stronger subject-line suggestion, and local spam-filter heuristics. It understands {{variables}} as personalization slots - using {{personalized_intro}} early scores well. Works on unsaved edits, so iterate: edit → score → edit.

Press blurb generation (Release modal → ✨ Generate blurbs)

Writes the long + short blurb from lyrics, bio, press highlights, and metadata - natively in every language you have locale rows open for, one call. Grounded: it won't invent quotes or achievements. Click again to regenerate.

Pitch Studio (Releases → Pitch)

Platform-tailored pitches from all release+artist metadata: Spotify for Artists (≤500 chars, savable, "mark as pitched"), Apple Music, Amazon Music, Deezer, radio one-sheet, press-release opening. Two variants each.

Song analysis (Release modal → AI Song Analysis)

Two paths, both saved to the release and exposed as {{song_*}} template variables:

Re-run either path any time to refresh.

Audio sections & lyric sync (release workspace → 🎛️ Sections & Lyrics)

🎯 Detect sections analyzes the uploaded MP3/WAV's signal (energy + novelty, decoded by a bundled ffmpeg - no cloud, works on any AI provider) and splits the track into the 3–5 most usable 15–30 s windows, guessing a category for each: the strongest window is the hook, a sharp entry rise marks a chorus, an opener is the intro, the rest are verses. When the release has a BPM, sections snap onto the bar grid so they start musically. Timing never comes from AI.

The studio draws the actual waveform on a zoomable timeline (1–8×) with the sections as colored blocks. Everything is direct manipulation: drag a block to move it, pull its edges to resize (edges snap to neighboring sections and the playhead), drag on empty timeline to draw a brand-new section, click to seek, double-click a block to audition it. Each section's card offers the same edits numerically (m:ss or seconds), plus the Musixmatch-style category (Intro / Verse / Pre-Chorus / Chorus / Hook / Bridge / Outro - sections saved before this change may still show a legacy "Drop"), label, and captions; ✕ in the card's corner removes it. Sections can be authored entirely by hand - the detector is optional. Re-running detection warns before replacing corrections. Saved sections feed the EPK hook player, the fan kit's hook download, and the Clips studio.

★ Favorite = where "Hear the hook" lands. Star a section and the EPK's ▶ Hear the hook button jumps there (the star also shows on the timeline block). It's usually the second chorus, which is exactly what the detector's score does not pick - on Ripples the top-scoring window is the intro (0.79). Only one section can be starred. Without one the button falls back to the strongest chorus (hook / chorus / post-chorus), and only if a track has none of those to the loudest window overall - a button promising the hook must never open on the intro. The button never names the section: press readers don't care whether it's called "Chorus 2". Playback drops in one second before the section (clamped at the top of the track) and fades up over 0.7 s, because cutting dead-on to a chorus sounds like a glitch; the run-in is what makes it land. A normal play, a pause, or a scrub cancels the ramp, so nothing can leave the player stuck at silence - and iOS, which ignores writes to volume, simply plays without the fade.

Lyric tap-sync (Musixmatch-style). Import the release's lyrics as lines, hit 🎙 Start tap-sync, and tap (button or Space) as each line starts singing - every line gets a real timestamp, stored in releases.lyrics_synced. ⏹ ENDS (Enter) marks where the line you just stamped stops being sung: without it a line is assumed to run until the next one, so the last line before an instrumental break hangs over the whole break. Rows also offer set-to-playhead ⏱, mark-end ⏹, play-from-line ▶, and clear ✕; while the track plays, the currently-sung line highlights karaoke-style, which is how you proof the sync. ⤢ Expand gives the lyric list the full page width so long lines stop being cut off mid-sync.

Captions are a projection of the sync, not a stored field. Once lines are stamped, a section's caption lines (the on-screen text of its short) are re-derived on every render, so the read-only textarea always shows the truth: drag a boundary, restamp a line, or mark an end, and the captions follow immediately; the server re-derives them authoritatively on save. ✏️ Edit by hand freezes the derived lines into a stored, editable list that the sync no longer overwrites (↻ Auto from lyrics reverts). A section holds up to 16 caption lines - a whole final chorus - while a short renders the first 3 as overlay plates. Without a sync, the one AI labeling pass still supplies label + caption guesses (estimated from song structure - genuinely un-synced).

The rule: a line belongs to the one section it starts in. A note held from the verse into the bridge is a verse line however long it rings on, so it never leads the bridge's captions. Both edges of the window carry a 0.5 s tolerance, because a tap and a waveform boundary miss each other by a beat - in Ripples the same chorus line lands 0.08 s after the Chorus 1 boundary and 0.07 s before the Chorus 2 one, and raw stamps would file it under different sections in the two choruses. Shifting both edges equally keeps the sections tiling, so no line can land in two of them. A section with no line of its own (a post-chorus under a held vowel, an outro under the last "whoa") shows the line still ringing over it - unless the preceding section already ends on that line, which would just be the duplicate by another route. And a window nothing is sung over is instrumental: it keeps its stored AI mood line rather than being blanked. Timed lyric overlays on shorts use the opposite (and correct) rule for their purpose - every line overlapping the clip, since there the question is what's being sung at each moment, not which section owns it.

Clean/explicit check (Release modal → 🏷️ AI clean/explicit check)

Classifies the saved lyrics parental-advisory style (profanity, sexual content, graphic violence, drug glorification - borderline leans explicit, since undisclosed explicit content burns radio relationships). Stores the verdict + reason, syncs the manual "Contains explicit lyrics" checkbox (you can override before saving), and feeds {{explicit_status}} - handy in radio templates ("The track is clean and cleared for broadcast").

Also AI-powered, elsewhere

Discovery page analysis & search-query generation, per-email personalization, reply triage, fit scoring, subject A/B variants, coverage-quote extraction, email extraction from contact pages, local-only detection (skips outlets that only cover their own region unless you match).


9. Background automation

All schedulers are in-process and start with the server. Nineteen of them, and every one is armed through the same registry (services/sweeps.ts), which is what makes them visible at all - a sweep that stops working has no symptom, because these lanes are defined by something happening on its own.

Nothing here ever auto-approves a pitch or auto-submits a web form. The two sweeps that really put something into the world - the publish queue and the release autopilot - act only on work you armed or queued yourself, and each has a kill switch (section 10).

Job Cadence What it does
Drip sender 5 min Sends one already-approved email per workspace per tick until the daily cap; optional recipient-local Tue–Thu 8–12 timing
Campaigns hourly Queues drafts for campaign stages whose date arrived (drafts - you still approve), up to the pitches waiting at a time cap in Settings → Sending and topped back up as pitches go out. Campaigns with a wave plan dispatch typed cohorts at release-anchored offsets (long-lead magazines −45d, blogs/radio at the pitch window, playlists −10d), with catch-up for offsets missed while the app was closed; a wave whose cohort was cut by the cap stays planned and finishes on later ticks
Digest 10 min check One morning summary email: new replies (classified), S4A pitches due, pitch waves due soon, follow-ups eligible, queue counts; quiet days are skipped
Beacon sync 5 min Drains tracking events (email opens/clicks, page visits) parked on the beacon into the local DB; redeploys the beacon when this build ships a newer function; refreshes outlet track records when new evidence arrives
Offer auto-close 6 h Closes 💰 payment-ask offers you ignored past the window, as your decline. Bookkeeping only - it never sends anything
Social ingest 30 min Pulls logged posts' view counts from their platforms into the clip journal, and discovers Instagram posts whose captions carry a tagged link. No-ops until an account is connected
Publish queue 1 min Publishes due queued posts to the artist's own accounts. No-ops while the queue is empty; sweeps up mid-publish crash leftovers at boot
Release autopilot 5 min An armed release runs its runway by itself: renders each rung on its due day, AI-captions it and queues it into the publish sweep above. No-ops while nothing is armed
Release-day watch hourly For releases up to 7 days past their date with a Spotify album URI and no Spotify streaming link yet: one cheap API check per release - the moment the album is live, the streaming link is filled and the smart link re-published (its Spotify button flips to "Listen")
Fan notify mail 30 min The other half of release day: strangers who left an address on the pre-release smart link get their one promised email, in their own language
Curator re-audit 6 h Keeps vetted curators' trust verdicts fresh and accrues the follower history the spike signal reads. Only re-audits curators you already vetted, slowly, and no-ops while Spotify is unconfigured
Retention 6 h Deletes what the privacy documents promise to delete: visit and open/click events past their window, a fan's address past its window, and a closed workspace's data and published pages past the grace period
Hub domain watch 15 min Notices when a connected custom domain finally resolves, and only then moves every published URL onto it. One-way: a failed probe is a network we could not reach, never a reason to rewrite an address a stranger holds
Mail auth check 6 h Asks DNS whether the domain your pitches leave from publishes SPF and DMARC, and stores the answer for the dashboard and the Compose pre-flight
Spotify back-fill 20 min Fills in an artist's missing Spotify profile link from the free catalogs, one artist per tick, because it shares a one-request-per-second queue with the lanes somebody is waiting on
Country database daily Checks for a new copy of the free country-lookup database used to put a country on a visit. Downloads at most monthly
Lifecycle mail 6 h saas only. Tells a hosted tenant their trial or AI budget is nearly gone while there is still something to decide
Operator roll-up hourly saas only. The anonymous daily roll-up the operator's dashboards read. It holds no workspace id, which is the condition on which it may outlive a deletion request
Site roll-up 30 min saas only. The marketing site's own traffic, beside the one above

The error log

When something in the app throws - a request that ends in a 500, a background sweep whose tick fails, an uncaught exception, a page in your browser that crashes, or a vendor answering 4xx where the code only stored a warning - it is recorded in the error_events table.


9b. The tracking beacon

Email open/click tracking needs a URL the recipient's mail client can actually reach - which localhost never is, and even a public BASE_URL only works while your machine is awake at the moment the editor opens the email, possibly days later.

The beacon solves this: a tiny dedicated site on your own Netlify account (created from Settings → Connections → Tracking beaconSet Up Beacon, using the same personal access token that publishes your pages). It consists of one serverless function and a Netlify Blobs store:

  1. Outgoing emails embed a pixel (/o/<token>) and rewrite links (/c/<token>?u=…) pointing at the beacon's stable URL.
  2. When an editor opens the email or clicks a link, the function parks a small JSON event in the blob store. The click still redirects instantly; the pixel always renders.
  3. Whenever this app runs, it drains the store (on boot, every 5 minutes, and via Sync Now in Settings) into the local email_tracking / epk_events tables.

Notes:

Personalized press kits (per-outlet)

Every pitch email already links the EPK with a per-recipient token (?v=…). The published page now uses that token for more than analytics: it greets the outlet by name - "Hi <outlet> 👋 - <one curated sentence>" - and leads the streaming row with their platform (Spotify for playlist curators, YouTube for channels). The sentence is written once per send (a fast AI call at send time, rewriting the pitch's personalized intro reader-facing; internal fit scores never appear) and parked on the beacon, so the published page stays fully static. If the blob is missing or the AI was unavailable, the page simply renders unpersonalized - nothing breaks.

Preview it per outlet (Releases → EPK → As outlet…): pick any outlet and the preview opens exactly as they would see it - greeting, line, platform order. It reuses the line from the real send when one exists; otherwise it writes one on the spot for the preview only. No send, no beacon, nothing stored.

▶ Hear the hook: when the release has analyzed (or hand-corrected) audio sections and a hosted MP3, the EPK player gains a button that jumps straight to the strongest section - journalists rarely give a track 60 seconds to get going. Hook plays are tracked and show in the EPK stats.

Press-kit visit analytics

Published EPK pages (not the localhost preview) carry a small beacon script that reports pageviews, first audio play, MP3 downloads, and outbound link clicks. Each pitch email links the EPK with a per-recipient token (?v=…), so visits attribute to the outlet that clicked. Once a kit is published, an analytics panel sits inline under the preview on the release workspace's Press Kit subpage: total and unique visits, the per-recipient visit table with hook plays, browser and country breakdowns, what was clicked, and views over the last 30 days. (This replaced a modal called Stats, which older screenshots and notes still name.) The Sent page shows an EPK column - an editor who viewed the kit but never replied is flagged, and eligible follow-ups for those editors compose first.


9h. The four published pages, and where they live

There are four pages a stranger can land on, and they are not four sites. Everything you publish goes onto one hub, and publishing any single surface re-renders and re-deploys the whole tree:

Surface Address on the hub Who it is for
Link in bio the root, / fans, from a social profile (section 9f)
Smart link /<release-slug> everybody, from anywhere - the one link to put in a bio or a post (section 9e)
Press kit /<release-slug>/epk journalists, linked from a pitch by {{epk_url}}
Fan kit /<release-slug>/kit people who want to make something with the song (below)

The fan kit (/<slug>/kit)

Virality is mechanically other people posting your song, and every one of them today has to rip the audio themselves and guess at the words. The fan kit lowers that to a click: the hook already cut as a downloadable sound, the lyrics ready to quote, the artwork in every crop, and - when you have rendered one - a caption-free vertical template to build on.

Every download is beacon-tracked (page:'kit'), and the page honors ?src=, so a kit link sent to a creator reports back exactly like a tagged reel does. It has its own appearance controls (the same three-way color choice the press kit and smart link offer) on the release workspace's Fan Kit subpage.


Every release can publish a smart link (release workspace → Smart Link → Publish): a public one-page surface on your hub at /<release-slug> (section 9h) with the cover as hero, a background theme derived from the artwork's dominant color, one button per platform, socials footer, and full OG/Twitter meta so the link unfurls with artwork wherever it's shared. This is the one link to put everywhere (bios, posts, videos) - the self-hosted orcd.co replacement: you own the link and the analytics.

Every artist can publish a link-in-bio page (artist workspace → 🔗 Link in Bio) at the root of the hub (section 9h): circular press photo (square press photo preferred, avatar as fallback) with a theme from its dominant color, name, short bio, social links, your custom rows (website, merch, newsletter, your press kit if you want it public), and a Music block listing every release newest-first. Each release links to its smart link when published (the whole point - one hub feeding the per-release pages), else its first streaming link.

The link-in-bio studio on that subpage controls the whole page:

Each published surface lives on its own subpage of the release or artist workspace - Press Kit (the unlisted journalist page), Smart Link (the public release page) and Fan Kit - and each one carries its own preview, its Publish / Re-publish / Unpublish buttons, its appearance controls and, once published, its analytics panel.


9g. Clips studio - shortform videos (release workspace → 🎬 Clips)

Turns audio sections into ready-to-post 9:16 MP4s (1080×1920@30, H.264 + AAC, faststart) for Reels / Shorts / TikTok. Everything renders locally through the bundled ffmpeg - no cloud, no per-render cost, nothing leaves the machine.

Each clip layers: an animated background (Blurred cover slow-zoom, Color gradient in the cover's dominant color, or Dark vignette), the cover art as a rounded, drop-shadowed card with its own slow zoom, artist - title, an "OUT NOW" / "OUT JUL 15" chip tinted by the cover color, up to three caption plates (the section's captions - tap-synced lyric lines when a sync exists), a live waveform strip, and a progress bar. Audio is the section window with 300 ms fades and loudness normalization (-14 LUFS, the platform standard). Text renders in a bundled OFL font (Poppins SemiBold) - no system-font dependency.

Workflow: pick sections (each renders as its own clip; captions editable per render without touching the stored ones), pick one or more background looks, 🎬 Render - the job runs in the background with a per-clip progress list (a 20 s clip takes roughly 30–60 s; max 12 clips per job), then each finished video appears as an inline player with ⬇ Download (named Artist - Title (Section, look).mp4) and delete. Files live in data/videos/<releaseId>/ beside the database with a manifest, survive restarts, and are cleaned up when the release is deleted (orphan folders are swept at boot). Without a cover image every look falls back to the gradient.

🎥 Music video looks. Link your own music video (the card at the top of the subpage - a direct URL to the file, e.g. a download link from your label, Dropbox, or your own server; never a YouTube page) and the app downloads it and finds where the song sits inside it by cross-correlating the audio - accurate to ~10 ms, no manual sync. The card then shows the measured offset, an overall match percentage, and a ✓/⚠ chip per section: ⚠ means the video seems re-cut in that window (narrative intro, dialogue break) and clips from it may be out of sync. Once aligned, two extra looks join the list - full-bleed (the video fills the 9:16 frame, center-cropped) and letterboxed (the whole frame floating over its own blurred fill). Video looks skip the cover card and waveform (the footage is the visual) but keep the title, chip, and progress bar - and the audio is always the uploaded master, never the video's soundtrack. If the audio genuinely doesn't match, the link is refused with the reason. Remove unlinks the video and deletes the cached file; the source is cached in data/video-src/ so re-renders never re-download.

✨ AI cover art. The problem this solves: you have one square cover, and every cover look is a different way of animating that same square - a stitched reel across four sections shows it four times. The ✨ card generates a small pool of imagery from the song itself (its title, genre, mood keywords and a real lyric line become a written art brief), and the AI art look plays the pool full-frame with the same slow Ken-Burns push the press-photos look uses - a different image at each section. A batch of three rotates through different visual treatments (cinematic, graphic, dreamlike, photographic) so the candidates genuinely differ rather than being three near-copies.

The pool is a drawer, not a gallery: generate a few, keep the ones that read at thumbnail size, delete the rest (deleting removes the file too). Every image keeps the brief it was made from - hover a thumbnail to see it. The art is generated portrait, because it exists to fill a 9:16 frame, and the briefs forbid any lettering in the image: the app draws the title, captions and end card itself, so baked-in type would collide with all three.

Generation needs an image lane - Codex signed in (your ChatGPT subscription, no per-image billing) or an OpenAI API key (billed per image); see section 2. With neither, the button is disabled and says so. Images are generated one at a time, so if a budget runs out mid-batch you keep whatever was already made.

Overlay text (step 3 in the render card) has three modes: Section captions (the static plates described above), and - once lyric lines are tap-synced in 🎛️ Sections & Lyrics - Lyric lines (each plate appears and disappears in time with the sung line) and Word by word (one big outlined word at a time, TikTok caption style; word timing is spread by character count inside each real line window - honest because the line stamps are real, no transcription involved). The timed modes work on every look, cover or video.


9c. Response analytics & learned fit scoring

Outlet analytics. The Outlets page has sortable stat columns (Sent, Open %, Reply %, Coverage) with median reply time and last interaction as tooltips. Each campaign card shows a response funnel (sent → opened → replied → covered) plus the subject A/B split when one ran; the dashboard lists the best-responding outlets.

Learned fit scoring. Every outlet accrues a track record: replies and confirmed coverage push its fit scores up, rejections and (beacon-tracked) never-opened sends push them down - time-decayed with a one-year half-life and smoothed so low volume barely moves the number, capped at ±15. The adjustment is applied on top of the AI fit score everywhere scores are used (compose, campaign gating, the queue) and always shown as a breakdown ("Fit 80 (72 +8)") with the evidence in the tooltip. The AI score cache stays pure; the track record recomputes automatically as new evidence arrives. Sends from before the beacon existed are never penalized for being "unopened" - there was no pixel to prove anything.

9d. Pitch waves

Campaigns can pitch in waves anchored to the release date instead of one big window: long-lead magazines at −45 days, blogs/radio/newsletters at the pitch window, playlists and channels at −10 days. New campaigns get this plan automatically; existing ones opt in with "Use wave plan" on the release's Campaign subpage. The panel shows a timeline (dots per wave, release-day bar, red today line) and chips to skip/restore planned waves. Waves whose date passed while the app was closed dispatch on the next hourly sweep - nothing is missed, and everything still lands in the approval queue. After release day the "out now" toggle keeps sweeping newly discovered outlets, exactly as before.

The sidebar Campaigns page is a list (like Artists and Releases): every release's campaign at a glance - status, pitch window, queued/approved/sent counts, wave progress, last run and next check. Click a row (or the Open button) to jump to that release's Campaign subpage (/releases/<id>/campaign), where all the controls live: the status


10. Environment variable reference

Every variable is optional - the app boots without any of them and warns about what is missing. .env at the repo root is loaded automatically and is gitignored; .env.example lists the lot with comments.

Truth lives in src/config.ts. If this table and that file ever disagree, the file is right - it is the only place a default is written down.

Core

Variable Default Purpose
PORT 3000 Server port
HOST 127.0.0.1 Bind address (localhost-only by default)
BASE_URL http://localhost:$PORT The app's public origin. OAuth redirects, emailed links and the legal-page URLs all derive from it. ⚠ Do not put a production value in a local .env. dev:saas derives its own (http://localhost:3998) but only when the variable is ABSENT - scripts/dev-saas.ts spells it `process.env.BASE_URL
DB_PATH ./data/getheard.db SQLite location. ⚠ A relative value follows the working directory, so two directories mean two different databases
FOLLOW_UP_DAYS 14 Days before a follow-up becomes eligible
APP_EDITION (unset)selfhost saas builds the hosted, multi-tenant edition (section 1b). ⚠ It also decides which env file is read - see below
APP_ENV (unset)production dev marks this process as the STAGING deployment (dev.getheard.now), read once as config.deployEnv. ⚠ A different question from APP_EDITION: both deployments of the hosted product are saas, and a check that asks "am I saas?" to decide whether real money moves has been asking the wrong one. Almost nothing should read it - staging differs from production by the VALUES in its env file (a test-mode Stripe key, its own DB_PATH, its own BASE_URL), not by branching code, because a fork here means production runs a path staging never exercised. Defaulting to production makes a forgotten line safe rather than silently unmetered
FFMPEG_PATH bundled ffmpeg Optional path to an operator-provided FFmpeg binary. Hosted Linux uses the checksum-pinned build installed by deploy/install-ffmpeg.sh, identically in CI, staging and production; ordinary selfhost installs keep the zero-setup bundled binary

Which file these are read from. Three, all optional, highest precedence first: the real process environment, then .env.<edition> (.env.saas or .env.selfhost), then .env. So the hosted edition and the single-owner app can hold different secrets on one machine - a hosted-only Anthropic key in .env.saas, your own in .env.selfhost, anything shared in .env. ⚠ The edition file wins over .env, which is worth stating because it is the reverse of the order they are read in (dotenv never overwrites a name that is already set, so the file that must win is loaded first). An install with only .env behaves exactly as it always has. APP_EDITION may be set in .env itself - a deployed host does exactly that - and the matching edition file is still layered on top. An installed copy (npx getheard) also looks beside its database, since it has no checkout to stand in.

AI

Variable Default Purpose
AI_PROVIDER auto auto | claude-code | anthropic | openai | codex (the Settings picker overrides). ⚠ codex is never auto-selected - its credential is a login file, not an env var that can be read as intent
CLAUDE_CODE_OAUTH_TOKEN - Claude subscription token from claude setup-token (no API billing)
ANTHROPIC_API_KEY - Claude API key (pay-as-you-go)
OPENAI_API_KEY - OpenAI key. Required for audio song analysis and image generation - no other provider does either
CODEX_HOME - Where the Codex CLI keeps auth.json (a ChatGPT-plan subscription lane)
ANTHROPIC_MODEL claude-opus-4-8 Anthropic API model
OPENAI_MODEL gpt-5-mini OpenAI text model
CLAUDE_CODE_MODEL claude-sonnet-5 Model for the claude-code provider; runs with extended thinking
CODEX_MODEL - Model for the codex provider (its own default when unset)
ANTHROPIC_FAST_MODEL claude-haiku-4-5 The cheap lane, for short mechanical calls
OPENAI_FAST_MODEL gpt-5-nano ditto
CLAUDE_CODE_FAST_MODEL claude-haiku-4-5 ditto
CODEX_FAST_MODEL - ditto
OPENAI_AUDIO_MODEL gpt-4o-audio-preview Audio-capable model for song analysis
OPENAI_IMAGE_MODEL gpt-image-1 Cover-art generation

Connections

Variable Default Purpose
GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET - Your Google OAuth client for connecting your mailbox (also settable in Settings). ⚠ In the saas edition this pair is the operator's sign-in client and does NOT open the mailbox lane - that is GMAIL_MAILBOX_CLIENT_ID below, deliberately separate so configuring login cannot light up a mailbox button nobody chose
CREDENTIAL_KEY generated key file 32 bytes (64 hex characters, or base64) encrypting mailbox passwords and OAuth tokens at rest. Unset generates credential.key in the data directory (0600) and uses that. ⚠ On a server set this explicitly: a key file beside the database is not part of the Litestream/restic backup - which is what makes those backups safe to hold, and what loses every connected mailbox if the disk goes without it being copied separately
GMAIL_REDIRECT_URI $BASE_URL/api/settings/gmail/callback OAuth callback. ⚠ Google matches this exactly - it derives from BASE_URL, so changing the port changes it, and the copy box in Settings → Connections shows the live value
GMAIL_SENDER_EMAIL - Your Gmail address (From header)
SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET - Curator discovery and the playlist trust audit
GOOGLE_CSE_KEY / GOOGLE_CSE_ID - CLOSED TO NEW CUSTOMERS - do not spend an afternoon on this (measured 2026-08-22 and confirmed on Google's own page: "The Custom Search JSON API is closed to new customers", discontinued 2027-01-01). A project created today enables the API, takes a valid key and a valid engine id, and still answers 403 This project does not have the access to Custom Search JSON API - and it is NOT the ordinary api-not-enabled error, so every "is it enabled?" check passes while nothing works. The lane keeps the provider because an EXISTING customer's key still works until 2027; for anyone else use BRAVE_SEARCH_KEY
BRAVE_SEARCH_KEY - The web index that finds an artist on Spotify from a typed name (§AF, services/webIndex.ts), and the only one of the two open to new signups. $5 of free credit a month is about 1,000 queries. ⚠ Brave takes a card, so the 1,001st CAN be billed - unlike Google's, this quota is a credit rather than a wall
WEB_INDEX_BROWSER_URL / WEB_INDEX_BROWSER_EXECUTABLE - The same lane read through a REAL BROWSER, for engines that render their results in JavaScript (services/webIndexBrowser.ts). WEB_INDEX_BROWSER_URL is a template containing {q} - the operator names which results page is opened, this repo hardcodes no engine. WEB_INDEX_BROWSER_EXECUTABLE is the path to a Chromium on the box; deploy/install-chromium.sh --print-path prints where the deploy puts one. ⚠ The URL alone ARMS it and the executable is an OVERRIDE - the driver installs and finds its own version-matched browser (deploy/install-chromium.sh). ⚠ Do not pin the path it prints: it carries the driver's build id, so the next bump leaves an env file naming a directory that is gone, and the lane then fails on every search. ⚠ It is tried LAST, after any configured API key: a launch and a render is hundreds of milliseconds and a few hundred megabytes, against ~50ms for an API call. ⚠ Measured 2026-08-22: at this volume the production box is not blocked by Google (google.com/search answers 200 with the enablejs page, not a challenge) - but what refuses a lane like this at volume is IP reputation, which no browser setting touches, so treat a working measurement as a fact about today
NETLIFY_AUTH_TOKEN - Publishing. ⚠ Read as a fallback when the settings row is absent, so blanking the row alone does not disarm publishing

Platform apps - Instagram, Facebook, TikTok, YouTube

These are the developer apps a social connection signs into, and which edition reads them is the whole point of services/social/platformApp.ts (round 139). In selfhost you register your own apps and paste their credentials into the artist workspace → 🔌 Connections (per artist, since every one of these credentials is stored per artist); the variables below are ignored. In saas they are the operator's, because a hosted tenant cannot be asked to pass a platform's app audit - that audit is the operator's cost of doing business, paid once.

Every one of them is optional and unset degrades quietly: the Connections card offers that platform disabled with the reason instead of a button that dead-ends at the vendor. That is the right behavior and it is also why a missing one is easy to miss - nothing fails, nothing logs, and the product looks exactly as designed.

Variable Default Purpose
META_APP_ID / META_APP_SECRET - The Facebook app: Pages, ad accounts, and the HMAC on Meta's data-deletion callback (POST /data-deletion). ⚠ Unset makes that endpoint refuse every request, which Meta's App Review fails on - boot warns wherever a Meta connection is plausible. ⚠ The secret is only half of that callback: the other half is the Data deletion callback URL box in the Meta dashboard, which Meta prefills with https://www.facebook.com/ - a real URL that answers 200, so a placeholder nobody replaced looks identical to a configured one from every screen. Set it to $BASE_URL/data-deletion, and see LAUNCH.md for the rest of the boxes. Both editions: a selfhost owner registering their own Facebook app needs the secret too
INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET - Not the same app as the Facebook one, even on the same Meta dashboard entry. The Instagram product issues its own id and secret - different values - and they sign the "Instagram API with Instagram Login" exchange. Meta signs a deletion callback with whichever product the person removed, which is why /data-deletion verifies against every secret configured
TIKTOK_CLIENT_KEY / TIKTOK_CLIENT_SECRET - TikTok Login Kit. ⚠ The redirect URI to register is $BASE_URL/api/settings/tiktok/callback - not the beacon relay page, which exists only because a selfhost install lives on localhost and which refuses to forward anywhere else
YOUTUBE_API_KEY - A Data API key, not an OAuth app: a public video's view count is public data, so this only decides whose 10,000-units/day quota the reads spend. A tenant supplies only their channel

saas only

Ignored entirely in selfhost.

Variable Default Purpose
GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET - "Sign in with Google" only (redirect $BASE_URL/api/auth/google/callback), asking for openid/email/profile and nothing else - which is what lets the project publish with no review. ⚠ It used to drive the mailbox lane too, and that coupling meant configuring login silently offered every tenant a Gmail connect button
CREDENTIAL_KEY generated key file 32 bytes (64 hex characters, or base64) encrypting mailbox passwords and OAuth tokens at rest. Unset generates credential.key in the data directory (0600) and uses that. ⚠ On a server set this explicitly: a key file beside the database is not part of the Litestream/restic backup - which is what makes those backups safe to hold, and what loses every connected mailbox if the disk goes without it being copied separately
GMAIL_MAILBOX_CLIENT_ID / GMAIL_MAILBOX_CLIENT_SECRET - The operator's OAuth client for connecting a tenant's mailbox (redirect $BASE_URL/api/settings/gmail/callback). Unset, and expected to stay unset: Google refused gmail.send for this product on 2026-08-23 on acceptable-use grounds, so there is no such app to configure. Hosted artists connect Gmail with an app password over SMTP + IMAP. Kept as a credential rather than an edition check so the predicate stays "is one configured"
RESEND_API_KEY - Transport for mail the operator sends a tenant (login links, lifecycle notices). Unset → those print to the server log
AUTH_EMAIL_FROM - From header for the above; its domain must be DKIM-verified in Resend
SALES_ALERT_TO sales@getheard.now Where a new signup is announced (saas). Set it empty to disable - staging does, because a stranger poking at dev. is not a lead. Our own addresses are never announced
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET - Billing. Prices resolve by lookup key after scripts/stripe-bootstrap.ts runs once
STRIPE_WINBACK_COUPON - The win-back discount, applied at checkout for a workspace whose trial lapsed or subscription was canceled. A promotion code you chose yourself, a coupon id, or a promo_... id. The code string is the one form you can make identical in test and live mode, so the same value works on every box; a promo_... id is generated per mode and differs between dev and prod. Unset = no reward. A coupon Stripe refuses is logged and checkout continues at full price
SAAS_AI_BUDGET_CENTS 300 Fallback monthly AI budget when a workspace has no plan-derived one
PAGES_DOMAIN - The operator's own zone. Since round 234 the hosted edition serves published pages itself at $BASE_URL/a/<artist>/…, so this no longer addresses a tenant's hub - what still reads it is the tracking beacon's host (go.$PAGES_DOMAIN), the reserved-name list that stops a tenant claiming a subdomain of it, and the netlify-operator pages provider, which is kept for installs still on that layout. Unset, the beacon falls back to the raw provider URL
OPERATOR_LEGAL_NAME - Who legally runs the service. ⚠ Unset renders /privacy and /terms naming nobody, which the pages say out loud and boot warns about
OPERATOR_CONTACT_EMAIL - The address on those documents for data requests
OPERATOR_ADDRESS - The registered address on those documents
OPERATOR_COMPANY_NUMBER - The company registration number, printed beside the name on /privacy, /terms, every hub's privacy page and the landing-page footer. ⚠ Set it with the register's own label included - CVR 46673425, Company No. 09876543 - because that word belongs to the issuing authority, not to the reader's language, so the app prints the value verbatim and never supplies one
LANDING_SHOWCASE real Which smart-link illustration the landing page draws. real uses the operator's own artwork and release name (public/landing/showcase-cover.jpg plus the showcase constant in src/services/landingPage.ts, which must describe the same record); drawn keeps the wordless one. ⚠ It picks one variant for everybody and is flipped by hand, not a per-visitor split test
TRUST_PROXY 0 Hops to trust behind a reverse proxy. 1 behind Caddy - without it every visitor shares one rate-limit bucket
CREW_PORT 3997 Port for Crew, the internal back-office (src/crew/) - a separate process, started separately. Never the app's port
CREW_HOST 127.0.0.1 Interface Crew binds. ⚠ Loopback by default and it should stay there: a staff console that can read every customer's row belongs behind an SSH tunnel or a VPN, not behind a firewall rule in a different system. Set it only if something else is doing the isolating
CREW_BASE_URL http://localhost:$CREW_PORT Origin Crew builds its sign-in links from, and what decides the Secure cookie attribute
CREW_BOOTSTRAP_EMAILS - Comma-separated staff addresses that may always sign in to Crew; the first click creates that person as an admin. ⚠ Empty means nobody can sign in - deliberately, since who may use the tool must not be grantable from inside it. ⚠ It only decides for an address that has no staff row: once a row exists it decides, so disabling someone in Crew holds even while their address is still listed here
INTERNAL_EMAILS - Comma-separated addresses whose workspaces are the operator's own, so Crew's funnel, revenue, AI cost, margin and active counts stop counting the house as a customer. Any address on the app's own hostname is internal already without listing it; this is for personal mailboxes that cannot be derived. The rows stay visible and serviceable in Crew's tenant list, tagged ours - only the numbers skip them
CREW_APP_SECRET - Shared secret letting Crew read the app process's health (GET /internal/health). Set the same value in both processes. ⚠ Unset, that route is not mounted at all; set but shorter than 24 characters it is also not mounted, and boot says so - a weak secret in front of the install's disk, timers and slowest routes is worse than none
CREW_APP_BASE_URL $BASE_URL Where Crew reaches the app process for that read. Needed only when the two are not on the same host
CREW_DEV_DB_PATH - The staging deployment's database file, which turns on Crew's dev/prod realm switch (/var/lib/getheard-dev/data/getheard.db on the box). Flipping it points every customer-facing page - tenants, the funnel, the support queue, the outlet catalog - at that deployment instead, the way Stripe's test mode works. ⚠ Opened read-only, so the dev realm can never be written to from here; staff sign-in, the audit trail and the error log stay this deployment's whatever the switch says. Unset, the switch is shown disabled with the reason

(The platform-app credentials a tenant connects through are the section above, because META_APP_SECRET is read in both editions.)

Kill switches and test stubs

For scratch runs against a copy of real data (see the verify skill) and for the test suite. npm run dev:saas sets the mail/publish ones for you.

Variable Purpose
AUTOPILOT_DISABLED=1 Stops the release autopilot tick. ⚠ An armed release in a copied database is a standing publisher
NOTIFY_MAIL_DISABLED=1 Stops the release-day mail to fans. ⚠ These recipients are strangers, not outlets
LIFECYCLE_MAIL_DISABLED=1 Stops trial/budget notices to tenants and the new-signup alert to sales (saas). One switch, because what a verify run disarms is "the platform's automated mail"
TRUST_SWEEP_DISABLED=1 Stops the curator re-audit sweep spending real Spotify quota
MAIL_AUTH_SWEEP_DISABLED=1 Stops the six-hourly SPF/DKIM/DMARC DNS sweep. The browser harness sets this so its injected DNS readings cannot be replaced by the deliberately blocked network
SPOTIFY_LINK_SWEEP_DISABLED=1 Stops the back-fill that fills in an artist's missing Spotify profile link from the free catalogs (§Y). Set it during a verification run so the copy's artist rows are not edited by a background tick while you are looking at them
SUPPORT_AI_CAP_DISABLED=1 Removes the help chat's daily limit on AI answers, so the assistant keeps replying instead of handing the thread to the help desk. ⚠ For local development only - npm run dev:saas sets it, because the limit is sized for a real customer and whoever is exercising the chat spends it in an afternoon. On a deployment the cap is the operator's own AI bill
ENV_FILES_DISABLED=1 Read no .env file at all - the process gets only what its own environment passes it. ⚠ The test suite sets this (tests/noNetwork.ts), so a green run means the same thing on a machine with three env files as on a fresh clone with none. Before it existed, a developer's .env.saas leaked operator credentials into tests that assert their absence
RETENTION_SWEEP_DISABLED=1 The most destructive one, and the only one that acts on the COPY itself. Stops the retention sweep deleting real visit/open rows and blanking fan addresses past their stated windows - and stops it purging a closed workspace, which un-publishes that tenant's hub through the real pages provider. Blanking NETLIFY_AUTH_TOKEN stops the un-publish and not the row deletion
NETLIFY_AUTH_TOKEN= (blank) The only way to stop a scratch run draining and deleting the live beacon's events
GEO_DB_DISABLED=1 Stops the daily check that downloads DB-IP's country database (§AH). It is the least destructive switch on this list - it reaches no vendor account and writes only a file in data/geo/ - but a scratch run has no reason to spend 4MB, and the copy already carries whatever file the real box downloaded
DBIP_BASE_URL (https://download.db-ip.com/free) Point the country-database download at a stub. ⚠ Unlike the vendor bases below, blanking it does not switch the lane off - it makes the URL relative and the fetch fail. GEO_DB_DISABLED=1 is the off switch
NETLIFY_API_BASE, STRIPE_API_BASE, RESEND_API_BASE, SPOTIFY_API_BASE, SPOTIFY_ACCOUNTS_BASE, INSTAGRAM_API_BASE, INSTAGRAM_OAUTH_BASE, FACEBOOK_API_BASE, TIKTOK_API_BASE, YOUTUBE_API_BASE, YOUTUBE_WEB_BASE, INSTAGRAM_WEB_BASE, FACEBOOK_WEB_BASE, SOCIAL_STAGE_BASE, MUSICBRAINZ_API_BASE, COVERART_API_BASE, DEEZER_API_BASE, DEEZER_WEB_BASE, SPOTIFY_OEMBED_BASE, SPOTIFY_WEB_BASE, GOOGLE_CSE_BASE, BRAVE_SEARCH_BASE Point a vendor lane at a stub instead of the real API. ⚠ Instagram takes two: INSTAGRAM_API_BASE is the Graph host the clients read, INSTAGRAM_OAUTH_BASE is the token exchange (api.instagram.com), and stubbing one leaves the other pointed at the real vendor. ⚠ The release-metadata lookup takes three, for the same reason and because it asks two catalogs: MUSICBRAINZ_API_BASE (https://musicbrainz.org/ws/2) is the catalog the label, credits and dates come from, COVERART_API_BASE (https://coverartarchive.org) is the separate Internet Archive host that catalog's cover image comes from, and ITUNES_API_BASE (https://itunes.apple.com) is Apple's store, which answers the genre and the artwork. Stubbing one leaves the others pointed at the real vendor. ⚠ DEEZER_API_BASE (https://api.deezer.com) is a FOURTH on that lane and the only vendor that answers BOTH exact keys - it is also what turns an artist's NAME into a key, so blanking it switches off the lookup for every release that carries no ISRC or barcode. ⚠ SOUNDCLOUD_API_BASE (https://soundcloud.com) is a FOURTH, for the artist-profile lane rather than the release one: SoundCloud has no keyless search, so a handle guessed from the artist's name is verified through its oEmbed endpoint (services/handleProfiles.ts). ⚠ TIKTOK_OEMBED_BASE (https://www.tiktok.com) is the same lane for TikTok, and it is NOT TIKTOK_API_BASE - that one is the authenticated posting API the connector uses, and pointing one at the other's stub arms a lane nobody meant to arm. ⚠ YOUTUBE_WEB_BASE (https://www.youtube.com) is the same lane for YouTube - a channel page names its channel in its head, and a handle nobody holds answers 404 in 390 bytes - and it is likewise NOT YOUTUBE_API_BASE, which is the authenticated Data API the connector uploads through. ⚠ INSTAGRAM_WEB_BASE (https://www.instagram.com) is the THIRD Instagram variable and the public web host: a login wall carries no Open Graph tags where a profile page does, which is what makes a guessed handle checkable there at all. ⚠ FACEBOOK_WEB_BASE (https://www.facebook.com) is the THIRD Facebook variable and the same lane for Pages - measured 2026-08-09, its login wall likewise carries no Open Graph tags where a Page does, and a Page's og:url is the CANONICAL address rather than the handle that was asked for. It is not FACEBOOK_API_BASE (the Graph host a tenant's token reads) and not FACEBOOK_UPLOAD_BASE (where a reel's bytes go), and pointing any of the three at another's stub arms a lane nobody meant to arm. ⚠ SPOTIFY_OEMBED_BASE (https://open.spotify.com/oembed) is Spotify's PUBLIC, keyless oEmbed - the one Spotify lane that survives the edition wall, because it needs no credentials at all. It is what re-reads a pasted artist link (name and picture) and what checks a resolved profile is still live before the app writes it onto a press kit; it is NOT SPOTIFY_API_BASE, which is the authenticated Web API and selfhost-only. ⚠ Only a 404 from it means "no such profile" - a malformed id was measured answering 504 - so blanking it is the way to switch the lane off, never a way to make it say no. ⚠ SPOTIFY_WEB_BASE (https://open.spotify.com) is the public album PAGE, whose Open Graph tags name the record's artist - the one lane that finds an artist's Spotify profile from a release link they pasted themselves, with no catalog and no credentials. It is the same class as INSTAGRAM_WEB_BASE, FACEBOOK_WEB_BASE and YOUTUBE_WEB_BASE, and like those it is NOT the matching *_API_BASE - pointing one at the other's stub arms a lane nobody meant to arm. ⚠ DEEZER_WEB_BASE (https://www.deezer.com) is Deezer's SECOND seam and is not DEEZER_API_BASE: the public track PAGE is the one vendor surface that publishes CREDITS to a logged-out reader (the contributor list, the ISRC and the copyright line, in 21 KB), where the API does not carry them at all. ⚠ GOOGLE_CSE_BASE and BRAVE_SEARCH_BASE are the web-index lane's two, and unlike every other seam in this row blanking them is not how that lane is switched off - it is off already unless a KEY is set. ⚠ Set blank rather than to a stub and that lane is off - no catalog is asked at all, which is what a scratch run wants
FACEBOOK_UPLOAD_BASE (https://rupload.facebook.com/video-upload/v21.0) A SECOND Facebook host, and it is not FACEBOOK_API_BASE. Publishing a reel to a Page is three phases across two hosts: start and finish go to the Graph host, and the bytes go to Meta's separate upload host. Stubbing only one leaves the other pointed at the real vendor, which is the trap INSTAGRAM_API_BASE/INSTAGRAM_OAUTH_BASE already carries one product over
INSTAGRAM_POLL_MS (5000), TIKTOK_GAP_MS (1500) Pacing knobs, in milliseconds: how often a publish waits on Instagram's container processing, and the politeness gap between consecutive TikTok page loads. Tests set the second to 0; there is no reason to change either in a real install

11. API quick reference

All endpoints are JSON under /api. In selfhost the server binds to 127.0.0.1 and there is no login, so anything on the machine can call them; in the hosted edition every one is behind a session. The ones you might script against:

Area Endpoints
Releases GET/POST /api/releases, GET/PUT/DELETE /api/releases/:id, POST /generate-blurbs, GET /pitch-platforms, POST /:id/platform-pitch, POST /:id/s4a-pitch/generate, PUT /:id/s4a-pitch, POST /analyze-upload (analyze an uploaded track by audio_id, works pre-save), POST /:id/analyze-song (raw audio/mpeg/audio/wav body ≤30 MB, or empty JSON for lyrics-based), POST /:id/analyze-sections (signal-based hook finder on the uploaded audio + AI labels; synced lyrics override caption guesses), PUT /:id/sections (manual section corrections; accepts a duration fallback for hand-authored sections; re-derives captions from synced lyrics), PUT /:id/lyrics-sync (tap-synced lyric line timestamps; refreshes stored section captions), POST /:id/videos/render (queue shorts renders, 202 + poll; body takes variants incl. video-crop/video-fit and caption_style plates/lines/words), GET /:id/videos (rendered list + live render state), GET /:id/videos/file/:file + DELETE /:id/videos/:file, POST /:id/video-source (link + align the music video by URL; synchronous) + DELETE /:id/video-source, POST /:id/check-explicit, GET /:id/cover, POST /:id/link/publish + DELETE /:id/link (smart-link page), POST /:id/epk/preview-line (banner line for the outlet preview), PUT /:id/epk-theme (per-release accent + font), PUT /:id/link-theme (smart-link layout card/stack + accent + font)
Audio POST /api/audio?name=… (raw audio/* body ≤150 MB → { id, url }), GET /api/audio/:id/:filename (download), DELETE /api/audio/:id. Link uploads to a release via audio_file_ids on release create/update
Artists GET/POST /api/artists, GET/PUT/DELETE /:id, GET /:id/avatar, press photos: GET/POST /:id/photos, GET /:id/photos/:photoId/image, DELETE /:id/photos/:photoId, link-in-bio: POST /:id/bio/publish, DELETE /:id/bio, own domain: GET/POST/DELETE /:id/domain, POST /:id/domain/verify
Blogs GET/POST /api/blogs, PUT/DELETE /:id, POST /verify-emails, POST /import-csv
Outreach GET/POST /api/outreach, POST /compose, POST /score-fit, GET /fit-scores, GET /last-contacted, POST /:id/send, POST /send-all, POST /check-replies, POST /:id/compose-form-text, POST /:id/follow-up (owner-initiated early follow-up - drafts to the queue, skipping only the time gate)
Scraper POST /api/scraper/start, GET /status, POST /cancel, POST /curators, GET /curators/status, POST /curators/analyze, GET /curators/run, POST /queries-from-release
Settings GET/PUT /api/settings/ai, GET/PUT /sending (incl. followUpDays), GET/PUT /languages, GET/PUT /theme (accent color + app base font), GET/PUT /credentials (Spotify/Gmail app credentials; settings-saved values echoed for display, env values never), templates CRUD + POST /templates/score + POST /templates/preview, GET /funnel, GET /dashboard (includes kpis, attention, missionControl, coverageWall), mail: GET /mail (which lane is sending, which is reading), POST /mail/deliverability (re-check SPF/DMARC), GET /mailbox/suggested, POST /mailbox/detect, POST /mailbox/connect, plus the Gmail OAuth endpoints
Preview GET /api/preview/epk/:id, /api/preview/link/:id, /api/preview/kit/:id, /api/preview/bio/:artistId - the exact HTML a publish would deploy, rendered in-app. Session-gated and ownership-guarded; an unpublished release must not be readable by guessing ids
Public The tracking pixel and click redirects. In the hosted edition the published pages themselves are served from this process too; in selfhost they are static files on your hub
Beacon GET /api/tracking/beacon/status, POST /api/tracking/beacon/provision, POST /api/tracking/beacon/sync, GET /api/tracking/epk/:releaseId (visit analytics), GET /api/tracking/link/:releaseId (smart-link analytics), GET /api/tracking/kit/:releaseId (fan-kit analytics), GET /api/tracking/bio/:artistId (bio-page analytics)
Campaigns GET/POST /api/campaigns (rows include funnel + waves), PATCH/DELETE /:id, POST /:id/run, POST /:id/waves/seed, PATCH /:id/waves/:waveId, GET /release/:releaseId/insights (funnel + wave timeline + angle performance + daily activity for the campaign subpage)

12. Data, backups & migrations


13. Troubleshooting

Symptom Fix
"No AI credentials configured" at startup Set one of the four credentials (section 2) in the environment, not in Settings; check the startup line AI provider: … says what you expect
AI calls bill your API account despite the subscription token Remove ANTHROPIC_API_KEY from .env - it wins over the OAuth token in the auth chain
Claude Code query failed: … errors Model not available on your plan (pick another in Settings), subscription rate limit hit (wait for the 5-hour window), or the CLI isn't logged in (claude setup-token)
Audio analysis says it needs an OpenAI key Expected - Claude can't hear audio. Add OPENAI_API_KEY or use "Analyze from lyrics"
Gmail "connection failed" The redirect URI in Google Cloud must match the one Settings → Connections prints, byte for byte - it derives from BASE_URL, so a changed port changes it. Then re-connect in Settings
Emails land in spam Check Settings → Connections → Email provider: it says whether your sending domain publishes SPF and DMARC, which is what a mail server needs and Gmail supplies for you. Then run 🧪 template scoring, keep links minimal, warm up volume slowly, use drip mode
Curator discovery button disabled Set SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET and restart. In the hosted edition the lane is off entirely (section 4)
Send fails with "email undeliverable" The MX check flagged the address - fix the email on the Outlets page or find another contact
Something is broken and you want to report it The ? help bubble in the corner of every screen (there is no Help tab in Settings - the bubble replaced it, because it costs no navigation away from whatever is broken). Selfhost points you at the project's issue tracker; hosted opens a thread that reaches the operator, with answers by email and in the same place. The error_events table (section 9) is what to quote from
Port already in use PORT=3001 npm run dev, or npx getheard --port 3001. ⚠ Changing it changes GMAIL_REDIRECT_URI, which Google matches exactly - re-register the value Settings prints
Tests hang instead of failing after a Node upgrade npm rebuild better-sqlite3. It is compiled against whichever Node major last installed it, and the mismatch does not present as an error
A published page shows an old version Publishing any surface re-deploys the whole hub, so re-publish once. If a custom domain is attached but links have not moved, the domain has not answered yet (section 9h) - the hub-domain sweep moves them on its own within 15 minutes of it resolving
Every mailbox password was lost after restoring a backup CREDENTIAL_KEY encrypts them, and the generated key file beside the database is deliberately not part of a database backup. Set the variable explicitly on anything you back up (section 10)