I got tired of losing my mind.
Not dramatically. Just the slow, grinding kind — where you know you wrote something down somewhere, you just can't remember if it was in Notion, a Obsidian vault, a Google Doc, a sticky note, a GitHub issue, a Slack message, or a ChatGPT thread that's now buried under forty others.
I'm an engineer. I work on multiple things at once. I have architecture decisions I made six months ago that I need to remember today. I have tasks that belong to projects that relate to notes that reference PDFs I read once and can't find. I have daily journals that never connect to the work they're about. I have GitHub issues that have no relationship to the notes I took while fixing them.
Everything is somewhere. Nothing is connected. Nothing remembers.
So I built this.
EngineerOS is a workspace that treats your knowledge like a database — not a pile of documents. Notes link to tasks. Tasks belong to projects. Everything is indexed and searchable by meaning, not just keywords. You can ask it questions and it will cite the notes it pulled the answer from. Your daily journal connects to today's tasks. Your architecture notes are one wikilink away from the code decision they explain.
Mental model: Obsidian + Linear + Notion + NotebookLM — in one place, with an AI layer on top that actually understands what's in there.
The core data model is six objects:
User → Workspace → Project → Task
→ Note
→ Daily Note
Everything else is derived from or attached to these. No dead ends. No orphaned documents.
Notion is a document tool pretending to be a database. Tasks are second-class citizens. Search is mediocre. The AI feels bolted on.
Obsidian is a graph of markdown files. No tasks. No projects. No structured data. Syncing is painful. The AI plugins are plugins.
Linear is tasks only. No notes. No daily journal. No knowledge base.
Logseq / Roam — outliner-centric. I don't think in outliners.
Cursor Memories — only knows about your code, not your architecture docs, your meeting notes, or your 3am ideas.
None of them talked to each other. I was maintaining context across six different tools. Every context switch was a tax. Every search was a lottery. I was the glue, and I was failing at it.
The closest thing to what I wanted was "what if everything lived in one place and an AI could see all of it." So I started building that instead.
One dialog (⌘K or the Quick Capture button), two keystrokes, and whatever's in your head is either a note, a task, or sitting in the inbox until you triage it. Auto-triage rules can route captures by keyword automatically.
- Notes — markdown with frontmatter, tags, project assignment, pinning,
[[wikilinks]]that resolve to backlinks - Tasks — kanban board (Backlog / Todo / In Progress / Done), priority, due date, linked notes
- Projects — notes, tasks, timeline, and resources under one roof with visible progress
- Daily notes — one per day, auto-created, with sections: Morning goals, Journal, Today's tasks, Learned, Wins, Problems, Tomorrow. Unfinished tasks roll over automatically.
- Resources — code snippets, bookmarks, reading list, architecture notes, meeting notes, all typed and queryable
- ⌘K command palette — instant search across notes, tasks, projects, tags
- Semantic search — toggle to meaning-based search; the index finds the right note even when you don't remember the exact words. Falls back to local keyword scoring without an API key. See Retrieval for how ranking works and how it's measured.
- AI assistant — ask questions about your workspace in plain English. Answers cite the exact notes they came from. Threads are persisted so you can continue a conversation.
- Knowledge graph — force-directed graph of how your notes connect via wikilinks, task links, and project membership. Filter by project, drag nodes, click to open.
- PDF chat — upload a paper or doc, ask questions about it, get cited answers
- Voice notes — record and transcribe; transcription falls back to local extraction without a key
- Mind maps — any note becomes a visual mind map
- Recurring tasks — create daily, weekly, or monthly tasks automatically
- Auto-triage — keyword rules route quick captures to notes or tasks
- Daily rollover — yesterday's unfinished tasks carry forward
- All of this runs in a client-drained Postgres job queue — no cron, no edge functions, no separate worker
- GitHub — import issues into tasks; link repos to projects. With the webhook configured, issue edits, closes, reopens and deletes flow back into the imported tasks and get re-indexed within seconds. Turn on "Capture merged PRs as notes" for a repo and every merged PR becomes a searchable decision note (description + links to the tasks it closes) — the why gets recorded without anyone filing it
- Decisions — each captured PR (or any note, via "Extract decision") is turned into a lightweight decision record by AI: the decision, the context that forced it, rejected alternatives, trade-offs, and when to revisit. Browse them all at
/decisions. Needs an AI provider key; with none, no record is invented - Paste a chat — drop a ChatGPT / Claude / Slack thread into
/decisions → Paste a chat; it's saved as a searchable note and its decision becomes a record - Decided before — check first — while you type a new task, related past decisions appear under the title (semantic when embeddings are on, keyword otherwise), so settled questions don't get re-litigated
- Stale-decision alerts — when a new decision looks like it reverses or conflicts with an earlier one ("add Redis" vs "no Redis, Postgres is enough"), the earlier record is flagged on
/decisionsunder "Needs review". Mark it superseded or dismiss the alert; superseded decisions drop out of related-decision hints - Automatic webhook — linking a repo for PR capture registers the GitHub webhook for you when you're a repo admin (production only; local dev can't receive deliveries). Unlinking removes the hook EngineerOS created
- Calendar — export tasks with due dates to
.ics
- Workspaces — separate contexts (work, personal, a client), switched from the sidebar. Notes, tasks and the assistant are scoped to one at a time
- Teams — invite by email, or share the token link directly; membership is what row-level security checks, so a member can see their workspace's content and nothing else
- Plans — Free / Pro / Team, enforced server-side at workspace creation, invites, and assistant calls rather than hidden in the UI. Bring your own AI key on any plan and the assistant allowance disappears — that usage is billed to your provider account, not to us. See
/pricing
Self-hosting is unchanged: with no billing configured, no limits apply.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router), React 19 |
| Language | TypeScript throughout |
| Database | Supabase (Postgres + pgvector + RLS) |
| Auth | Supabase Auth |
| State | TanStack Query (server state), Zustand (UI state) |
| UI | shadcn/ui, Tailwind CSS v4, Framer Motion |
| AI | Pluggable providers (OpenAI, Anthropic, Gemini, Mistral, Groq, Cohere, HuggingFace, NVIDIA NIM, OpenRouter) — fully optional, local fallbacks for everything |
| Resend (optional) | |
| Monitoring | Sentry (optional), Plausible (optional) |
| Tests | Vitest |
Supabase is optional. Without credentials the app runs in local/demo mode and AI features use keyword-based fallbacks. Nothing hard-requires a paid service to run.
npm install
cp .env.example .env.local
npm run devOpen http://localhost:3000. Register, create a workspace, and start capturing.
Everything is opt-in. At minimum you need the Supabase pair for real data.
| Variable | What it unlocks |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Real data — required |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Real data — required |
OPENAI_API_KEY |
Semantic search, AI assistant, PDF chat, voice transcription, summaries. Other providers (Anthropic, Gemini, Mistral, Groq, Cohere, HuggingFace, NVIDIA NIM, OpenRouter) are selectable in Settings. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
GitHub integration OAuth |
GITHUB_WEBHOOK_SECRET |
GitHub webhook at /api/github/webhook (JSON; "Issues" + "Pull requests" events) — issue → task sync and merged PR → note capture. Needs SUPABASE_SERVICE_ROLE_KEY |
RESEND_API_KEY / EMAIL_FROM |
Email reminders (Resend) |
NEXT_PUBLIC_SENTRY_DSN / SENTRY_DSN |
Error tracking |
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN |
Sentry source-map upload (CI only) |
NEXT_PUBLIC_PLAUSIBLE_DOMAIN |
Analytics |
NEXT_PUBLIC_APP_URL |
Canonical origin for metadata — auto-derived from VERCEL_URL if not set |
CRON_SECRET |
Server cron auth — Vercel sends it as Authorization: Bearer to /api/cron/drain |
SUPABASE_SERVICE_ROLE_KEY |
Server cron drain and GitHub webhook only — never expose to the client |
NEXT_PUBLIC_BILLING_ENFORCED |
Set to true to enforce Free/Pro/Team limits. Off by default: the tenancy migration makes plan exist, this makes it bind. Inlined at build, so it needs a redeploy |
Never expose SUPABASE_SERVICE_ROLE_KEY to the client.
GitHub OAuth, two flows: account sign-in and the workspace integration are separate.
- Sign-in ("Continue with GitHub" on
/login+/register) runs through Supabase Auth — no app env vars. Enable it in the Supabase dashboard: Auth → Sign In / Providers → GitHub (set a GitHub OAuth app's Client ID/Secret), and registerhttps://<project-ref>.supabase.co/auth/v1/callbackas that OAuth app's callback URL. Under Auth → URL Configuration, addhttps://engineeros-delta.vercel.app/auth/callback(andhttp://localhost:3000/auth/callbackfor dev) to the allowed Redirect URLs. The app completes the session exchange at/auth/callback. - Integration (linking repos, issue → task sync) uses
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETand the callback/api/auth/github/callbackon the same or a separate OAuth app — see the table above.
npm run dev # start dev server
npm run build # production build
npm run lint # eslint
npm run test # vitest
npm run typecheck # tsc --noEmit
npm run eval:retrieval # retrieval quality — recall@k and MRR, no DB or API key needed27 migrations in supabase/migrations/, applied in filename order. supabase/config.toml is committed so the CLI behaves identically locally and in CI:
supabase start # boots the local stack and applies migrations
supabase db lint # lints the applied schema (fail on errors)
supabase stopFor a remote project:
supabase link --project-ref <your-ref>
supabase db pushsrc/types/database.ts is the app's hand-written interface layer (it mirrors the migrations). The generated baseline lives at supabase/types/database.ts and is what CI diffs against the schema. Generate it from the local stack (matches CI exactly — needs supabase start running):
npm run types:gen # supabase gen types typescript --local > supabase/types/database.tsAlready linked to a remote project? supabase gen types typescript --linked > supabase/types/database.ts works too — just note CI checks against --local, so prefer the local command for the committed baseline.
The db-verify job in .github/workflows/ci.yml starts the local stack (migrations must apply cleanly), runs supabase db lint --fail-on error, regenerates types, and fails the build if supabase/types/database.ts drifts from the schema — commit a baseline once and drift is enforced on every push/PR.
Create a project at supabase.com, apply the migrations, and copy the project URL and anon key.
The repo is Vercel-ready. Push to GitHub, import at vercel.com, and set the environment variables under Project → Settings → Environment Variables.
| Variable | Environments |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Production + Preview + Development |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Production + Preview + Development |
OPENAI_API_KEY |
Production (Preview optional) |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
Production only |
GITHUB_WEBHOOK_SECRET |
Production only |
RESEND_API_KEY / EMAIL_FROM |
Production |
NEXT_PUBLIC_SENTRY_DSN |
Production + Preview |
SENTRY_AUTH_TOKEN |
Build-time, Production + Preview |
NEXT_PUBLIC_PLAUSIBLE_DOMAIN |
Production only |
NEXT_PUBLIC_APP_URL |
Production only — optional; auto-derived from VERCEL_URL if not set |
Every push to the production branch deploys. Every PR gets a preview URL.
Notes:
- GitHub OAuth only works on production — the preview callback/redirect origins won't match the GitHub OAuth app or Supabase allow-list. The integration shows as disconnected on previews, nothing breaks.
NEXT_PUBLIC_APP_URLis auto-derived fromVERCEL_URLon previews. Only set it for a custom domain on production.- Sentry source-map upload requires
SENTRY_AUTH_TOKENas a build-time variable. vercel.jsonpins functions toiad1(us-east-1), adds security headers (Permissions-Policy keepsmicrophone=(self)so voice notes keep working), and registers a cron at/api/cron/drain— setCRON_SECRET(any long random string) +SUPABASE_SERVICE_ROLE_KEYfor it to run.- The shipped cron schedule is
0 8 * * *(daily, 08:00 UTC) — the Hobby plan runs at most one cron per day. On Pro you can tighten it (e.g.*/15 * * * *) for near-real-time reminders when the app is closed. The client-drain hooks still cover everything while the app is open. The cron also re-embeds up to 10 queued changes per workspace per run, so the search index catches up even when nobody opens the app.
Sentry, Plausible, Resend — create projects, add the keys, redeploy. Each activates only when its variable is present.
RLS everywhere. Every table scoped by workspace_id via is_workspace_member(). All server work runs as the signed-in user. No service role key required at runtime.
Background work is a client-drained queue. Postgres triggers enqueue durable jobs rows. useAutoIndex and useAutoAutomation hooks drain them on page load and on a visibility-gated interval — through authenticated API routes. No cron, no worker, no separate process.
AI is a progressive enhancement. Every AI feature has a local fallback. Semantic search falls back to keyword scoring. The assistant falls back to extractive answers from note text. Transcription falls back to stored audio without a transcript. The app is fully functional without any provider key.
Supabase is optional. src/lib/supabase/config.ts gates everything. Without credentials you see a setup notice and can still explore the UI.
Answer quality is bounded by retrieval, not by the model. A strong model over bad retrieval produces confident, well-formatted, wrong answers. So retrieval is measured rather than assumed.
Chunking is structure-aware. chunkMarkdown splits on markdown headings —
the author's own statement of where a topic changes — rather than fixed
character windows. It skips fenced code blocks, where # is a comment and not a
heading, and prepends title › heading to every chunk so chunk 3 of a note is
still identifiable as that note.
Ranking fuses two legs on rank position. Embeddings miss exact tokens
(ERR_JWT_EXPIRED, auth.uid()); keyword search misses paraphrase ("how do
users sign in" → a note titled "JWT refresh flow"). Their scores are not
comparable — a cosine of 0.82 and a keyword score of 4.7 are different units —
so reciprocal rank fusion scores on rank instead.
Empty retrieval refuses. When nothing matches, the assistant says so and lists the terms it actually searched, so you can tell a retrieval miss from an empty workspace. There is no path where an empty context reaches the model and lets it answer from training data. The degradation ladder is explicit and labelled in the response: embeddings → keyword → extractive → refuse.
Retrieved text is untrusted. Your notes can contain anything pasted from anywhere, including text written to hijack an assistant that reads it. The system prompt treats excerpts as data, never as instructions.
Vectors carry provenance. embeddings.embedding_model records what produced
each row. Providers return different dimensions and the indexer pads to 1536;
padding preserves cosine ordering within one provider but means nothing across
them. Without provenance, switching providers gives you an index where old and
new rows sit in incomparable spaces — search keeps returning results, they are
just quietly wrong.
npm run eval:retrieval runs 20 typed queries over a 22-document corpus with no
database and no API key. Questions are labelled paraphrase / exact / mixed,
and 14 of the 22 documents are deliberate distractors — topically adjacent notes
that must not outrank the true answer.
The distractors exist because the first version had 8 documents and every strategy scored 100% recall@3. A saturated eval measures nothing.
| strategy | recall@1 | recall@3 | recall@5 | MRR |
|---|---|---|---|---|
| keyword | 82.5% | 100.0% | 100.0% | 92.5% |
| semantic | 52.5% | 85.0% | 95.0% | 72.2% |
| hybrid (w=1.0) | 72.5% | 97.5% | 100.0% | 86.7% |
| hybrid (w=0.35) | 82.5% | 100.0% | 100.0% | 92.5% |
Equal-weight fusion scored worse than keyword alone. Down-weighting the semantic leg to 0.35 recovered parity — it did not beat it.
That is not a verdict on hybrid retrieval. It is a verdict on this eval's semantic leg, which is a bag-of-words fingerprint standing in for a real embedding model. Fusing a lexical ranker with another lexical ranker adds no independent signal, only noise. The honest reading: this eval cannot demonstrate hybrid's value, because it has no real embedder. With no API key configured, keyword-only is the correct default, and that is what the code does.
The regression tests assert the measured floors, so a change that degrades ranking fails CI. One assertion is a deliberate tripwire: when a stronger embedder makes "down-weighting beats equal-weight" fail, that is the signal to raise the weight — not to delete the test.
Full reasoning, including what this eval does not cover, is in
docs/adr/0001-retrieval.md.
Non-obvious decisions are written down with their reasoning and the alternatives rejected, because "why" survives being questioned and "what" just gets overruled.
docs/adr/0001-retrieval.md— chunking, rank fusion, refusal behaviour, embedding provenance, and what the eval measured
.github/workflows/ci.yml runs lint, typecheck, Vitest, and a production build on every push and PR.
Long-form pieces on the problems behind this project, published at /blog:
- AGENTS.md, Cursor rules, and Claude skills are all solving the same problem — badly — three formats, one failure: they only carry what you remembered to write down in advance
- Your AI assistant is only as good as what it can retrieve — chunking, rank fusion, and why citations are load-bearing
- Self-hosting your second brain — where notes should live, and the ten-year test
Source lives in src/content/posts.ts. The blog is statically generated, with
per-post schema, an RSS feed at /blog/rss.xml, and an llms.txt that carries
each post's direct-answer paragraph.
Because I would have killed for something like this when I was starting out and drowning in tools. If it helps someone else stop losing their mind, that's enough.
V1 was never about AI. It was about data.
The whole point of spending months building a notes system, a kanban board, a daily journal, and a resource library — before touching any AI — was to produce clean, structured, connected data. Because AI is only as useful as what it has to reason over. If your knowledge base is a pile of unconnected documents in three different apps, no LLM is going to save you.
Every schema decision in this project was made with AI in mind:
- Notes have
embeddingsupport via pgvector from the start - Tasks link to notes so the assistant knows what work relates to which thinking
- Daily notes follow a fixed structure so the AI can parse them predictably
- Wikilinks create an explicit knowledge graph the assistant can traverse
- Resources are typed (code, bookmark, paper, architecture) so retrieval can be filtered by kind
The AI layer built on top of this isn't bolted on. It's the reason the foundation was designed the way it was.
V1 was deliberately single-workspace, single-user. Not because I couldn't build multi-tenant — because I wanted to use this myself, daily, and prove it works before adding the complexity of teams, billing, and permissions. Ship fast, use it, learn, then expand. That expansion is now underway: workspaces are real memberships rather than ownership, and plans exist as an enforced layer rather than a pricing screenshot.
These aren't promises. They're the direction I'm thinking.
Better AI reasoning
Right now the assistant does RAG — retrieve chunks, stuff them in a prompt, generate an answer. That's fine. But the real version reasons across the graph. It knows that this task is linked to this note which references this architecture decision which was made because of this tradeoff. It doesn't just find relevant text; it traces reasoning chains. That's what I want to build toward.
Proactive surfaces
The workspace should surface things you didn't ask for. "You haven't updated this project in two weeks and it has 3 overdue tasks." "You wrote about this problem in your daily note on Monday — here's what you said." "This note hasn't been touched in 6 months but you linked to it from something you're working on right now." The data is all there. It just needs to be surfaced without being noisy.
Agent actions
Right now the assistant answers questions. The next version takes actions. Create a task from the conversation. Update a note. Schedule a recurring reminder. The backend is already there — the automation engine, the job queue, the write APIs. The assistant just needs to be allowed to call them.
Team workspaces
The schema was already multi-workspace — row-level security was always is_workspace_member(), never is_owner(); the function just only ever answered "am I the owner?". Adding a workspace_members table and redefining that one function made the entire schema tenancy-aware without touching a single other policy. Invitations, roles and a workspace switcher are shipped; read-only viewer enforcement is the remaining sweep.
Mobile
Not a native app. A PWA that makes quick capture instant from your phone. The whole point of quick capture is that it has to be fast. If it takes 4 taps to get to the input, you'll just text yourself instead. One tap from the home screen, one field, one button.
Integrations
GitHub is already there. Calendar export is there. What I actually want: Linear (import issues, sync status), Slack (capture from messages), browser extension (capture from any tab), Raycast extension (capture without opening the app). The workspace is only as useful as how easy it is to get stuff into it.
The thing I keep thinking about
There's a version of this where the AI doesn't just answer questions — it starts to understand how you think. What kinds of problems you get stuck on. What your project patterns look like. What your daily journal reveals about what's actually draining you versus what energizes you. Not in a creepy way. In the way a really good second brain would — one that's read everything you've written and actually retained it.
That's the long-term bet. The workspace as a memory layer that gets smarter the more you use it. V1 builds the foundation. Everything after is about making that foundation think.
The full write-up of EngineerOS (problem, architecture, trade-offs and results) lives at kunjshah.in/projects/engineeros.
Built by Kunj Shah, AI engineer.