A remote-first Markdown workspace for editing notes through an SSH tunnel.
npm install
npm run devWith no workspace configured, WebMD opens its sandbox: a small example
workspace with a guided tour of links, tasks, citations, and daily notes.
Change anything you like there. On the first run the sandbox is copied from
sandbox/ in this repository to ~/.local/share/webmd/sandbox (or
WEBMD_SANDBOX_DIR), and that copy is what you edit, so the repository stays
clean. Your edits are kept across restarts. Delete the copy to start over. The
sandbox is never auto-committed.
To open your own notes instead:
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 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: absolute path to the Markdown workspace. Without it (orWORKSPACE_ROOTS), WebMD opens the sandbox.WORKSPACE_ROOTS: optional path-delimited list of Markdown workspaces.WEBMD_SANDBOX_DIR: where the sandbox copy lives, defaults to~/.local/share/webmd/sandbox.PORT: backend port, defaults to3000.IMAGE_ASSET_FOLDER: fallback folder for pasted and uploaded images and PDFs in workspaces whose.webmd/settings.jsondoes not setimageAssetFolder; defaults to/assets. See Workspace settings.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.INDICO_<NAME>_TOKEN: optional Indico personal access token, for protected meetings in the Meetings view and for naming pasted Indico links that need a login. Each token is bound to one exact origin:INDICO_CERN_TOKENtohttps://indico.cern.ch,INDICO_FNAL_TOKENtohttps://indico.fnal.gov,INDICO_GLOBAL_TOKENtohttps://indico.global. It never leaves the backend and is sent to that origin only. See Meetings for scopes.INDICO_<NAME>_URL: thehttps://address of any other Indico, paired with its token, asINDICO_DESY_URL=https://indico.desy.debesideINDICO_DESY_TOKEN. A token with no known or configured address is ignored.ARXIV_NEWS_CATEGORIES: optional comma-separated arXiv categories for the News view, defaults tohep-ex,hep-ph,cs.LG,cs.AI,physics.data-an.ARXIV_NEWS_INTERESTS: optional ranking instructions for the News view, used when the workspace has no.webmd/news.md.ARXIV_NEWS_MAX_CANDIDATES: optional number of papers the News view's AI ranking reads each day, the best matches by lexical score, defaults to120. More papers mean a longer, costlier model call. Press Re-rank to apply a change to a day already ranked.WEBMD_CACHE_DIR: where the News listings (a month of them), their AI rankings, and the Meetings view's Indico answers are kept across restarts, defaults to~/.cache/webmd. Meetings shows its last copy at once and refreshes it behind the scenes; protected meetings are cached there too, readable only by you. Nothing there is needed; delete it any time.
How a workspace is laid out belongs to that workspace, so each one can carry a
.webmd/settings.json. Commit it with your notes and every machine gets the
same layout. There are no controls for these in the UI. Edit the file, then
switch to the workspace again or reload the page.
{
"imageAssetFolder": "/assets",
"dailyNoteFolder": "/raw/dailynotes",
"dailyNoteTemplate": "/raw/dailynotes/template.md"
}Every key is optional:
imageAssetFolder: where pasted and uploaded images and PDFs go. It is created on the first upload. If you leave it out, WebMD usesIMAGE_ASSET_FOLDERfrom the environment or~/.webmd.conf, and then/assets.dailyNoteFolder: where today's note, Tasks, and date links look for daily notes. If you leave it out, WebMD uses/raw/dailynotes, or/when the workspace has no such folder.dailyNoteTemplate: the note a new daily note starts from (see Daily note template).""means no template.
WebMD skips a value it cannot use, keeps the rest, and names the problem when it next creates a daily note.
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.
Keep bibliography entries in references.bib at the workspace root and cite
them with Pandoc syntax such as [@Ju:2026abc]. Typing @ completes known
BibTeX keys. Preview shows inline citations, hover metadata, and a generated
References section; the graph connects notes to the papers they cite.
Pasting an arXiv, DOI, or INSPIRE literature link, a nature.com article, or a
journal article link with the DOI in its URL imports its BibTeX entry and
replaces the link with its [@key] citation. DOI metadata comes from doi.org;
arXiv and INSPIRE metadata comes from INSPIRE-HEP.
A daily note is where the day's work lands, but it is not where you look for a project later. File to projects in the AI panel takes the lines you select in the open day and files them into the project notes they advanced.
Select first. A day is mostly noise no project wants, and deciding which part is worth keeping is your judgement, not the model's. The button stays disabled until you select something, and only the selection is filed; the rest of the day goes along as context, so a selected line that says "traced it to the batch size" can still be written up as a sentence that makes sense on its own.
It ranks every Markdown note in the workspace by how much wording it shares with the selection, sends the strongest dozen to the model as a shortlist, and asks which projects the selection actually moves and what one line each should gain. Notes in your daily-note folder are never targets β filing one day into another would only copy a log sideways.
The button is only enabled on a daily note in your daily-note folder, which is
set per workspace in .webmd/settings.json. This one writes into notes you are
not looking at, so it is deliberately hard to fire by accident.
Nothing is written until you say so, twice:
- The list. Every project note the selection would touch, with a checkbox. Uncheck anything you would rather leave alone.
- One note at a time. Each kept note is shown on its own, with the exact line it would gain and where that line lands. File it, skip it, or close the panel and stop. Notes you never reach are never touched.
A filed line is a dated backlink and a summary, appended under a ## Log heading
in the project note, which is added at the end if the note has none:
## Log
- [[2026-07-08]] β ruled out the detector geometry by rerunning with the old alignment
- [[2026-07-09]] β requests started dropping once the GPU instance count went past fourNothing else in the project note changes. Because the link points back at the day, the day shows every project it fed among its own linked mentions, with no second pass and nothing written into it.
Filing the same day twice cannot double an entry: a project note that already links to the day is left out of the list and counted as already filed. Each backlink is emitted in the shortest form that resolves back to the day from that note, so it can never be a dead link, 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 task's text opens its note in preview,
scrolled to that task; ticking its box completes it without leaving the list,
and Completed shows finished tasks so one can be reopened. A [text](url)
link in a task shows as just its text and opens in a new tab, without opening
the note. A who:me reads as a highlighted Me in the sentence, and
clicking it β or a #tag chip β filters to that person or tag.
A reading-list entry written as a conventional citation, Yu et al., "Title" β [arXiv:β¦](β¦), shows the paper's title and an Open paper link. 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 task's ink, so old
work fades rather than earning another badge. Each row shows the note it came
from 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.
The summary counts open tasks and shelved references separately; a shelved item with a due date or priority counts as a task. Edit sections and Refresh tasks are in the toolbar's β― menu.
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 four 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 |
| arXiv | - [ ] arxiv.org/abs/2608.00146 |
paper, tagged or not |
Singular and plural are the same term, and a leading # is optional, so paper
finds #papers and ## Papers alike. A task that mentions arXiv anywhere on
the line counts as #paper, since pasting a link in is how a paper usually
arrives. 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 dailyNoteTemplate set in
.webmd/settings.json. The template may use {{date}},
{{title}}, {{weekday}}, and {{quote}}. If no template is set, WebMD
looks for a conventionally named one (dailynote_template.md,
daily-template.md, or template.md) in the daily-note folder, then in the
workspace root. Setting "dailyNoteTemplate": "" keeps just the
# 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.
The newspaper button in the left bar opens today's arXiv announcements for the
categories in ARXIV_NEWS_CATEGORIES. They are read from arXiv's public RSS
feed, so no key is needed. The listing is cached for as long as arXiv says it
stands (until the next announcement), kept on disk so a restart does not
refetch it, and checked by ETag once stale, so an unchanged feed costs a 304.
The AI ranking is saved the same way: one model call per listing, until the
instructions change or you press Re-rank.
Each day's listing is kept for a month (31 days) under WEBMD_CACHE_DIR, so
papers you missed are still there to catch up on. Once there is more than one
day, the arrows and day menu next to the arXiv heading step through them.
Filters, clipping, and ranking work on an earlier day as they do on today's,
and a clipped paper still goes to today's daily note. Each day is ranked once,
the first time you open it. Refresh goes back to the latest listing.
The server checks the feed every hour, so a day is kept even when you never open News that day. Days when the server was not running are not kept, because arXiv's feed only ever carries the latest announcement. Weekends have no listing and are skipped.
- Filter keeps papers whose title, authors, or abstract contain every word you type. The category chips narrow the list to the ones you pick. Both are remembered, so tomorrow's listing opens filtered the same way.
- Clip adds the paper to today's daily note, under a
## Readingheading that is created the first time. The line is the same citation a pasted arXiv link becomes. If today's note does not exist yet, it is created from the daily template. A paper already linked from today's note shows as Clipped.
For you orders the listing for you. The configured AI model reads the day's papers against three things:
- Your instructions in
.webmd/news.md: your research, and how papers should be judged, in your own words. Instructions in the News view opens the note, and starts one the first time. HTML comments in it are not sent. With no note,ARXIV_NEWS_INTERESTSis used instead. - What you read: the titles of arXiv papers cited in your notes, including
clipped ones, and the titles in
references.bib. - What you are working on: your most recently edited notes.
The model picks up to 20 papers, each with a score from 1 to 10, the research
area it connects to, and a one-sentence reason. The five strongest are listed
first as Top picks, then Also relevant. Everything else follows,
ordered by how much of your profile's rarer vocabulary each paper uses. That
same lexical score chooses the 120 papers the model reads (see
ARXIV_NEWS_MAX_CANDIDATES), which keeps a day
of listings to one call. Replacements β new versions of older papers β are not
listed at all.
β and β on a paper say more or fewer papers like this. Votes are kept
in .webmd/news-votes.json, with each paper's title and abstract, so they
outlast the listing. They move the lexical score towards the words of papers
you upvoted and away from those you downvoted, and the AI sees the titles of
your most recent votes. A paper you voted down is never a pick. Press the same
arrow again to take a vote back. Like clipping, a vote does not reshuffle the
page; it counts from the next day's ranking, or straight away with Re-rank.
A listing is ranked once per day, so reloads and other tabs reuse it, and clipping a paper does not reshuffle the page. Editing the instructions note earns a fresh ranking the next time you open News. Re-rank asks again straight away. Without a reachable model the order falls back to the lexical score, with a note saying so. arXiv switches back to arXiv's own order.
arXiv publishes no listing on Saturday or Sunday, so the view is empty on weekends; Friday's is in the day menu.
The lectern button in the left bar opens Meetings: the upcoming meetings of the Indico categories and events you follow, one meeting's agenda, and a Markdown note for it.
- Add source takes an Indico category link (
β¦/category/1234/) or event link (β¦/event/5678/, or any page of the event). The link is checked and stored in canonical form; Remove takes it off again. - Categories are read two weeks ahead and one week back. Meetings are grouped into Ongoing, Today, Tomorrow, This week, Next week, Later, and Past week (newest first) by your browser's local day. Times show in local time, with the event's own time beside them when its timezone reads differently. A meeting listed by two sources appears once.
- Choose a meeting to see when and where, its agenda (times, titles, speakers, and links to each contribution), and Open in Indico.
- Create note writes a note for the meeting and opens it. Pressing it again, now labelled Open note, opens the same note. Refresh asks Indico again, skipping the ten-minute cache.
- A Zoom meeting shows its meeting ID and passcode, and Join Zoom. From a quarter of an hour before it starts until it ends, Join also appears beside it in the list. See Zoom.
Indico's Zoom plugin shows its room on the event page but leaves it out of the
export API, so WebMD reads the page for it: for the meeting you open, and for
meetings under way or starting within a day (at most 12, cached for ten
minutes). It also picks up what organizers type into the location, room, or
description: a zoom.us (or zoomgov.com) join link, or a written "Meeting
ID". Only a link's embedded passcode (pwd) is kept. No room, no Join button.
Nothing about Zoom is guessed.
- A public event's page shows its meeting ID and link to anyone. The join link
with its passcode built in appears only to signed-in users, so WebMD gets it
only with a token that has the
read:everythingscope. - A
read:legacy_apitoken is turned away from event pages, so WebMD then reads the page anonymously. That works for public events. For protected events it finds no room unless the organizer pasted the link into the description.
Once a meeting has started, its detail shows Recording and transcript:
- Recording: Add link saves a recording share link (any web address)
as
recording:in the meeting note's frontmatter. A Zoom recording link found in the Indico description is offered with Save to note. - Transcript: Add file takes the transcript Zoom gives with a cloud
recording (the recording page's Audio transcript, a
.vtt), or SubRip (.srt), Teams-style voice-tagged WebVTT, or Zoom's saved captions (.txt). It becomes a note of its own beside the meeting note,Title (YYYY-MM-DD) transcript.md, withtype: transcript, the sameindico:link, a wiki link back to the meeting note, and one paragraph per speaker turn (**00:03:12 Ada Lovelace:** β¦). Replace rewrites it with a better file. - Summarize into note asks the AI provider for the meeting's minutes and
writes them into the meeting note: a
## Summarysection ahead of## Notes, with any decisions, and new action items under## Action itemsin task syntax (- [ ] β¦ who:ada π 2026-09-20), so they show up in the Tasks view. Items already listed are not added twice. If the note already has a## Summary, it is never replaced; delete it to write a new one. Transcripts longer than about 160,000 characters are summarized from their first part, and the note says so.
Any of these creates the meeting note first if it has none. WebMD's own edits to a note reach an editor that has it open as an ordinary change, so nothing typed there is lost. Recordings themselves stay on Zoom: WebMD needs no Zoom account and never signs in to Zoom.
Sources are kept per workspace in .webmd/meetings.json, which is safe to
commit: it never holds a token.
{
"version": 1,
"noteFolder": "/meetings",
"sources": [
{
"id": "indico.cern.ch-category-1234",
"label": "Weekly meetings",
"origin": "https://indico.cern.ch",
"url": "https://indico.cern.ch/category/1234/",
"enabled": true
}
]
}id and origin are derived from url. Set "enabled": false to pause a
source, and noteFolder to put meeting notes elsewhere. An entry that does not
check out is skipped with a warning naming it, and the others still load. If
the file is not valid JSON, Meetings says so and refuses to overwrite it.
Public meetings need no token. For protected ones, create a personal token in
Indico under My profile β Settings β API tokens and put it in
~/.webmd.conf, then restart WebMD:
INDICO_CERN_TOKEN=indp_REPLACE_WITH_YOUR_TOKEN- Meetings reads only Indico's documented HTTP export API (
/export/categ/β¦and/export/event/β¦), which needs theread:legacy_apiscope ("Classic API (read only)"). That is the least privilege it needs. - Naming a pasted protected link reads the event's page first, which needs
read:everything. With aread:legacy_apitoken it falls back to the export API, which names events and contributions but not sessions. - The token goes in an
Authorization: Bearerheader to its own origin only. A redirect to another host, or off HTTPS, is not followed. The browser is told only whether a token is set.
A note is created in noteFolder as Title (YYYY-MM-DD).md, with frontmatter
naming the event, the time in the event's timezone, the Indico link, the room,
a snapshot of the agenda, and empty ## Notes and ## Action items sections:
---
type: meeting
indico: https://indico.cern.ch/event/5678/
date: 2026-09-11
tags: [meeting]
---
# Tracking weekly (2026-09-11)The note's header also links to its day, - **Day:** [[2026-09-11]]: the
day in your browser's timezone that the meeting starts on, which is the one
whose daily note it belongs with. WebMD never writes into the daily note. The
meeting shows up there among its backlinks. If that daily note does not exist
yet, following the link offers to create it, from your daily template and in
your daily-note folder, as with any date link to a missing daily note.
The indico: line is what ties the note to the meeting, so retitling or moving
the note keeps the link. Creating never overwrites a file: if the name is taken
by another note, the event id is added to the name. After that the note is
yours. Refreshing Indico never touches it, including its agenda. The only later
changes WebMD makes are the ones you ask for from Meetings: the recording:
line and a summary.
- "rejected INDICO_CERN_TOKEN": the token is expired, revoked, or lacks
read:legacy_api. Create a new one and restart WebMD. - "No upcoming meetings visible without a login": without a token Indico answers a protected category with an empty list, not an error. Set the token.
- "is not a known Indico": the host does not start with
indico.. AddINDICO_<NAME>_URL=https://β¦for it. - Timeouts or "Could not reach": the server running WebMD needs outbound HTTPS to the Indico. Each request gives up after 15 seconds.
- To check a token from the shell without starting WebMD:
INDICO_SMOKE_SOURCE=https://indico.cern.ch/category/1234/ npm run smoke:indico. It only reads, and never prints the token.
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.
npm 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/.npm run fixture: serve a seeded throwaway workspace with a stub AI provider, arXiv feed, and Indico on port 3197 (AI_MODE=slow|error,NEWS_MODE=error|empty,MEETINGS_MODE=notoken|auth|offline|none|zoom). It never reads~/.webmd.conf, and its Indico token is a placeholder.npm run scenarios: afternpm run build, run the headless Chrome layout and AI-context acceptance checks against fixtures (OUT_DIRkeeps screenshots). Needs Google Chrome, orCHROME_PATH.npm run scenarios:meetings: the same for Meetings: list, agenda, notes, add source, token failures, Zoom join, transcript and summary, and narrow layouts.npm run smoke:indico: opt-in, read-only check of a real Indico source with your own token (INDICO_SMOKE_SOURCE=<link>). Not part ofnpm test.