A self-hosted local server that mirrors your Plaud recordings to disk and fires webhooks when new recordings or transcripts arrive, so you can easily hook it up to n8n (or any other custom integration). Runs on your machine, uses your existing Plaud browser session for auth, and ships with a web UI for setup and browsing.
RootScribe is not affiliated with Plaud. It talks to the same undocumented web API that the Plaud web app uses, via your own logged-in session.
- Automatic sync — polls Plaud every 10 minutes (configurable) and downloads audio, transcripts, and AI summaries to local disk
- Full-text search — search recordings by filename or transcript content
- Audio player — custom player with waveform visualization, play/pause, skip -10s/+30s, click-to-seek
- Transcript viewer — speaker-labeled, timestamped, color-coded blocks with auto-scroll during playback, click-to-seek, and full-text search within transcripts
- AI summaries — rendered markdown with expandable full-screen modal for long summaries
- Webhooks — POST JSON payloads on
audio_readyandtranscript_readyevents for n8n, Zapier, or custom integrations - Dark & light mode — toggle between themes, defaults to system preference
- Setup wizard — guided 5-step onboarding (auth, folder, webhook, review)
First, you should never run commands you find on the internet that end in | sh. With that said, here's the easiest way to install RootScribe:
macOS / Linux / WSL:
curl -fsSL https://raw.githubusercontent.com/Root-Functional-Medicine/rootscribe/v0.5.6/install.sh | shWindows (PowerShell):
irm https://raw.githubusercontent.com/Root-Functional-Medicine/rootscribe/v0.5.6/install.ps1 | iexThe installer does everything needed to install RootScribe into a subfolder named ./rootscribe. To run it:
cd rootscribe
pnpm startYour browser will open to http://127.0.0.1:44471/setup. Walk through the 5-step wizard and you're done.
git clone https://github.com/Root-Functional-Medicine/rootscribe.git
cd rootscribe
pnpm install
pnpm build
pnpm startRequires Node.js >= 20 and pnpm >= 9.
-
Auth: RootScribe reads your existing Plaud session from Chrome (or Edge / Brave / Arc / Vivaldi) by copying the browser's
Local Storage/leveldbdirectory to a temp path (which sidesteps Chrome's file lock) and pulling the JWT bearer from thetokenstrkey forweb.plaud.ai. No passwords, no OAuth, no Playwright — just your existing session. Tokens are good for ~10 months. -
Sync: every 10 minutes (configurable), the server calls
/file/simple/webonapi.plaud.aito list your latest recordings. New ones get a per-recording subfolder, their audio streamed down from S3, and — once Plaud finishes transcribing — transcript + summary pulled via/ai/transsumm/(with S3 fallback for older recordings). -
Webhook: if configured, RootScribe POSTs a JSON payload to your URL whenever a new
audio_readyortranscript_readyevent happens. Includes file paths (relative to your recordings dir) plus ready-to-fetch HTTP URLs that the local media server serves, and — ontranscript_ready— the flattened transcript text and summary markdown inline so n8n-style workflows don't need a second fetch.
Each recording gets its own folder under your chosen recordings directory:
<recordings-dir>/
2026-04-11_My_meeting_title__74560101/
audio.ogg
transcript.json # raw Plaud transcript segments (with speaker embeddings)
transcript.txt # speaker-labeled, timestamped plaintext
summary.md # Plaud's AI-generated summary (when available)
metadata.json # full /file/detail response
{
"event": "audio_ready | transcript_ready",
"recording": {
"id": "74560101636422f79bacd66696bab17b",
"filename": "04-11 Validation of Automated Transcription...",
"start_time_ms": 1775929909000,
"duration_ms": 22000,
"filesize_bytes": 95744,
"serial_number": "8810B30227298497"
},
"files": {
"folder": "2026-04-11_...__74560101",
"audio": "2026-04-11_...__74560101/audio.ogg",
"transcript": "2026-04-11_...__74560101/transcript.json",
"summary": "2026-04-11_...__74560101/summary.md"
},
"http_urls": {
"audio": "http://127.0.0.1:44471/media/2026-04-11_...__74560101/audio.ogg",
"transcript": "http://127.0.0.1:44471/media/2026-04-11_...__74560101/transcript.json",
"summary": "http://127.0.0.1:44471/media/2026-04-11_...__74560101/summary.md"
},
"content": {
"transcript_text": "[00:01] Speaker: ...",
"summary_markdown": "## Core Synopsis\n\n..."
}
}contentis only present ontranscript_readyevents. Both fields are nullable — if Plaud didn't generate a summary for a recording,summary_markdownwill benull.- Webhook consumers should treat
(id, event)as idempotent.audio_readyalways fires beforetranscript_ready; on recordings that are already fully transcribed when first seen, both fire back-to-back in the same poll cycle. - Custom headers on every webhook:
User-Agent: rootscribe/0.1.1andX-RootScribe-Event: audio_ready|transcript_ready.
The n8n/ folder contains importable n8n workflow templates. To use one, open n8n, create a new workflow, then Import from File and select the JSON file. See n8n/README.md for setup details.
Pull the pre-built image and run:
docker run -d \
--name rootscribe \
-p 44471:44471 \
-v rootscribe-config:/data/config \
-v rootscribe-recordings:/data/recordings \
ghcr.io/root-functional-medicine/rootscribe:latestOr build from source:
docker build -t rootscribe .
docker run -d \
--name rootscribe \
-p 44471:44471 \
-v rootscribe-config:/data/config \
-v rootscribe-recordings:/data/recordings \
rootscribeOpen http://localhost:44471/setup to configure. On first run, the setup wizard will ask you to paste your Plaud token manually (browser auto-detect doesn't work inside a container).
RootScribe is a foreground process. To keep it running without a terminal:
macOS (launchd): create ~/Library/LaunchAgents/dev.rootscribe.plist pointing to pnpm start in the install dir.
Linux (systemd user): create ~/.config/systemd/user/rootscribe.service with ExecStart=pnpm --dir=%h/rootscribe start.
Both platforms: or just run it inside tmux / screen.
Settings live in ~/.config/rootscribe/settings.json (or ~/Library/Application Support/rootscribe/ on macOS, %APPDATA%\rootscribe\ on Windows). Recording state is in state.sqlite alongside. Both are managed through the web UI — you shouldn't need to edit them by hand.
The bearer token is stored as plaintext in settings.json (with chmod 600). The file lives in a user-only directory, and the token's scope is equivalent to "read this user's own Plaud data." OS keychain integration is a future enhancement.
pnpm devRuns the Vite dev server on port 44470 with a proxy for /api and /media to the Express server on port 44471. The server runs in tsx watch mode. Hot reload works on both sides.
All four workspace packages (shared, server, web, inbox-mcp) share one test harness: Vitest for unit + integration, Playwright for end-to-end user journeys. A single root command exercises the whole suite.
# Unit + integration, every package
pnpm test
# Watch mode
pnpm test:watch
# With V8 coverage (HTML + LCOV + json-summary)
pnpm test:coverage
# End-to-end (Playwright, chromium, headless)
pnpm test:e2e
# E2E with a visible browser (handy while authoring specs)
pnpm test:e2e:headed
# Open the HTML report after a failed E2E run
pnpm test:e2e:report
# Everything CI runs, sequenced locally
pnpm ciIndividual package suites can be run with pnpm -C <package> vitest run (e.g. pnpm -C server vitest run).
- Test real behavior, not mocks. Server tests hit real Express routes via
supertest; database tests use real SQLite against tmp fixtures; web component tests render with@testing-library/reactagainst the real React tree. Mocks are reserved for the network boundary (seeweb/src/api.test.ts). - One test harness per package. Each package declares its own
vitest.config.ts(environment, setup, aliases). The rootvitest.config.tsdiscovers them via its inlinetest.projectsarray (Vitest 4's replacement for the oldvitest.workspace.ts); coverage is aggregated across all four. - TDD is the default. Write the test first, watch it fail, make it pass. See
SproutKit:test-driven-developmentfor the workflow this repo expects. - Fishery for test fixtures. Data shapes used in 2+ spec files (or with 5+ fields) live as Fishery factories with chainable traits for their common variations —
recordingDetailFactory.reviewed().withJiraLinks(link).build()instead of a 26-field inline literal. Shared-type factories (RecordingDetail,JiraLink,RecordingRow,PlaudRawRecording,InboxMutationResponse) live inshared/src/test-factories/and import via@rootscribe/shared/test-factories. Web-, server-, and inbox-mcp-specific shapes each live in their own package'ssrc/test-factories/(ortests/factories/for inbox-mcp, whoseSeedRecordingis a test-only type). One-off literals (single use, under 5 fields) stay inline. See DEVX-104 for the audit and pattern.
Coverage is tracked across lines, branches, functions, and statements (V8 provider). The root vitest.config.ts enforces baseline thresholds on every pnpm test:coverage run.
Current baseline (Apr 2026, Vitest 4, post-DEVX-102 ratchet #7 final push): ~97% lines, ~87% branches, ~96% functions, ~95% statements. Three of four axes now clear the 95% target that DEVX-102 opened with; branches plateau at 87% because the remaining uncovered branches are genuinely defensive — optional-chains (?.) on values the surrounding code already guarantees, ?? 0 fallbacks on indices the caller clamps in bounds, and RecordingDetail.tsx's auto-scroll math that requires a real browser layout engine (Playwright covers the real scroll path; jsdom reports 0 for offsetTop/scrollTop/clientHeight). Those branches are now marked with /* v8 ignore */ where obviously unreachable; the remaining 13% are spread across many small UI catches that would benefit more from integration tests than unit tests. Ratchet #7 added 46 tests in this final push on top of the earlier 68 — covering profiles.ts WSL fs mocks, chrome-leveldb scanProfile + decode branches, routes/auth parseJwt + validateToken catches, webhook/post readIfExists EISDIR, Settings + AuthStep + Dashboard + RecordingDetail + SyncStatusBadge + SnoozeMenu remaining branches, browser-watch listener-throw, poller non-Error branch, state rename fast-path, config.ts malformed-JSON fallback. Earlier ratchets: #6 covered every remaining 0%-covered server-infra file (sync/poller, webhook/post, auth/chrome-leveldb, auth/browser-watch, routes/auth) plus inbox-mcp platform branches. #5 covered the setup wizard. #4 covered the three major web routes (Dashboard, RecordingDetail, Settings). #3 introduced web/src/test-utils.tsx and covered every untested web component + the useTheme hook. #2 covered the server routes cluster. #1 landed the pure-function server cluster + the four Playwright journey specs. DEVX-102 closes with this ratchet.
CI uploads the HTML coverage report as a workflow artifact and posts a per-PR summary comment via davelosert/vitest-coverage-report-action.
rootscribe/
├── .github/workflows/
│ ├── ci.yml # lint + typecheck + unit/integration + coverage gate
│ └── e2e.yml # Playwright smoke (chromium, headless, artifacts on failure)
├── vitest.config.ts # inline project discovery + coverage aggregation + thresholds
├── vitest.shared.ts # shared exclude patterns + reporter wiring
├── playwright.config.ts # chromium project, traces on retry, HTML reporter
├── eslint.config.js # flat config (ESLint 10) + vitest/testing-library plugins
├── tests/e2e/ # cross-package user journeys
└── <package>/
├── vitest.config.ts # per-package environment + setup
├── src/**/*.test.ts # co-located unit tests
└── tests/ # integration tests (supertest, SQLite fixtures)
Two GitHub Actions workflows gate every PR against main:
ci.yml— installs dependencies, runs ESLint, typechecks all four packages, executes the Vitest suite with coverage on Node 20 and 22, uploads artifacts, and posts a coverage summary comment.e2e.yml— builds the production bundle, installs Chromium, runs Playwright smoke tests against a scratchROOTSCRIBE_CONFIG_DIR, and uploads traces + videos on failure.
Once both workflows are green on a PR, require the CI / test and E2E / playwright checks in branch protection on main (admin step, not automated here).
- Follow TDD. Red → green → refactor, one commit per cycle where practical.
- Touch production code only to fix bugs the tests surface. Refactors, style tweaks, and speculative abstractions belong in their own tickets.
- Run
pnpm cibefore pushing. Matches exactly what GitHub Actions will run. - Avoid the anti-patterns in
SproutKit:testing-anti-patterns. In particular: don't test mock behavior, don't add test-only methods to production classes, and don't mock something you don't understand.
MIT



