cd /news/ai-tools/bulk-backup-of-all-plaud-ai-recordin… · home › topics › ai-tools › article
[ARTICLE · art-163915] src=gist.github.com ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

Bulk backup of all Plaud.ai recordings (audio, transcripts, AI summaries) across multiple accounts, using Plaud's official CLI (@plaud-ai/cli). Resumable, per-account logins.

A developer published an open-source Node.js script, plaud-backup.mjs, that uses Plaud's official CLI (@plaud-ai/cli) to bulk-export all recordings from one or more Plaud.ai accounts, including audio, transcripts, AI summaries and highlight images. The tool isolates each account in its own login profile under a per-account home directory so multiple accounts can be backed up without logging out, and re-runs resume by skipping completed recordings while fetching any missing photos. It retries on network and timeout errors but stops immediately on authentication failures.

by read14 min views1 publishedNov 3, 2026

| | #!/usr/bin/env node | | | // plaud-backup.mjs — back up every Plaud recording (audio, transcripts, summaries) | | | // from one or more Plaud accounts, using Plaud's official CLI (@plaud-ai/cli). | | | // | | | // Usage (PowerShell): | | | // node plaud-backup.mjs -> prompts for an account label, logs in, backs up |

|  | //   node plaud-backup.mjs --out "D:\PlaudBackup" | 
|  | //   node plaud-backup.mjs --account work       -> skip the prompt | 
|  | //   node plaud-backup.mjs --account work --yes -> also skip the "is this the right account?" check | 
|  | //   node plaud-backup.mjs --no-audio           -> skip audio files | 
|  | //   node plaud-backup.mjs --no-images          -> skip photos from highlights/summaries | 

| | // | | | // Each account gets its own login profile under <out>_profiles<label>, so you can | | | // back up several accounts without logging out/in. Re-running resumes: recordings | | | // already finished are skipped (but photos missing from older runs are fetched). | | | | | | import { spawnSync, execSync } from "node:child_process"; | | | import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, createWriteStream, unlinkSync, readdirSync } from "node:fs"; |

|  | import { join, extname } from "node:path"; | 
|  | import { homedir } from "node:os"; | 
|  | import { createInterface } from "node:readline/promises"; | 
|  | import { Readable } from "node:stream"; | 
|  | import { pipeline } from "node:stream/promises"; | 

| | |

|  | // ---------- args ---------- | 
|  | const args = process.argv.slice(2); | 
|  | const argVal = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; | 
|  | const OUT_ROOT = argVal("--out") ?? join(homedir(), "Documents", "PlaudBackup"); | 
|  | let ACCOUNT = argVal("--account"); | 
|  | const SKIP_AUDIO = args.includes("--no-audio"); | 
|  | const AUTO_YES = args.includes("--yes"); | 
|  | const SKIP_IMAGES = args.includes("--no-images"); | 

| | |

|  | // ---------- locate the official CLI ---------- | 
|  | function findCli() { | 

| | if (process.env.PLAUD_CLI_PATH) return process.env.PLAUD_CLI_PATH; | | | let root; |

|  | try { root = execSync("npm root -g", { encoding: "utf8" }).trim(); } catch { root = ""; } | 
|  | const p = join(root, "@plaud-ai", "cli", "dist", "index.js"); | 
|  | if (!existsSync(p)) { | 
|  | console.error("Plaud CLI not found. Install it first:  npm install -g @plaud-ai/cli"); | 
|  | process.exit(1); | 

| | } | | | return p; | | | } | | | const CLI = findCli(); | | | |

