- TypeScript 99.7%
- CSS 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| packages | ||
| public | ||
| scripts | ||
| tests | ||
| .gitattributes | ||
| .gitignore | ||
| .oxfmtrc.json | ||
| .oxlintrc.json | ||
| bun.lock | ||
| eslint.config.js | ||
| knip.json | ||
| knip.production.json | ||
| package.json | ||
| playwright.config.ts | ||
| react-router.config.ts | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
Planner
Self-hosted project and task planner with private encrypted agent memory, a Markdown notepad, dark mode, nested project folders with tags, and PDF export. Optional Ollama-powered AI helpers.
Run
bun run dev starts the Vite dev server. It is for development only: it serves
the project directory as static files and applies only an HMR-safe subset of the
security headers, while the binary applies the full Content-Security-Policy. The
supported way to actually run Planner is the single binary below.
bun install --frozen-lockfile
export PLANNER_POSTGRES_MODE=socket
export PLANNER_POSTGRES_SOCKET=/var/run/postgresql
export PLANNER_POSTGRES_DATABASE=planner
export PLANNER_POSTGRES_USER=planner_runtime
export PLANNER_CREDENTIAL_GENERATION_FILE=/run/credentials/planner-credential-generation
bun run dev
Open http://127.0.0.1:2137. The dev server binds loopback only. Reaching it
from another device — a phone on the same LAN at http://<host-lan-ip>:2137 —
is an explicit opt-in, because exposing a server that also serves the project
directory should be a decision:
$env:PLANNER_DEV_HOST='0.0.0.0' # bind all IPv4 interfaces
bun run dev
Planner has one runtime storage backend: PostgreSQL. The development server uses
the same PostgreSQL repository as the standalone binary. Its default master key
file is ./.planner-master-key.json; never expose or commit that file.
Single binary
Development and builds require Bun 1.4.0 plus Node.js 22.22 or newer in
the Node 22 line, or Node.js 24 or newer. Node 23 is not supported. The release
compiler requires the exact Bun version pinned by packageManager because that
runtime is embedded in every executable; engines.bun is the broader range for
ordinary repository tooling. Check both with bun --version and
node --version. The resulting binary runs on its own.
Build a standalone executable for the current OS and CPU:
bun run build:binary
The host build writes dist/planner.exe on Windows and dist/planner elsewhere. To cross-build, pass one target:
bun run build:binary -- windows-x64
bun run build:binary -- linux-x64
bun run build:binary -- linux-arm64
bun run build:binary -- macos-x64
bun run build:binary -- macos-arm64
Run it with:
dist\planner.exe --host 127.0.0.1 --port 2137
On Linux or macOS:
./dist/planner --host 127.0.0.1 --port 2137
The binary serves the app, API, and public share pages from one process. Keep
the loopback listener for local use. To bind 0.0.0.0 or another non-loopback
address, put Planner behind an HTTPS reverse proxy and configure the public
HTTPS origin/secure cookies; the built-in listener is plain HTTP and must not be
exposed directly to an untrusted network. The master key defaults to
.planner-master-key.json beside the executable and can be changed with
PLANNER_MASTER_KEY_FILE or --master-key-file. Back up the PostgreSQL database
and master key together. Losing either makes the encrypted records unusable.
The runtime requires an explicit PostgreSQL connection profile. Unix socket
mode uses PLANNER_POSTGRES_SOCKET; TCP mode requires
PLANNER_POSTGRES_HOST, a secure PLANNER_POSTGRES_PASSWORD_FILE, and
PLANNER_POSTGRES_TLS_CA_FILE. TCP always verifies the server certificate and
hostname. Raw password variables and DATABASE_URL are rejected.
| Variable | Purpose |
|---|---|
PLANNER_POSTGRES_MODE |
Required: socket or tcp |
PLANNER_POSTGRES_DATABASE |
Required database name |
PLANNER_POSTGRES_USER |
Required least-privilege runtime role |
PLANNER_POSTGRES_SOCKET |
Absolute socket directory in socket mode |
PLANNER_POSTGRES_HOST |
Database hostname in tcp mode |
PLANNER_POSTGRES_PORT |
Database port; default 5432 |
PLANNER_POSTGRES_PASSWORD_FILE |
Owner-protected password file in tcp mode |
PLANNER_POSTGRES_TLS_CA_FILE |
Trusted CA file in tcp mode |
PLANNER_POSTGRES_TLS_CERT_FILE |
Optional client certificate |
PLANNER_POSTGRES_TLS_KEY_FILE |
Optional client key; required with cert |
PLANNER_CREDENTIAL_GENERATION_FILE |
Required owner-protected credential-generation secret |
PostgreSQL provisioning
Provisioning is separate from the least-privilege runtime service. Planner requires PostgreSQL 17 or 18 with these server settings; startup rejects a database that does not match them:
data_checksums = on
fsync = on
full_page_writes = on
synchronous_commit = on
log_statement = none
log_parameter_max_length = 0
log_parameter_max_length_on_error = 0
WAL archiving is not part of this baseline. A previous unbounded archive path consumed about 40 GB, so do not enable it as part of Planner provisioning. Any later PITR project must first provide bounded retention, tested cleanup, capacity alarms, and enough repository capacity, then validate an isolated restore independently of this runbook.
Create an empty database, a provisioning login that owns it, and a distinct
runtime login with no role-creation, database-creation, replication, bypass-RLS,
ownership, or privileged role membership. Configure the provisioning login with
PLANNER_POSTGRES_PROVISIONING_USER; TCP deployments also use
PLANNER_POSTGRES_PROVISIONING_PASSWORD_FILE and, when client certificates are
required, PLANNER_POSTGRES_PROVISIONING_TLS_CERT_FILE plus
PLANNER_POSTGRES_PROVISIONING_TLS_KEY_FILE. The common mode, database, socket
or host, port, and CA variables are shared with the runtime profile.
Before bootstrapping, create both secret files as owner-protected regular files
outside any data, export, backup, or temporary tree. PLANNER_MASTER_KEY_FILE
must be absolute and contain a keyring such as
{"active":1,"keys":{"1":"<base64-encoded 32-byte key>"}}.
PLANNER_CREDENTIAL_GENERATION_FILE contains exactly one canonical
base64-encoded 32-byte secret, optionally followed by one newline. Back up both
files with the database.
Install pgvector 0.8.6 on the PostgreSQL host, then run this database-local
prerequisite as postgres before applying Planner's schema. postgres retains
ownership of both the extension and its dedicated schema. Replace
planor_provisioning with the quoted provisioning role used by the deployment.
CREATE SCHEMA planor_vector AUTHORIZATION postgres;
REVOKE ALL PRIVILEGES ON SCHEMA planor_vector FROM PUBLIC;
CREATE EXTENSION vector WITH SCHEMA planor_vector VERSION '0.8.6';
REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA planor_vector FROM PUBLIC;
DO $body$
DECLARE item record;
BEGIN
FOR item IN
SELECT namespace.nspname, type.typname
FROM pg_catalog.pg_type AS type
JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = type.typnamespace
WHERE namespace.nspname = 'planor_vector' AND type.typelem = 0
LOOP
EXECUTE format(
'REVOKE ALL PRIVILEGES ON TYPE %I.%I FROM PUBLIC',
item.nspname,
item.typname
);
END LOOP;
END
$body$;
GRANT USAGE ON SCHEMA planor_vector TO planor_provisioning;
GRANT USAGE ON TYPE planor_vector.vector TO planor_provisioning;
GRANT EXECUTE ON FUNCTION
planor_vector.vector_dims(planor_vector.vector),
planor_vector.vector_norm(planor_vector.vector),
planor_vector.cosine_distance(planor_vector.vector, planor_vector.vector)
TO planor_provisioning;
PostgreSQL has no REVOKE ... ON ALL TYPES IN SCHEMA form. Array types also
cannot be revoked directly, so the loop deliberately covers only base types
with typelem = 0. Do not grant the provisioning role schema CREATE,
extension ownership, or broader function or type privileges.
Apply the schema and initialize its encrypted bootstrap with the provisioning profile. Each command requires a fresh UUID run identifier:
./dist/planner storage postgres schema apply --run-id=<uuid>
./dist/planner storage postgres bootstrap --run-id=<uuid>
Then grant the runtime role only the privileges enforced by Planner's startup
preflight. Replace planner and planner_runtime with the quoted database and
role names used by the deployment. Run the database and public object
statements as the provisioning owner. Run the planor_vector revoke loop and
grants as postgres, because the extension remains DBA-owned:
REVOKE ALL PRIVILEGES ON DATABASE planner FROM PUBLIC;
REVOKE ALL PRIVILEGES ON DATABASE planner FROM planner_runtime;
GRANT CONNECT ON DATABASE planner TO planner_runtime;
REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC;
REVOKE ALL PRIVILEGES ON SCHEMA public FROM planner_runtime;
GRANT USAGE ON SCHEMA public TO planner_runtime;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM planner_runtime;
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM planner_runtime;
REVOKE ALL PRIVILEGES ON SCHEMA planor_vector FROM planner_runtime;
REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA planor_vector FROM planner_runtime;
DO $body$
DECLARE item record;
BEGIN
FOR item IN
SELECT namespace.nspname, type.typname
FROM pg_catalog.pg_type AS type
JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = type.typnamespace
WHERE namespace.nspname = 'planor_vector' AND type.typelem = 0
LOOP
EXECUTE format(
'REVOKE ALL PRIVILEGES ON TYPE %I.%I FROM planner_runtime',
item.nspname,
item.typname
);
END LOOP;
END
$body$;
GRANT USAGE ON SCHEMA planor_vector TO planner_runtime;
GRANT USAGE ON TYPE planor_vector.vector TO planner_runtime;
GRANT EXECUTE ON FUNCTION
planor_vector.vector_dims(planor_vector.vector),
planor_vector.vector_norm(planor_vector.vector),
planor_vector.cosine_distance(planor_vector.vector, planor_vector.vector)
TO planner_runtime;
GRANT SELECT ON TABLE
public.schema_migrations,
public.crypto_bootstrap,
public.storage_datasets,
public.storage_control
TO planner_runtime;
GRANT DELETE ON TABLE
public.account_generations,
public.account_heads
TO planner_runtime;
GRANT SELECT (dataset_id, owner_id) ON TABLE public.account_generations TO planner_runtime;
GRANT SELECT (dataset_id, owner_id) ON TABLE public.account_heads TO planner_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
public.auth_users,
public.collection_heads,
public.opaque_records,
public.derived_records,
public.attachment_uploads,
public.attachment_chunks,
public.attachment_heads,
public.auth_sessions,
public.auth_api_tokens,
public.auth_agent_installs,
public.auth_shares,
public.archive_imports,
public.archive_chunks,
public.archive_export_leases,
public.storage_changes,
public.embedding_profiles,
public.embedding_documents
TO planner_runtime;
GRANT SELECT, INSERT, DELETE ON TABLE public.embedding_chunks TO planner_runtime;
GRANT USAGE, SELECT ON SEQUENCE public.storage_changes_change_seq_seq TO planner_runtime;
Start Planner only after provisioning and grants are complete. The runtime preflight fails closed on missing or excess privileges, unsafe role attributes, schema drift, or an unauthenticated bootstrap.
Legacy filesystem trees are not a runtime option. The explicit
planner storage postgres migrate and source-recover operator workflows can
read an offline source tree for migration or rollback recovery. Account archives
remain the supported user-facing transfer format.
Smoke-test the built binary against a fresh disposable PostgreSQL database on a
host with pgvector 0.8.6 installed. The smoke refuses to run unless the database
name starts with planner_smoke_, its public schema is empty, and the
destructive-test opt-in is explicit. It connects as postgres to install and
lock down the extension, checks its ownership and PUBLIC grants, applies and
verifies the schema, bootstraps temporary encryption credentials, grants the
runtime role its exact exported privilege allowlist, then verifies an
authenticated write survives a binary process restart. It also writes a
two-chunk note projection, exercises exact cosine search and fail-closed RLS,
then proves a compiled-binary note update invalidates the stale projection.
export PLANNER_BINARY_SMOKE_DISPOSABLE=1
export PLANNER_POSTGRES_MODE=socket
export PLANNER_POSTGRES_SOCKET=/var/run/postgresql
export PLANNER_POSTGRES_DATABASE=planner_smoke_$(date +%s)
export PLANNER_POSTGRES_USER=planner_smoke_runtime
export PLANNER_POSTGRES_PROVISIONING_USER=planner_smoke_provisioning
bun run smoke:binary
The database and both login roles must already exist. The provisioning role
must own the empty database; the runtime role must remain a non-owner without
BYPASSRLS. Socket mode expects local peer access as postgres. TCP mode also
requires PLANNER_BINARY_SMOKE_DBA_PASSWORD_FILE, held to the same secure-file
rules as the other PostgreSQL password files. Use a new database for every run
and dispose of it afterward.
Agent access (optional)
Planner can issue short-lived, single-use install URLs for CLI and MCP clients. Agent access reuses the signed-in user's scoped API tokens and is disabled by default. Build the downloadable CLI sidecar — this also compiles with Bun, one binary per platform — and enable it with:
bun run build:agent-cli
export PLANNER_AGENT_ENABLED=true
export PLANNER_PUBLIC_URL=https://planner.example.com
export PLANNER_AGENT_CLI_DIR=/opt/planner/agent-cli
Deploy the complete dist/agent-cli directory beside the server binary as
agent-cli, or point PLANNER_AGENT_CLI_DIR at it. In Settings > API, select
the scopes and lifetime, then create an install URL. Viewing the URL is safe;
only planner agent install <url> consumes it. Install URLs last 15 minutes
and the resulting credential lasts 90 days by default.
Agent clients can use PLANNER_URL and PLANNER_TOKEN_FILE. MCP clients use
Streamable HTTP at /mcp with Authorization: Bearer <token>.
The ordinary note, project, task, and sub-task agent/MCP tools include resources
shared directly with the authenticated account. List and get results carry a
stable ref plus access metadata; pass that ref back to get or mutation
tools so Planor can route the call through owner or share authorization. Shared
resources remain subject to their view or edit grant, cannot be deleted by
the grantee, and disappear immediately when the owner revokes the grant. Public
share links remain separate anonymous capabilities. workspace_get,
workspace_discover, note folders, project folders, and note search stay
owner-only.
Logging
Planner keeps runtime diagnostics local. The standalone server writes readable
lines to stdout/stderr for terminals and journalctl, plus structured JSONL
files under logs beside the executable. Browser crash, sync, and AI error reports are
posted only to the same-origin Planner server. Logging never sends telemetry to
an external service; the configured AI provider and SMTP server remain the only
outbound integrations.
Production defaults are info level, 14 UTC days of files, a rolling 50 MB per
day, a 1 second slow-request threshold, and a 30 second threshold for AI calls.
Direct bun run dev defaults to debug and writes JSONL under ./logs. Set
PLANNER_LOG_DIR to override that location.
| Variable | Purpose |
|---|---|
PLANNER_LOG_LEVEL |
debug, info, warn, error, or fatal |
PLANNER_LOG_DIR |
JSONL directory; defaults to logs beside the standalone executable |
PLANNER_LOG_RETENTION_DAYS |
UTC dates to retain; default 14 |
PLANNER_LOG_MAX_DAILY_MB |
Rolling per-day file limit; default 50 |
PLANNER_SLOW_REQUEST_MS |
Slow ordinary API threshold; default 1000 |
PLANNER_AI_SLOW_REQUEST_MS |
Slow AI API threshold; default 30000 |
PLANNER_TRUSTED_PROXIES |
Comma-separated proxy IP/CIDR list allowed to supply X-Forwarded-For; default empty |
Invalid logging values stop startup rather than silently changing behavior. If the file destination becomes unavailable at runtime, Planner continues on the console and retries the file transport. Log files contain operational metadata, internal IDs, and sanitized stacks, but not workspace content, request bodies, credentials, prompts, model responses, email addresses, or raw client IPs.
For a normal systemd service, both console streams are available together:
journalctl -f -u planner.service
Set PLANNER_TRUSTED_PROXIES only for proxy addresses you control. With an
empty value, forwarded client-address headers are ignored. A production unit
should use Restart=on-failure so a deliberately fatal process error restarts
after Planner flushes its final log record.
Semantic embeddings and note search
Memory search keeps its encrypted lexical index and can optionally add semantic ranking through user-configured embedding and tokenizer request URLs. The feature is off for each account until that user saves a complete profile in Settings. Owned-note semantic search is a separate opt-in using the same profile; enabling memory embeddings does not upload notes. When enabled, note indexing sends the title, content, and tags to the configured provider. Memory and notes remain separate corpora with different derivation schemas.
The Memory and Notepad search fields use these hybrid retrieval paths directly. Without semantic ranking, each field uses deterministic lexical ranking.
Agent clients can search active owned notes through GET /api/agent/v1/notes/search, planner agent note search --query TEXT, or the
MCP note_search tool. These surfaces require notes:read, exclude trashed and
shared-in notes, and never mix memory observations into note results. Search
uses deterministic lexical title/content/tag ranking when note embeddings are
disabled, incomplete, or unavailable.
The profile and optional bearer token are encrypted with the user's workspace and redacted from API responses. Planor sends the selected model ID to both provider routes, so one profile works with a single-model server or a model-aware router. No server-wide embedding host, model, credential, or ranking policy is built into the application.
Embedding vectors are plaintext-derived numeric values inside PostgreSQL, not application-encrypted payloads. Encrypt and tightly control PGDATA and tablespaces, WAL and any archive if later used, physical and logical disaster recovery backups, snapshots and replicas, temporary spill, swap, and core dumps. Portable account archives and active inverse exports exclude vectors, which can be rebuilt from decrypted source data. Deletion removes logical rows but cannot promise immediate physical erasure from MVCC storage, WAL, replicas, snapshots, or retained backups.
Stack and storage
React 19 · TypeScript · Vite · Zustand · Marked. PostgreSQL is authoritative for accounts, credentials, shares, workspaces, notes, memory, collaboration checkpoints, attachments, archive staging, and change notifications. Sensitive payloads are encrypted and authenticated before they enter PostgreSQL; lookup columns use scoped blind indexes rather than plaintext logical identifiers.
The remaining files are operational boundaries, not alternate data stores: the master key, PostgreSQL password and TLS material, logs, build/static assets, and user-selected import/export archives. Protect these with a dedicated service account and restrictive file permissions or ACLs. Theme, sidebar width, folder expansion, and the archived section are account preferences; saved changes notify other signed-in tabs.
Development checks
The individual quality gates are composable:
bun run typecheck
bun run lint
bun run lint:react
bun run format:check
bun run knip
bun run test
bun run test:ui
bun run check runs type generation/typechecking, Oxlint, React ESLint,
formatting, Knip, serial Bun server tests, Vitest component tests, and the web
build/bundle budget. bun run check:full additionally audits dependencies, runs
cross-browser E2E, builds both server and agent distributions, and runs their
smoke suites. Use bun run format to apply the Oxfmt baseline.
Exact dependency overrides are release invariants, not ordinary update pins:
@codemirror/view keeps CodeMirror class identity single-versioned;
fast-uri, hono, and nanoid force patched transitives retained by Bun's
resolver; vite keeps React Router and direct build tooling on the audited
release. bun outdated does not report all override drift, so review this block
with bun why <package>, bun audit, and notice regeneration during upgrades.
AI (optional)
Open Settings & AI and point Planner at an Ollama-compatible server (default http://localhost:11434, model gemma3:4b). Planner proxies /api/tags and /api/chat through its authenticated backend. Saved API keys are encrypted at rest, added to upstream requests only by the server, and never returned to the browser.
PLANNER_AI_ALLOWED_HOSTS controls outbound AI hosts. It defaults to *, which permits public hosts plus loopback Ollama on port 11434. Entries without a port allow only the URL scheme's default port; private/LAN hosts and custom ports must be listed explicitly as comma-separated host[:port] entries, for example:
$env:PLANNER_AI_ALLOWED_HOSTS='pool.kleb.sh,ollama.lan:11434'
PLANNER_AI_RATE_LIMIT caps AI requests per account per five minutes and
defaults to 30, which suits interactive use. Raise it only for controlled
batch workloads, then restore the default afterwards.
Without an AI server the rest of Planner continues to work; AI actions show a connection error.
When an AI API key is configured, Planner requires an HTTPS endpoint unless every resolved address is loopback. Allowlisting a LAN hostname permits routing to it but does not waive this transport rule; terminate TLS at the AI service or an authenticated reverse proxy.
Email recovery (optional)
SMTP enables verified recovery emails, password reset, and opt-in share notifications. Configure it through environment variables so credentials do not appear in command-line arguments:
$env:PLANNER_SMTP_URL='smtps://user:password@smtp.example.com:465'
$env:PLANNER_MAIL_FROM='Planner <planner@example.com>'
$env:PLANNER_PUBLIC_URL='https://planner.example.com'
If PLANNER_SMTP_URL is unset, Planner starts normally with email features disabled. New and existing accounts can optionally add a recovery email; it must be verified before password recovery or share notifications can use it.
For HTTPS deployments, set PLANNER_PUBLIC_URL to the external HTTPS origin or set PLANNER_SECURE_COOKIES=true. Planner also detects direct TLS and HTTPS browser origins automatically. Set PLANNER_SECURE_COOKIES=false only for an intentionally plain-HTTP deployment such as local loopback use.
Feedback (optional)
Set PLANNER_FEEDBACK_EMAIL to put a feedback form in Settings > General. Submissions are rendered into one email to that address and are never stored, so the instance needs no administrator account and no inbox screen:
$env:PLANNER_FEEDBACK_EMAIL='you@example.com'
It requires PLANNER_SMTP_URL; without both, the form does not appear. Each submission carries a summary, a message, and up to three image or text attachments (2 MB each, 5 MB total), plus the build id and the sender's username and language. When the sender has a verified recovery email, the message's Reply-To is set to it. Accounts are limited to five submissions per hour.
Features
- Dashboard, project view, calendar, statistics
- Private encrypted memory with search, project context, sessions, timelines, and reversible trash
- Notepad with Markdown preview and Export PDF
- Nested project folders, star-to-pin, drag-and-drop projects between folders
- Per-project tags with filter + sort on the dashboard
- Dark mode (persisted)
- Resizable sidebar (persisted)