Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


commit 131d20531da89d73f667eba61c47a3f672ed731e
equwal <13551856+equwal@users.noreply.github.com>
2026-09-21 18:16:24 -0700

ideamine 0.6.0: buttons for the slash commands on the dashboard

/ideas-web starts `ideamine serve` in the background. It serves the
dashboard on 127.0.0.1 with a button for each command. The header has
+ Idea, Triage inbox, Build next, Ask, and Watcher. Each ticket has
Build with Claude, Start, Done, Drop, Reopen, Delete, a note, and the
build model. On Windows, Build opens Claude Code in a new terminal.

The commands run on this PC, because the archive and the Claude Code
login are here. The server takes commands only from its own page: JSON
from the same origin, and a Host header that names the server. The
copy on the dashboard server has no buttons and says "Read-only copy".

The triage and the questions share one helper for headless calls.
`ideamine go` and the Build button share goPlan.

 .claude-plugin/marketplace.json |   2 +-
 .claude-plugin/plugin.json      |   2 +-
 README.md                       |  32 +++-
 bin/ideamine.js                 |  25 ++-
 dashboard/index.html            | 279 +++++++++++++++++++++++++++++++--
 package-lock.json               |   4 +-
 package.json                    |   2 +-
 skills/ideas-web/SKILL.md       |   8 +
 src/claude.js                   | 119 +++++++++++----
 src/config.js                   |   5 +
 src/hook.js                     |  11 +-
 src/mcp.js                      |   3 +-
 src/serve.js                    | 331 ++++++++++++++++++++++++++++++++++++++++
 tests/fixtures/fake-claude.js   |  10 +-
 tests/mcp.test.js               |   2 +-
 tests/serve.test.js             | 263 +++++++++++++++++++++++++++++++
 16 files changed, 1039 insertions(+), 59 deletions(-)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 51b5526..5055c13 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -10,7 +10,7 @@
     {
       "name": "ideamine",
       "description": "An idea inbox for Claude Code. /idea saves an idea without a model call. Claude triages the ideas and picks the cheapest model that can build each one.",
-      "version": "0.5.0",
+      "version": "0.6.0",
       "author": {
         "name": "equwal"
       },
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index b449e53..a1b495a 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
 {
   "name": "ideamine",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "An idea inbox for Claude Code. /idea saves an idea without a model call, so it works at any time, even at your usage limit. Claude triages the ideas and picks the cheapest model that can build each one.",
   "author": {
     "name": "equwal",
diff --git a/README.md b/README.md
index dba01b9..948fe87 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ Marketplaces you add yourself do not auto-update. To upgrade, run `claude plugin
 | `/ideas-reopen 12` | Put an idea back in the queue, for example one that the triage skipped | **No** |
 | `/ideas-find sync subtitles` | Search every lane by meaning, not only by the words. See [Search by meaning](#search-by-meaning-and-groups). | **No** |
 | `/ideas-groups` · `done` · `-a` | Show the ideas grouped by meaning | **No** |
+| `/ideas-web` · `off` | Start the dashboard with a button for each command on this PC, and show its address. See [Buttons](#buttons). | **No** |
 | `/ideas-go [12]` | Build the idea that fits this chat, else the first in the queue, or #12, on its recommended model. New ideas are triaged first. | Yes, this is the build |
 | `/ideas-all` | Claude reads every idea, takes the ones that fit this chat out of the queue, and does them. The others stay in the queue. | Yes, this is the build |
 | `/ideas-sort` | Triage the inbox now and show the queue. You do not have to: `/ideas-go` triages when it must. | Yes, briefly |
@@ -129,7 +130,30 @@ server {
 }
 ```
 
-`data.json` has `version` (1), `generated`, `embed` (model, query prefix, thresholds, and whether vectors are present), `counts`, `ideas`, and `groups`. Each idea has its ticket key (`IDEA-12`), lane, rank in the queue, triage, times (`created`, `triaged`, `started`, `closed`), timeline `phases`, notes, `group`, `related` ideas with their similarity, and `vec`, the vector as base64 of little-endian float32.
+`data.json` has `version` (1), `generated`, `embed` (model, query prefix, thresholds, and whether vectors are present), `counts`, `ideas`, and `groups`. Each idea has its ticket key (`IDEA-12`), lane, rank in the queue, triage, times (`created`, `triaged`, `started`, `closed`), timeline `phases`, notes, `group`, `related` ideas with their similarity, and `vec`, the vector as base64 of little-endian float32. The `data.json` of `ideamine serve` also has `live` (see below).
+
+### Buttons
+
+```
+> /ideas-web
+  ideamine web: http://127.0.0.1:4332/ (started)
+```
+
+`/ideas-web` starts `ideamine serve` in the background and shows its address. That page is the same dashboard, with a button for each command:
+
+| Button | Command |
+|---|---|
+| **+ Idea** | `/idea`. A bulleted list adds one idea for each bullet. |
+| **Triage inbox** | `/ideas-sort` |
+| **Build next**, and **Build with Claude** on a ticket | `/ideas-go`, `/ideas-go N`. New ideas are triaged first. Then a new terminal window opens Claude Code on the recommended model, in the project of the idea. Windows only. |
+| **Ask** | `/ideas <question>`. Claude answers from the whole archive, and each `#12` in the answer opens that ticket. |
+| **Watcher** | `/ideas-watch`, `/ideas-watch off` |
+| **Start**, **Done**, **Drop**, **Reopen**, **Delete** on a ticket | `ideamine start`, `/ideas-done`, `ideamine drop`, `/ideas-reopen`, `/ideas-rm` |
+| **Add note**, **Build model** on a ticket | `ideamine note`, `ideamine model` |
+
+The board, the timeline, the groups, and the search show `/ideas`, `/ideas-ls`, `/ideas-cat`, `/ideas-groups`, and `/ideas-find`. `/ideas-all` has no button, because it needs the chat that it works in.
+
+The commands run on your PC, because the archive and your Claude Code login are there. Thus the server listens on `127.0.0.1` only, and it takes commands only from its own page: each command must be JSON from the same origin, and the Host header must name the server. Another web page in your browser cannot send commands to it, and no other page can show it in a frame. After a change, the server uploads the dashboard again when `publish_url` is set, so the copy on your dashboard server stays current. That copy has no buttons: it shows "Read-only copy". `/ideas-web off` stops the server. In a terminal, `ideamine serve` runs it in the foreground. To use another port, run `ideamine config serve_port 5000`. The log is `~/.ideamine/serve.log`.
 
 ## Model routing
 
@@ -174,6 +198,7 @@ ideamine find sync subtitles                      # search by meaning
 ideamine groups [-a]                              # ideas grouped by meaning
 ideamine embed                                    # embed new ideas, show the similarity numbers
 ideamine publish [url|off] [--dir folder]         # the dashboard (see above)
+ideamine serve [--port 4332]                      # the dashboard with buttons, on 127.0.0.1
 ideamine config [key [value]]                     # show or change a setting
 ideamine export IDEAS.md                          # Markdown copy of everything
 ```
@@ -209,6 +234,7 @@ Tools: `idea_add`, `idea_list`, `idea_triage`, `idea_update`, `idea_next`, `idea
 | `IDEAMINE_SEARCH_THRESHOLD` | `search_threshold` | `0.5` | lowest similarity of a search result |
 | `IDEAMINE_GROUP_THRESHOLD` | `group_threshold` | `0.65` | lowest mean similarity in a group, and of a related idea |
 | `IDEAMINE_PUBLISH_URL` | `publish_url` | *(off)* | dashboard server for `ideamine publish` |
+| `IDEAMINE_SERVE_PORT` | `serve_port` | `4332` | port of `ideamine serve` and `/ideas-web` |
 
 ## How it works
 
@@ -225,9 +251,11 @@ watcher on: any prompt ──► hook ──► background pass ──► claude
 
 /ideas-find, /ideas-groups ──► hook ──► embedding server (new ideas only) ──► cosine similarity ──► answer
 publish on: any prompt after a change ──► hook ──► background publish ──► PUT index.html + data.json
+
+/ideas-web       ──► hook ──► ideamine serve on 127.0.0.1 ──► the buttons on the page ──► the same archive
 ```
 
-The plugin contains a Node MCP server with no dependencies, thirteen skills (the slash commands), and one hook. The hook answers `/idea`, `/ideas`, and the local `/ideas-*` commands before any API call, and it lets every other prompt through. The hook runs directly, not through a shell, and takes about 130 ms per prompt on Windows. The skills are user-only, so their descriptions add no tokens to your sessions. If the archive cannot be read, the hook lets the prompt through, so the `/idea` skill can still save it with the MCP tool. Your text is never dropped.
+The plugin contains a Node MCP server with no dependencies, fourteen skills (the slash commands), and one hook. The hook answers `/idea`, `/ideas`, and the local `/ideas-*` commands before any API call, and it lets every other prompt through. The hook runs directly, not through a shell, and takes about 130 ms per prompt on Windows. The skills are user-only, so their descriptions add no tokens to your sessions. If the archive cannot be read, the hook lets the prompt through, so the `/idea` skill can still save it with the MCP tool. Your text is never dropped.
 
 ## Development
 
diff --git a/bin/ideamine.js b/bin/ideamine.js
index 60050e2..6e177af 100644
--- a/bin/ideamine.js
+++ b/bin/ideamine.js
@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 // ideamine CLI. Also the entry point the plugin uses for its MCP server (`mcp`) and hook (`hook`).
 
+import { spawnSync } from 'node:child_process';
 import fs from 'node:fs';
 
 const HELP = `ideamine: an idea inbox for Claude Code
@@ -24,6 +25,7 @@ const HELP = `ideamine: an idea inbox for Claude Code
   ideamine publish [url|off] [--dir <folder>]
                                   upload the dashboard (index.html, data.json) now; a url also turns
                                   on the upload after each change; --dir writes the files to a folder
+  ideamine serve [--port 4332]    the dashboard with a button for each command, at 127.0.0.1
   ideamine config [key [value]]   show or change a setting (an empty value restores the default)
   ideamine export [file.md]       Markdown export of the whole archive
   ideamine path                   where the archive lives (override with IDEAMINE_HOME)
@@ -38,7 +40,7 @@ function parseArgs(argv) {
     if (a.startsWith('--')) {
       const [key, inline] = a.slice(2).split('=', 2);
       if (inline !== undefined) flags[key] = inline;
-      else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && ['model', 'limit', 'budget', 'query', 'dir'].includes(key)) flags[key] = argv[++i];
+      else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && ['model', 'limit', 'budget', 'query', 'dir', 'port'].includes(key)) flags[key] = argv[++i];
       else flags[key] = true;
     } else words.push(a);
   }
@@ -53,6 +55,11 @@ async function readStdin() {
 
 function fail(message) {
   process.stderr.write(`ideamine: ${message}\n`);
+  // A window that the dashboard opened closes when the command ends. Wait for a key, so that the
+  // user can read the error.
+  if (process.env.IDEAMINE_WINDOW && process.stdin.isTTY) {
+    spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', 'pause'], { stdio: 'inherit' });
+  }
   process.exit(1);
 }
 
@@ -128,13 +135,11 @@ async function main() {
       break;
     }
     case 'go': {
-      const { buildPrompt, launchSession } = await import('../src/claude.js');
+      const { goPlan, launchSession } = await import('../src/claude.js');
       const db = store.load();
       const idea = words[0] ? store.findIdea(db, words[0]) : store.pickNext(db, { project: cwd });
       if (!idea) fail(words[0] ? `no idea #${words[0]}` : 'nothing is ready to build; run: ideamine sort');
-      const model = idea.triage?.model || 'sonnet';
-      const dir = idea.project && fs.existsSync(idea.project) ? idea.project : cwd;
-      const prompt = buildPrompt(idea);
+      const { model, dir, prompt } = goPlan(idea, cwd);
       if (flags.print) {
         console.log(`directory: ${dir}\nmodel:     ${model}\n\n${prompt}`);
         break;
@@ -215,6 +220,16 @@ async function main() {
       if (out.note) console.log(out.note);
       break;
     }
+    case 'serve': {
+      const serve = await import('../src/serve.js');
+      const { server, url } = await serve.start({
+        ...(flags.port ? { port: Number(flags.port) } : {}),
+        log: (text) => console.log(serve.logLine(text)),
+      });
+      server.on('close', () => process.exit(0)); // /ideas-web off
+      console.log(serve.logLine(`the dashboard with buttons runs at ${url} (Ctrl+C stops it)`));
+      break;
+    }
     case 'config': {
       const config = await import('../src/config.js');
       if (words.length >= 1 && rest.length >= 2) config.set(words[0], words.slice(1).join(' '));
diff --git a/dashboard/index.html b/dashboard/index.html
index 54331a5..ea63deb 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -61,6 +61,7 @@
 }
 
 * { box-sizing: border-box; }
+[hidden] { display: none !important; } /* a class with a display value must not show a hidden element */
 html, body { margin: 0; padding: 0; }
 body {
   background: var(--bg);
@@ -198,8 +199,32 @@ main { padding: 12px; }
 .tooltip-title { color: var(--text-dim); margin: 2px 0; }
 .tooltip-phase { color: var(--text-muted); }
 
+/* commands: only on the page of `ideamine serve` */
+.header-actions { padding-top: 0; padding-bottom: 10px; }
+.readonly-note { color: var(--text-muted); font-size: 12px; padding-top: 0; padding-bottom: 8px; }
+.btn { display: inline-flex; align-items: center; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--surface-2); color: var(--text); font-size: 13px; cursor: pointer; }
+.btn:hover:not(:disabled) { border-color: var(--text-muted); }
+.btn:disabled { opacity: .5; cursor: default; }
+.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.btn-danger { background: transparent; border-color: var(--lane-dropped); color: var(--lane-dropped); }
+body.busy .btn, body.busy #watch-toggle, body.busy .model-select { pointer-events: none; opacity: .5; }
+.panel { display: flex; flex-direction: column; gap: 8px; margin: 12px 12px 0; padding: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
+.panel[hidden] { display: none; }
+.panel-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.panel-hint { color: var(--text-muted); font-size: 12px; }
+textarea, select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; }
+textarea { width: 100%; resize: vertical; }
+.answer { white-space: pre-wrap; font-size: 13px; }
+.answer:empty { display: none; }
+.drawer-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
+.model-label { font-size: 12px; color: var(--text-dim); }
+.toast { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 40; width: max-content; max-width: min(560px, calc(100% - 32px)); padding: 8px 12px; background: var(--surface-2); border: 1px solid var(--border-strong); border-left: 4px solid var(--lane-done); border-radius: 6px; box-shadow: var(--shadow); font-size: 13px; white-space: pre-wrap; cursor: pointer; }
+.toast[hidden] { display: none; }
+.toast-busy { border-left-color: var(--accent); }
+.toast-error { border-left-color: var(--lane-dropped); }
+
 /* focus */
-button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
+button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, [tabindex]:focus-visible {
   outline: 2px solid var(--focus-ring);
   outline-offset: 2px;
 }
@@ -223,8 +248,34 @@ button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
     </label>
     <input type="search" id="search-input" class="search-input" placeholder="Search ideas…" aria-label="Search ideas">
   </div>
+  <div class="header-row header-actions" id="actions" hidden>
+    <button type="button" class="btn btn-primary" id="new-idea" title="Save an idea (/idea)">+ Idea</button>
+    <button type="button" class="btn" id="triage" title="Triage the inbox with one headless Claude call (/ideas-sort)">Triage inbox</button>
+    <button type="button" class="btn" id="build-next" title="Triage new ideas, then open Claude Code on the first idea of the queue (/ideas-go)">Build next</button>
+    <button type="button" class="btn" id="ask-open" title="Ask Claude about your ideas (/ideas &lt;question&gt;)">Ask</button>
+    <label class="parked-toggle" id="watch-label"><input type="checkbox" id="watch-toggle"> Watcher</label>
+  </div>
+  <p class="header-row readonly-note" id="readonly-note" hidden>Read-only copy. For buttons, run /ideas-web in Claude Code on your PC.</p>
 </header>
 
+<section class="panel" id="composer" hidden aria-label="New idea">
+  <textarea id="composer-text" rows="3" placeholder="One idea, or a bulleted list: one idea for each bullet. #tags work." aria-label="Idea text"></textarea>
+  <div class="panel-row">
+    <button type="button" class="btn btn-primary" id="composer-save">Save</button>
+    <button type="button" class="btn" id="composer-cancel">Cancel</button>
+    <span class="panel-hint">Ctrl+Enter saves.</span>
+  </div>
+</section>
+
+<section class="panel" id="ask-panel" hidden aria-label="Ask about your ideas">
+  <div class="panel-row">
+    <input type="text" id="ask-input" class="search-input" placeholder="For example: which ideas fit in an hour?" aria-label="Question">
+    <button type="button" class="btn btn-primary" id="ask-send">Ask</button>
+    <button type="button" class="btn" id="ask-close">Close</button>
+  </div>
+  <div class="answer" id="ask-answer" aria-live="polite"></div>
+</section>
+
 <main id="main" role="tabpanel"></main>
 
 <div class="drawer-backdrop" id="drawer-backdrop" hidden></div>
@@ -234,12 +285,14 @@ button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
 </aside>
 
 <div class="tooltip" id="tooltip" hidden role="status"></div>
+<div class="toast" id="toast" hidden role="status" aria-live="polite" title="Click to close"></div>
 
 <script>
 'use strict';
 
 /* ideamine dashboard. One file, no build step, no external calls
-   besides data.json and v1/embeddings on the same origin. */
+   besides data.json and v1/embeddings on the same origin. On the page of
+   `ideamine serve`, data.json has `live`, and the buttons call api/ there. */
 
 const SVG_NS = 'http://www.w3.org/2000/svg';
 const MIN = 60000, HOUR = 3600000, DAY = 86400000;
@@ -252,6 +305,8 @@ const LANE_LABELS = { doing: 'Doing', do: 'Do', maybe: 'Maybe', inbox: 'Inbox',
 const PHASE_LABELS = { inbox: 'Inbox', queued: 'Queued', doing: 'Doing' };
 const SEARCH_DEBOUNCE_MS = 300;
 const EMBED_TIMEOUT_MS = 4000;
+const MODELS = ['haiku', 'sonnet', 'opus', 'fable'];
+const OPEN_LANES = ['doing', 'do', 'maybe', 'inbox'];
 
 const state = {
   data: null,
@@ -262,11 +317,13 @@ const state = {
   openKey: null,
   query: '',
   searchResult: null,
+  busy: false,
 };
 
 let searchTimer = null;
 let searchToken = 0;
 let lastFocused = null;
+let toastTimer = null;
 const vecCache = new Map();
 
 const $main = document.getElementById('main');
@@ -280,6 +337,24 @@ const $drawerBackdrop = document.getElementById('drawer-backdrop');
 const $drawerClose = document.getElementById('drawer-close');
 const $drawerBody = document.getElementById('drawer-body');
 const $tooltip = document.getElementById('tooltip');
+const $actions = document.getElementById('actions');
+const $readonlyNote = document.getElementById('readonly-note');
+const $newIdea = document.getElementById('new-idea');
+const $triage = document.getElementById('triage');
+const $buildNext = document.getElementById('build-next');
+const $askOpen = document.getElementById('ask-open');
+const $watchLabel = document.getElementById('watch-label');
+const $watchToggle = document.getElementById('watch-toggle');
+const $composer = document.getElementById('composer');
+const $composerText = document.getElementById('composer-text');
+const $composerSave = document.getElementById('composer-save');
+const $composerCancel = document.getElementById('composer-cancel');
+const $askPanel = document.getElementById('ask-panel');
+const $askInput = document.getElementById('ask-input');
+const $askSend = document.getElementById('ask-send');
+const $askClose = document.getElementById('ask-close');
+const $askAnswer = document.getElementById('ask-answer');
+const $toast = document.getElementById('toast');
 
 /* ---- small DOM builders. Text children become text nodes, never HTML. ---- */
 
@@ -767,6 +842,7 @@ function buildDrawerContent(idea) {
   if (idea.project) metaLines.push('Project: ' + idea.project);
   if (idea.tags && idea.tags.length) metaLines.push('Tags: ' + idea.tags.join(', '));
   if (metaLines.length) frag.append(el('p', { class: 'drawer-meta' }, metaLines.join(' · ')));
+  if (isLive()) frag.append(renderDrawerActions(idea));
 
   frag.append(renderDateGrid(idea));
 
@@ -807,6 +883,161 @@ function renderDrawer() {
 function openTicket(key) { location.hash = key; }
 function closeDrawer() { location.hash = state.view; }
 
+/* ---- commands: the slash commands as buttons, on the page of `ideamine serve` ---- */
+
+function isLive() { return !!(state.data && state.data.live); }
+
+function showToast(text, kind) {
+  clearTimeout(toastTimer);
+  $toast.textContent = text;
+  $toast.className = 'toast toast-' + kind;
+  $toast.hidden = false;
+  if (kind !== 'busy') toastTimer = setTimeout(() => { $toast.hidden = true; }, kind === 'error' ? 15000 : 8000);
+}
+
+async function callApi(action, body) {
+  const res = await fetch('api/' + action, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body || {}),
+  });
+  let json = null;
+  try { json = await res.json(); } catch (err) { json = null; }
+  if (!res.ok || !json || !json.ok) throw new Error((json && json.error) || 'HTTP ' + res.status);
+  return json.message;
+}
+
+function setBusy(on) {
+  state.busy = on;
+  document.body.classList.toggle('busy', on);
+  $main.setAttribute('aria-busy', String(on));
+}
+
+/** Run one command, show its answer, and load the new data. Resolves to true when the command worked. */
+async function runAction(action, body, busyText) {
+  if (state.busy) return false;
+  setBusy(true);
+  showToast(busyText || 'Working…', 'busy');
+  let worked = false;
+  try {
+    const message = await callApi(action, body);
+    worked = true;
+    showToast(message, 'ok');
+    await loadData();
+  } catch (err) {
+    showToast(worked ? 'Done, but the page could not load the new data: ' + err.message : err.message, 'error');
+  }
+  setBusy(false);
+  if (state.openKey && !findByKey(state.openKey)) {
+    // The idea of the open ticket is gone: close the ticket without a new history entry.
+    history.replaceState(null, '', '#' + state.view);
+    state.openKey = null;
+  }
+  render();
+  if (!$drawer.hidden && !$drawer.contains(document.activeElement)) $drawerClose.focus();
+  if (worked && state.query.trim()) runSearch(state.query);
+  return worked;
+}
+
+function renderToolbar() {
+  const live = isLive();
+  $actions.hidden = !live;
+  $readonlyNote.hidden = live;
+  if (!live) return;
+  const inbox = (state.data.counts && state.data.counts.inbox) || 0;
+  $triage.textContent = 'Triage inbox (' + inbox + ')';
+  $triage.disabled = inbox === 0;
+  $buildNext.hidden = !state.data.live.window;
+  const watch = state.data.live.watch || {};
+  $watchToggle.checked = !!watch.on;
+  $watchLabel.title = watch.status || '';
+}
+
+function actionButton(label, kind, onClick, title) {
+  return el('button', { type: 'button', class: 'btn' + (kind ? ' btn-' + kind : ''), title: title || null, onclick: onClick }, label);
+}
+
+function renderDrawerActions(idea) {
+  const id = idea.id;
+  const open = OPEN_LANES.includes(idea.lane);
+  const closed = idea.lane === 'done' || idea.lane === 'dropped';
+  const note = el('textarea', { rows: '2', placeholder: 'Note. Done and Drop save it too.', 'aria-label': 'Note' });
+  const noteText = () => note.value.trim() || undefined;
+  const update = (patch) => runAction('update', Object.assign({ id }, patch));
+
+  const buttons = [];
+  if (!closed && state.data.live.window) {
+    buttons.push(actionButton('Build with Claude', 'primary', () => runAction('go', { id }, 'Opening Claude Code for ' + idea.key + '…'),
+      'Open Claude Code on the recommended model, in the project of the idea (/ideas-go ' + id + ')'));
+  }
+  if (!closed && idea.lane !== 'doing') buttons.push(actionButton('Start', '', () => update({ status: 'doing' })));
+  if (open) buttons.push(actionButton('Done', '', () => update({ status: 'done', note: noteText() }), '/ideas-done ' + id));
+  if (open || idea.lane === 'skip') buttons.push(actionButton('Drop', '', () => update({ status: 'dropped', note: noteText() })));
+  if (!open || idea.lane === 'doing') buttons.push(actionButton('Reopen', '', () => update({ status: 'reopen' }), 'Put the idea back in the queue (/ideas-reopen ' + id + ')'));
+  buttons.push(actionButton('Delete', 'danger', () => {
+    if (confirm('Delete ' + idea.key + ' for good?')) runAction('rm', { ids: [id] });
+  }, '/ideas-rm ' + id));
+
+  const model = el('select', { class: 'model-select', 'aria-label': 'Model for the build' },
+    idea.model ? null : el('option', { value: '' }, 'none'),
+    MODELS.map((m) => el('option', { value: m, selected: idea.model === m }, m)));
+  model.addEventListener('change', () => { if (model.value) update({ model: model.value }); });
+  const addNote = actionButton('Add note', '', () => {
+    const text = noteText();
+    if (text) update({ note: text });
+    else note.focus();
+  });
+
+  return el('section', { class: 'drawer-section' },
+    el('h3', {}, 'Actions'),
+    el('div', { class: 'drawer-actions' }, buttons),
+    note,
+    el('div', { class: 'drawer-actions' }, addNote, el('label', { class: 'model-label' }, 'Build model ', model)));
+}
+
+async function saveIdea() {
+  const text = $composerText.value.trim();
+  if (!text) { $composerText.focus(); return; }
+  if (await runAction('add', { text }, 'Saving…')) {
+    $composerText.value = '';
+    $composer.hidden = true;
+  }
+}
+
+/** The text of an answer, with each #12 that names an idea as a link to its ticket. */
+function linkIdeas(text) {
+  const parts = [];
+  let last = 0;
+  for (const m of text.matchAll(/#(\d+)/g)) {
+    const idea = state.byId.get(Number(m[1]));
+    if (!idea) continue;
+    parts.push(text.slice(last, m.index), el('button', { type: 'button', class: 'link-button', onclick: () => openTicket(idea.key) }, m[0]));
+    last = m.index + m[0].length;
+  }
+  parts.push(text.slice(last));
+  return parts;
+}
+
+async function askQuestion() {
+  const question = $askInput.value.trim();
+  if (!question || state.busy) return;
+  setBusy(true);
+  $askAnswer.textContent = 'Claude reads your ideas…';
+  try {
+    const answer = await callApi('ask', { question });
+    $askAnswer.textContent = '';
+    $askAnswer.append(...linkIdeas(answer));
+  } catch (err) {
+    $askAnswer.textContent = 'No answer: ' + err.message;
+  }
+  setBusy(false);
+}
+
+function togglePanel(panel, focusTarget) {
+  panel.hidden = !panel.hidden;
+  if (!panel.hidden) focusTarget.focus();
+}
+
 /* ---- load error ---- */
 
 class HttpError extends Error {
@@ -826,6 +1057,7 @@ function renderLoadError(err) {
 function render() {
   renderHeader();
   renderTabsActive();
+  renderToolbar();
   if (state.query.trim() !== '') renderSearch();
   else if (state.view === 'timeline') renderTimeline();
   else if (state.view === 'groups') renderGroups();
@@ -872,19 +1104,46 @@ function bindStaticEvents() {
   $drawerBackdrop.addEventListener('click', closeDrawer);
   document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$drawer.hidden) closeDrawer(); });
   document.addEventListener('click', () => { $tooltip.hidden = true; });
+
+  $newIdea.addEventListener('click', () => togglePanel($composer, $composerText));
+  $composerSave.addEventListener('click', saveIdea);
+  $composerCancel.addEventListener('click', () => { $composer.hidden = true; });
+  $composerText.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveIdea(); }
+  });
+  $triage.addEventListener('click', () => runAction('sort', {}, 'Claude triages the inbox…'));
+  $buildNext.addEventListener('click', () => runAction('go', {}, 'Claude triages the new ideas, then Claude Code opens on the next one…'));
+  $askOpen.addEventListener('click', () => togglePanel($askPanel, $askInput));
+  $askSend.addEventListener('click', askQuestion);
+  $askInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') askQuestion(); });
+  $askClose.addEventListener('click', () => { $askPanel.hidden = true; });
+  $watchToggle.addEventListener('change', () => runAction('watch', { on: $watchToggle.checked }));
+  $toast.addEventListener('click', () => { $toast.hidden = true; });
+  // The watcher and other sessions change the archive too. Show their changes when the page comes back.
+  document.addEventListener('visibilitychange', () => {
+    if (document.hidden || !isLive() || state.busy) return;
+    loadData().then(render, () => {});
+  });
+}
+
+async function loadData() {
+  const res = await fetch('data.json', { cache: 'no-store' });
+  if (!res.ok) throw new HttpError(res.status);
+  const data = await res.json();
+  state.data = data;
+  state.byId.clear();
+  state.byKey.clear();
+  vecCache.clear();
+  for (const idea of data.ideas) {
+    state.byId.set(idea.id, idea);
+    state.byKey.set(idea.key.toUpperCase(), idea);
+  }
 }
 
 async function main() {
   bindStaticEvents();
   try {
-    const res = await fetch('data.json', { cache: 'no-store' });
-    if (!res.ok) throw new HttpError(res.status);
-    const data = await res.json();
-    state.data = data;
-    for (const idea of data.ideas) {
-      state.byId.set(idea.id, idea);
-      state.byKey.set(idea.key.toUpperCase(), idea);
-    }
+    await loadData();
     window.addEventListener('hashchange', onHashChange);
     applyHash();
     render();
diff --git a/package-lock.json b/package-lock.json
index 3c4bbe0..6613423 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "ideamine",
-  "version": "0.4.0",
+  "version": "0.6.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "ideamine",
-      "version": "0.4.0",
+      "version": "0.6.0",
       "license": "MIT",
       "bin": {
         "ideamine": "bin/ideamine.js"
diff --git a/package.json b/package.json
index e7da825..7b7d487 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "ideamine",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "An idea inbox for Claude Code. /idea saves an idea without calling the model; Claude later triages each idea and picks the cheapest model that can build it.",
   "type": "module",
   "bin": {
diff --git a/skills/ideas-web/SKILL.md b/skills/ideas-web/SKILL.md
new file mode 100644
index 0000000..164fd51
--- /dev/null
+++ b/skills/ideas-web/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: ideas-web
+description: Start the ideamine dashboard with a button for each command, on this PC, and show its address. "off" stops it. The ideamine hook answers this with no model call.
+argument-hint: "[off]"
+disable-model-invocation: true
+---
+
+The ideamine hook did not answer this command, and only the hook or the ideamine CLI can start the dashboard. Tell the user to run `ideamine serve` in a terminal. Then stop.
diff --git a/src/claude.js b/src/claude.js
index 6ed5dd5..863b17e 100644
--- a/src/claude.js
+++ b/src/claude.js
@@ -1,7 +1,10 @@
-// Running the Claude Code CLI from ideamine: headless triage, and launching a session to build an idea.
+// Running the Claude Code CLI from ideamine: headless triage and questions, and launching a session
+// to build an idea.
 
 import { spawn, spawnSync } from 'node:child_process';
+import fs from 'node:fs';
 import { knownProjects } from './projects.js';
+import { renderMarkdown } from './render.js';
 import { BATCH_SCHEMA, pendingIdeas, triagePrompt } from './rubric.js';
 import { applyTriage, counts, findIdea, load, normalizeModel } from './store.js';
 
@@ -18,13 +21,15 @@ function command(args) {
 
 /**
  * Environment for a separate, independent Claude Code process: drop the variables that tie a
- * child to the session that started us (nesting guard, host messaging, the parent's effort).
+ * child to the session that started us (nesting guard, host messaging, the parent's effort), and
+ * IDEAMINE_WINDOW, so that an ideamine command in that session never waits for a key.
  */
 function childEnv() {
   const env = { ...process.env };
   for (const key of Object.keys(env)) {
     if (
       key === 'CLAUDECODE' ||
+      key === 'IDEAMINE_WINDOW' ||
       key === 'CLAUDE_PID' ||
       key === 'CLAUDE_EFFORT' ||
       key === 'AI_AGENT' ||
@@ -69,6 +74,50 @@ function run(args, input, timeoutMs, env = {}) {
   });
 }
 
+/**
+ * Arguments for a one-shot `claude -p` call: no tools, no MCP servers, no settings, JSON output.
+ * Skipping settings drops hooks, plugins, and skill listings: ~4k fewer input tokens per call.
+ * Set IDEAMINE_SETTING_SOURCES=user if your login depends on settings.json (apiKeyHelper, env).
+ */
+function headlessArgs({ model, budget, system, extra = [] }) {
+  const args = [
+    '-p',
+    '--model', model,
+    '--output-format', 'json',
+    ...extra,
+    '--tools', '',
+    '--strict-mcp-config',
+    '--setting-sources', process.env.IDEAMINE_SETTING_SOURCES ?? '',
+    '--no-session-persistence',
+    '--max-budget-usd', String(budget),
+    '--system-prompt', system,
+  ];
+  // Haiku takes no effort setting.
+  if (model !== 'haiku') args.push('--effort', 'low');
+  return args;
+}
+
+/** Run a headless call. Returns the JSON result of the CLI, or throws with the reason. */
+async function runHeadless(args, prompt, { timeoutMs, env = {} }) {
+  const res = await run(args, prompt, timeoutMs, env);
+  let out;
+  try {
+    out = JSON.parse(res.stdout);
+  } catch {
+    const detail = (res.stderr || res.stdout || '').trim().split(/\r?\n/).slice(-5).join('\n');
+    throw new Error(`claude exited with code ${res.code}: ${detail || 'no output'}`);
+  }
+  if (out.is_error) throw new Error(`claude: ${out.result || out.subtype || 'error'}`);
+  return out;
+}
+
+/** Input tokens (with the cache) and output tokens of a headless call. */
+function tokensOf(out) {
+  const u = out.usage || {};
+  const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
+  return { input, output: u.output_tokens || 0 };
+}
+
 /**
  * Triage the inbox with a one-shot `claude -p` call: no tools, no MCP servers, no settings, a
  * two-line system prompt, and JSON-schema output. Runs on the user's normal Claude Code login and
@@ -83,23 +132,14 @@ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL
 
   const projects = knownProjects(db);
   const prompt = `${triagePrompt(db, pending, projects)}\n\nReturn one verdict for every idea listed above.`;
-  // Skipping settings drops hooks, plugins, and skill listings: ~4k fewer input tokens per call.
-  // Set IDEAMINE_SETTING_SOURCES=user if your login depends on settings.json (apiKeyHelper, env).
-  const args = [
-    '-p',
-    '--model', alias,
-    '--output-format', 'json',
-    '--json-schema', JSON.stringify(BATCH_SCHEMA),
-    '--tools', '',
-    '--strict-mcp-config',
-    '--setting-sources', process.env.IDEAMINE_SETTING_SOURCES ?? '',
-    '--no-session-persistence',
-    '--max-budget-usd', String(budget),
-    '--system-prompt', 'You triage a developer\'s backlog of ideas. Follow the rubric exactly and answer only with the requested JSON.',
-  ];
-  if (alias !== 'haiku') args.push('--effort', 'low');
-  // Haiku takes no effort setting. Its thinking was about 70% of its output tokens and did not change
-  // the verdicts, so it gets no thinking.
+  const args = headlessArgs({
+    model: alias,
+    budget,
+    system: 'You triage a developer\'s backlog of ideas. Follow the rubric exactly and answer only with the requested JSON.',
+    extra: ['--json-schema', JSON.stringify(BATCH_SCHEMA)],
+  });
+  // Haiku's thinking was about 70% of its output tokens and did not change the verdicts, so it gets
+  // no thinking.
   const env = alias === 'haiku' ? { MAX_THINKING_TOKENS: '0' } : {};
   if (dryRun) {
     const shown = args.map((a) => (/[\s"{]/.test(a) || !a ? JSON.stringify(a) : a)).join(' ');
@@ -107,21 +147,26 @@ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL
     return { message: `${vars}${claudeBin()} ${shown}\n\n${prompt}` };
   }
 
-  const res = await run(args, prompt, 5 * 60 * 1000, env);
-  let out;
-  try {
-    out = JSON.parse(res.stdout);
-  } catch {
-    const detail = (res.stderr || res.stdout || '').trim().split(/\r?\n/).slice(-5).join('\n');
-    throw new Error(`claude exited with code ${res.code}: ${detail || 'no output'}`);
-  }
-  if (out.is_error) throw new Error(`claude: ${out.result || out.subtype || 'error'}`);
+  const out = await runHeadless(args, prompt, { timeoutMs: 5 * 60 * 1000, env });
   const data = out.structured_output ?? parseLooseJson(out.result);
   if (!Array.isArray(data?.verdicts)) throw new Error('claude answered without verdicts');
   const results = applyTriage(data.verdicts, { by: `${alias} (headless)`, projects });
-  const u = out.usage || {};
-  const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
-  return { results, model: alias, cost: out.total_cost_usd, tokens: { input, output: u.output_tokens || 0 } };
+  return { results, model: alias, cost: out.total_cost_usd, tokens: tokensOf(out) };
+}
+
+/**
+ * Answer a question about the archive, like `/ideas <question>`, with one headless call. The
+ * prompt is the Markdown export: every idea with its lane, verdict, model, and brief.
+ */
+export async function askAboutIdeas(question, { model = 'sonnet', budget = 0.5 } = {}) {
+  const alias = normalizeModel(model) || model;
+  const args = headlessArgs({
+    model: alias,
+    budget,
+    system: 'You answer questions about a developer\'s backlog of ideas. Answer in a few short lines of plain text. Name each idea by its number, like #12.',
+  });
+  const out = await runHeadless(args, `${renderMarkdown(load())}\nQuestion: ${question}`, { timeoutMs: 2 * 60 * 1000 });
+  return { answer: String(out.result || '').trim(), model: alias, cost: out.total_cost_usd, tokens: tokensOf(out) };
 }
 
 /**
@@ -150,6 +195,18 @@ export function buildPrompt(idea) {
   return lines.join('\n');
 }
 
+/**
+ * How `ideamine go` builds an idea: on its recommended model, in its project folder when that
+ * folder still exists, else in `fallback`.
+ */
+export function goPlan(idea, fallback) {
+  return {
+    model: idea.triage?.model || 'sonnet',
+    dir: idea.project && fs.existsSync(idea.project) ? idea.project : fallback,
+    prompt: buildPrompt(idea),
+  };
+}
+
 /** Start an interactive Claude Code session on the recommended model. */
 export function launchSession({ model, prompt, cwd }) {
   const [bin, argv] = command(['--model', model, prompt]);
diff --git a/src/config.js b/src/config.js
index 63af809..508bc13 100644
--- a/src/config.js
+++ b/src/config.js
@@ -27,6 +27,11 @@ export const SETTINGS = {
     value: '',
     about: 'dashboard server that gets index.html and data.json by HTTP PUT (empty: off)',
   },
+  serve_port: {
+    env: 'IDEAMINE_SERVE_PORT',
+    value: '4332',
+    about: 'port of `ideamine serve`, the dashboard with buttons on 127.0.0.1',
+  },
 };
 
 const configPath = () => path.join(home(), 'config.json');
diff --git a/src/hook.js b/src/hook.js
index 0af0987..7eff289 100644
--- a/src/hook.js
+++ b/src/hook.js
@@ -1,5 +1,5 @@
 // UserPromptSubmit hook: answers /idea, /ideas, and the local /ideas-* commands (ls, cat, rm, done,
-// reopen, find, groups, watch) and blocks the prompt, so the model is never called. That makes
+// reopen, find, groups, watch, web) and blocks the prompt, so the model is never called. That makes
 // capture free, instant, and possible even when the session is out of usage. Every other prompt
 // passes through untouched, including /ideas-go, /ideas-all, /ideas-sort, and questions, which their
 // skills answer. After each prompt, the hook lets the watcher and the dashboard catch up.
@@ -25,6 +25,7 @@ const USAGE = `Usage: /idea <text>                add an idea (a bulleted list a
        /ideas-done N [note] · /ideas-reopen N
        /ideas-find <words>          search by meaning (all lanes)
        /ideas-groups [lane|-a]      ideas grouped by meaning
+       /ideas-web [off]             the dashboard with a button for each command, on this PC
 These call the model:
        /ideas-go [N]                build the next idea, or #N, on its model. New ideas are triaged first.
        /ideas-all                   do every idea that fits this chat. The others stay in the queue.
@@ -67,6 +68,14 @@ export async function handlePrompt(prompt, { cwd = process.cwd(), session = null
     return watch.status(); // runHook starts the first pass after this
   }
 
+  if (command === 'ideas-web') {
+    // Loaded here, not at the top: the hook runs for each prompt.
+    const serve = await import('./serve.js');
+    if (!arg) return serve.ensureRunning();
+    if (/^off$/i.test(arg)) return serve.stopRunning();
+    return null;
+  }
+
   const words = arg.split(/\s+/).filter(Boolean);
   const ids = words.length > 0 && words.every((w) => ID.test(w)) ? words : null;
   const db = load();
diff --git a/src/mcp.js b/src/mcp.js
index 0827e9f..37fcbf1 100644
--- a/src/mcp.js
+++ b/src/mcp.js
@@ -169,6 +169,7 @@ const PROMPTS = [
   { name: 'ideas-all', description: 'Do every idea that fits this chat', arguments: [] },
   { name: 'ideas-sort', description: 'Triage the inbox now and show the queue', arguments: [] },
   { name: 'ideas-watch', description: 'Turn the background watcher on or off', arguments: [{ name: 'off', required: false }] },
+  { name: 'ideas-web', description: 'Start or stop the dashboard with buttons on this PC', arguments: [{ name: 'off', required: false }] },
 ];
 
 function skillBody(name, args) {
@@ -191,7 +192,7 @@ function summarizeTriage(results, how = '') {
   return lines.join('\n');
 }
 
-function headlessSummary(out) {
+export function headlessSummary(out) {
   return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`);
 }
 
diff --git a/src/serve.js b/src/serve.js
new file mode 100644
index 0000000..08c7052
--- /dev/null
+++ b/src/serve.js
@@ -0,0 +1,331 @@
+// The dashboard with buttons. `ideamine serve` runs a web server on this PC. It serves the page of
+// `ideamine publish`, a live data.json, and an API for the slash commands: add, delete, done,
+// reopen, start, drop, note, model, triage, build, ask, and the watcher. The archive and the Claude
+// Code login are on this PC, so the commands run here. The server listens on 127.0.0.1 only, and
+// it takes commands only from its own page.
+
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import http from 'node:http';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import * as config from './config.js';
+import * as publish from './publish.js';
+import { renderAdded, stamp } from './render.js';
+import * as store from './store.js';
+import { clip, splitIdeas } from './text.js';
+import * as watch from './watch.js';
+
+const PAGE = new URL('../dashboard/index.html', import.meta.url);
+const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
+const MAX_BODY = 1024 * 1024;
+const EMBED_TIMEOUT_MS = 5000; // data.json must not wait long for an embedding server that is away
+const HEADERS = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
+
+const logPath = () => path.join(store.home(), 'serve.log');
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/** The port from the setting serve_port. */
+export function port() {
+  const value = config.get('serve_port');
+  const n = Number(value);
+  if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`serve_port must be a whole number from 1 to 65535, not "${value}"`);
+  return n;
+}
+
+export const address = (p = port()) => `http://127.0.0.1:${p}/`;
+
+/**
+ * True when the server may answer a request. The Host header must name this server, which stops a
+ * DNS rebinding page. A POST must send JSON, and its Origin, if any, must be this server. A page of
+ * another site cannot send JSON here without a CORS preflight, and this server allows none.
+ */
+export function allowed({ method, host, origin, type }, port) {
+  const h = String(host ?? '').toLowerCase();
+  if (h !== `127.0.0.1:${port}` && h !== `localhost:${port}`) return false;
+  if (method === 'GET' || method === 'HEAD') return true;
+  if (method !== 'POST' || !/^application\/json\s*(;|$)/i.test(String(type ?? '').trim())) return false;
+  return origin == null || origin === `http://${h}`;
+}
+
+/**
+ * Run `node <args>` in a new console window, so that Claude Code gets a terminal. Windows only.
+ * `start` opens the window. A Windows path cannot hold a quote, so quotes around each argument
+ * keep cmd.exe from reading a & or | in a path. IDEAMINE_WINDOW keeps the window open after an error.
+ */
+export function openWindow(args, cwd) {
+  if (process.platform !== 'win32') {
+    return Promise.reject(new Error(`the page can open a terminal only on Windows. Run in a terminal: ideamine ${args.slice(1).join(' ')}`));
+  }
+  const line = ['start', '""', ...[process.execPath, ...args].map((a) => `"${a}"`)].join(' ');
+  return new Promise((resolve, reject) => {
+    const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/c', line], {
+      cwd,
+      env: { ...process.env, IDEAMINE_WINDOW: '1' },
+      detached: true,
+      stdio: 'ignore',
+      windowsVerbatimArguments: true,
+    });
+    child.on('error', reject);
+    child.on('spawn', () => {
+      child.unref();
+      resolve();
+    });
+  });
+}
+
+function send(res, status, json, headers = {}) {
+  res.writeHead(status, { ...HEADERS, 'content-type': 'application/json; charset=utf-8', ...headers });
+  res.end(JSON.stringify(json));
+}
+
+function sendPage(res) {
+  res.writeHead(200, {
+    ...HEADERS,
+    'content-type': 'text/html; charset=utf-8',
+    'content-security-policy': "frame-ancestors 'none'", // no other page can frame the buttons
+  });
+  res.end(fs.readFileSync(PAGE));
+}
+
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    const chunks = [];
+    let size = 0;
+    req.on('data', (chunk) => {
+      size += chunk.length;
+      if (size > MAX_BODY) {
+        reject(new Error('the request is too big'));
+        req.destroy();
+      } else chunks.push(chunk);
+    });
+    req.on('end', () => resolve(Buffer.concat(chunks)));
+    req.on('error', reject);
+  });
+}
+
+async function readJson(req) {
+  const raw = (await readBody(req)).toString('utf8');
+  let body;
+  try {
+    body = raw.trim() ? JSON.parse(raw) : {};
+  } catch {
+    throw new Error('the request is not valid JSON');
+  }
+  if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('the request must be a JSON object');
+  return body;
+}
+
+/** The snapshot of `ideamine publish`, and `live`: what only this server can tell the page. */
+async function liveData() {
+  const { data, note } = await publish.build(store.load(), { timeoutMs: EMBED_TIMEOUT_MS });
+  const w = watch.readState();
+  return { ...data, live: { note, window: process.platform === 'win32', watch: { on: !!w.on, status: watch.status() } } };
+}
+
+/** Search by meaning on the page: the page sends its query to the embedding server through here. */
+async function proxyEmbeddings(req, res) {
+  const endpoint = `${config.get('embed_url').replace(/\/+$/, '')}/embeddings`;
+  let upstream;
+  try {
+    upstream = await fetch(endpoint, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: await readBody(req),
+      signal: AbortSignal.timeout(8000),
+    });
+  } catch {
+    return send(res, 502, { ok: false, error: `cannot reach ${endpoint}` });
+  }
+  res.writeHead(upstream.status, { ...HEADERS, 'content-type': upstream.headers.get('content-type') || 'application/json' });
+  res.end(Buffer.from(await upstream.arrayBuffer()));
+}
+
+// The slash commands. Each one takes the JSON body and returns the text to show on the page.
+const ACTIONS = {
+  /** /idea. A bulleted list adds one idea per bullet. */
+  add({ text }) {
+    const results = store.addIdeas(splitIdeas(String(text ?? '')), { source: 'web' });
+    return renderAdded(results, store.load());
+  },
+
+  /** /ideas-rm. An unknown id deletes nothing. */
+  rm({ ids }) {
+    if (!Array.isArray(ids) || !ids.length) throw new Error('name the ideas to delete');
+    return store.removeIdeas(ids).map((i) => `Removed #${i.id} · ${clip(i.title, 60)}`).join('\n');
+  },
+
+  /** /ideas-done, /ideas-reopen, and the verbs start, drop, note, and model of the CLI. */
+  update({ id, status, note, model }) {
+    if (!status && !note && !model) throw new Error('nothing to change');
+    const idea = store.updateIdea(id, { status, note, model });
+    const bits = [`#${idea.id} ${clip(idea.title, 60)} → ${store.lane(idea)}`];
+    if (model) bits.push(`model ${idea.triage.model}`);
+    if (note) bits.push('note added');
+    return `✓ ${bits.join(' · ')}`;
+  },
+
+  /** /ideas-sort */
+  async sort() {
+    const { headlessTriage } = await import('./claude.js');
+    const { headlessSummary } = await import('./mcp.js');
+    return headlessSummary(await headlessTriage());
+  },
+
+  /**
+   * /ideas-go [N]. New ideas are triaged first. Then a new window runs `ideamine go N`: Claude Code
+   * on the recommended model, in the project of the idea.
+   */
+  async go({ id }, { open }) {
+    const { goPlan, triageFirst } = await import('./claude.js');
+    const { headlessSummary } = await import('./mcp.js');
+    const lines = [];
+    try {
+      const triaged = await triageFirst({ id: id ?? null });
+      if (triaged) lines.push(headlessSummary(triaged));
+    } catch (e) {
+      lines.push(`Triage failed: ${e.message}`);
+    }
+    const db = store.load();
+    const idea = id != null ? store.findIdea(db, id) : store.pickNext(db);
+    if (!idea) throw new Error(id != null ? `no idea #${id}` : 'Nothing is ready to build. Triage the inbox first.');
+    const { model, dir } = goPlan(idea, os.homedir());
+    await open([BIN, 'go', String(idea.id)], dir);
+    store.updateIdea(idea.id, { status: 'doing' });
+    lines.push(`Opened Claude Code (${model}) in ${dir} for #${idea.id} · ${clip(idea.title, 60)}`);
+    return lines.join('\n\n');
+  },
+
+  /** /ideas <question> */
+  async ask({ question }) {
+    const q = String(question ?? '').trim();
+    if (!q) throw new Error('ask a question');
+    const { askAboutIdeas } = await import('./claude.js');
+    return (await askAboutIdeas(q)).answer || 'Claude gave no answer.';
+  },
+
+  /** /ideas-watch [off] */
+  watch({ on }) {
+    if (on) {
+      watch.turnOn();
+      watch.kick();
+    } else watch.turnOff();
+    return watch.status();
+  },
+};
+
+// These actions do not change the archive, so the dashboard server needs no new upload.
+const READ_ONLY = new Set(['ask', 'watch']);
+
+async function handle(req, res, ctx) {
+  if (!allowed({ method: req.method, host: req.headers.host, origin: req.headers.origin, type: req.headers['content-type'] }, ctx.port)) {
+    return send(res, 403, { ok: false, error: 'forbidden' });
+  }
+  const { pathname } = new URL(req.url, 'http://127.0.0.1');
+  const route = `${req.method === 'HEAD' ? 'GET' : req.method} ${pathname}`;
+  if (route === 'GET /' || route === 'GET /index.html') return sendPage(res);
+  if (route === 'GET /data.json') return send(res, 200, await liveData());
+  // A ping gets a new connection each time, so it never reaches a server that stops on an old one.
+  if (route === 'GET /api/ping') return send(res, 200, { ok: true, app: 'ideamine' }, { connection: 'close' });
+  if (route === 'POST /v1/embeddings') return proxyEmbeddings(req, res);
+  if (route === 'POST /api/stop') {
+    // After "stopped", no kept-alive connection may answer a request.
+    res.on('finish', () => ctx.server.closeAllConnections?.());
+    send(res, 200, { ok: true, message: 'stopped' }, { connection: 'close' });
+    ctx.server.close();
+    return;
+  }
+  const action = route.startsWith('POST /api/') ? route.slice('POST /api/'.length) : '';
+  if (!Object.hasOwn(ACTIONS, action)) return send(res, 404, { ok: false, error: 'not found' });
+  try {
+    const message = await ACTIONS[action](await readJson(req), ctx);
+    ctx.log(`${action}: ok`);
+    if (!READ_ONLY.has(action)) {
+      try {
+        ctx.afterChange();
+      } catch {
+        // The upload to the dashboard server must never fail a command.
+      }
+    }
+    return send(res, 200, { ok: true, message });
+  } catch (e) {
+    ctx.log(`${action}: ${e.message}`);
+    return send(res, 400, { ok: false, error: e.message });
+  }
+}
+
+/**
+ * The server, not listening yet. `open` opens the window for a build, `afterChange` runs after a
+ * command changes the archive (by default it uploads the dashboard again), and `log` gets a line
+ * for each command.
+ */
+export function createServer({ open = openWindow, afterChange = publish.kick, log = () => {} } = {}) {
+  const ctx = { open, afterChange, log, port: null };
+  ctx.server = http.createServer((req, res) => {
+    handle(req, res, ctx).catch((e) => {
+      if (!res.headersSent) send(res, 500, { ok: false, error: e.message });
+    });
+  });
+  ctx.server.on('listening', () => (ctx.port = ctx.server.address().port));
+  return ctx.server;
+}
+
+/** Listen on 127.0.0.1. Resolves to { server, url }. */
+export function start({ port: p = port(), ...options } = {}) {
+  const server = createServer(options);
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(p, '127.0.0.1', () => resolve({ server, url: address(server.address().port) }));
+  });
+}
+
+/** True when an ideamine server answers at `url`. */
+async function ping(url) {
+  try {
+    const res = await fetch(new URL('api/ping', url), { signal: AbortSignal.timeout(1000) });
+    return res.ok && (await res.json()).app === 'ideamine';
+  } catch {
+    return false;
+  }
+}
+
+/** Start `ideamine serve` in the background. Its output goes to serve.log, and so does a failed start. */
+function startProcess() {
+  fs.mkdirSync(store.home(), { recursive: true });
+  const log = fs.openSync(logPath(), 'a');
+  try {
+    const child = spawn(process.execPath, [BIN, 'serve'], { cwd: store.home(), detached: true, stdio: ['ignore', log, log], windowsHide: true });
+    child.on('error', (e) => fs.appendFileSync(logPath(), `${logLine(`cannot start: ${e.message}`)}\n`));
+    child.unref();
+  } finally {
+    fs.closeSync(log);
+  }
+}
+
+/** /ideas-web: start the server when it does not run. Resolves to the text for the user. */
+export async function ensureRunning({ startServer = startProcess, waitMs = 5000 } = {}) {
+  const url = address();
+  if (await ping(url)) return `ideamine web: ${url}`;
+  startServer();
+  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
+    await sleep(150);
+    if (await ping(url)) return `ideamine web: ${url} (started)`;
+  }
+  return `ideamine web did not start at ${url}. The reason is in ${logPath()}.`;
+}
+
+/** /ideas-web off */
+export async function stopRunning() {
+  const url = address();
+  if (!(await ping(url))) return 'ideamine web: not running.';
+  try {
+    await fetch(new URL('api/stop', url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
+  } catch {
+    // The server can close the connection before its answer arrives. The ping below tells the result.
+  }
+  return (await ping(url)) ? `ideamine web: still running at ${url}` : 'ideamine web: stopped.';
+}
+
+/** A line for serve.log: the time and the text. */
+export const logLine = (text) => `${stamp(new Date().toISOString())}  ${text}`;
diff --git a/tests/fixtures/fake-claude.js b/tests/fixtures/fake-claude.js
index 52aa75f..6b63643 100644
--- a/tests/fixtures/fake-claude.js
+++ b/tests/fixtures/fake-claude.js
@@ -1,7 +1,8 @@
 // Stand-in for the Claude Code CLI: returns a verdict for every idea in the prompt and records how
 // it was called, so tests can check flags and environment without spending tokens. It pairs an
 // idea with a listed project when the idea names that project. It answers with the folder path,
-// or with the project name when FAKE_CLAUDE_ANSWER=name (as Haiku did).
+// or with the project name when FAKE_CLAUDE_ANSWER=name (as Haiku did). To a question about the
+// ideas, it answers with the first idea of the prompt.
 import fs from 'node:fs';
 
 let input = '';
@@ -35,12 +36,15 @@ const verdicts = ideas.map(([, id, text], i) => ({
   project: answer(projects.find((p) => text.toLowerCase().includes(p.name.toLowerCase()))),
 }));
 
+const question = input.split('\nQuestion: ')[1];
+const first = input.match(/\*\*#(\d+) /);
+
 process.stdout.write(JSON.stringify({
   type: 'result',
   subtype: 'success',
   is_error: false,
-  result: '',
-  structured_output: { verdicts },
+  result: question ? `#${first ? first[1] : '?'} fits "${question.trim()}".` : '',
+  structured_output: question ? undefined : { verdicts },
   total_cost_usd: 0.0012,
   usage: { input_tokens: 321, output_tokens: 45 },
 }));
diff --git a/tests/mcp.test.js b/tests/mcp.test.js
index 90986d8..82fa498 100644
--- a/tests/mcp.test.js
+++ b/tests/mcp.test.js
@@ -109,7 +109,7 @@ test('tool errors come back as isError results, not protocol errors', async () =
 test('prompts mirror the skills', async () => {
   const list = await request('prompts/list');
   const names = list.result.prompts.map((p) => p.name);
-  assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-find', 'ideas-groups', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort', 'ideas-watch']);
+  assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-find', 'ideas-groups', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort', 'ideas-watch', 'ideas-web']);
   // One prompt for each skill, so that other MCP clients get the same commands as the plugin.
   assert.deepEqual([...names].sort(), fs.readdirSync(new URL('../skills', import.meta.url)).sort());
   const go = await request('prompts/get', { name: 'ideas-go', arguments: { id: '12' } });
diff --git a/tests/serve.test.js b/tests/serve.test.js
new file mode 100644
index 0000000..8ea127f
--- /dev/null
+++ b/tests/serve.test.js
@@ -0,0 +1,263 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import http from 'node:http';
+import net from 'node:net';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import fc from 'fast-check';
+import { handlePrompt } from '../src/hook.js';
+import * as serve from '../src/serve.js';
+import * as store from '../src/store.js';
+import { startFakeServer } from './fixtures/fake-server.js';
+
+const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
+
+let server; // the server under test
+let url;
+let embedServer;
+let opened; // the windows that /api/go asked for
+let changes; // how often a command asked for a new upload of the dashboard
+
+beforeEach(async () => {
+  process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-serve-'));
+  // The triage and the questions must never reach the real claude or the projects of this machine.
+  process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url));
+  process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME;
+  embedServer = await startFakeServer();
+  process.env.IDEAMINE_EMBED_URL = `${embedServer.url}/v1`;
+  opened = [];
+  changes = 0;
+  ({ server, url } = await serve.start({
+    port: 0,
+    open: async (args, cwd) => opened.push({ args, cwd }),
+    afterChange: () => changes++,
+  }));
+});
+
+afterEach(async () => {
+  await new Promise((resolve) => server.close(resolve));
+  await embedServer.close();
+  delete process.env.IDEAMINE_EMBED_URL;
+});
+
+/** POST to the server under test the way the page does. */
+async function api(action, body = {}, headers = {}) {
+  const res = await fetch(new URL(`api/${action}`, url), {
+    method: 'POST',
+    headers: { 'content-type': 'application/json', ...headers },
+    body: typeof body === 'string' ? body : JSON.stringify(body),
+  });
+  return { status: res.status, ...(await res.json()) };
+}
+
+/** A raw request, so a test can send any Host header. */
+function raw(method, pathname, headers) {
+  return new Promise((resolve, reject) => {
+    const req = http.request(new URL(pathname, url), { method, headers }, (res) => {
+      res.resume();
+      res.on('end', () => resolve(res));
+    });
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+test('only this page may use the server: a property over hosts, origins, and content types', () => {
+  const port = 4332;
+  const post = (h) => ({ method: 'POST', type: 'application/json', ...h });
+  fc.assert(
+    fc.property(fc.string(), (host) => {
+      const own = ['127.0.0.1:4332', 'localhost:4332'].includes(host.toLowerCase());
+      assert.equal(serve.allowed({ method: 'GET', host }, port), own);
+      assert.equal(serve.allowed(post({ host }), port), own);
+    }),
+  );
+  const origins = fc.oneof(fc.string(), fc.webUrl(), fc.constantFrom('null', 'http://127.0.0.1:4333', 'https://127.0.0.1:4332', 'http://localhost:4332/'));
+  fc.assert(
+    fc.property(origins, (origin) => {
+      fc.pre(origin !== 'http://127.0.0.1:4332');
+      assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', origin }), port), false);
+    }),
+  );
+  fc.assert(
+    fc.property(fc.string(), (type) => {
+      fc.pre(!/^\s*application\/json\s*(;|$)/i.test(type));
+      assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', type }), port), false);
+    }),
+  );
+  assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', origin: 'http://127.0.0.1:4332', type: 'application/json; charset=utf-8' }), port), true);
+  assert.equal(serve.allowed(post({ host: '127.0.0.1:4332' }), port), true); // no Origin: a program on this PC
+  for (const method of ['PUT', 'DELETE', 'OPTIONS', 'PATCH']) assert.equal(serve.allowed({ method, host: '127.0.0.1:4332' }, port), false);
+});
+
+test('requests from other pages change nothing', async () => {
+  const text = await fetch(new URL('api/add', url), { method: 'POST', headers: { 'content-type': 'text/plain' }, body: '{"text":"x"}' });
+  assert.equal(text.status, 403); // a form or a no-cors fetch of another page
+  assert.equal((await api('add', { text: 'x' }, { origin: 'http://evil.example' })).status, 403);
+  assert.equal((await raw('GET', '/data.json', { host: `evil.example:${server.address().port}` })).statusCode, 403); // DNS rebinding
+  const preflight = await raw('OPTIONS', '/api/add', { origin: 'http://evil.example', 'access-control-request-method': 'POST' });
+  assert.equal(preflight.statusCode, 403);
+  assert.equal(preflight.headers['access-control-allow-origin'], undefined);
+  assert.equal(store.load().ideas.length, 0);
+  assert.equal(changes, 0);
+  assert.equal((await api('nope')).status, 404);
+  assert.equal((await api('add', 'not json')).error, 'the request is not valid JSON');
+});
+
+test('the page comes with live data, and no other page can frame it', async () => {
+  store.addIdeas(['alpha']);
+  const page = await fetch(url);
+  assert.equal(page.status, 200);
+  assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'none'/);
+  assert.match(await page.text(), /id="actions"/);
+  const data = await (await fetch(new URL('data.json', url))).json();
+  assert.equal(data.version, 1);
+  assert.equal(data.ideas[0].key, 'IDEA-1');
+  assert.deepEqual(data.live, { note: '', window: process.platform === 'win32', watch: { on: false, status: data.live.watch.status } });
+  assert.match(data.live.watch.status, /^ideamine watch: off/);
+});
+
+test('add, update, and rm do what /idea, /ideas-done, /ideas-reopen, and /ideas-rm do', async () => {
+  const added = await api('add', { text: '- alpha #web\n- beta' });
+  assert.match(added.message, /Saved 2 ideas: #1, #2/);
+  const [alpha] = store.load().ideas;
+  assert.deepEqual([alpha.source, alpha.tags, alpha.project], ['web', ['web'], null]);
+
+  assert.equal((await api('update', { id: 1, status: 'done', note: 'shipped' })).message, '✓ #1 alpha → done · note added');
+  assert.deepEqual(store.findIdea(store.load(), 1).notes.map((n) => n.text), ['shipped']);
+  assert.equal((await api('update', { id: 1, status: 'reopen' })).message, '✓ #1 alpha → inbox');
+  assert.equal((await api('update', { id: 2, status: 'doing' })).message, '✓ #2 beta → doing');
+  assert.ok(store.findIdea(store.load(), 2).started);
+  assert.equal((await api('update', { id: 2, model: 'opus' })).message, '✓ #2 beta → doing · model opus');
+  assert.equal((await api('update', { id: 2, status: 'dropped' })).message, '✓ #2 beta → dropped');
+  assert.equal((await api('update', { id: 2 })).error, 'nothing to change');
+
+  const missing = await api('rm', { ids: [1, 9] });
+  assert.deepEqual([missing.status, missing.error], [400, 'no idea #9']);
+  assert.equal(store.load().ideas.length, 2); // an unknown id deletes nothing
+  assert.equal((await api('rm', { ids: [1] })).message, 'Removed #1 · alpha');
+  assert.deepEqual(store.load().ideas.map((i) => i.id), [2]);
+  assert.equal(changes, 7); // each command that changed the archive, and no failed one
+});
+
+test('sort triages the inbox with one headless call, like /ideas-sort', async () => {
+  await api('add', { text: '- alpha\n- beta' });
+  const out = await api('sort');
+  assert.match(out.message, /^Saved 2 verdicts: 2 do · 0 maybe · 0 skip/);
+  assert.ok(store.load().ideas.every((i) => i.status === 'triaged'));
+  assert.equal((await api('sort')).message, 'Nothing to triage: the inbox is empty.');
+});
+
+test('go triages a new idea, then opens a window that runs `ideamine go N` in the project', async () => {
+  const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-project-'));
+  store.addIdeas(['alpha'], { project });
+  store.addIdeas(['beta'], { project: path.join(project, 'gone') });
+  const out = await api('go', { id: 1 });
+  assert.match(out.message, /^Saved 1 verdict: 1 do[\s\S]*\n\nOpened Claude Code \(sonnet\) in .* for #1 · Idea 1$/);
+  assert.deepEqual(opened, [{ args: [BIN, 'go', '1'], cwd: project }]);
+  assert.equal(store.findIdea(store.load(), 1).status, 'doing');
+
+  // A folder that is gone: the build starts in the home folder. Without an id: the first in the queue.
+  await api('go');
+  assert.deepEqual(opened[1], { args: [BIN, 'go', '2'], cwd: os.homedir() });
+  const none = await api('go');
+  assert.deepEqual([none.status, none.error], [400, 'Nothing is ready to build. Triage the inbox first.']);
+});
+
+test('a window that cannot open changes nothing', async () => {
+  await new Promise((resolve) => server.close(resolve));
+  ({ server, url } = await serve.start({ port: 0, open: async () => { throw new Error('no terminal'); }, afterChange: () => changes++ }));
+  store.addIdeas(['alpha']);
+  store.applyTriage([{ id: 1, verdict: 'do', impact: 3, size: 's', model: 'haiku', brief: 'b' }]);
+  const out = await api('go', { id: 1 });
+  assert.deepEqual([out.status, out.error], [400, 'no terminal']);
+  assert.equal(store.findIdea(store.load(), 1).status, 'triaged');
+});
+
+test('ask answers a question about the ideas with one headless call, like /ideas <question>', async () => {
+  await api('add', { text: 'alpha' });
+  const before = changes;
+  assert.equal((await api('ask', { question: 'which first?' })).message, '#1 fits "which first?".');
+  assert.equal((await api('ask', { question: ' ' })).error, 'ask a question');
+  assert.equal(changes, before); // a question changes nothing
+});
+
+test('watch turns the watcher on and off, like /ideas-watch', async () => {
+  assert.match((await api('watch', { on: true })).message, /^ideamine watch: on since /);
+  assert.equal(JSON.parse(fs.readFileSync(path.join(store.home(), 'watch.json'), 'utf8')).on, true);
+  assert.match((await api('watch', { on: false })).message, /^ideamine watch: off/);
+});
+
+test('search by meaning on the page goes through the server to the embedding server', async () => {
+  const res = await fetch(new URL('v1/embeddings', url), {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ model: 'nomic-embed-text', input: ['search_query: subtitles'] }),
+  });
+  assert.equal(res.status, 200);
+  assert.equal((await res.json()).data[0].embedding.length, 64);
+  assert.deepEqual(embedServer.inputs, ['search_query: subtitles']);
+});
+
+test('serve_port must be a port number', () => {
+  process.env.IDEAMINE_SERVE_PORT = 'abc';
+  try {
+    assert.throws(() => serve.port(), /serve_port must be a whole number from 1 to 65535/);
+  } finally {
+    delete process.env.IDEAMINE_SERVE_PORT;
+  }
+  assert.equal(serve.port(), 4332);
+});
+
+/** A port that nothing listens on. */
+async function freePort() {
+  const s = net.createServer();
+  await new Promise((resolve) => s.listen(0, '127.0.0.1', resolve));
+  const { port } = s.address();
+  await new Promise((resolve) => s.close(resolve));
+  return port;
+}
+
+test('/ideas-web starts the server in the background, and /ideas-web off stops it', async () => {
+  process.env.IDEAMINE_SERVE_PORT = String(await freePort());
+  const address = `http://127.0.0.1:${process.env.IDEAMINE_SERVE_PORT}/`;
+  try {
+    // Several rounds, because a stopped server once went on to answer on a kept-alive connection:
+    // the next /ideas-web then said "running" and started nothing.
+    for (let round = 0; round < 3; round++) {
+      assert.equal(await handlePrompt('/ideas-web'), `ideamine web: ${address} (started)`);
+      assert.equal(await handlePrompt('/ideamine:ideas-web'), `ideamine web: ${address}`);
+      assert.equal((await fetch(new URL('data.json', address))).status, 200);
+      assert.equal(await handlePrompt('/ideas-web off'), 'ideamine web: stopped.');
+      assert.equal(await handlePrompt('/ideas-web off'), 'ideamine web: not running.');
+    }
+    assert.match(fs.readFileSync(path.join(store.home(), 'serve.log'), 'utf8'), /the dashboard with buttons runs at /);
+    assert.equal(await handlePrompt('/ideas-web something else'), null); // not a command: the skill answers
+  } finally {
+    await serve.stopRunning(); // never leave a server behind
+    delete process.env.IDEAMINE_SERVE_PORT;
+  }
+});
+
+test('/ideas-web says why the server did not start', async () => {
+  process.env.IDEAMINE_SERVE_PORT = String(await freePort());
+  const node = process.execPath;
+  process.execPath = path.join(store.home(), 'no-such-node');
+  try {
+    assert.match(await serve.ensureRunning({ waitMs: 300 }), /^ideamine web did not start at .*\. The reason is in .*serve\.log\.$/);
+  } finally {
+    process.execPath = node;
+    delete process.env.IDEAMINE_SERVE_PORT;
+  }
+  assert.match(fs.readFileSync(path.join(store.home(), 'serve.log'), 'utf8'), /cannot start: .*ENOENT/);
+});
+
+test('a window that failed waits for a key only when a person can press one', () => {
+  // IDEAMINE_WINDOW comes from the window of the page. Without a terminal, the error must not wait.
+  const r = spawnSync(process.execPath, [BIN, 'cat', '9'], { encoding: 'utf8', env: { ...process.env, IDEAMINE_WINDOW: '1' }, timeout: 10000 });
+  assert.equal(r.status, 1);
+  assert.match(r.stderr, /no idea #9/);
+});