|  | const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; | 
|  | const strip = (s) => (s ?? "").replace(ANSI, ""); | 
|  | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | 
|  | const safe = (s, max = 60) => | 
|  | (s \|\| "untitled").replace(/[<>:"/\\\|?*\x00-\x1f]/g, "_").replace(/\s+/g, " ").trim().replace(/[. ]+$/, "").slice(0, max) \|\| "untitled"; | 

| | | | | // ---------- run CLI under a per-account home ---------- | | | let PROFILE_HOME; | | | function cliEnv() { | | | return { | | | ...process.env, | | | USERPROFILE: PROFILE_HOME, // Windows: os.homedir() -> tokens in <PROFILE_HOME>.plaud | | | HOME: PROFILE_HOME, // macOS/Linux | | | FORCE_COLOR: "0", | | | NO_COLOR: "1", | | | PLAUD_TELEMETRY_DISABLED: "1", | | | PLAUD_NO_UPDATE_NOTIFIER: "1", | | | }; | | | } |

|  | function runCli(cliArgs, { inherit = false } = {}) { | 
|  | const r = spawnSync(process.execPath, [CLI, ...cliArgs], { | 
|  | env: cliEnv(), | 

| | encoding: "utf8", | | | stdio: inherit ? "inherit" : "pipe", | | | maxBuffer: 256 * 1024 * 1024, |

|  | }); | 
|  | return { code: r.status ?? 1, out: strip(r.stdout), err: strip(r.stderr) }; | 

| | } |

|  | // Retry on network (3) / timeout (4) / generic (1); stop hard on auth (2). | 
|  | async function cli(cliArgs, tries = 4) { | 

| | let last; |

|  | for (let i = 0; i < tries; i++) { | 
|  | last = runCli(cliArgs); | 
|  | if (last.code === 0) return last; | 
|  | if (last.code === 2) throw new Error("AUTH: login expired or invalid — re-run the script to log in again."); | 
|  | await sleep(2000 * (i + 1)); | 

| | } | | | return last; | | | } | | | |

|  | // ---------- parsing ---------- | 
|  | function parseFilesPage(out) { | 
|  | const ids = []; | 
|  | let inTable = false; | 
|  | for (const line of out.split(/\r?\n/)) { | 
|  | if (line.includes("\u2500\u2500\u2500")) { inTable = true; continue; } | 
|  | if (!inTable) continue; | 
|  | if (/^\s*Page \d+/.test(line)) break; | 
|  | const m = line.match(/^\s{2}(\S+)\s{2,}/); | 
|  | if (m) ids.push(m[1]); | 

| | } |

|  | const countMatch = out.match(/Files on this page:\s*(\d+)/); | 
|  | return { ids, count: countMatch ? Number(countMatch[1]) : ids.length }; | 

| | } |

|  | function parseDetails(out) { | 
|  | const get = (k) => { const m = out.match(new RegExp(`^\\s*${k}:\\s*(.*)$`, "m")); return m ? m[1].trim() : ""; }; | 
|  | return { name: get("name"), created_at: get("created_at"), start_at: get("start_at"), duration: get("duration") }; | 

| | } |

|  | function stamp(iso) { | 
|  | const d = new Date(iso); | 
|  | if (isNaN(d)) return "unknown-date"; | 
|  | const p = (n) => String(n).padStart(2, "0"); | 
|  | return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`; | 

| | } |

|  | function audioExt(url, contentType) { | 
|  | const fromUrl = extname(new URL(url).pathname).toLowerCase(); | 
|  | if (fromUrl && fromUrl.length <= 6) return fromUrl; | 
|  | const ct = (contentType \|\| "").toLowerCase(); | 
|  | if (ct.includes("mpeg")) return ".mp3"; | 
|  | if (ct.includes("ogg") \|\| ct.includes("opus")) return ".opus"; | 
|  | if (ct.includes("wav")) return ".wav"; | 
|  | if (ct.includes("mp4") \|\| ct.includes("m4a") \|\| ct.includes("aac")) return ".m4a"; | 

| | return ".audio"; | | | } | | | |

|  | // ---------- images (highlight photos + images embedded in summaries) ---------- | 
|  | function imageExt(url, contentType) { | 
|  | let fromUrl = ""; | 
|  | try { fromUrl = extname(new URL(url).pathname).toLowerCase(); } catch {} | 
|  | if (/^\.(png\|jpe?g\|webp\|gif\|heic\|bmp)$/.test(fromUrl)) return fromUrl; | 
|  | const ct = (contentType \|\| "").toLowerCase(); | 
|  | if (ct.includes("png")) return ".png"; | 
|  | if (ct.includes("jpeg") \|\| ct.includes("jpg")) return ".jpg"; | 
|  | if (ct.includes("webp")) return ".webp"; | 
|  | if (ct.includes("gif")) return ".gif"; | 
|  | if (ct.includes("heic")) return ".heic"; | 

| | return ".img"; | | | } |

|  | function mmss(ms) { | 
|  | const s = Math.floor((Number(ms) \|\| 0) / 1000); | 
|  | return `${String(Math.floor(s / 60)).padStart(2, "0")}m${String(s % 60).padStart(2, "0")}s`; | 

| | } |

|  | function findImageLinks(dir) { | 
|  | const links = []; | 
|  | const hl = join(dir, "highlights.txt"); | 
|  | if (existsSync(hl)) { | 

| | try { |

|  | const items = JSON.parse(readFileSync(hl, "utf8")); | 
|  | if (Array.isArray(items)) items.forEach((it, i) => { | 

| | const url = it?.picture_full_url || it?.picture_link; |

|  | if (typeof url === "string" && /^https?:\/\//.test(url)) | 
|  | links.push({ url, base: `highlight_${mmss(it.timestamp)}_${String(i + 1).padStart(2, "0")}` }); | 
|  | }); | 
|  | } catch {} | 

| | } |

|  | const sm = join(dir, "summary_and_notes.md"); | 
|  | if (existsSync(sm)) { | 
|  | const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)/g; | 
|  | let m, k = 0; | 
|  | const text = readFileSync(sm, "utf8"); | 
|  | while ((m = re.exec(text))) links.push({ url: m[1], base: `summary_${String(++k).padStart(2, "0")}` }); | 

| | } | | | return links; | | | } |

|  | async function downloadImages(dir) { | 
|  | const problems = []; | 
|  | const imgDir = join(dir, "images"); | 
|  | if (existsSync(join(imgDir, ".done"))) return problems; | 
|  | const links = findImageLinks(dir); | 
|  | if (!links.length) return problems; | 
|  | mkdirSync(imgDir, { recursive: true }); | 
|  | for (const { url, base } of links) { | 
|  | if (readdirSync(imgDir).some((f) => f.startsWith(base + "."))) continue; // already have it | 

| | try { |

|  | const res = await fetch(url); | 
|  | if (!res.ok) throw new Error(`HTTP ${res.status}`); | 
|  | const target = join(imgDir, base + imageExt(url, res.headers.get("content-type"))); | 
|  | await pipeline(Readable.fromWeb(res.body), createWriteStream(target + ".part")); | 
|  | renameSync(target + ".part", target); | 
|  | } catch (e) { | 
|  | problems.push(`image ${base}: ${e.message}`); | 

| | } | | | } | | | if (!problems.length) writeFileSync(join(imgDir, ".done"), new Date().toISOString()); | | | return problems; | | | } | | | | | | // ---------- main ---------- | | | let rl; |

|  | const ask = async (q) => { rl ??= createInterface({ input: process.stdin, output: process.stdout }); return rl.question(q); }; | 
|  | if (!ACCOUNT) ACCOUNT = (await ask("Label for this Plaud account (e.g. personal, work): ")).trim(); | 
|  | ACCOUNT = safe(ACCOUNT, 40); | 
|  | PROFILE_HOME = join(OUT_ROOT, "_profiles", ACCOUNT); | 
|  | mkdirSync(PROFILE_HOME, { recursive: true }); | 
|  | const ACCT_DIR = join(OUT_ROOT, ACCOUNT); | 
|  | mkdirSync(ACCT_DIR, { recursive: true }); | 
|  | const ERRLOG = join(ACCT_DIR, "errors.log"); | 

| | | | | console.log(\nBacking up account "${ACCOUNT}" to ${ACCT_DIR}\n); | | | | | | // Login if needed (each account has its own token store) |

|  | let me = runCli(["me"]); | 
|  | if (me.code !== 0) { | 

| | console.log("Signing in. A browser window will open."); | | | console.log("IMPORTANT: if your browser is already signed in to a DIFFERENT Plaud account, sign out of"); | | | console.log("web.plaud.ai first, or copy the printed URL into an InPrivate/Incognito window.\n"); |

|  | runCli(["login"], { inherit: true }); | 
|  | me = runCli(["me"]); | 
|  | if (me.code !== 0) { console.error("Login failed:\n" + me.err); process.exit(2); } | 

| | } |

|  | console.log(me.out.trim()); | 
|  | const ok = AUTO_YES ? "y" : (await ask("\nIs this the account you want to back up? (y/n): ")).trim().toLowerCase(); | 
|  | if (ok !== "y") { | 

| | console.log(Logging this profile out. Re-run and sign in with the right account.); |

|  | runCli(["logout"], { inherit: true }); | 
|  | process.exit(0); | 

| | } | | | rl?.close(); | | | | | | // 1) List every recording |

|  | const allIds = []; | 
|  | for (let page = 1; page <= 1000; page++) { | 
|  | const r = await cli(["files", "-p", String(page), "-s", "100"]); | 
|  | if (r.code !== 0) { console.error(`Failed listing page ${page}:\n${r.err}`); process.exit(1); } | 
|  | const { ids, count } = parseFilesPage(r.out); | 
|  | allIds.push(...ids); | 
|  | process.stdout.write(`\rListed ${allIds.length} recordings...`); | 
|  | if (count < 100) break; | 

| | } |

|  | const ids = [...new Set(allIds)]; | 
|  | console.log(`\nFound ${ids.length} recordings.\n`); | 

| | |

|  | // Map already-finished folders (resume support) | 
|  | const doneById = new Map(); | 
|  | for (const d of readdirSync(ACCT_DIR, { withFileTypes: true })) { | 
|  | if (d.isDirectory() && existsSync(join(ACCT_DIR, d.name, ".done"))) { | 
|  | const m = d.name.match(/__(.{1,8})$/); | 
|  | if (m) doneById.set(m[1], d.name); | 

| | } | | | } | | | |

|  | const indexPath = join(ACCT_DIR, "index.csv"); | 
|  | if (!existsSync(indexPath)) writeFileSync(indexPath, "id,date,name,duration,folder,audio,transcript,summary,status\n"); | 
|  | const csv = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; | 

| | | | | // Catch-up pass: recordings finished by an older version of this script may have | | | // photos that were never downloaded. Refresh their links and fetch the images. |

|  | if (!SKIP_IMAGES) { | 
|  | const needImages = [...doneById.entries()].filter(([, folder]) => { | 
|  | const dir = join(ACCT_DIR, folder); | 
|  | return !existsSync(join(dir, "images", ".done")) && findImageLinks(dir).length > 0; | 
|  | }); | 
|  | if (needImages.length) console.log(`Fetching images for ${needImages.length} previously saved recording(s)...`); | 
|  | for (const [tag, folder] of needImages) { | 
|  | const dir = join(ACCT_DIR, folder); | 
|  | const fullId = ids.find((x) => x.endsWith(tag)); | 
|  | if (fullId) await cli(["transcript", fullId, "--block", "mark_memo", "-o", join(dir, "highlights.txt")]); // fresh links | 
|  | const probs = await downloadImages(dir); | 
|  | if (probs.length) appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${folder} (images)\n  ` + probs.join("\n  ") + "\n"); | 

| | } | | | } | | | |

