← Blog
>Article

Building SunoKit: A Headless CLI for AI Music Generation

February 19, 2026/7 min read
Open SourceBrowser AutomationPuppeteerTypeScriptAI

The Problem

I was building a content pipeline that needed AI-generated background music. Loop starters, ambient textures, cinematic stingers — all generated to order. Suno produces the best results I've tested. But Suno has no public API.

Their UI is great. The models are impressive. But if you want to trigger generation programmatically, you hit a wall. No REST endpoint, no webhooks, no official SDK. The only path in is through the browser.

So I built SunoKit: a CLI tool and Node.js library that drives the Suno web app headlessly, handles auth persistence, and downloads output files directly from their CDN.

The Core Challenge: Bot Detection

The first version of SunoKit was three files and worked for exactly one day. Suno noticed automated traffic and started returning blank pages to anything that looked like headless Chrome.

This is the fundamental problem with browser automation against modern web apps: they fingerprint your browser, check for CDP (Chrome DevTools Protocol) exposure, scan for automation flags in the JS runtime, and block anything that looks wrong.

Standard Puppeteer launches Chromium with --enable-automation flags baked in. Any halfway-decent bot detection will catch it.

The fix was switching to rebrowser-puppeteer-core — a fork of Puppeteer that patches the CDP communication layer to avoid the classic "CDP over-exposure" signature. Paired with a real Chrome installation (not Chromium), you get authentic browser fingerprints that are genuinely hard to distinguish from a human session.

// Find the system Chrome installation — real Chrome, not Chromium
function findChromePath(): string | undefined {
  const platform = os.platform();
  const candidates: string[] = [];

  if (platform === 'darwin') {
    candidates.push(
      '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
      '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
    );
  } else if (platform === 'linux') {
    candidates.push(
      '/usr/bin/google-chrome',
      '/usr/bin/google-chrome-stable',
    );
  }

  return candidates.find(fs.existsSync);
}

Real Chrome matters. Chromium has well-known fingerprint gaps — missing font tables, slightly different V8 behavior, audio stack differences. Real Chrome is what 65% of web users run. It's the least suspicious option.

Persistent Sessions

Nobody wants to log in every time they run a CLI tool. Suno uses OAuth — Google, Discord, or email. The login flow involves redirects, cookie setting, and browser-level state that's painful to reconstruct programmatically.

The solution: treat the browser profile as the session store. Puppeteer lets you launch Chrome with a persistent userDataDir. After the first login, cookies, local storage, and IndexedDB all persist across restarts.

const browser = await rebrowser.launch({
  executablePath: findChromePath(),
  headless: options.headless ?? false,
  userDataDir: path.join(os.homedir(), '.sunokit', 'browser-profile'),
  args: [
    '--no-first-run',
    '--disable-blink-features=AutomationControlled',
  ],
});

On first run, Chrome opens in headed mode for the user to log in. After that, every subsequent run reuses the same profile — no login needed. The --disable-blink-features=AutomationControlled flag removes the navigator.webdriver property that bot detection checks for.

Reset is simple: rm -rf ~/.sunokit/browser-profile.

The Abstraction Layer

Rather than scattering raw Puppeteer calls through the codebase, I wrapped the browser behind a thin interface:

export interface BrowserPage {
  goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<void>;
  url(): string;
  evaluate<T>(fn: (...args: any[]) => T, ...args: any[]): Promise<T>;
  $$(selector: string): Promise<BrowserElement[]>;
  $(selector: string): Promise<BrowserElement | null>;
  createCDPSession(): Promise<CDPSession>;
}

This was the right call. Suno updates their frontend regularly. DOM selectors break. When they do, the fix is isolated to browser.ts — the rest of the codebase is untouched.

Generation Flow

Song generation on Suno's UI involves typing a prompt, optionally switching to custom mode, setting styles and lyrics, choosing a model, and clicking generate. The page then polls for completion and eventually shows audio players you can interact with.

Automating this is a matter of finding the right elements, filling them in correctly, and waiting for state changes. The tricky parts:

React inputs don't respond to raw DOM assignment. Setting element.value = 'my prompt' doesn't trigger React's synthetic event system. You have to simulate real keyboard events:

async function typeIntoReact(page: BrowserPage, selector: string, text: string) {
  const el = await page.$(selector);
  if (!el) throw new Error(`Element not found: ${selector}`);

  // Clear existing value
  await el.evaluate((node: HTMLInputElement) => {
    node.value = '';
    node.dispatchEvent(new Event('input', { bubbles: true }));
  });

  // Type character by character with small delay
  await el.type(text, { delay: 30 });
}

Waiting for generation. After clicking generate, the UI shows a spinner, then reveals audio players when the clips are ready. Rather than polling on an arbitrary timer, I wait for the specific DOM elements that only appear when generation succeeds:

await page.waitForSelector('[data-testid="song-card"]', { timeout: 120_000 });

Two minutes is the practical timeout. Suno is usually faster, but during peak hours generation can take a while.

CDN Downloads Without the Browser

Once you have a song ID, downloading doesn't require a browser at all. Suno stores generated audio on a CDN with predictable URL patterns. A simple HTTP request gets you the MP3, WAV, or video file directly:

export async function downloadSong(songId: string, outputPath: string) {
  const cdnUrl = `https://cdn1.suno.ai/${songId}.mp3`;
  const response = await fetch(cdnUrl);

  if (!response.ok) throw new Error(`Download failed: ${response.status}`);

  const buffer = await response.arrayBuffer();
  fs.writeFileSync(outputPath, Buffer.from(buffer));
}

This makes the download path fast and robust. You can batch-download a library of songs you've already generated without spinning up a browser at all.

Testing Without Breaking Suno

SunoKit has 85 unit tests that run without a browser. They cover the parsing logic, format conversion, error handling, and the various option combinations. These run in CI and catch regressions in the library layer.

The harder problem is testing the DOM automation. Suno ships UI updates without warning. A selector that worked yesterday breaks today. The solution is a smoke test that runs against live Suno and verifies all the selectors still match:

npm run test:smoke  # ~7 seconds, requires network + Chrome

The smoke test doesn't generate any songs — it just navigates to Suno and checks that every selector the library depends on resolves to at least one element. When generation breaks, running the smoke test immediately tells you whether Suno changed their UI or if it's something else.

What I'd Do Differently

Better error recovery. When generation fails mid-way (network blip, Suno returns an error modal), the current code throws. A retry loop with exponential backoff would make it significantly more robust.

Session validity check. The library assumes the browser profile is still logged in. If the session expired, it silently fails on certain operations. A checkAuth() step before every command would improve the error experience a lot.

Selector versioning. Right now, when Suno updates their UI, you need to update the selectors and release a new version. A more resilient approach would be multiple fallback selectors — try the primary, fall back to alternatives if it's missing.

Using It

npm install sunokit

# First run — opens Chrome for login
sunokit credits

# Generate a song
sunokit generate "rainy day lo-fi" -o lofi.wav

# Custom mode with full control
sunokit generate "late night drive" \
  --styles "synthwave, 80s, instrumental" \
  --model v5 \
  --headless \
  -o drive.wav

# As a library
import { generateSong } from 'sunokit';
const songs = await generateSong('ambient forest', { output: './forest.mp3', headless: true });

The full source is on GitHub — MIT licensed, open to issues and PRs.

The Bigger Picture

SunoKit is part of a personal stack I've been building for AI content pipelines. SunoKit for music, GeminiKit for images, DiscordVoiceBot for real-time voice interaction. Each is a standalone tool, but they compose: generate a background track, generate visual art, pipe the output into a video workflow.

Browser automation isn't glamorous engineering. It's fragile, it breaks when the target site updates, and it exists in a legal grey area depending on the platform's terms. But when there's no API and the tool is genuinely useful, it's the pragmatic choice.

Suno will probably ship a proper API eventually. Until then, SunoKit works.