First off — thank you. LocalMode is local-first, privacy-first, offline-first AI for the browser, and it gets better every time someone files a sharp bug report, tightens a type, writes a test, or ships a new component. This guide explains how to set up the repo, the conventions we hold the line on, and how to get a change merged.
New here? Jump to Your first contribution.
- Code of conduct
- Ways to contribute
- Repository layout
- Prerequisites
- Getting started
- The golden rule: read the existing code first
- Development workflow
- Architecture rules (non-negotiable)
- Testing — the most important section
- Code style
- Changesets & versioning
- Contributing to
apps/ui(the UI registry & blocks) - Contributing to docs
- Submitting a pull request
- Reporting bugs & requesting features
- Reporting a security issue
- Your first contribution
- License
Be kind, be constructive, assume good faith. We welcome contributors of every background and experience level. Harassment, personal attacks, and dismissive behavior aren't tolerated in issues, pull requests, or discussions. If something crosses that line, contact the maintainers privately (see Reporting a security issue for the private channel).
You don't need to write framework internals to help:
- Report a bug with a minimal reproduction (see Reporting bugs).
- Improve docs — package
README.mds, the docs site (apps/docs), JSDoc, or this file. - Add tests for an under-covered function or edge case.
- Fix a bug — small, well-tested fixes are the easiest reviews.
- Add a UI component or block to
apps/ui(see the dedicated section below). - Add a model to a provider catalog, or a provider for an existing core interface.
- Triage — reproduce open issues, add missing details, suggest labels.
If a change is large or changes public API, open an issue or a discussion first so we can align on the approach before you invest the time.
LocalMode is a pnpm monorepo.
packages/
core/ Zero-dependency core — all functions, interfaces, VectorDB, agents, RAG, security
react/ React hooks for every core function (+ pipeline step factories)
ai-sdk/ Vercel AI SDK provider
transformers/ HuggingFace Transformers.js provider (ONNX)
webllm/ WebLLM provider (WebGPU)
wllama/ GGUF provider via llama.cpp WASM
litert/ Google LiteRT-LM provider
mediapipe/ Google MediaPipe Tasks provider
chrome-ai/ Chrome Built-in AI provider (Gemini Nano)
langchain/ LangChain.js adapters
devtools/ In-app AI observability (hooks)
pdfjs/ PDF text extraction
dexie/ idb/ localforage/ Storage adapters
apps/
ui/ @localmode/ui — the shadcn-style registry + /blocks gallery (localmode.ai)
docs/ Documentation site (localmode.dev)
| Tool | Version | Notes |
|---|---|---|
| Node.js | >= 18 |
20 LTS or newer recommended |
| pnpm | >= 10 |
The required package manager — do not use npm or yarn to install |
| git | any recent |
A modern browser (Chrome/Edge for WebGPU, or any modern browser for WASM) is needed to run the apps/ui demos and Playwright end-to-end tests.
# 1. Fork the repo on GitHub, then clone your fork
git clone https://github.com/<your-username>/LocalMode.git
cd LocalMode
# 2. Install every workspace's dependencies (pnpm links them together)
pnpm install
# 3. Build all packages once (providers peer-depend on core's build output)
pnpm build
# 4. Run the test suite to confirm a clean baseline
pnpm test
pnpm test:typesWorking on a single package? Filter to it:
pnpm --filter @localmode/core dev # tsup --watch for the core package
pnpm --filter @localmode/core test # run just core's tests
pnpm --filter ui dev # run the localmode.ai app locally
pnpm --filter docs dev # run the localmode.dev docs site locallyThe implemented code is the source of truth. Before writing anything new, find the closest existing implementation and match it exactly — its structure, naming, error handling, and JSDoc style. A change that looks like it was always part of the codebase is a change that's easy to review and easy to maintain.
| You're touching… | Read first |
|---|---|
| A core function | packages/core/src/embeddings/embed.ts, classification/classify.ts |
| A core interface | packages/core/src/<domain>/types.ts |
| Error handling | packages/core/src/errors/index.ts, errors/format.ts |
| A provider implementation | packages/transformers/src/implementations/ |
| A storage adapter | packages/dexie/src/storage.ts, packages/idb/src/storage.ts |
| A UI primitive | apps/ui/registry/localmode/<family>/<component>/ |
| A block | apps/ui/src/app/blocks/<name>/ |
- Create a branch off
main:git checkout -b fix/embed-abort-signal # or feat/…, docs/…, test/…, chore/… - Make the change, matching the surrounding code.
- Add or update tests — see the testing section; this is not optional.
- Add a changeset if you changed any published package (see below).
- Run the local checks (the same gates a reviewer will expect green):
pnpm lint # ESLint over packages/** pnpm test # Vitest — packages/**/*.test.ts and *.spec.ts pnpm test:types # tsc type-level contract tests (*.test-d.ts) pnpm typecheck # tsc --noEmit across every workspace pnpm build # ensure every package still builds
- Commit using Conventional Commits and open a PR.
| Command | What it does |
|---|---|
pnpm build |
Build every package (pnpm -r build) |
pnpm test |
Run the Vitest suite over packages/** |
pnpm test:watch |
Vitest in watch mode |
pnpm test:coverage |
Vitest with a v8 coverage report |
pnpm test:types |
Type-level tests (tsc --noEmit on the typetest project) |
pnpm typecheck |
tsc --noEmit across all workspaces |
pnpm lint / pnpm lint:fix |
ESLint over packages/** (autofix with :fix) |
pnpm format |
Prettier --write over the repo |
pnpm changeset |
Record a version bump + changelog entry |
pnpm check:peers |
Verify provider peer-dependency ranges |
Apps carry their own scripts (Next.js presets). Run app-specific checks with
pnpm --filter ui <script>/pnpm --filter docs <script>— see the UI section.
These are what keep LocalMode small, portable, and private. A PR that breaks one won't merge until it's addressed.
- Zero-dependency core.
packages/core/package.jsonhas"dependencies": {}and it stays empty. No external runtime deps in core, ever. ML frameworks live in provider packages. - Provider pattern. A provider package depends on its external library as a normal dependency, keeps
@localmode/coreas a peer dependency, and implements core interfaces. Studypackages/transformers/before adding a new provider. - Function-first API. Public API is top-level async functions (
embed(),classify(),streamText()), not class methods. - Options object. Every function takes a single options object, not positional parameters.
- Structured results. Functions return
{ result, usage, response }-shaped objects — never a bare value. AbortSignaleverywhere. Every async function acceptsabortSignaland callsabortSignal?.throwIfAborted()before starting and before each expensive step.Float32Arrayfor vectors. Never plainArrayfor embeddings/vectors.- No telemetry, no network from core. No
fetch, no analytics, no tracking anywhere inpackages/core(or in shipped UI primitives). Providers may download model files from their hubs; that's the only network exception. A CI check (check-no-shipped-telemetry) enforces this for shipped UI surfaces.
Errors must answer three questions — what happened, why, and how to fix it — via LocalModeError's code/hint/context. See packages/core/src/errors/.
Storage adapters must persist the full Collection object on read and write (never a cherry-picked subset) and must pass the shared conformance suite:
import { createStorageAdapterConformanceSuite } from '@localmode/core';
// 21 cases: document/vector/index/collection ops, Uint8Array fidelity,
// extended-field round-trip, ingest → close → reopen → search, SQ8 fidelity after reopen.
⚠️ Tests must verify real functionality through real flows. A test that reaches green by avoiding the thing it claims to verify is worse than no test — it lies to the next contributor.
When fixing a bug, write the failing test first (red), then fix the code (green). Reproduce the reported failure in the harness before you touch the fix. If the test passed while the bug existed, the test is the bug — rewrite it from the reporter's perspective.
Never, to make a test pass:
- Bypass the real call path. If production goes through a public API / message bus / UI / queue, the test goes through it too — no reaching into private state or calling internal helpers to shortcut it.
- Stub the boundary the test claims to cover. A unit test may mock the layer below it; a test for that boundary may not. An end-to-end test for a feature that loads a real model may not swap in an instant mock — that latency and failure surface is the point.
- Loosen assertions until they pass (widening a regex,
toBeTruthy()where a specific value matters, adding the offending value to an allowlist). - Skip or swallow — no
it.skip/xit/it.todoon a failing assertion left inmain, notry { …assert… } catch {}, noif (process.env.CI) return. - Shrink the search window until the bug disappears (checking only the last N events, sampling a subset). Capture the full record, assert against the whole thing.
Every test should: replicate the real call path, capture everything observable (console, errors, side effects), assert on observable side effects (prefer two independent witnesses for a non-trivial outcome), run against a realistic backing environment for its layer, fail on the first divergence, and be reproducible from a clean checkout. Document any gap you couldn't exercise end-to-end (real GPU, real microphone, real third-party service) and say what manual verification is still needed.
pnpm test # Vitest over packages/** (jsdom environment, globals on)
pnpm test:types # Type-level tests (*.test-d.ts) — NOT run by `pnpm test`
pnpm --filter @localmode/core test # just one package- Where tests live:
packages/<pkg>/tests/**and colocated*.test.ts/*.spec.ts. - Type-level contracts go in
packages/core/tests/**/*.test-d.tsand are checked bypnpm test:types(Vitest does not run them — run both when you touch generic signatures). - Mock utilities (
createMockEmbeddingModel(),createMockStorage(),createSeededRandom(), the conformance suite, …) live inpackages/core/src/testing/index.ts. - Benchmarks use Vitest's
bench(). apps/uihas its own layers — unit (test:unit), consumer install tests (test:portability,test:blocks), and real-model Playwright E2E (test:e2e). See below.
- TypeScript strict. No
any— the ESLint config warns onno-explicit-any, unsafe function types, and unused bindings. Prefer precise types and generics. - Formatting is automated. Run
pnpm format(Prettier) before committing;pnpm lint:fixfor autofixable lint. - Named exports only — no default exports (better tree-shaking). Keep
"sideEffects": falseaccurate. - JSDoc on every public API — description,
@paramfor each parameter,@returns, one concise@example,@throws, and@seefor related functions. Match the existing style inpackages/core/src/embeddings/embed.ts. - Comment hygiene — comments must be timeless. They explain what the code does and why for a future reader. Never reference internal process artifacts: no design-decision labels, spec/change names, roadmap/phase/"wave" language, or task numbers. State the technical fact directly. This matters doubly in
apps/uiblock sources, which ship to consumers. - Don't frame features against another SDK. Describe functionality directly rather than "AI SDK compatible" / "follows the X pattern."
Use Conventional Commits — it matches the existing history and pairs cleanly with changesets:
feat(core): add streamObject() partial-object events
fix(ui): guard static preview scroll during demo mount
docs(transformers): correct Kokoro voice count
test(wllama): cover GGUF metadata parser rate-limit path
chore(react): bump peer range for @localmode/core
Keep the subject imperative and under ~72 chars; explain the why in the body when it isn't obvious.
We publish with Changesets. If your change affects any published package under packages/*, add a changeset:
pnpm changesetPick the affected package(s), choose a bump level, and write a short, user-facing summary (it becomes the CHANGELOG.md entry):
- patch — bug fix, no API change
- minor — backward-compatible new feature
- major — breaking change (discuss in an issue first)
Changes that touch only apps/*, docs, tests, or tooling generally don't need a changeset. Maintainers handle the actual version bump and npm publish (pnpm version / pnpm release) — you don't publish.
apps/ui is the @localmode/ui platform — a shadcn-style registry of copy-owned primitives plus the /blocks gallery. It is not published to npm; consumers install components with the shadcn CLI and own the copied .tsx. apps/ui/README.md is the source of truth; the highlights:
- Styling is shadcn/ui CSS variables (
bg-background,text-foreground,border-border, …). - Portability invariant. A primitive must compile with zero
@localmode/*packages installed: define prop shapes locally, list only real npm imports independencies, and pull browser helpers from the copy-owned@localmode/ui/lib/*items via@/lib/<name>. Blocks are the sole carve-out — they're the wiring layer and may declare@localmode/*deps. - No model download on page load. Docs demos auto-render and
/blockspages default-mount the live block, but every model load must be gated behind an explicit user action. Mark a docs demo<ComponentPreview gated>if it would otherwise fetch on mount. - No
data-testidin blocks. The block tree is testid-free; E2E specs select via role/label/text accessibility selectors. Give every control a role and an accessible name instead. - Aggregates and
public/r/are generated — never hand-editui/all/ family aggregates or commitpublic/r/(it's gitignored). The gallery grid derives fromblocks-catalog.tsand redirects fromlegacy-redirects.ts— edit those, notpage.tsx/next.config.mjs.
Adding something? Follow the step-by-step "How to add a component" / "How to add a block" recipes in apps/ui/README.md, then verify:
pnpm --filter ui build # runs registry:build, then next build
pnpm --filter ui test:unit # transform + helper unit tests
pnpm --filter ui test:portability # real shadcn-CLI install; asserts zero @localmode/* leakage
pnpm --filter ui test:blocks # real install of a block; asserts the carve-out
pnpm --filter ui test:e2e # Playwright — real model downloads + inference, no mocked model boundaryThe E2E suite runs real models in a real browser and captures to
e2e-artifacts/. When you change vision or audio streaming behavior, also do a manual real-hardware sweep (real webcam/microphone) — fixtures stand in for CI, and that gap is documented in the affected spec headers.
- Package
README.mds live next to each package and should match the API. Keep numbers (package counts, model counts, versions) accurate — stale claims are a common bug. - The docs site (
apps/docs, localmode.dev) has strict, non-overlapping ownership: core pages own the API reference (options, result types,AbortSignal, custom-provider examples, middleware); transformers/provider pages own model tables, provider config, and recipes. Never duplicate the API reference across the two — cross-link instead. - Run the docs site locally with
pnpm --filter docs dev.
- Push your branch to your fork and open a PR against
LocalMode-AI/LocalMode:main. - Fill in the description: what changed, why, how you verified it, and any follow-ups. Link the issue it closes (
Closes #123). - Confirm the checklist:
- Followed the closest existing implementation's patterns
- Added/updated tests that exercise the real path (bug fixes include a red-first reproducer)
-
pnpm lint,pnpm test,pnpm test:types,pnpm typecheck, andpnpm buildare green locally - Added a changeset if a published package changed
- Updated docs / README / JSDoc if behavior or API changed
- For
apps/ui: ran the relevant--filter uichecks; no on-mount model download, nodata-testidin blocks
- Keep PRs focused — one logical change per PR reviews far faster than a grab-bag.
- Respond to review feedback by pushing new commits (we squash on merge, so you don't need to rebase-squash yourself unless asked).
Open an issue at https://github.com/LocalMode-AI/LocalMode/issues. A good bug report includes:
- A clear description of the problem and expected vs. actual behavior
- Steps to reproduce — ideally a minimal code snippet
- Browser name and version (and whether WebGPU or WASM was in use)
- Relevant console output or error messages
- The LocalMode package(s) and version(s) involved
For features, describe the use case and the outcome you want before proposing a specific API — it helps us find the design that fits the rest of the library.
Please do not open a public issue for security vulnerabilities. Use GitHub's private vulnerability reporting on the repository (Security → Report a vulnerability) so we can triage and fix it before disclosure. Because LocalMode runs entirely client-side and ships no telemetry, most reports concern model-loading, storage, or crypto boundaries — include a reproduction and the affected package/version.
Looking for an entry point? These tend to be the smoothest:
- Issues labeled
good first issueandhelp wanted - Docs and JSDoc fixes — accurate examples and corrected counts are genuinely valuable
- Adding a test for an under-covered function (grep for functions with thin coverage)
- A new UI component or block built from the existing recipe in
apps/ui/README.md
Not sure if an idea fits? Open a Discussion or a lightweight issue and ask — we'd rather talk early than have you build the wrong thing.
LocalMode is MIT licensed. By submitting a contribution, you agree that your work is licensed under the MIT License and that you have the right to license it — don't paste in code you don't have the rights to.
Built for Privacy. Designed for Developers. Powered by the Browser.