|  | let n = 0, failed = 0, skipped = 0; | 
|  | for (const id of ids) { | 

| | n++; |

|  | const tag = id.slice(-8); | 
|  | if (doneById.has(tag)) { skipped++; continue; } | 

| | | | | const problems = []; | | | try { |

|  | const det = await cli(["file", id]); | 
|  | if (det.code !== 0) throw new Error(`file details: ${det.err.trim()}`); | 
|  | const info = parseDetails(det.out); | 
|  | const folderName = `${stamp(info.start_at && info.start_at !== "-" ? info.start_at : info.created_at)}_${safe(info.name)}__${tag}`; | 
|  | const dir = join(ACCT_DIR, folderName); | 
|  | mkdirSync(dir, { recursive: true }); | 
|  | console.log(`[${n}/${ids.length}] ${info.name \|\| id}`); | 
|  | writeFileSync(join(dir, "details.txt"), `id: ${id}\n` + det.out.trim() + "\n"); | 

| | | | | // Transcripts (missing blocks are simply skipped by the CLI) | | | const blocks = [ | | | ["transaction", "transcript.txt"], | | | ["transaction_polish", "transcript_polished.txt"], | | | ["outline", "outline.txt"], | | | ["mark_memo", "highlights.txt"], |

|  | ]; | 
|  | for (const [block, fname] of blocks) { | 
|  | const r = await cli(["transcript", id, "--block", block, "-o", join(dir, fname)]); | 
|  | if (r.code !== 0) problems.push(`transcript(${block}): ${r.err.trim()}`); | 

| | } | | | | | | // Summaries / notes: readable + raw JSON |

|  | let r = await cli(["summary", id, "--all", "-o", join(dir, "summary_and_notes.md")]); | 
|  | if (r.code !== 0) problems.push(`summary: ${r.err.trim()}`); | 
|  | r = await cli(["summary", id, "--json", "-o", join(dir, "notes.json")]); | 
|  | if (r.code !== 0) problems.push(`notes json: ${r.err.trim()}`); | 

| | | | | // Images (photos from highlights + images embedded in summaries) | | | if (!SKIP_IMAGES) problems.push(...await downloadImages(dir)); | | | | | | // Audio |

|  | let gotAudio = false, noAudio = false; | 
|  | if (!SKIP_AUDIO) { | 
|  | for (let attempt = 0; attempt < 3 && !gotAudio; attempt++) { | 
|  | const a = await cli(["audio", id]); | 
|  | const url = (a.out.match(/https?:\/\/\S+/) \|\| [])[0]; | 
|  | if (!url) { | 
|  | if (/not available for this recording/i.test(a.out)) { noAudio = true; break; } // genuinely no audio | 
|  | await sleep(5000); | 

| | continue; | | | } | | | try { |

|  | const res = await fetch(url); | 
|  | if (!res.ok) throw new Error(`HTTP ${res.status}`); | 
|  | const target = join(dir, "audio" + audioExt(url, res.headers.get("content-type"))); | 
|  | const part = target + ".part"; | 
|  | await pipeline(Readable.fromWeb(res.body), createWriteStream(part)); | 
|  | renameSync(part, target); | 
|  | gotAudio = true; | 
|  | } catch (e) { | 
|  | problems.push(`audio download attempt ${attempt + 1}: ${e.message}`); | 
|  | for (const f of readdirSync(dir)) if (f.endsWith(".part")) try { unlinkSync(join(dir, f)); } catch {} | 
|  | await sleep(5000); | 

| | } | | | } | | | if (!gotAudio && !noAudio) problems.push("audio: not downloaded"); | | | } | | | |

|  | const has = (f) => existsSync(join(dir, f)) ? "yes" : "no"; | 
|  | const audioFile = readdirSync(dir).find((f) => f.startsWith("audio.")); | 
|  | const status = problems.length ? "partial" : "ok"; | 
|  | appendFileSync(indexPath, [id, (info.start_at && info.start_at !== "-" ? info.start_at : info.created_at), info.name, info.duration, folderName, | 
|  | audioFile ? "yes" : "no", has("transcript.txt"), has("summary_and_notes.md"), status].map(csv).join(",") + "\n"); | 

| | | | | if (problems.length) { | | | failed++; |

|  | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id} ${info.name}\n  ` + problems.join("\n  ") + "\n"); | 
|  | } else { | 
|  | writeFileSync(join(dir, ".done"), new Date().toISOString()); | 

| | } | | | } catch (e) { | | | failed++; |

|  | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id}\n  ${e.message}\n`); | 
|  | console.error(`  ! ${e.message}`); | 
|  | if (e.message.startsWith("AUTH")) process.exit(2); | 

| | } | | | } | | | |

|  | console.log(`\nDone. ${ids.length} recordings: ${ids.length - failed - skipped} saved, ${skipped} already done, ${failed} with problems.`); | 
|  | if (failed) console.log(`See ${ERRLOG}. Re-run the same command to retry anything incomplete.`); | 
|  | console.log(`Backup folder: ${ACCT_DIR}`); |
── more in #ai-tools 4 stories · sorted by recency
── more on @plaud.ai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/bulk-backup-of-all-p…] indexed:0 read:14min 2026-11-03 · —