A remote-first Markdown workspace for editing notes through an SSH tunnel.
npm install
WORKSPACE_ROOT=/absolute/path/to/notes npm run devTo switch between server folders from the sidebar, pass a path-delimited list:
WORKSPACE_ROOTS="/absolute/path/to/notes:/absolute/path/to/other-notes" npm run devThe backend refuses to start without WORKSPACE_ROOT or WORKSPACE_ROOTS and always binds to 127.0.0.1.
The Vite dev server also binds to 127.0.0.1 and proxies /api to the backend.
Instead of passing env vars on the command line, put them in ~/.webmd.conf (KEY=VALUE per line, same format as .env). The backend loads it automatically on startup; real environment variables still take precedence:
WORKSPACE_ROOT=/absolute/path/to/notes
PORT=3000If a workspace root is a git repo, AUTO_COMMIT_MINUTES snapshots it on an
interval, and once more when the server shuts down:
AUTO_COMMIT_MINUTES=15Unset or 0 disables it. Each tick runs git add -A and commits everything git
would track under that root — including changes you deliberately left unstaged —
as WebMD autosave <date> <time>. Nothing is pushed. A clean tree, a directory
that is not a repo, and a repo mid-merge or mid-rebase are all skipped. Commits
run with --no-verify and signing off, so no hook or passphrase prompt can
block an unattended snapshot; if git has no user.email configured anywhere,
the commit is attributed to WebMD <webmd@localhost>.
This is the safety net that outlives the browser: the editor's undo history is
per-note and dies with the tab, so git show HEAD:note.md (or
git checkout HEAD -- note.md) is what recovers a note deleted by mistake.
On the remote server:
npm install
npm run build
WORKSPACE_ROOT=/absolute/path/to/notes PORT=3000 npm startFrom your local machine:
ssh -N -L 3000:127.0.0.1:3000 user@remote-hostThen open http://127.0.0.1:3000 locally. The app still binds only to
127.0.0.1 on the remote host, so the SSH tunnel remains the access boundary.
For persistent private access without keeping an SSH app open, install Tailscale on the server and iPhone, sign both into the same tailnet, and run on the server:
tailscale serve --bg http://127.0.0.1:3000Open the HTTPS URL printed by Tailscale in Safari. Use Share → Add to Home Screen, enable Open as Web App, and tap Add. Keep Tailscale connected on the phone; do not use Tailscale Funnel, which would make WebMD public.
WORKSPACE_ROOT: required absolute path to the Markdown workspace.WORKSPACE_ROOTS: optional path-delimited list of Markdown workspaces.PORT: backend port, defaults to3000.IMAGE_ASSET_FOLDER: workspace folder for pasted and uploaded images and PDFs, defaults to/assets. It is created on the first upload if missing.VITE_API_PROXY_TARGET: optional dev proxy target, set bynpm run dev.AI_PROVIDER: optionalollamaoropenai, defaults toopenaiwhenOPENAI_API_KEYis set andollamaotherwise.AI_MODEL: optional model override. Ollama defaults tollama3.2; OpenAI defaults togpt-5.6.OLLAMA_BASE_URL: optional Ollama URL, defaults tohttp://127.0.0.1:11434.OPENAI_API_KEY: required forAI_PROVIDER=openai; never sent to the browser.OPENAI_BASE_URL: optional OpenAI-compatible base URL, defaults tohttps://api.openai.com/v1.
The AI panel's Prompts picker is a group rail with that group's prompts beside it — one click runs a prompt. There are two kinds:
- Rewrite prompts (
kind: "edit") act on the selected text. Select text first, or they stay disabled. The result lands in the diff preview, so nothing changes until you accept it. - Ask prompts (
kind: "chat", marked with a dot) act on the whole note and need no selection. They answer in the chat transcript and never touch the file.
Anything typed in the chat box refines the prompt you click.
Built-in presets:
| Group | Kind | Presets |
|---|---|---|
| Paper | Rewrite | Tighten (academic), Active voice, Methods-section voice, Calibrate claims, Compress to abstract, Plain-language summary |
| Rewrite | Polite reply, Concise reply, Soften a decline, Follow-up nudge | |
| Notes | Rewrite | Condense to bullets, Clean up dictation, Extract action items, Expand shorthand |
| Ask | Chat | Summarize this note, Open questions, Skeptical review, Suggest next steps |
Add your own in $WORKSPACE_ROOT/.webmd/prompts.json. Reusing a built-in id
replaces that preset, so you can retune one without redefining the rest:
{
"presets": [
{
"id": "grant-aims",
"label": "Specific Aims voice",
"group": "Paper",
"system": "You rewrite text in the voice of an NIH Specific Aims page. Return only the replacement Markdown, with no explanations or code fences.",
"instruction": "Tighten to active voice and cut hedging."
},
{
"id": "ask-reviewer",
"label": "Reviewer 2",
"group": "Ask",
"kind": "chat",
"system": "You review a note as a demanding but fair referee. Answer in concise Markdown and do not rewrite the note.",
"instruction": "How would a hostile reviewer attack this?"
}
]
}id, label, and system are required; group defaults to Custom, kind
defaults to edit, and instruction is derived from the label when omitted. An
edit preset's system prompt should tell the model to return only the
replacement Markdown — anything else it says ends up in your document. System
prompts stay on the server and are never sent to the browser. Invalid entries
are skipped with a warning in the AI panel rather than dropping the whole file.
A note's file name follows its title, the way Obsidian's does. Retitle a note —
the frontmatter title: field when it has one, otherwise the first heading —
and the next save renames the file to match: # Reading list in
/wiki/Untitled.md moves the note to /wiki/Reading list.md.
Every [[wiki link]] in the workspace that pointed at the old name is rewritten
to the new one, keeping its alias, heading anchor, and ! embed marker, so
renaming never leaves a dead link behind. Characters a file name cannot carry
(/, :, ?, and friends) are dropped from the name; the title in the note
keeps them.
Two notes are left alone: a daily note, which is addressed by its date rather than its heading, and a note whose new name is already taken — that rename is reported as an error instead of overwriting the other note.
Typing [[ in the editor offers the notes it could mean — matched on the name
and on the folder, so iaas finds /raw/projects/iaas/triton.md too. Accepting
one writes the shortest form that still resolves back to that note, so a
completed link is never ambiguous and never dead.
Typing # after the note name switches to that note's headings:
[[hybrid-search#Setup]] opens the note and scrolls to its ## Setup, in the
preview or in the editor, whichever pane is open. ![[hybrid-search#Setup]]
embeds that one section as a card.
A link to a note that is not in the workspace is drawn wavy and warm in the preview, since a dead link is usually a typo worth seeing while reading. Clicking one offers to create the note rather than opening an empty page that belongs to no file.
The graph view counts the same dead links as the mentions it cannot draw, and its footer lists them: every unresolved link in the workspace, with the note it is written in. Clicking one opens that note in the editor with the cursor already on the line the link sits on. The list is capped at 200 entries and says so when there are more; the count above it is always the true total.
The bottom of every note lists its linked mentions — the notes that link
here, with the line each link sits on. Clicking a mention opens that note at
that line. Mentions are resolved rather than string-matched, so [[triton]],
[[iaas/triton]] and [[/raw/projects/iaas/triton|Triton]] all count as the
same link.
Connect notes in the AI panel links the open note to notes you already have. It ranks every Markdown file in the workspace by how much wording it shares with the open note, sends the strongest dozen to the model as a shortlist, and asks which ones a reader would want to follow.
The suggestions arrive as a review panel — one checkbox per link, showing the
exact bullet that will be written. Accepting appends them to a ## Related
section at the end of the note, reusing a trailing ## Related pages or
## Related concepts section when the note already has one:
## Related
- [[concepts/hybrid-search]] — the retrieval scheme this run replaced
- [[2026-07-08]] — earlier pass over the same datasetLinks are one-directional and written only into the open note; no other file is touched. Each link is emitted in the shortest form that resolves back to the note it names, so it can never be a dead link. Notes the file already links to are never suggested again, and the model can only choose from the shortlist, so it cannot invent a path.
Typing /date and pressing Tab writes 2026-08-21 into the note. Tab anywhere
else still indents, so an unknown /word is left alone.
| Snippet | Inserts |
|---|---|
/date /time /now |
2026-08-21, 14:30, or both |
/lastupdate |
Last update: 2026-08-21 |
/today /tomorrow /yesterday |
a link to that day's note |
/task |
- [ ] |
/log |
- **14:30** , for a running log |
/meeting |
date heading, Present, Notes, Actions |
/table /code /details |
a skeleton, caret in the first field |
/note /idea /warning |
the matching callout |
Snippets expand to plain Markdown, once, at the moment you type them: nothing
is re-evaluated when the note is rendered, so Last update: 2026-08-21 keeps
saying the day it was written — in this editor, in Obsidian, and in
git show HEAD:note.md. Add or edit snippets in src/snippets.js.
Any - [ ] checkbox is a task. Ticking one in the preview writes today's date
into the note, so a finished task records when it was finished:
- [ ] Write the intro
- [x] Draft outline ✅ 2026-08-14Tasks can also carry a due date and a priority, in the Obsidian Tasks emoji convention, so notes stay portable and readable as plain text:
| Field | Syntax | Effect |
|---|---|---|
| Due | 📅 2026-08-20 |
Preview badges it red when overdue, amber when due today |
| Done | ✅ 2026-08-14 |
Written and removed for you as the box is ticked |
| Created | ➕ 2026-08-01 |
Shown as typed; never written automatically |
| Priority | 🔺 ⏫ 🔼 🔽 ⏬ |
Highest to lowest; sorts the Tasks view |
A note in preview shows how far along it is (7/12 done) above the text.
Anything unrecognised — including recurring tasks (🔁), which WebMD does not
support — is left in the task's text untouched.
The emoji never have to be typed. On a task line, write due: and a date, or
:p1:–:p5: for priority, and the editor rewrites it as soon as the caret
leaves the line — so the file itself stays plain Obsidian syntax:
- [ ] Submit the abstract due:friday :p2:becomes
- [ ] Submit the abstract ⏫ 📅 2026-08-21| Shorthand | Means |
|---|---|
due: created: (or added:) done: |
📅 ➕ ✅ |
:p1: :p2: :p3: :p4: :p5: |
🔺 ⏫ 🔼 🔽 ⏬ |
2026-08-20 |
That date |
10-01 10/1 |
The next time that day comes round |
today tomorrow yesterday |
Also tod and tmr |
monday … sunday |
The next one to come; mon … sun work too |
+3d +2w |
Days or weeks from today |
Shorthand replaces a field the line already has, so due:tomorrow on a task
that is already dated just moves it. Anything that does not resolve to a real
date — due:someday, or a typo — is left exactly as typed rather than guessed
at, and shorthand in ordinary prose or inside a fenced code block is ignored.
Priority carries its colons so that a task about the p2 bug keeps its own
words.
The checklist button in the global bar (or Cmd/Ctrl+Shift+T) opens every open
task in the workspace. Clicking a row opens its note in preview, scrolled to
that task and ready to tick. A [text](url) link in a task shows as just its
text and opens in a new tab, without opening the note. Tasks inside fenced code
blocks are ignored, so an example in a how-to never turns into work.
There are three ways to look at the same list.
Board is what the view opens on: four short columns, ranked rather than filed, for answering "what now" without reading everything.
| Lane | Holds |
|---|---|
| Now | Overdue, due today, or marked :p1: |
| Soon | Dated within the month, marked :p2:, or written recently |
| Later | Real work, but nothing about it is pressing yet |
| Shelf | Reading and ideas — an unread paper is not late |
A task's place comes from three things a note already carries: its due date
(the strongest signal — an overdue task reaches Now on its date alone), its
priority mark, and how long ago it was written, taken from its daily
note's filename or its ➕ created date. Age cuts both ways: something written
this week is surfaced, and something written two months ago and never dated
sinks, which is what keeps Now short. It also sets the card's ink, so old work
fades rather than earning another badge. Each card shows the section that
claimed it and, in a daily note, the ## it sits under.
Shelf holds the sections marked as reading rather than work — Ideas, Interesting papers and Interesting software, by default. Those skip the ranking entirely, because scoring a paper against a deadline it never had would only bury the actual backlog. Any section can be shelved or unshelved under Edit sections; the catch-all never can.
Filter narrows the list before any of the three views slice it, matching a
task's prose, tags, headings and path alike, so gnl finds "GNLarge" halfway
through a word. / puts the cursor in it, and Escape clears it.
Sections is a dashboard: a task is filed under the first section whose terms
it matches, and whatever matches nothing lands in Other tasks. That keeps a
reading list, a stack of ideas, and real work in one - [ ] habit without them
crowding each other out. Urgency is the third view, grouped Overdue /
Today / This week / Later / No date. Both sort by due date then priority, and
both group a note's tasks under the note — except in a daily note, where they
group under the ## they sit beneath, so a project's work reads as one pile
across the week rather than one per day.
A term matches three things, so notes can be organised whichever way reads best:
| Source | Example | Matches |
|---|---|---|
| Inline tag | - [ ] Read the GNN paper #paper |
paper |
| Frontmatter | tags: [paper, reading] |
every task in the note |
| Heading | ## Interesting papers |
the whole heading, and each of its words |
Singular and plural are the same term, and a leading # is optional, so paper
finds #papers and ## Papers alike. Edit sections renames a section,
changes its terms, sets whether it shows open, done, or all tasks, marks it as
Shelf, and reorders or adds sections; the layout, the chosen view, and any
folded lanes are remembered in the browser. Completed loads
finished tasks as well, and shows them struck through in place.
The note button in the global bar (or Cmd/Ctrl+Shift+D) opens today's note
from wherever you are, creating it from the template if the day has none. The
dashboard's Open today's note card does the same thing.
A new daily note starts from the template picked in the Calendar header, which
may use {{date}}, {{title}}, {{weekday}}, and {{quote}}. With nothing picked, WebMD
uses a conventionally named template — dailynote_template.md,
daily-template.md, or template.md — from the daily-note folder, or failing
that from the workspace root. Choosing None keeps the bare # YYYY-MM-DD
heading.
{{quote}} asks the configured model for one attributable quote on the day's
theme, written into the note as a single line so a > {{quote}} template stays one blockquote. The line always reads
{quote} -- {author} ({date}), where the date is when the quote was said or
published, not the day of the note. An unattributed quote becomes Unknown
rather than a differently-shaped line, and a quote whose date the model does not
know drops the parentheses, so notes from different days line up. Three things
keep it from repeating itself:
-
The theme rotates with the date, so consecutive days cannot land on the same subject, and the same day always asks for the same one. Set your own rotation with
QUOTE_THEMESin the environment or~/.webmd.conf:QUOTE_THEMES=life,programming,financeAny comma-separated list works —
stoicism,music,physicsrotates over three days, a single theme asks for that one every day. Unset, it rotates over life, programming, and finance. -
Every quote already used is stored in
.webmd/quotes.jsonand sent back to the model as an exclusion list, along with the authors of the last twenty. -
A reply that repeats one anyway is caught and asked again once.
Today's quote is written to that history, so reopening or recreating today's note reuses it instead of spending another model call. If no model is reachable the placeholder is simply left empty — the note is still created.
The Home dashboard shows the same quote under its heading, and asks for one on
the first visit of the day whether or not your template uses {{quote}}.
Whichever surface asks first pays for the call and the other reads it back, so
the dashboard and the note never disagree about today's quote.
A new daily note is the template and nothing else — yesterday's unfinished tasks are not copied into it. An open task stays in the note that raised it, and the Tasks view is where you see the whole backlog: it reads every note in the workspace, so a task written weeks ago is one row there rather than a line duplicated into every day since. Clicking a row opens that note in preview at the task's line, where the box can be ticked once and for all.
Notes written before this carry ↩ [[origin]] links from the old carry-over
behaviour. Nothing writes them any more, but they are still parsed and shown as
backlinks, so those notes keep reading the way they did.
WebMD displays today's Codex-generated daily brief from:
raw/dailybrief/YYYY-MM-DD.md
For example, on 2026-07-17 it reads raw/dailybrief/2026-07-17.md.
raw/dailybrief/latest.md is still supported as a fallback.
Saving a note stamps its frontmatter with creation-date and
last-modified-date, so you can tell at a glance how old the information in a
note is:
---
creation-date: 2024-03-02
last-modified-date: 2026-08-19
---Both are written automatically, with no frontmatter block needed up front — one
is added when the note has none. creation-date is written once and never
rewritten; a note that predates this feature is dated by the age of its file
rather than by the day you happened to reopen it. last-modified-date moves at
most once a day, so a note only changes when its contents actually do.
Daily notes are exempt: their file name is already the date.
WebMD understands the Open Knowledge Format fields type, title,
description, resource, tags, and timestamp in a Markdown file's leading
YAML frontmatter. Preview renders the Markdown body, and workspace search can
filter any field with field:value, for example type:Playbook, tags:oncall,
or timestamp:2026-07. Tag matching is exact; other fields support partial,
case-insensitive matching.
Recommended Markdown shape:
# Daily Brief - 2026-07-16
_Generated: 2026-07-16 07:30 America/Los_Angeles_
## Focus
- ...
## Updates
- ...
## Follow-ups
- [ ] ...
## Sources
- raw/dailynotes/2026-07-16.md
- raw/projects/example.mdnpm run dev: start backend and frontend locally.npm run test: run focused workspace safety tests.npm run lint: run syntax checks.npm run build: build the frontend intodist/.