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
- Getting started
- AI setup: tokens, providers, and models
- Mail setup
- Spotify setup (optional)
- Core workflow
- Feature guide, page by page
- Email templates & template variables
- AI features reference
- Background automation
- Environment variable reference
- API quick reference
- Data, backups & migrations
- Troubleshooting
1. Getting started
Requirements
- Node.js 24+. Not a preference: the app is native ESM and runs its TypeScript through Node's own type stripping, and
package.jsonpinsengines.node >= 24. (If you change Node versions, runnpm rebuild better-sqlite3- it is compiled against whichever major last installed it, and the mismatch shows up as tests hanging rather than as an error.) - A mailbox you can send from - either your own mail server over SMTP + IMAP (an address and an app password is the whole setup) or a Gmail account through a Google OAuth client of your own. Section 3.
- AI credentials - one lane of four: a Claude subscription with Claude Code installed (recommended, no extra cost), a ChatGPT subscription through the Codex CLI, an Anthropic API key, or an OpenAI API key. Section 2. ⚠ These are read from the environment, never from Settings - Settings picks the provider and the models and reports which credentials it can see.
- Everything else is optional and prompted for when you first need it: a Netlify token to publish pages, a domain to publish them under, Spotify credentials for playlist-curator discovery, and your own developer app per social platform you want to post to.
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
- Put AI credentials in
.env(section 2 - this is the "how to inject tokens" part). - Connect a mailbox in Settings → Connections (section 3).
- Create an Artist, then a Release (add lyrics, streaming links, blurbs - the more metadata, the better every AI feature works).
- 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 |
Option A (recommended): your Claude subscription - no extra cost
This routes all AI calls through the Claude Agent SDK, authenticated by your Claude Pro/Max plan.
- Install Claude Code and log in (
npm install -g @anthropic-ai/claude-code, thenclaudeand follow the login). - Mint a long-lived token from your subscription:
claude setup-token - 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:
claude-codeifCLAUDE_CODE_OAUTH_TOKENis setanthropicifANTHROPIC_API_KEYis setopenaiotherwise
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:
- the OpenAI API if you explicitly chose the OpenAI provider and have a key (you picked billing);
- otherwise Codex if you are signed in (free at the margin - it uses your ChatGPT plan);
- otherwise the OpenAI API if a key exists;
- 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:
- Provider: Auto / Claude Code subscription / Anthropic API / OpenAI API.
- Claude Code model: Sonnet, Opus, Fable, Haiku - default
claude-sonnet-5, and all calls run with extended thinking enabled. Note that which models actually work depends on your plan (e.g. Fable access varies); if a model isn't available to your account the call fails with a clear error, just pick another. - Anthropic / OpenAI models: listed live from each provider's API when its key is configured, otherwise a curated list. Saved custom values are preserved.
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.
- The address is never guessed. A guessed username sends your pitches from a mailbox you do not read, and logging in successfully is exactly what a wrong-but-real address does.
- SMTP and IMAP are saved independently. If SMTP verifies and IMAP does not, you have a working outreach lane and no reply detection, and the card says so rather than reporting the whole mailbox as unconnected.
- For a Gmail address this means three steps inside Google, which the card spells out and links: turn on 2-Step Verification, create an app password for Mail, and switch IMAP on (Gmail ships it off). ⚠ A Google Workspace account cannot use app passwords at all - the card says that outright instead of leaving you retrying.
- Passwords are encrypted at rest with
CREDENTIAL_KEY(section 10).
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:
- Google Cloud Console → create a project (any name).
- APIs & Services → Library → enable Gmail API.
- APIs & Services → OAuth consent screen → External, 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.
- 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. - Paste the client ID and secret into Settings (or set
GMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET/GMAIL_SENDER_EMAILin.env), save, then Authorize Gmail. Google will warn that the app is unverified - it is your own app; choose Advanced → continue. 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:
- developer.spotify.com → create an app (any name, no redirect needed).
.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
- 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.
- 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.
- 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.
- Send: immediately, throttled batch, or drip mode (a few per hour, recipient-local Tue–Thu mornings if enabled).
- 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
- KPI hero tiles: pitches sent / email opens / replies / coverage over the last 30 days, each with a daily sparkline and a ▲/▼ delta against the 30 days before (hover for the exact windows).
- Needs attention: every signal the morning digest nags about, as one clickable row each. At the very top sit the published pages a stranger cannot play - a live smart link, press kit or link-in-bio page whose song has no master uploaded, no detected hook, or a master file that has gone missing from disk. Getting someone to the page is the expensive part; arriving and finding a dead play button is the one failure this app exists to prevent, so it outranks everything else here. (A player you deliberately switched off is a choice, not a fault, and is never reported.) Then: replies waiting on you (with the oldest wait), pending 💰 offers before they auto-close, campaign waves due within 3 days, Spotify editorial pitches inside the 28-day window (deep-links straight into that release's Pitch Studio), post-campaign retrospectives due, plus queued approvals and failed sends. Empty means the queue is genuinely clear.
- Releases (mission control): one card per active release (upcoming, recently released, or campaign-enabled) - cover, days-to-release countdown (gold inside the last week), wave progress (🌊 dispatched/total), sent/queued chips, EPK + smart-link 14-day traffic sparklines, and a derived next action button (pitch Spotify → publish EPK → publish smart link → set up campaign → approve queue → answer replies → watch the stats). Clicking the card opens the release workspace.
- Coverage wall: harvested pull-quotes as cards with a ★ toggle - on appends the attributed quote to the artist's press highlights (shown on the EPK), off removes exactly that line again. Article links ride along.
- Reach Funnel by Channel: targeted → sent → opened → listened → replied → accepted, per site type (blog, radio, magazine, …) - shows which channels actually convert for you.
- Reply Outcomes and Top genres: bar-row breakdowns of the AI triage results and where the outreach goes.
- Top Responding Outlets, A/B subject performance, and Email Deliverability (with a nudge to run Verify Emails before the next wave) round out the page. The old stat-card grid, Upcoming Releases, Recent Coverage, and Recent Sent tables are retired - the tiles, mission control, the wall, and the Sent activity feed replaced them.
Content
- Every recent post on every connected account, with its numbers, in one place - the monitoring half of the app's social lane (the posting half is the Clips studio's composer and the Posts page).
- Each post carries the release it belongs to, or the picker to attach one; platform copies of the same post fold into a single cross-post group card rather than being counted three times.
- Mentions (fan and creator posts that tagged you), the 🕐 best times read for each platform, and the 📣 boost composer - the one place in the app that can start a paid campaign, behind a confirm showing the exact total.
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
- AI web search discovery with genre/region focus; region-aware queries are written in local languages (Danish blogs get searched in Danish).
- Discovery packs: one-click curated bundles (college radio, YouTube channels, etc.).
- Sync licensing outlets (packs: Music supervisors (film & TV) and Sync music libraries): a placement in a show, an ad or a game is the largest batch of listens an independent song can land, and the workflow is the same one this app already runs - find the contact, pitch, track the reply. Both arrive as their own outlet types, so a pitch to them uses its own seeded template (see Templates below) rather than the "would love coverage" default that these inboxes delete. Pages that charge an upfront submission or listing fee are rejected during discovery, the same way pay-to-play playlists are: a sync library that bills you to be in its catalog is this lane's version of payola.
- Search from a release: don't know what to search for? Pick a release and Fill Searches from Release generates the outlet search queries from its genres, subgenres, moods, instrumentation, and origin - like a pack, but tailored to the record. With regions or outlet languages selected, some queries are written natively in those languages.
- ♻ More like my best outlets: once 3+ outlets have a positive learned track record, this generates searches shaped like your actual responders - their genre territory, outlet types, and markets (native-language queries included) - instead of hand-picked presets.
- Playlist curator discovery (needs Spotify credentials): finds independent curator playlists by genre and follower band, resolves contact emails from playlist descriptions and Linktree-style pages, flags stale playlists. Pay-to-play reality check: playlists showing SubmitHub/Groover/fee signals are stored disabled - free cold pitches to paid-placement curators essentially never land. Spend that energy on the Spotify editorial pitch (Releases → Pitch Studio) instead.
- Curator search from a release: the same idea for playlists - Search from Release generates up to 12 Spotify search terms from the release's traits and searches them all in the background with live progress (the manual box caps at 5 comma-separated terms). Runs are gentle on Spotify's rate limits: requests are paced, short
Retry-Afterrate-limit responses are waited out and retried, and playlist details are cached for 14 days so repeat runs re-check known playlists without spending API quota. - Scraper runs as resumable background jobs; interrupted jobs recover on restart.
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.)
- Full contact database: every discovered/imported outlet with site type, genre focus, submission guidelines, language, country.
- 🤝 Relationships view: your responders ranked by learned track record - tier, lifetime replies/coverage, last contact, approved people - with "quiet Nd" flags on champions not contacted in 90+ days (the digest nudges too). Share news with selected composes short, personal milestone notes ("the video is out", "we hit a playlist") via the seeded "Milestone update" template and the
{{milestone}}variable; notes land in the Queue for approval and respect the contact-frequency guard. It shares, it doesn't pitch. - People (editors & hosts): coverage comes from a person, not an inbox. Each outlet can carry named editors/hosts with role, email, socials, and the beats they cover. Find Editors scrapes the outlet's staff/about/masthead pages; bylines on articles that covered you are proposed automatically; a 🔍 enrich button hunts for a missing address (public Instagram bio when reachable - it often isn't, by Instagram's design - then web search). Discovered people arrive unreviewed; once you approve someone whose beat matches a release's genres, pitches are addressed to them personally (their email,
{{editor_name}}/{{editor_first_name}}template variables, and a "→ Name" badge on Queue/Approved/Sent). - Crawling manners: every page the discovery engine fetches is checked against the site's own
robots.txtfirst (the*group), and a page that says no is skipped with a line in the log saying so. A round-up or directory page contributes at most a dozen outlets, because a curated list is somebody else's database and only an insubstantial part of one may be taken. - Email verification: MX-record checks (✓ deliverable / ⚠ no mail server / ⚠ invalid). Runs after scraping and imports; "Verify emails" re-checks stale entries. Sending to a known-bad address fails fast instead of bouncing.
- Engagement tiers: ★ champion (opened/replied/covered before), responsive, contacted, new - with a quality score per outlet.
- 💰 pay-to-play flag: outlets that ask for payment (in a reply, or scraped signals) are chipped so you skip them consciously; the flag never auto-disables an outlet mid-conversation.
- CSV import for lists you already have.
- Removing an outlet keeps its history, and adding it again brings that back. Remove takes an outlet out of your list without throwing away the record of who was pitched and when. So adding one later at the same URL restores the row you removed - with what you just typed on it - rather than refusing as a duplicate, and the toast says it was restored rather than created.
Compose
- Pick release + template + outlets; AI personalizes each email (references the outlet's actual content, respects submission guidelines and subject-format requirements, writes in the outlet's language when you speak it).
- Release pre-flight: picking a release checks, before you spend a wave, that the places a pitch sends a stranger are actually ready to let them hear the song - the master is analyzed so a hook can be cut, cover art is set, the press kit and smart link are published, streaming links exist. Each check says its own honest sentence and carries a jump-to-the-fix button. It surfaces, it never blocks: generating with blockers outstanding asks "pitch anyway?" and takes your answer. This panel shows only the walls - the fuller picture (story, genre, credits, lyrics) lives in the release's own readiness meter, so the list you read before pitching stays short enough to read.
- Fit scores: AI rates how well this release matches each outlet before you spend a send.
- Tier tabs and badges: filter by champion/responsive/new; champions get a warm re-introduction tone automatically.
- Every outlet you cannot pitch says why, on its own row: already queued/sent/accepted for this release, doesn't take singles, too early for its lead-time window, form-only, or inside the contact gap. The row is disabled and the reason is on it - the number of outlets you can tick is the number the batch will actually write.
- Contact-frequency guard: an outlet contacted (for any release) within the last N days (Settings → Sending, default 30) is not pitchable again yet, and its row says "Contacted N days ago (gap: 30 days)". Lower the gap in Settings if you want a shorter cycle.
- Form-only outlets: blogs with a submission form instead of email get paste-ready text generated for manual submission, and Mark as Submitted records it so it is tracked like any other outreach.
- A/B subject variants (opt-in checkbox), A/B story angles, and spam-phrase preflight on every draft.
Posts
- The shorts queue across every release, in one place. Everything you have queued from a clip's Queue post button - drafts, scheduled posts, publishes in flight, failures with the reason, and what went out in the last 30 days - grouped by who it is waiting on rather than by its internal status.
- Waiting on you: a publish that failed (with the platform's own reason, and ↻ Retry), and a post that is live but needs one step in the platform's own app - a TikTok inbox draft to finish, or an Instagram story that needs its link sticker. Copy caption hands over the text the API could not carry.
- The app has these: scheduled and publishing right now. Nothing to do; they go out by themselves. ✕ cancels one.
- Drafts are composed and saved but have no time yet; Recently posted is the last 30 days, whose numbers land on the clip's journal in the release.
- Clicking a release name opens that release's Clips studio, where the clip, its journal and the composer live. Posts are always queued from there - this page is where you watch them.
Ads
- Every boost you are running, across every release, in one place - the same campaigns the release workspace's 📢 Ads panel shows, without having to know which releases to open. Grouped as Running, Paused and Finished; the Running group is drawn even when it is empty, because "nothing is spending right now" is the answer this page exists to give.
- Three headline numbers, per currency: Spent (what the campaigns have actually cost, as Meta reports it, against the total you committed), Spending today (the daily budgets of the campaigns that are LIVE, added up - what leaves your card today if they all keep running; paused campaigns are not counted), and how many campaigns the app has created on this ad account. Two ad accounts in two currencies are listed separately, never converted or added.
- The ad account these are billed to is named at the top, with its
act_…id - two ad accounts can share a name, so the id is the half that tells them apart in Ads Manager. - Each card carries the release it boosts, the status, the run's dates, spend against the ceiling, reach/engagements/clicks, the audience the money was pointed at, and a link to the boosted post. Pause is reversible, Resume picks up where it left off, End is final and asks first.
- Nothing here can start a campaign. Boosts are created from a post on the Content page, behind a confirm showing the exact total - one page that can spend is one page to audit.
Sent
- Every outreach with status, opens, clicks, and replies. By default the list is an activity feed: a thread rises when the pitch goes out, when the outlet replies, and when you answer from the app - so your most recent actually-sent email (including replies sent from triage) starts on top. Every column header is click-to-sort (Outlet, Release, Subject, Sent At, Reply, Opens, Clicks, EPK); clicking the same header again flips ascending/descending.
- Click any row (replied or not, in either view) to read the email that actually went out - subject and body as sent. From that viewer you can jump to their reply, or record an outcome without hunting for the pair elsewhere: Mark Rejected… (outlet passed) and Record Your Decline… both open the Mark Rejected dialog prefilled with the row's outlet and release, with "Who passed?" preset accordingly - switchable there, since only genuine outlet rejections touch the learned track record. Rows recorded via "Scan Sent Mail" have no stored body and say so.
- ✉️ Needs reply: the "get back to everyone" queue - every human reply that is neither an acceptance nor a rejection and still waits on your answer (interested, payment asks, unclassified replies; autoresponders and bounces excluded), oldest first with aging badges and the date each reply actually arrived. Rows clear when you send a reply through the app, click Mark handled (for answers you wrote directly in your own mail client), or the pair resolves as accepted/rejected - declining a money-ask offer counts. The daily digest shows the count and the oldest wait. Reply dates come from the mails' own Date headers (and View Original quietly corrects a row whose stored date is off).
- Check replies: scans your connected mailbox for answers and AI-classifies them (accepted / interested / rejected / 💰 payment ask / auto-reply / bounce), with a suggested response. View Original in the reply view fetches their actual email(s) live from the conversation in your mailbox - the summary is a lens, the thread stays the source of truth (works for replies triaged long ago; nothing is duplicated into the DB). The reply view's Classification selector lets you correct the AI when it misreads - switching a reply to 💰 unlocks the offer actions (polite-decline draft, Decline Offer, disable outlet) and flags the outlet pay-to-play, exactly as if the triage had caught it. Every replied row has a View button.
- 💰 Payment asks are not rejections. When an outlet offers a placement for money, they said yes-for-money - the decision is yours. The reply card offers Decline Offer (recorded as your call: the Rejected page shows "You declined" vs "Outlet passed", and the outlet's learned track record is never penalized) and one-click Disable Outlet (pay-to-play). Offers you simply ignore auto-close as your decline after N days (Settings, default 14; the digest warns before it happens).
- Draft Reply: for any triaged reply, one click drafts a response to their actual message - in the conversation's language, in your voice, grounded only in real facts, with the right links pre-filled (EPK, Spotify, private stream, downloads). Payment asks get a polite decline draft. You edit the draft, then Send Reply threads it onto the existing conversation. Nothing ever sends unreviewed. After sending a reply to a 💰 payment ask, the app offers to record the pair as declined by you in the same motion (skip it if your reply wasn't a decline).
- Follow-ups become eligible after the follow-up window (Settings → Sending, default 14 days;
FOLLOW_UP_DAYSis the env fallback) and are never auto-sent - they queue for your approval. Any single sent pitch can be chased sooner: the Follow up button on its Sent row drafts the follow-up immediately (same composer, same thread), skipping only the wait - the button hides once the outlet replied, the pair resolved, or a follow-up already exists. - Coverage harvesting: accepted outlets are periodically scanned for the published article; a quote is extracted for your press highlights - and the article's byline author is proposed as a contact on the outlet's People list.
- Clipbook: on Accepted (and the dashboard's Coverage wall, where ★ is a toggle), every harvested quote has "★ Highlights" (append it, attributed, to the artist's press highlights) and "Copy" (attributed quote to the clipboard). Coverage quotes for a release also render as a "Press on this release" section on its published EPK - journalists trust other journalists.
Releases
- The page is a list + per-release workspace. The list shows the essentials (cover, artist, title, type, genre, release date) with Open / Edit / Delete; clicking a row (or Open) enters the release's workspace - an inner sidebar with one subpage per feature: Press Kit, Smart Link, Campaign, Insights, Pitch Studio, Angles, Sections & Lyrics, Clips, Runway, Fan Kit and Ads. Insights is the release's numbers whether or not a campaign exists: the pitch funnel with its angle table and 30-day activity, why outlets passed, where placements come from, and how the clips posted for the song are doing, added up by day. Campaign keeps only the campaign: its status, the wave plan and the controls. Each subpage carries its status, actions, and (for the page types) an embedded live preview. New features get new subpages.
- Outreach readiness (a bar above the sub-tabs, on every one of them): how complete this release is, as a percentage, with what is left and why it is worth closing. Once nothing is left the bar becomes a small 100% badge in the header row, so a finished release gives the space back without the app going quiet about it. Open it for the full list - the walls a pitch would hit (the song can be heard, cover art, press kit and smart link published, streaming links) and the information that makes a pitch worth reading (the story, genre, release date, one-liner, credits, lyrics). Each row says its own honest sentence and carries a button that opens the exact field or page that fixes it. Nothing here blocks anything; it is there to be climbed, and 100% is always reachable - a check that cannot apply to this release is left out rather than counted against it.
- 🎛️ Sections & Lyrics (workspace subpage): the audio-section studio - waveform timeline with draggable sections and the Musixmatch-style lyric tap-sync. See section 8 ("Audio sections & lyric sync").
- 🎬 Clips (workspace subpage): renders 9:16 videos from the sections, locally via ffmpeg. See section 9g.
- 🚀 Runway (workspace subpage): the T-minus content ladder - one rung per day in the run-up to release, each computed from the release's own sections, sync and date, each with the post it wants and a composer to queue it. Arming it hands the ladder to the autopilot, which renders and queues each rung on its due day; nothing publishes that you did not arm.
- 🎁 Fan Kit (workspace subpage): the fourth published page - see section 9h.
- 📢 Ads (workspace subpage): the paid boosts running for this release. The cross-release view is the sidebar's Ads page above.
- Press Kit appearance (workspace → Press Kit): per-release accent color (overrides the app-wide theme color) and font (a curated set of Google Fonts; the default is Work Sans, the face every Get Heard surface is set in). Changes hit the embedded preview immediately and reach the published kit on the next Re-publish.
- Smart Link appearance (workspace → Smart Link): layout picker (orcd-style Card vs the classic Stack), accent color, and font - same live-preview-then-Save flow; see section 9e.
- Inline analytics panels (workspace → Press Kit / Smart Link, once published): KPI tiles with 7-day delta badges, a 30-day views+clicks chart (hover for any day; "View as table" for exact values), platform-click and traffic-source breakdowns, browsers/countries - and, on the Press Kit, the per-recipient visit table with hook plays. These replace the old cramped Stats modal (the Artists page's bio Stats modal shows the same panel).
- Campaign analytics (workspace → Campaign): the funnel (sent → opened → replied → covered, with conversion percentages), the wave timeline (status, due date, 📐 angle chip, and per-wave sent/reply counts bucketed by dispatch time), an angle-performance table, and a 30-day sent/opens/replies activity chart - under the usual status chips and Manage button.
- Full release metadata; localized fields (blurbs, about) per language. Locale override rows for your default languages (Settings → Languages) are pre-added on every new release.
- Spotify album URI (Edit release, under Streaming Links): paste the album URI or URL that Spotify for Artists shows before release day (
spotify:album:…or anopen.spotify.com/album/…link - any form works). It is the exact album address from the second the release goes live: the release-day watcher (section 9) checks it hourly around release day, fills the Spotify streaming link automatically the moment the album is live, and re-publishes the smart link so its hidden Spotify button appears with zero manual steps. - Music video (workspace → 🎬 Clips): link your own video file and the app aligns it to the uploaded master by matching the audio - see section 9g.
- Moods & Instruments: two free-text keyword fields (e.g. moods "energetic, cinematic, sad"; instruments "analog synths, live drums, saxophone"). They're injected into every AI prompt that describes or pitches the release - personalized intros, blurbs, the about text, Pitch Studio, fit scoring, and guideline rewrites - and merged with whatever the AI song analysis detected. Instruments matter for cross-genre pitching: they let the AI argue why a pop-rock track heavy on synthesizers still fits an experimental synth blog. Also available as
{{moods}}/{{instruments}}template variables. - Audio Files: upload the track as MP3 and/or WAV directly in the modal - before the release is even saved. The upload is reused everywhere: AI song analysis listens to it, the Duration field is auto-filled from the file, and the "Direct MP3/WAV download" fields can optionally point at the app-hosted file (checkbox) instead of an external URL. After each upload the app offers to AI-auto-fill genre, BPM, and the song analysis from the audio.
- Instrumental (same section, its own slot): the vocal-free mix, as MP3. It is what a music supervisor asks for after the clearance line, and it is kept strictly apart from the master everywhere - nothing that plays your song will ever play it. Uploading one fills nothing in the form (an instrumental is a separate recording with its own ISRC, so reading its tag would fill your release's fields with another track's facts), and it is not published unless you say so: the checkbox beside it offers the file for download on your published press kit, which is a public page, so anyone who finds the kit can keep the file. Leave it off and the instrumental stays yours, ready to send to the people you choose. When it is on and the press kit is published,
{{instrumental_url}}fills with the direct link for use in pitch templates - and it is empty in every other case, so a template can never mail out a dead link. - ✨ Generate blurbs: AI writes the long + short press blurb in every language you have locale rows for - grounded in lyrics, bio, and metadata; click again to regenerate. (See section 8.)
- 🎧 AI Song Analysis and 🏷️ clean/explicit check: see section 8.
- Pitch Studio: Spotify for Artists editorial pitch (the 500-char pitch that actually gets playlisted - submit ≥7 days before release; the app badges releases when the window is closing), plus tailored pitches for Apple Music, Amazon Music, Deezer, radio one-sheets, and press-release openings. Two variants each, copy-paste ready.
- Story Angles: editors cover stories, not songs. "Angles" generates 4–5 distinct press hooks from the release's real facts (local scene, production story, backstory, sonic identity - never invented), editable and extendable by hand. Pick one per compose batch (Compose page) or assign one per campaign wave (campaign modal): each wave's personalized intros then lead with that hook, and different angles on different waves is a built-in A/B test. Wave chips show their angle (📐).
- Campaigns: schedule waves of outreach around a release date (announce → premiere ask → release day → follow-up).
- Campaign goals: optional targets (opens / replies / coverage) set in the campaign modal; 🎯 progress chips on the campaign card track them against the live funnel.
- Release retrospective: after a campaign wraps (the digest nudges 3 weeks post-release), Retro writes an honest AI narrative from the real numbers - funnel conversion, wave and story-angle performance (each pitch records the angle it carried), standout responders, track-record movement, goal attainment - plus concrete recommendations that pre-fill your next campaign's defaults (always shown, never silently applied).
- EPK: every release can publish a press-kit page at
/<release-slug>/epkon your hub (cover, blurbs, streaming embeds, downloads) - linked in emails via{{epk_url}}. Publishing is localized: besides the English root page, one sibling page per targeted language (Settings → Localization) deploys as<code>.html(e.g.da.html), rendered from the release/artist locale overrides (missing overrides fall back to English - run 🌐 Add missing language overrides first for fully native pages).{{epk_url}}then sends each outlet its own language's page - a Danish magazine reads the press kit in Danish (with its per-recipient tracking token and personalized greeting attached, same as the root page). Re-publish after adding languages or overrides to refresh the pages - the localized pages only exist once you publish while the language is targeted.
Artists
Clicking a row (or Open) opens the artist's workspace - an inner sidebar like the release workspace, with one subpage per feature:
- Profile completeness (a bar above the sub-tabs, on every artist subpage): the same meter, asked about the artist - the bio, a photo, the short bio, the origin, somewhere for fans to follow, the link-in-bio page, press highlights. It becomes a 100% badge in the header row the same way. Open it for the rows and their fix buttons, which open the right accordion of the Edit artist dialog. Pronouns are deliberately not scored.
- 👤 Profile: the artist at a glance - origin, current location, pronouns, bios, press highlights, socials - with Edit artist opening the full form (bio long/short, press highlights, origin, socials, avatar; shared across releases and injected into every pitch).
- 🔗 Link in Bio: the link-in-bio studio - section 9f.
- 📷 Press Photos: upload any number of photos; they're automatically grouped by aspect ratio - Square, Portrait (9:16), Landscape (16:9) - and published on every release's EPK page under "Press photos", so outlets can grab the format they need. Uploads and deletions here save immediately (photos can also still be managed inside the Edit artist dialog).
- 🔌 Connections: this artist's Instagram, Facebook, TikTok and YouTube accounts. Connections are per artist, not per install, so two artists in one workspace never share a token. In selfhost you register your own developer app per platform and paste its credentials here (section 10); a platform with no credentials is offered disabled with the reason rather than as a button that dead-ends at the vendor.
- 📊 Audience: everyone who asked to hear about a song of yours, across every release - the shape of the list (how it grew, where the signups came from), never the addresses. Nothing on the page needs one, and the app is what sends the mail; Settings → Your data exports the list itself.
- ⚖️ Compare: your recent campaigns lined up on the same axes, so you can see whether you are getting better at this.
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.
- ✉️ Templates: per-site-type templates (radio pitch vs. blog pitch vs. channel pitch), before/after-release variants, localized subjects/bodies with 🌐 Translate (AI-fills the subject+body override rows from the English base - targets the language rows shown plus the languages you target in Settings;
{{placeholders}}are preserved; each override row also has its own 🌐 to re-translate just that language), live preview against a real blog+release (renders what's currently in the dialog, saved or not - iterate without closing), and 🧪 Score with AI (section 8). - 📬 Sending: your sender identity (artist vs. representative, response email & phone used in signatures) plus drip mode (max ~12/hour, daily cap), pitches waiting at a time (a campaign stops composing new pitches for a release once this many are queued or approved, and composes more as they go out - default 20, so the AI spend follows what is actually sent), daily digest email, minimum contact gap, the follow-up window (days before a chaser is due -
FOLLOW_UP_DAYSis the env fallback), offer auto-close, smart send timing (recipient-local Tue–Thu mornings), and the optional polish pass. - 🌍 Localization: your native language plus the languages you target (one merged setting; you don't have to speak them): override rows for these locales are pre-added on every new release, 🌐 Translate fills them, and outlets in a targeted (or native) language get native-language emails. 🌐 Add missing language overrides scans every release, artist and template for override gaps in the targeted languages and AI-fills them in the background (never overwrites existing text) - also offered automatically when you add a new language and save.
- 🌍 Localization → your timezone: the clock everything with a date or an hour is measured against - when a runway rung is due, when the daily digest arrives, when release-day mail goes out to your waiting list, when the daily send limit resets, and which day and evening the best-times board files a post under. Your browser's timezone is picked up automatically the first time you open the app, and you can pin any zone (or "follow the server") afterwards; the card shows the current time in the zone it is using, so a wrong one is obvious at a glance. This matters most when the app is not running on your own laptop: a rented server keeps its own clock, and without this every deadline would be measured in the machine's timezone rather than yours.
- 🤖 AI: provider & model pickers (section 2) and the discovery cost controls.
- 🔌 Connections: your Email provider, the Netlify token for publishing, your domain, the tracking beacon (section 9b), and the Spotify API app (client ID/secret).
- Email provider holds every mailbox the app can send your pitches through - Gmail (with its OAuth app credentials - client ID/secret/redirect URI - editable in an accordion) and your own mail server (SMTP, with IMAP inside it for reading the answers back). One of them is sending at any moment and the card says which, with the address an editor will see and reply to: a mail server takes over whenever one is connected, and disconnecting it hands sending back to Gmail. Each row is chipped In use, Standing by or Not connected, so a mailbox that is connected and not being used cannot look like one that is. There is deliberately no "which mailbox?" picker - the app derives it, so the answer can never disagree with what actually happens. Sending and reading are two separate facts and the card states both: a mail server can send and cannot read, so Check Replies stays off there until IMAP is connected. A Gmail address connects through the mail-server row, and the card teaches that path: a disclosure spells out the three steps that happen inside Google (2-Step Verification, an app password for Mail, and IMAP, which Gmail ships off), links each one, and a button fills in Gmail's hosts and ports. ⚠ A Google Workspace account cannot use app passwords at all, and the guide says so rather than leaving you retrying.
- Your domain points your own hostname at every page the app publishes. You paste the four nameservers it shows you at your registrar once; the card's three lights go green on their own, and only then do your published links move onto the domain - until they do, everything keeps serving from the Netlify URL, which works. If a domain that was live stops answering (a lapsed registration, nameservers moved elsewhere), the dashboard says so and the card offers Stop using this domain, which puts every link back on an address that serves. Links you already shared on the dead hostname stay broken either way, which is why the app never takes the decision for you. Every credential follows the same rule: a value saved here beats the corresponding env var, and clearing it falls back to the env. Saved values stay visible in their fields after saving (secrets sit in password inputs, so they display masked); env-sourced values are never echoed - the status line names the env vars instead. Cards show a state chip (Connected / Configured / Not set) and whether the value comes from Settings or the env.
- 🎨 Appearance: the app's accent color and base font (the same curated Google Fonts set the EPK and Smart Link use; the default is Work Sans). Picking a font applies immediately for judging, Save persists it. (Published pages have their own appearance controls on each release's Press Kit and Smart Link subpages.)
- ♿ Accessibility: three settings, each with a System option that follows your device and two that overrule it in either direction - because a device setting you cannot change (a work laptop) and a device setting that is on for some other reason are both real.
- Text size scales the app's text from 90% to 140% without resizing the layout. That is the part browser zoom cannot do:
⌘+grows the boxes too, so you get the same amount of text in a bigger window, while this gives you bigger text in the same window. Use zoom when you want everything larger and this when the 13px hint lines are the problem. It applies as you pick, so you can judge it; Save keeps it. - Motion turns off transitions and decorative animation. Spinners keep turning either way - a still spinner would be telling you work had stopped.
- Contrast switches the whole app to a higher-contrast palette: stronger text, stronger edges, no background texture. Your accent color is kept, and its link and hover shades are recalculated so they stay readable against the new background.
- Settings are saved to your workspace, so they follow you to another browser, and they change nothing a fan sees on your published pages - those pages carry no settings of their own and simply honor whatever the visitor's device asks for.
- Not settings, because they are always on: every text/background pairing in the app is contrast-checked in every palette as part of the test suite, keyboard focus draws a visible ring on every control, Skip to content is the first thing Tab reaches, and dialogs announce themselves, keep the keyboard inside while open, and hand focus back when they close.
- Text size scales the app's text from 90% to 140% without resizing the layout. That is the part browser zoom cannot do:
- 👤 Account (saas): your sign-in - the email on the account, the devices signed into it, and your password. Selfhost has no login, so the subpage is not offered.
- 📦 Your data: Export everything and Import an export, plus closing the account down. Section 12 has the detail, including the three things an import deliberately does not carry across.
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:
- 🎧 Analyze uploaded audio - uses the MP3/WAV uploaded in the modal's Audio Files section (no separate upload); an audio-capable OpenAI model listens and reports genre, subgenres, moods, instruments, vocal character, tempo, energy, and a paste-ready sonic description. Requires
OPENAI_API_KEY(works even when Claude is your main provider). Works before the release is first saved - the analysis rides along with the save. Right after an upload the app also offers to auto-fill genre and BPM from this analysis. - 📝 Analyze from lyrics/metadata - works on any provider; infers the same fields from lyrics, genre, and about-text, and is explicit about being an inference. Needs a saved release (it reads the stored lyrics).
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.
- One row per distinct failure, with an occurrence count, grouped on the error name (or its
err.*code), the scrubbed message and the topmost frame in the app's own source. A sweep failing every tick for a week is one row, not twenty thousand. - A 4xx is not recorded. "Release not found" and "over your AI budget" are the app working.
- Scrubbed on the way in. Email addresses, access tokens, absolute file paths, URLs (host kept, path and query dropped) and long digit runs are replaced before anything is stored, and the request body, headers, cookies and query string are never read at all. The route pattern is stored, never the URL, so no release or artist id is in the table. There is no workspace or user column.
- Deleted 30 days after a failure last happened (
ERROR_LOG_RETENTION_DAYSinshared/legal.ts, enforced by the retention sweep). A defect that is old but still firing is kept. - Nothing is sent anywhere. There is no reporting vendor, no key and no toggle - the table is on your own disk, in both editions. In the hosted edition the operator reads it in Crew; on a selfhost install it is the thing to look at (or quote from) when reporting a bug.
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 beacon → Set Up Beacon, using the same personal access token that publishes your pages). It consists of one serverless function and a Netlify Blobs store:
- Outgoing emails embed a pixel (
/o/<token>) and rewrite links (/c/<token>?u=…) pointing at the beacon's stable URL. - 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.
- 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_eventstables.
Notes:
- Tokens are random, not sequential ids, so events can't be forged or enumerated by strangers who find the public function.
- Privacy: events carry the user-agent, coarse geo (country/city, when Netlify provides it), and a short one-way hash of the IP for unique-visitor counting - never the raw address, and no cookies.
- Provisioning ends with a selftest (the function round-trips a blob); Settings shows the result. Free-tier Netlify limits (125k function calls/month) are far beyond what music-PR volumes generate.
- Deleting the site in the Netlify dashboard kills all tracking URLs in already-sent emails; re-provisioning mints a new URL for future sends.
- Without a beacon, behavior falls back to the old rules: tracking only if
BASE_URLis public, otherwise plain-text emails with no tracking. - ⚠ The beacon's URL is the one URL in this app that can never be moved, because it is baked into emails already sitting in strangers' inboxes. Attaching a custom domain gives it an additional address; it never replaces the old one.
- In the hosted edition there is nothing to set up - the service collects tracking events itself, and the card says so instead of offering a button.
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) |
- In selfhost the hub is a site on your own Netlify account. It is created once, named
getheard-hub-<random>.netlify.app, and that name never changes - existing hubs keep whatever they were created with. In the hosted edition the service hosts it, atgetheard.now/a/<artist>/…. - Attach your own domain and everything moves onto it -
yourname.comis the bio page,yourname.com/song-titlethat release's smart link,yourname.com/song-title/epkits press kit. ⚠ Only a domain that has actually answered moves a link. Being attached is what we did; being live is what the internet says, and only the second one rebases a URL. It is a one-way ratchet: a failed probe is a sleeping laptop far more often than a dead domain. Everything served on the old address keeps working. - ⚠ A public name is never served twice. Deleting a release does not put its slug back in the pool, because the next claimant would inherit a stranger's links.
- Preview any of them without publishing. Every studio embeds the exact page a publish would
deploy, rendered in-app at
/api/preview/epk/:id,/api/preview/link/:id,/api/preview/kit/:idand/api/preview/bio/:id- same layout and content, images served locally, no tracking script. Nobody types those URLs; they are loaded into an iframe, and they sit under/apibecause that is the one prefix already reserved and already behind the session gate.
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.
9e. Smart links - the release landing page
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.
- Buttons come from the release's Streaming Links (release modal). Each button carries its platform's official logo and brand color (Spotify green, Apple Music red, the rest as white rows with the brand mark) - inlined into the page, no external requests. There is deliberately no pre-save support: a real pre-save needs per-fan OAuth on an extended-quota Spotify app (closed to indie developers), and the "pre-save" URLs distributors hand out just bounce fans through the distributor's own dialog - the page links fans straight to the music instead.
- Spotify appears without a link. If the release carries a Spotify album URI
(release modal, under Streaming Links), the page uses
open.spotify.com/album/<id>as the Spotify target even before you've entered a streaming link. That URL is dead until release day, so pre-release the button renders hidden and reveals itself at local midnight on release day with zero redeploys (client-side; the app also re-deploys once on the first boot after release day so crawlers see it too) - and the hourly release-day watcher fills the real streaming link and re-publishes the page the moment the album is actually live (section 9). - Press play, right here. Above the buttons sits one play control, and which one it is depends on the calendar. Before release day it is your own 30-second hook cut, taken from your master (the same cut the fan kit hands out) - the only thing that can make a sound while the song is on no service, which is exactly when an announce link works hardest. Once the song is out and a service can play it, the real streaming player takes that slot on its own and the hook cut steps aside: at that point both are a taste of the same song, and only one of them is a stream that counts for you. A page published before release day swaps over by itself at local midnight, no re-publish needed. A released song with nowhere to embed (no Spotify or SoundCloud link) keeps the hook player, so the page is never left without a way to hear the song. Turn the hook player off in Appearance if you would rather not put 30 seconds out early - that switch silences the slot entirely, both before and after release day.
- The follow ask. Under the buttons the page prints one line -
Follow <you> on Spotify- deep-linking to your own artist profile. It is the only thing on the page about the next song: a Spotify follow puts you in that fan's Release Radar, so your next release reaches them without anyone doing anything. It picks the single best profile you have filled in on the Artists page, ranked by how directly a follow there turns into a future listen (Spotify, Apple Music, YouTube, SoundCloud, then Instagram, TikTok, Facebook) - one ask, never a row of them, and never above the play button. Fill in no profile and the line is not rendered at all. It shows before release day too, which is when it is worth the most. Clicks land in the same stats panel as the streaming buttons, asFollow on <service>. There is no on/off switch: the control is the profile field itself. (It is a link, not a one-tap follow - following from inside a page needs the same per-fan Spotify OAuth that rules out pre-save.) - The fan list. Every smart link, before and after release day, carries a small form: Email me when {artist} releases new music. One email per release, unsubscribe any time. The same form sits on your link-in-bio page and on the fan kit. A fan who fills it in is on your list: on the day each later release comes out, the app mails them its smart link from your own mailbox, in the language they were reading the page in, with a one-click unsubscribe at the bottom (which removes their address from every row you hold for it). A fan who joined on a pre-release page gets that song's release-day mail first; a song that came out before they joined is never news to them. The switch is on the Smart Link subpage (it governs the fan kit too) and in the bio studio for the bio page, and the form only renders while the tracking beacon exists to carry the address. Addresses collected before the list existed (under the old "one email, on release day" promise) are still mailed once and then anonymised; they never join the list. The Audience tab on the artist shows who is on the list and where they came from, never the addresses.
- Publish / Re-publish / Unpublish work exactly like the EPK: the slug is minted once and the URL survives every re-publish; unpublishing takes the page off the hub and kills every shared copy of the link. Unlike the EPK, the page is not noindexed - being found is its job.
- Stats live inline on the Smart Link subpage (under the preview, once published):
views / unique visitors / platform clicks tiles with 7-day deltas, a 30-day daily
views+clicks chart, per-platform click bars (URLs folded to brand names), and
Traffic sources - the referrer domains visitors arrived from (instagram.com,
t.co, …). Events ride the same beacon as everything else (
page:'link'), so the beacon must be set up in Settings for analytics (the page itself works without it). - Preview (no publish needed): every published surface has a Preview that renders the exact page in-app - same layout and content as a publish, with images served locally and no tracking. See section 9h for the addresses.
- Appearance (Smart Link subpage): pick between two layouts - Card (the
orcd-style look: cover art beside a dark button panel, floating over the full-bleed
blurred artwork; stacks by itself under ~700 px) and Stack (the classic centered
column) - plus an accent color (overrides the cover-derived background and
highlights the release date) and a Google Font from the same curated list the
EPK uses. Picking anything refreshes the embedded preview immediately
(
?preview_layout/accent/font- nothing persists until Save); Save storesreleases.link_theme, and the live page picks it up on the next Re-publish. Pages published before choosing keep the classic stack. The pre-release button flip works identically in both layouts. - The chrome speaks the fan's language. The page is served in English, but a visitor whose browser is set to a language you target (Settings → Localization) sees the chrome around the song - "Listen on Spotify", "Out now", "Choose your service" - in their own language, swapped in on load (the fan's language, read from the browser, not your UI locale). It costs a few hundred bytes only when you target an extra language, and nothing at all when you don't. The song's title, your name and any blurb stay as you wrote them - only the words around them translate. The same applies to the link-in-bio page (9f). Full press-kit localization is separate and richer (per-outlet, 9 languages).
9f. Link in bio - the artist page
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:
- Layout: Stack (the classic centered column) or Card (photo on the left, content on the right, floating over the full-bleed blurred photo - the same two-panel Card layout smart links use). Both share one markup (only body/wrapper classes switch), so tracking behaves identically in both.
- Color: the page is built out of one color - the wash behind everything and the gradient across your name - and you choose where it comes from, the same three-way choice the press kit, smart link and fan kit offer. Auto (from artist photo) is the default and is what every bio page has always done; Use my theme color takes the app-wide color from Settings → Appearance; Pick a color takes a hex. Auto with no photo yet falls back to a neutral pair, so the page still looks finished.
- The follow ask appears under the Music block, the same one line the smart link prints and picked the same way - with one difference: it only ever offers a profile this page is still showing. Toggle Spotify off in Links below and the ask moves to your next-best profile; toggle them all off and there is no ask.
- The fan list form sits after the Music block: Email me when {artist} releases new music. A fan who fills it in hears from you, from your own mailbox, on the day each later release comes out, with a one-click unsubscribe. The switch is in the bio studio; the form only renders while the tracking beacon exists to carry the address. Section 9e has the whole of it.
- Links: every link the page would render, grouped as Socials / Custom links /
Music. Toggle any link off, drag to reorder (or use the ▲▼ buttons), edit and
add custom links inline. Everything refreshes the embedded live preview immediately
(
?preview_layout/hidden/order- nothing persists until Save); new custom links appear in the preview after saving. Save storesartists.bio_links+ the curation inartists.bio_theme({layout, hidden, order, font, preview, accent}on stable keys - social platform names,release-<id>,custom-<index>); the live page picks changes up on the next Re-publish. Hidden releases don't upload their cover art at all. - Publish / Re-publish / Copy / Unpublish live on the same subpage, and once published
an inline analytics panel (views, unique visitors, link clicks, 30-day chart,
per-link table, traffic sources) replaces the old stats modal - stats key on the
artist (
page:'bio'beacon events). - Your own domain sits at the bottom of the same subpage on the hosted service, and
it is per artist rather than per account - the artist holds the ROOT of the domain,
so
yourname.comis this page,yourname.com/song-titlethat release's smart link andyourname.com/song-title/epkits press kit. Two DNS records do it: a TXT record (_getheard.yourname.com) holding the token the card shows, which proves the domain is yours, and an A record (or awwwCNAME) pointing the domain at the service - the card prints both, resolved from the service's own address rather than from a number somebody typed into a config file. Press Check now and three lights answer in order: you own it, the pages answer there, your links use it. The certificate is issued automatically on the first visit, and nothing you have already shared breaks: thegetheard.now/a/<artist>address keeps serving forever, and the pages name the domain as their one canonical home so search does not see two copies. Only a domain that has actually answered moves your links; if one that was live stops answering, the dashboard says so and Remove domain puts every link back on an address that serves. A second artist in the same account brings their own domain or stays on the service's address, because two artists cannot both hold one root. (Self-hosted installs connect the whole install's hostname in Settings → Connections instead - see section 6, Your domain.)
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
- funnel + goals + interactive wave timeline, run progress, Check now, Pause/Resume, Edit campaign (targeting, min fit, waves, goals), Retro (AI retrospective), View Queue, and Delete - plus the campaign analytics (funnel, 30-day activity chart).
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
- Single SQLite file at
DB_PATH(WAL mode, foreign keys on). Back up by copying the file (plus-wal/-shmsiblings if the server is running, or just stop it first). - Schema migrations are sequential and run automatically at boot; the version reached is stored in the
settingstable asschema_version. To find out what the current one is, read the last entry of the migration array insrc/db.ts- deliberately not restated here, because this line said "currently v54" for dozens of migrations and a number written down in a second place is a number that goes quietly wrong. The boot log prints it too (Migrations complete (now at …)), andtests/docs.test.tsgates it. - ⚠ A database can be many migrations behind the code - a copy taken months ago catches up on its first boot. That matters when running SQL against a copy before starting it: a statement naming a column a later migration added fails, and in a batch every statement after it silently never runs.
- ⚠
credential.keyin the data directory is not optional to back up, and it is not in the database. It encrypts your mailbox passwords and OAuth tokens at rest; without it a restored database has every connection dead. Either copy it alongside, or - better on a server - setCREDENTIAL_KEYexplicitly so the secret lives where the backup does not. - Base64 images (covers, avatars, press photos) live in the DB; list endpoints strip them for speed.
- Settings → Your data → Export everything writes a zip you can read without an account: one JSON file per subject (plus CSV for the outreach log and the waiting lists), a README that says what each file is and what is deliberately left out, and the real files -
audio/,artwork/,b-roll/,fonts/. Rendered videos are left out because they are made from what is in it. - Settings → Your data → Import an export reads one back. It restores into an empty workspace only - an import is a restore, not a merge, so the button is disabled with the reason once there is an artist or a release to overwrite. Every id is re-minted and every reference rewritten, so the archive can come from a different install. Three things deliberately do not come across, and the report names them: your plan and billing (a workspace's plan is settled where it is paid for, never by a file), your connected accounts and keys (they were never in the archive), and your published pages - the addresses in the archive belong to the install you left, so the pages start unpublished and come back at the same paths once you connect a domain and press Publish. An armed autopilot arrives disarmed and a scheduled post arrives as a draft, so nothing posts to a real account on the strength of a file.
- The
error_eventstable (section 9) is a diagnostic, not data - it prunes itself after 30 days and is safe to empty at any time. - Uploaded release audio (MP3/WAV) is stored on disk next to the DB in
data/audio/<id>.<format>with metadata in theaudio_filestable - include that folder in backups. Uploads never linked to a saved release are cleaned up after a day at startup; deleting a release deletes its audio. - Rendered shorts are stored on disk in
data/videos/<releaseId>/with amanifest.jsonper release (no DB rows) - include the folder in backups if the videos matter to you; they can always be re-rendered. Deleting a release removes its folder; folders for missing releases are swept at boot. - Linked music videos are cached on disk in
data/video-src/<releaseId>.<ext>. No need to back them up - unlinking deletes the file, and re-linking the URL downloads it again.
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) |