A local-first desktop app for notes, folders, and AI-assisted study. Bring your own AI models. Free and open source.
foldex is a self-directed learning workspace. Your notes live on your machine in a local SQLite database, the editor is block-based and extensible, and every AI feature runs against your API keys instead of a subscription we resell. It started as a school project and is built in the open.
- Website: foldex.space
- Download: foldex.space/download
- Discord: discord.gg/bMHfCXz6bv
- Highlights
- Architecture
- Repository layout
- Tech stack
- Getting started
- The desktop app
- The backend API
- Video generation pipeline
- Building and releasing the desktop app
- Deployment
- Monorepo tasks
- Troubleshooting
- Contributing
- License
Local-first by default. Notes, folders, chats, and settings are stored in a SQLite database on your device. The app is fully usable offline and without an account — guest mode writes to the same local database, and signing in only adds cloud features.
Block-based editor. Built on BlockNote with custom blocks for Mermaid diagrams, LaTeX math, syntax-highlighted code, YouTube embeds, AI-generated quizzes, AI-generated flashcards, and inline AI explainer videos.
Bring your own AI. Add keys for OpenAI, Anthropic, Google Gemini, DeepSeek, xAI Grok, or Moonshot Kimi and switch between them freely. Keys are encrypted at rest in a Tauri Stronghold vault; only a masked hint and a validity flag are kept in SQLite. AI calls stream directly from your machine to the provider — they do not pass through our servers.
AI that acts on your workspace. The assistant can create and update notes and folders, search the web (via Tavily), search YouTube, emit code and diagrams, and generate videos. A split canvas panel renders results next to the conversation.
Attachment understanding. Drop in PDFs, DOCX, PPTX, images, or plain text. Parsing runs locally: pdfjs for PDFs, mammoth for DOCX, jszip for PPTX slide text, and tesseract.js OCR for images.
AI explainer videos. Describe a concept and a Python Manim service renders a narrated animation, uploads it to Cloudflare R2, and streams it back into the app. Videos can be kept private or published to a shared "video vault".
Tabbed workspace. Notes and videos open in reorderable tabs (up to 15) that survive restarts, alongside a drag-and-drop folder tree.
Auto-updating. Signed updates are published to GitHub Releases and installed from within the app.
foldex deliberately keeps the heavy lifting on the client. The server exists only for authentication and for work the desktop cannot do itself (rendering videos).
flowchart TB
subgraph client [Your machine]
Desktop["Desktop app (Tauri v2 + React)"]
SQLite[("SQLite: notes, folders, chats")]
Vault[("Stronghold vault: API keys")]
Desktop --> SQLite
Desktop --> Vault
end
subgraph providers [Third-party AI]
LLM["OpenAI / Anthropic / Gemini / DeepSeek / Grok / Kimi"]
Tavily["Tavily web search"]
end
subgraph server [Render]
API["foldex-api (Hono)"]
Worker["foldex-worker (BullMQ)"]
Renderer["foldex-renderer (Flask + Manim)"]
PG[("PostgreSQL")]
KV[("Redis")]
API --> PG
API --> KV
KV --> Worker
Worker --> Renderer
Worker --> PG
end
R2[("Cloudflare R2: video files")]
Web["foldex-web (landing + download page)"]
Desktop -->|"streams directly, your keys"| LLM
Desktop --> Tavily
Desktop -->|"auth + video jobs"| API
Renderer --> R2
Desktop -->|"playback"| R2
Web -->|"GitHub Releases"| Desktop
The key consequence: your notes never leave your device, and your AI keys never reach our infrastructure.
A Bun workspaces monorepo orchestrated by Turborepo.
foldex/
├── apps/
│ ├── desktop/ # Tauri v2 desktop app (the product)
│ │ ├── src/ # React frontend
│ │ │ ├── components/
│ │ │ │ ├── notescomponent/ # BlockNote editor + custom blocks
│ │ │ │ ├── aicomponents/ # chat, canvas, model picker
│ │ │ │ ├── videos/ # generation modal, player, vault
│ │ │ │ ├── sidebarcomponents/ # icon rail, folder tree, DnD
│ │ │ │ ├── settingscomponents/
│ │ │ │ └── tabs/ # tab bar and tab content
│ │ │ ├── hooks/ # use-chat, use-settings, use-updater, ...
│ │ │ ├── lib/ # localdb, schema.local, providers, ai/
│ │ │ ├── routes/ # TanStack Router file routes
│ │ │ └── stores/ # zustand: tabs, notes, ai, canvas, settings
│ │ └── src-tauri/ # Rust shell, plugins, capabilities, bundle config
│ ├── web/ # Marketing site + OS-aware download page
│ ├── backend/ # Hono REST API, Better Auth, BullMQ worker
│ └── renderer/ # Python Flask + Manim video service
├── packages/
│ └── ui/ # Shared shadcn/ui component library (@workspace/ui)
├── .github/workflows/ # release-desktop.yml
├── render.yaml # Render Blueprint for all hosted services
└── turbo.json
| Area | Technology |
|---|---|
| Package manager | Bun 1.3.14 (workspaces) |
| Task runner | Turborepo |
| Desktop shell | Tauri v2 (Rust 1.77.2+) |
| UI | React 19, TanStack Start, TanStack Router, Tailwind CSS v4, shadcn/ui |
| Client state | Zustand (UI state), TanStack Query (async state) |
| Editor | BlockNote with custom block specs |
| Local storage | SQLite via @tauri-apps/plugin-sql, Drizzle ORM (sqlite-proxy) |
| Secret storage | Tauri Stronghold |
| AI | Vercel AI SDK v6, streamed client-side |
| API | Hono, consumed by the desktop app over Hono RPC |
| Server database | PostgreSQL 16 + Drizzle ORM |
| Auth | Better Auth (passwordless OTP) with Resend for email |
| Queue | BullMQ on Redis (noeviction) |
| Video rendering | Python, Flask, Manim 0.19, manim-voiceover, Google Cloud TTS |
| Object storage | Cloudflare R2 (S3-compatible, via boto3) |
| Hosting | Render Blueprint |
| Requirement | Needed for | Notes |
|---|---|---|
| Bun >= 1.3.14 | everything | The repo pins bun@1.3.14 |
| Rust >= 1.77.2 | desktop app | Only if you build or run the desktop shell |
| Docker | backend | Local PostgreSQL and Redis |
Python 3.12 + ffmpeg |
video renderer | Optional; only for the Manim service |
| Resend account | backend auth | OTP sign-in emails |
Linux desktop builds additionally need the WebKitGTK development headers. On Debian/Ubuntu:
sudo apt-get update && sudo apt-get install -y \
libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev \
libayatana-appindicator3-dev librsvg2-dev patchelf xdg-utilsTauri v2 requires WebKitGTK 4.1 — the older 4.0 package will not work.
git clone https://github.com/kerry883/foldex.git
cd foldex
bun installOne install at the root covers every workspace.
Each app ships an example file. Copy the ones you need:
cp apps/desktop/.env.example apps/desktop/.env
cp apps/backend/.env.example apps/backend/.env
cp apps/renderer/.env.example apps/renderer/.env # only for video renderingapps/desktop/.env
| Variable | Purpose |
|---|---|
VITE_API_URL |
Base URL of the backend (http://localhost:3000) |
VITE_GOOGLE_SEARCH_API_KEY |
Optional; enables YouTube search in the AI assistant |
Provider API keys are not set here — you enter them in Settings > API Keys so they land in the encrypted vault.
apps/backend/.env
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
REDIS_URL |
Redis connection string |
RESEND_API_KEY |
Sends OTP sign-in emails |
BETTER_AUTH_SECRET |
Session signing secret — generate with openssl rand -base64 32 |
BETTER_AUTH_URL |
Public URL of this API |
FRONTEND_URL |
Allowed origin for CORS |
MANIM_FLASK_URL |
Renderer service URL (http://localhost:5001) |
PORT |
HTTP port |
COOKIE_DOMAIN |
Leave unset locally and on *.onrender.com |
apps/renderer/.env
| Variable | Purpose |
|---|---|
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY |
Cloudflare R2 credentials |
R2_BUCKET_NAME, R2_PUBLIC_URL |
Bucket and its public base URL |
PORT |
HTTP port (default 5001) |
GOOGLE_APPLICATION_CREDENTIALS |
Optional; Google Cloud TTS for voiceovers |
cd apps/backend
docker compose up -dThis starts PostgreSQL 16 on 5432 and Redis 7 on 6379 with --maxmemory-policy noeviction, which BullMQ requires so queued jobs are never evicted.
cd apps/backend
bun run db:migrateThe API and worker also run pending migrations on boot, so this is mostly for a clean first setup. To author a new migration after editing src/db/schema.ts, run bun run db:generate.
The desktop app only needs the backend running if you want sign-in or video generation. For notes and AI chat, you can skip straight to the desktop app.
# Backend API (hot reload)
cd apps/backend && bun dev
# BullMQ worker — a separate process, required for video generation
cd apps/backend && bun run worker
# Desktop app (starts Vite, then the Tauri shell)
cd apps/desktop && bun run tauri:dev
# Landing site
cd apps/web && bun dev
# Video renderer (optional)
cd apps/renderer && pip install -r requirements.txt && python flask_api.py| Service | Port |
|---|---|
apps/backend API |
3000 |
apps/web dev server |
3000 |
apps/desktop Vite dev server |
3001 |
apps/renderer Flask |
5001 |
| PostgreSQL | 5432 |
| Redis | 6379 |
Note: the backend and the landing site both default to port 3000. If you need to run them at the same time, change
PORTinapps/backend/.envor pass--portto the web dev server, and updateVITE_API_URLto match.
Custom block specs are registered in apps/desktop/src/components/notescomponent/blocknote-editor.tsx, on top of all BlockNote defaults.
| Block | Description |
|---|---|
mermaid |
Mermaid diagram with live rendering and SVG download |
latex |
Standalone LaTeX math block (KaTeX) |
math |
Inline LaTeX, auto-converted from $...$ |
codeBlock |
Syntax highlighting via Shiki |
youtubeVideo |
YouTube embed from a URL |
quiz |
Interactive quiz generated from your note |
flashcard |
Flashcard deck generated from your note |
aivideo |
Inline AI-generated Manim explainer video |
Markdown serialization for the custom types lives in blocknotehelper.ts.
Providers are declared in apps/desktop/src/lib/providers.ts: OpenAI, Anthropic, Google Gemini, DeepSeek, xAI Grok, and Moonshot Kimi, plus Tavily as a separate web-search key.
Key handling in localapikeys.ts:
- The raw key is written to a Stronghold vault (
foldex-vault.bin) underapikey_{provider}. - SQLite stores only a masked display hint and a validity flag in
api_key_meta. - "Test" validates the key against the provider's own API.
- At request time the key is decrypted in memory and used by
streamText()inclient-transport.ts.
SQLite through @tauri-apps/plugin-sql (foldex.db, or foldex_dev.db in development), accessed with Drizzle's sqlite-proxy driver. Schema: apps/desktop/src/lib/schema.local.ts.
| Table | Contents |
|---|---|
folders |
Nested folders with pin state and colour |
notes |
Title, BlockNote JSON content, folder, pin state |
chats |
AI chat sessions |
messages |
Chat messages with structured parts |
templates |
Note templates |
api_key_meta |
Masked key hints and validity |
user_settings |
Custom system prompt |
local_user |
Cached profile plus login and sync state |
userId is nullable on user content, which is what makes guest mode work.
tabstore.ts holds up to 15 note/video tabs, persisted to localStorage under foldex-tabs. Tab IDs are {type}-{itemId} and map to /note/{id} or /video/{id}, so browser-style back and forward stay in sync with the tab bar. Home, Chat, and Watch are not tabbed and render normally.
The sidebar is dual-pane: a fixed icon rail for top-level navigation and a resizable panel with search, recents, pinned items, and a drag-and-drop workspace tree.
| Direction | Formats |
|---|---|
| Export | Markdown, PDF (jspdf), plain text, video .mp4, Mermaid .svg |
| Import / parse | PDF, DOCX, PPTX, images (OCR), plain text |
DOCX and PPTX are read for text extraction only — there is no DOCX export.
Hono server in apps/backend. Its AppType is exported and consumed by the desktop app through Hono RPC, so route changes surface as type errors in the client.
| Route | Purpose |
|---|---|
GET / |
Health check |
* /api/auth/* |
Better Auth — OTP send, verify, session, sign out |
GET /api/videos |
Public ready videos |
GET /api/videos/my |
Current user's videos (?folderId=) |
POST /api/videos/generate |
Queue a render job |
POST /api/videos/:id/retry |
Retry a failed render with corrected code |
PUT /api/videos/:id |
Update folder or public flag |
DELETE /api/videos/:id |
Delete or unpublish |
POST /api/videos/:id/feedback |
Submit like/dislike |
GET /api/videos/:id/feedback |
Read your vote |
GET /api/videos/:id/getstatus |
Poll generation status |
GET /api/videos/:id |
Fetch one video |
Server-side tables are only user, session, account, verification (Better Auth) plus videos and video_feedback. Notes, folders, chats, templates, and API keys are deliberately absent — they live on your device.
See apps/backend/README.md for backend-specific detail.
The one feature that cannot run locally. Manim needs LaTeX, ffmpeg, and a lot of CPU, so it runs as a private service.
sequenceDiagram
participant D as Desktop
participant A as foldex-api
participant Q as Redis queue
participant W as foldex-worker
participant R as foldex-renderer
participant S as Cloudflare R2
D->>A: POST /api/videos/generate (Manim code)
A->>Q: enqueue job
A-->>D: video id (status pending)
Q->>W: dequeue
W->>R: POST /generate-video
R->>R: validate, render, thumbnail
R->>S: upload mp4 + png
R-->>W: public URLs
W->>A: mark ready
D->>A: GET /api/videos/:id/getstatus
D->>S: stream video
The Manim code itself is written by the AI model on your machine, then sent to the server to be rendered. Before rendering, flask_api.py runs a pre-flight check (ast.parse plus flake8 --select=F821,E999) so obviously broken code fails fast with a structured error the app can show and retry.
Renderer endpoints: POST /generate-video, GET /health, POST /delete.
cd apps/desktop
bun run tauri:buildBundle targets are set to all in tauri.conf.json, which produces:
| Platform | Artifacts |
|---|---|
| Windows | NSIS .exe, .msi |
| macOS | .dmg, .app, .app.tar.gz (updater) |
| Linux | .AppImage, .deb, .rpm |
Releases are driven by git tags via .github/workflows/release-desktop.yml.
# Bump "version" in apps/desktop/src-tauri/tauri.conf.json first
git tag v0.1.0
git push origin v0.1.0The workflow builds on ubuntu-22.04, windows-latest, and macos-latest (universal binary via --target universal-apple-darwin), then publishes a GitHub Release. It can also be run manually with workflow_dispatch by supplying a tag.
Assets are named with the pattern [name]_[platform]_[bundle][ext], producing stable filenames such as foldex_windows_nsis.exe, foldex_macos_dmg.dmg, and foldex_linux_appimage.AppImage. The download page links directly to these through releases/latest/download/..., so it never needs updating when a new version ships.
The matrix runs with max-parallel: 1 on purpose. Each job merges its own platform entry into the single shared latest.json asset, so running the three jobs concurrently would silently drop platforms from the update manifest.
Tauri refuses unsigned updates, so a keypair is mandatory.
cd apps/desktop
# Use -p so the password is not mangled by an interactive prompt.
# Avoid ! $ ` \ in the password.
bunx tauri signer generate -w ~/.tauri/foldex.key -p 'your-password'The CLI prints a public key (starts with dW50cnVzdGVk...). Put that in plugins.updater.pubkey in tauri.conf.json — it is safe to commit.
The private key file looks like this (two lines; both are required):
untrusted comment: minisign encrypted secret key
RWRTY0Iy...base64...
Missing comment in secret key means GitHub got only the base64 line, or newlines were stripped.
Add repository secrets (Settings → Secrets and variables → Actions → Repository secrets — not Environment secrets):
| Secret | Value |
|---|---|
TAURI_SIGNING_PRIVATE_KEY |
Entire file, including the untrusted comment: line |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
The -p password. If you generated with an empty password, leave this secret unset (do not store a dummy value) |
VITE_GOOGLE_SEARCH_API_KEY |
YouTube Data API key for in-app video search |
If this public key in the repo already matches a private key you generated earlier, do not generate a new pair — paste that same private file into the secret. A new pair must replace pubkey in tauri.conf.json or updates will fail to verify.
Losing the private key means you can never ship an update to already-installed apps again. Back it up somewhere durable.
- The bundler emits update artifacts and
.sigsignatures (createUpdaterArtifacts: true). tauri-actionassembles alatest.jsonmanifest listing each platform's URL and signature, and attaches it to the release.- The app polls
https://github.com/kerry883/foldex/releases/latest/download/latest.json, which GitHub always redirects to the newest non-draft release. - A silent check on launch surfaces a toast; Settings > Updates shows the installed version, release notes, download progress, and a restart action.
- The signature is verified against the embedded public key before anything is installed.
Because the endpoint is pinned to latest, the in-app installer only ever moves users forward. Older releases are listed in Settings but open in the browser for manual installation — installing an older build over a newer one is not supported by the updater.
Linux caveat: in-app updates apply to the AppImage build only. .deb and .rpm installs are managed by the system package manager and must be reinstalled from the release page.
All hosted services are described by render.yaml as a Render Blueprint.
| Service | Type | Role |
|---|---|---|
foldex-web |
web | Landing site and download page (SSR) |
foldex-api |
web | Hono REST API |
foldex-worker |
worker | BullMQ consumer (same image, bun run worker) |
foldex-renderer |
private service | Manim/Flask renderer, reachable only internally |
foldex-kv |
key value | Redis with noeviction |
foldex-db |
database | PostgreSQL 16 |
BETTER_AUTH_SECRET is generated by Render; FRONTEND_URL, BETTER_AUTH_URL, RESEND_API_KEY, COOKIE_DOMAIN, and all five R2_* values are marked sync: false and must be set in the dashboard after the first deploy.
Do not use Render Free instances: spin-down interrupts the queue and drops in-flight render jobs.
The desktop app is not deployed — it is distributed through GitHub Releases and the download page.
Run from the repository root; Turborepo fans each task out across workspaces.
| Command | Description |
|---|---|
bun run dev |
Start every app's dev server |
bun run build |
Build all workspaces |
bun run lint |
ESLint across all workspaces |
bun run format |
Prettier write across all workspaces |
bun run typecheck |
tsc --noEmit across all workspaces |
To add a shadcn/ui component to the shared library:
bunx shadcn@latest add button -c packages/uiComponents land in packages/ui/src/components and are imported as @workspace/ui/components/button from any app.
Package glib-2.0 was not found when building the desktop app on Linux. The WebKitGTK development headers are missing. Install the system dependencies listed under Prerequisites.
Video generation stays "pending" forever. The BullMQ worker is a separate process from the API. Confirm bun run worker is running, that Redis is up, and that Redis uses noeviction.
Sign-in emails never arrive. Check RESEND_API_KEY, and that BETTER_AUTH_URL matches the URL the app actually calls. In development, OTP emails often land in spam.
"Could not check for updates" in Settings. Expected in development builds — there is no signed bundle or update manifest to compare against. It only works in a packaged release.
AI requests fail with an auth error. Use the "Test" button in Settings > API Keys. Keys are stored per provider, so switching models needs a key for that specific provider.
Port 3000 already in use. The backend and the landing site share that default. See the port map.
Contributions are welcome. apps/backend/CONTRIBUTING.md covers the full workflow: branch naming, Conventional Commit style, the pull request process, and the TypeScript, backend, and Tailwind style guides.
The short version:
- Branch off
mainasfeat/,fix/,docs/, orchore/ - Commit as
<type>: <short description>, explaining why in the body - Run
bun run typecheckandbun run lintbefore opening a PR - Keep pull requests focused on one change
- Open an issue first for anything substantial
If you are new, start with an issue labelled good first issue.
MIT.