mindash is a self-hosted dashboard you can edit visually — or hand to an AI agent. It pulls from feeds, APIs, web pages, and MCP tools; accepts live updates from jobs and automations; and keeps the last good data visible when an upstream fails.
It has the compact, glanceable feel of a personal homepage, but it is also useful as a lightweight operations board. The app runs as one Cloudflare Worker with server-rendered dashboard pages, passkey ownership, encrypted credentials, and versioned runtime config.
Live demo → — explore Home, Ops, Misc, and an Everything page containing all twenty widget types.
- Edit it three ways — use the visual editor, edit the YAML document, or connect an agent through MCP. The MCP server supports Claude.ai remote connectors through OAuth 2.1 as well as static tokens for header-capable clients.
- Pull and push data — read RSS, APIs, JavaScript-rendered web pages, and remote MCP tools; monitor sites; or push heartbeats and log lines directly from cron jobs, CI, and automations.
- Fail gracefully — scheduled refreshes happen away from page views. A failed upstream leaves the last successful payload on screen and marks it stale instead of turning the card into an error message.
- Own the whole thing — passkeys, encrypted origin-bound credentials, public and private pages, scoped agent access, compare-and-set updates, and 30-version history with restore.
- Use it every day — responsive themes, kiosk mode, a new-tab extension, and one-click deployment to your own Cloudflare account.
| Home — default theme | Operations — terminal preset |
Personal — paper preset |
|---|---|---|
![]() |
![]() |
![]() |
Three pages, three built-in themes. All of it was created, themed, and populated through the MCP API — no editor clicks involved — and remains fully editable in the visual editor.
Three ways in, easiest first:
1. One click - the Deploy to Cloudflare button above. It copies this repo into your GitHub account, provisions the KV/D1/R2 resources on your Cloudflare account, and deploys. The Worker bootstraps its own database schema on the first visit - nothing to run.
2. npm create - the standard Workers scaffolder accepts this repo as a template:
npm create cloudflare@latest my-dashboard -- --template=ddyy/mindash3. Clone it:
git clone https://github.com/ddyy/mindash && cd mindash
npm install
npm run devA fresh instance has no owner and no dashboard yet, so it shows one screen: create the first passkey to claim it (no token needed - that window closes the moment a passkey exists; every later enrollment needs a one-time token). Signing in then lands on setup, which asks two things:
- Timezone - pre-filled from your browser. Clocks, countdowns, and
calendars inherit it, so events land at the right hour instead of UTC.
Any widget can still override it, and
timezone:at the top of the config document is the same setting. - Example widgets or an empty page - the examples are a working dashboard to edit (news, weather, clocks, an MCP call); empty is one blank page and the gallery.
Setup only ever writes over an untouched instance - once a dashboard has content, it refuses and points at the editor.
Local dev needs no resource setup (state is name-keyed on disk). To
deploy a cloned copy, create the four resources and paste their ids
into wrangler.jsonc where marked:
npx wrangler kv namespace create CACHE
npx wrangler kv namespace create OAUTH_KV
npx wrangler d1 create mindash
npx wrangler r2 bucket create mindash-assets
npx wrangler deployHowever you install: open the instance and it offers a one-time
claim - create the first passkey and you own it. Then hit Edit and
build your dashboard (or point an agent at /mcp).
- Homepage / startup page (no extension needed): set your instance URL as the browser homepage and startup page - Chrome: Settings > On startup; Firefox: Settings > Home.
- New tab page: browsers need an extension for that - this repo
ships one in
extension/. Download the packaged extension (or use the folder from a clone), unzip it, then load it unpacked fromchrome://extensions(Developer mode → Load unpacked) and pick the extractedextensionfolder. Enter your instance URL once and every new tab is your dashboard.storageis its only permission and your URL is the only thing it stores. The search and bookmarks widgets are built for exactly this.
Twenty types, each a self-describing def in src/widgets/ (validation,
editor form, fetch, render, CSS in one file):
- Feeds — RSS/Atom (multiple feeds merged newest-first, entity decoding), YouTube channels/playlists (keyless via YouTube's RSS, thumbnails, @handle search), Hacker News
- Personal — weather (geocoded search, C/F), calendar (iCal with simple recurrence + EXDATE), bookmarks (optional favicons via one fixed icon origin), search box (DuckDuckGo, Google, Bing, Brave, Startpage, Ecosia, Kagi, Wikipedia, and YouTube presets — each setting the engine's own query parameter — or a custom engine), notes (safe markdown subset), world clock, countdown (timezone-aware, DST-correct)
- Markets — crypto (CoinGecko, searchable coin picker) and stocks (Yahoo, symbol search) with colored day deltas
- Display — JSON API (dot-path field mapping with a live field picker), web scrape (Browser Rendering loads the page in headless Chromium; CSS selectors map elements to a list - works on JS-only pages and sites that block plain server fetches), image (direct URL with webcam cache-busting, R2 upload, or JSON-resolved like xkcd/APOD with a pinned image origin), sandboxed iframe embeds
- Monitoring — site monitor (HEAD checks with latency, per-site up/down history bars; a failing site is a red row, never a broken card)
- Integration — MCP widget (call a tool on any Streamable-HTTP MCP server — unauthenticated, static bearer, or OAuth via a one-time Settings sign-in; fields, markdown, or link-list rendering), push heartbeats (cron jobs report in; late/missed turn red), push log (lines POSTed in - cron output, CI results, agent updates)
Pull widgets are prefetched by a cron sweep behind fenced D1 leases and
cached in D1 rows; a page view never fetches an upstream. A failed fetch
leaves the last good data in place, so the card keeps working and its
stamp says whose timestamp it is - showing data from 3h ago under the
failure, or updated 3h ago · overdue when data ages far past its
interval with nothing logged as failing. Healthy cards stay unmarked.
The JSON API widget's list mapping covers many "dedicated" widgets with plain config. GitHub releases:
- type: json-api
title: workers-sdk releases
url: https://api.github.com/repos/cloudflare/workers-sdk/releases?per_page=8
refresh_interval: 2h
items: "."
item_title: tag_name
item_url: html_url
item_meta: published_atitems: "." maps the response root (the releases array); each row links
its tag_name to the release page. Swap /releases for /tags or
/issues and rebind the paths for other GitHub lists. Unauthenticated
GitHub API calls are rate-limited per source IP, which Workers share -
if a card flakes, save a GitHub token under Settings - API credentials
and select it in the widget's Credential field.
Reddit blocks plain server fetches from datacenter IPs (its .json
and .rss endpoints 403 from Cloudflare), but a full browser passes -
so a subreddit is just a scrape recipe:
- type: scrape
title: r/selfhosted
url: https://www.reddit.com/r/selfhosted/
item_selector: a[slot="full-post-link"]
refresh_interval: 30m(Use www.reddit.com, not old.reddit.com - the old UI now bounces logged-out visitors to a login wall from many IPs.)
/settings/editor — outline · live preview · inspector, with YAML as an
escape hatch. Drag-and-drop plus keyboard/button equivalents everywhere,
draggable column widths snapping to named fractions, live draft probes
(new widgets show real data before saving), semantic save summaries,
version history with restore, undo, and a Theme panel.
Global theme + named presets, selectable per page (built-in palettes:
nord, solarized-dark, gruvbox, catppuccin, paper, terminal — or copy one
and customize). Colors (hex, native pickers), fonts, font/title sizes,
corner radius, card opacity, background image and logo (uploaded to R2 or
external URL), dashboard title. Per page: fit-to-screen layouts (rows
take height fractions), public sharing (no session, noindex, share link),
descriptions, and kiosk/fullscreen viewing (?kiosk=1 or the ⛶ button;
page switching stays in fullscreen).
- Rendering: escaping by construction (
htmltagged template); CSPdefault-src 'none'with exact per-feature allowlists (frame-srcfrom iframe widgets,form-actionfrom search engines,img-srcfrom theme/image/favicon origins). Pages ship only first-party scripts (script-src 'self'; connect-src 'self'): the clock/countdown ticker andui.js(background refresh, fullscreen page switching) — no third-party script can ever load, on public or private pages. - Outbound fetches: https/public-only with bounded bodies, deadlines, and manual redirects; credentialed requests never follow redirects.
- Credentials: never in config. API/MCP bearer credentials live in an
encrypted D1 vault (AES-GCM; master key auto-generated into KV, or a
MASTER_KEYWorker secret if you prefer) - added once in Settings, referenced by name from config. Each credential is pinned to its widget types and exact destination origin, and the pin is the AEAD associated data: retargeting a credential breaks decryption instead of leaking it. Credential-less MCP widgets may call any public https server - a call that carries no credential carries no authority worth allowlisting. Heartbeat push tokens are per-widget D1 rows (hash only, created in Settings, shown once);PUSH_TOKEN_*Worker secrets still work as a legacy fallback. - Auth: passkeys only (single-use enroll/recover tokens, owner epoch revocation). Config writes are compare-and-set with semantic diff classification: layout-scope vs sources-scope (source URLs, schedules, making a page public, external theme images).
mindash speaks MCP in both directions. As a client, the MCP widget calls a tool on any public Streamable-HTTP server each refresh — unauthenticated, with a vault credential, or through an OAuth connection (Settings → MCP connections runs the full discovery → registration → PKCE flow in one click and auto-refreshes tokens).
As a server: a stateless Streamable-HTTP endpoint at POST /mcp.
Two auth lanes:
- OAuth 2.1 (claude.ai remote connectors): the Worker is its own
authorization server — discovery, DCR (rate-limited, https/loopback
redirects only), PKCE S256, consent with passkey step-up for
config:sources, revocable grants in/settings. - Static bearer tokens for header-capable clients:
./scripts/seed-mcp-token.sh layout my-agent # ordering/titles/theme
./scripts/seed-mcp-token.sh sources my-agent # + sources/create/remove/publicclaude mcp add --transport http mindash http://localhost:8787/mcp \
--header "Authorization: Bearer <token>"Tools: list_config, update_config (full document — pages, themes,
layout), add_widget, update_widget, remove_widget,
refresh_widget, rollback_config, snapshot_config. Mutations take
base_version and return a structured conflict when stale.
Create a heartbeat or log widget, then mint its bearer token under Settings → Push tokens (shown once, stored hashed). Then:
# one-shot ping (claims its scheduled occurrence when in window)
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" -d '{"bytes": 12345}' \
http://localhost:8787/push/backup-demo
# explicit failure
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \
"http://localhost:8787/push/backup-demo?status=fail"
# timed run: /start returns a run_id, completion targets it
RID=$(curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8787/push/backup-demo/start | jq -r .run_id)
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \
"http://localhost:8787/push/backup-demo?rid=$RID"The cron sweep materializes timeout rows for missed occurrences (bounded catch-up) and times out started runs past their deadline.
Log widgets take lines instead of pings - plain text or JSON, newest 100 kept:
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \
-d 'backup finished: 12.4GB in 3m' \
"http://localhost:8787/push/deploy-log"
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"text": "disk 91% full", "level": "warn"}' \
http://localhost:8787/push/deploy-logA fresh instance has a claim window: until the first passkey exists,
/login offers a tokenless "create the first passkey" flow — whoever
enrolls first owns the instance. Deploy and claim in the same sitting.
If you want a custom domain, attach it before claiming: a passkey is
bound to the domain that created it, so one made on *.workers.dev will
not sign you in on your own domain. The login page says so while the
choice is still free. Claiming first is recoverable, not fatal — enroll
again on the new domain with a one-time token.
Every enrollment after the first requires a one-time token:
./scripts/seed-token.sh enroll # one-time token; enroll a passkey at /login
./scripts/seed-token.sh recover # account-reset ceremony tokenConfig is a versioned document in D1 (compare-and-set, copy-forward
rollback, 30-version history). Edit it in the editor, over MCP, or as
YAML. config.yaml is only the first-boot seed. Widget ids are
server-assigned — never write them by hand.
npm install
npm run migrate:local
npm run dev # wrangler dev --test-scheduled on :8787
npm test # node:test suite (esbuild-bundled, no framework)
npm run check # tsc
curl "http://localhost:8787/cdn-cgi/handler/scheduled" # trigger a sweepClient scripts (*.client.js) are real JavaScript files imported as text
via the wrangler Text rule — no template-literal escaping layer. Push
tokens are created in Settings (.dev.vars only matters for the legacy
PUSH_TOKEN_* lane).
Resource creation and the first deploy are the clone instructions above (one authoritative sequence — the Worker migrates its own schema on first request, so no separate migration step). After the first deploy:
npx wrangler deploy # subsequent deploys
./scripts/seed-token.sh enroll --remote # extra passkeys after the first claimCreate the KV namespaces and D1 database on first deploy (ids in
wrangler.jsonc). Nothing else to edit: the checked-in config is the
production posture, including the global_fetch_strictly_public
compatibility flag and the CIMD authentication lane. That flag breaks
outbound fetch in local workerd, so npm run dev and the integration
suites drop it on the command line - local development needs no config
changes either.




