HTMX views let users build custom local interfaces — dashboards, trackers,
browsers, forms — as plain .html files: ordinary HTML with
htmx attributes, rendered in an isolated tab and talking to
a token-authenticated loopback API. Every view is treated as untrusted
content; every filesystem operation is privileged and policy-checked.
User guide
Creating a view
- Command palette → New HTMX view in current folder (default
Ctrl+G), or create any file ending in.html. - Opening the file renders the view tab. The Source toggle in the tab header edits the same file as text; saving reloads only that tab.
- Starter templates are scaffolded into
.neuron/templates/the first time a view opens (dashboard, note browser, file list) — copy one to get going.
Writing a view
A view file is HTML body content (no <html>/<head> needed). Neuron
wraps it, injects the bundled htmx runtime, and attaches the neuron.css
stylesheet for you — so you write plain semantic HTML and it comes out
styled to match the app:
<header>
<h1>My dashboard</h1>
</header>
<div class="neuron-grid cols-3">
<section hx-get="/api/v1/fragments/workspace-summary" hx-trigger="load" hx-swap="innerHTML">
Loading…
</section>
</div>
<section>
<form hx-get="/api/v1/search" hx-target="#search-results"
hx-trigger="submit, input changed delay:300ms from:#query">
<label for="query">Search notes</label>
<input id="query" name="query" type="search" autocomplete="off" />
</form>
<div id="search-results"></div>
</section>
No token handling is required — authentication is injected by Neuron at runtime (an HttpOnly session cookie scoped to the view's isolated partition). Never paste tokens into view source; there is nothing to paste.
Semantic HTML is styled directly. A bare <section>/<article> is a
card; <button> is a primary button; <input>, <select>, <textarea>,
<table>, <label>, <code> all inherit the Neuron look. <header> and the
layout classes below stay transparent so they can wrap cards. Everything
follows the app's light/dark theme automatically.
On top of the semantic defaults, neuron.css is a small shadcn-style
component library (class-based, so you compose richer UI than bare elements
give you):
- Card —
cardwithcard-header,card-title,card-description,card-content,card-footer. (A bare<section>is a pre-padded card if you don't need the sub-parts.) - Button —
btnwith variantsbtn-secondary,btn-outline,btn-ghost,btn-destructiveand sizesbtn-sm,btn-lg,btn-icon. - Badge —
badgewithbadge-primary,badge-outline,badge-destructive. - Form —
input,label(a bare<input>/<select>/<textarea>already gets the field style). - Layout & bits —
grid(+cols-2/3/4),stack,row,toolbar,separator,metric/metric-label,muted, andkbd(a bare<kbd>orclass="kbd"/neuron-kbdrenders a keyboard-hint cap).
The legacy neuron-* classes (neuron-card, neuron-button, neuron-input,
neuron-table, neuron-badge, neuron-grid, neuron-stack, neuron-toolbar,
neuron-metric, neuron-list, neuron-alert, neuron-empty) are kept as
aliases, so existing views and the server-rendered fragments keep working.
{{ variables.name }} in a view or fragment is replaced server-side with the
variable's value, always HTML-escaped. It is a key lookup, not an expression
language — no code, no prototypes, unknown keys render as nothing.
The .neuron folder
.neuron/
config.json # feature config (schema version)
variables.json # typed variables exposed to views
manifests/ # per-view permission manifests → "My view.html" ⇒ manifests/My view.json
fragments/ # reusable HTML partials → GET /api/v1/fragments/<name>
styles/ # optional CSS; "My view.html" auto-loads "My view.css"
templates/ # starter views to copy
layout.json # (pre-existing) workspace shell layout
Everything is plain text and safe to edit in Neuron. Files are created lazily
and never overwritten. variables.json:
{
"version": 1,
"variables": {
"dashboardTitle": { "type": "string", "value": "My Dashboard", "writable": false },
"projectStatus": { "type": "string", "value": "active", "writable": true }
}
}
Custom CSS is local-only: @import and remote url() references are blocked.
Manifests and permissions
Views receive no capabilities by default. To read or write workspace data,
add a manifest under .neuron/manifests/, mirroring the view's path —
projects.html → .neuron/manifests/projects.json (a legacy
projects.neuron.json sidecar next to the view is still read if present):
{
"name": "Projects",
"version": "1.0.0",
"description": "Tracks project status.",
"permissions": ["workspace.files.read", "workspace.files.write", "variables.read"],
"allowedReadPaths": ["Projects/**"],
"allowedWritePaths": [".neuron/data/projects.json"],
"networkPolicy": "none"
}
- Capabilities:
workspace.files.read,workspace.files.write,workspace.files.create,workspace.files.delete,workspace.directories.list,workspace.search,notes.read,tags.read,variables.read,variables.write. - Unknown manifest fields and unknown permissions are rejected, not
ignored.
networkPolicyaccepts only"none"— views cannot reach the network at all. - Every requested capability requires approval on first open. Approval is bound to the exact manifest content, so an edit asks again.
- The approval dialog offers Allow for this view / Allow once. Approvals are stored in Neuron's protected application settings — never in the workspace — and are bound to the manifest's content hash: any manifest edit re-requests approval.
- Reset a view's approval with
htmxViews.resetApproval(exposed for the permission card) or by editing the manifest.
API routes (/api/v1)
| Route | Cap | Returns |
|---|---|---|
GET /context |
— | view, workspace, theme, granted capabilities, API version |
GET /variables, GET /variables/:key |
variables.read |
variable definitions/values |
PUT /variables/:key |
variables.write |
updates a writable: true variable (type-checked) |
GET /files?dir&glob&limit |
workspace.directories.list |
file listing (JSON, or HTML for htmx) |
GET /files/content?path |
workspace.files.read |
{path, content, hash} (≤ 2 MB, read-path-checked) |
PUT /files/content |
workspace.files.write |
atomic write; baseHash mismatch → 409 conflict |
POST /files |
workspace.files.create |
creates; 409 if the file exists |
DELETE /files?path |
workspace.files.delete |
deletes within write paths |
GET /search?query |
workspace.search |
note matches (HTML fragment for htmx requests) |
GET /notes?tag&folder&limit |
notes.read |
note metadata (HTML table for htmx) |
GET /tags |
tags.read |
tag list (HTML badges for htmx) |
GET /fragments/:name?params… |
— (notes.read + tags.read for workspace-summary) |
rendered fragment from .neuron/fragments |
Responses are HTML fragments when the request carries htmx's HX-Request
header, JSON otherwise. Errors are structured
({error: {code, message, requestId}} or a .neuron-alert fragment) and never
include stack traces, absolute paths, tokens, or internals.
There is deliberately no /execute, /ipc, /command, or generic
filesystem proxy. Every route is explicitly implemented, validated,
capability-checked, and rate-limited.
Maintainer guide
Modules (src/main/htmx/)
| Module | Responsibility |
|---|---|
server.ts |
Loopback HTTP server, routing, API handlers, resource limits. Electron-free. |
sessions.ts |
Session + token lifecycle (one-time boot token, cookie token, constant-time compare, TTL, rate bucket). |
pathPolicy.ts |
Glob compilation, relative-path normalization, canonical resolution, symlink-escape rejection. |
manifest.ts |
Manifest / variables / capability validation (strict, unknown-key-rejecting). |
html.ts |
Escaping, limited {{ }} interpolation, document wrapper, fragment renderers. |
theme.ts |
The neuron.css string served to views. |
index.ts |
Electron wiring: IPC (htmx-views:open/approve/close/reset-approval), approval store, .neuron scaffolding, server lifecycle. |
Renderer: src/renderer/surfaces/HtmxViewSurface.tsx (the tab — loading /
permission / error / crashed states, <webview> host). Registered for
html in the generic surface registry.
Server and session lifecycle
- The server starts lazily on the first view open, binds only to
127.0.0.1on an ephemeral port, and dies with the app. Workspace switches revoke all sessions (revokeAllViewSessions). - Opening a view creates a session bound to (workspace root, view path,
capability set, compiled path policies, theme). The renderer gets a URL
…/views/{sessionId}/document?boot={oneTimeToken}. - The first document request consumes the boot token and sets an
HttpOnly; SameSite=Strictcookienv={sessionId}:{cookieToken}. All later requests (htmx XHR, stylesheets, htmx.js) authenticate with that cookie. - Each tab's
<webview>uses a per-session in-memory partition (view-{sessionId}): cookies and storage are isolated per view, so one view can never present another view's token, and nothing persists after close. - Closing the tab revokes the session; tokens also expire after 12 h. Localhost is not treated as an authentication boundary — every request must present a valid session token, and the Host header must match the server origin (DNS-rebinding guard).
A custom neuron-view:// protocol was considered; a loopback HTTP server was
chosen because htmx needs real HTTP verbs (GET/POST/PUT/DELETE), standard
form encoding, and cookie semantics. The tradeoff — other local processes can
connect to the port — is mitigated by the unguessable per-view tokens.
Rendering isolation
- Views render in Electron
<webview>s. Main enforces on every web-contents (will-attach-webview): no preload, no Node, context isolation + sandbox on. Permission requests (camera, mic, …) are denied. - View documents get a strict CSP:
default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'— no remote scripts, no inline or user-authored script execution (the only'self'script is the bundled htmx runtime;X-Content-Type-Options: nosniffstops mime confusion). - Main-process guards pin view webviews to the server origin: navigation off
the origin is blocked,
window.openis denied outright (not even handed to the OS browser — popups are an exfiltration channel). - htmx 2 defaults to
selfRequestsOnly; CSPconnect-src 'self'enforces the same at the platform level.
Adding an API endpoint safely
- Add the route in
server.ts:serveApiwith an explicit method match. - Gate it with
requireCap(ctx, '…')— add a new capability tomanifest.tsif none fits (never widen an existing one). - Resolve any path through
checkedPath(ctx, raw, 'read'|'write')— neverpath.joinuser input directly. - Build HTML through the
html.tshelpers (esceverything); JSON viasendJson. Errors viafail(ctx, status, code, safeMessage). - Respect the resource ceilings at the top of
server.ts; add one if the route can amplify work. - Add unit/integration assertions in
tools/htmx-views.test.mjs.
Threat model
| Threat | Mitigation |
|---|---|
| Arbitrary file read/write | Default-deny capabilities; per-view glob path policies; canonical resolution against the workspace root only |
Path traversal (.., encoded, absolute, drive letters, UNC, ~, null bytes) |
normalizeRel rejects them all before resolution; post-resolution containment check |
| Symlink/junction escape | Deepest existing ancestor is realpathed and must remain inside the canonical root; the workspace walker never follows symlinks |
| Overwriting concurrent edits | baseHash optimistic concurrency → 409; atomic temp-file+rename writes |
| Deleting files | Separate workspace.files.delete capability + write-path policy + approval dialog |
| Localhost service probing / SSRF from a view | CSP connect-src 'self'; htmx self-requests only; navigation + popups blocked in main |
| CSRF from a browser page against the loopback port | Token cookie lives only inside the view's isolated partition; SameSite=Strict; Origin header rejected when foreign |
| Token theft | HttpOnly cookie (invisible to page content); boot token is one-time; tokens never logged, never in view files, never persisted |
| Cross-view access | Unguessable session ids; per-view cookie comparison; /views/{sid}/* requires the cookie's session to match sid; per-view partitions |
| XSS / HTML injection in fragments | All server-rendered values pass through esc(); interpolation is escaped key-lookup only; no string-concatenated HTML |
Prototype pollution via {{ }} or JSON |
__proto__/constructor/prototype lookups blocked; own-property checks; strict schema validation with unknown-key rejection |
| Malicious CSS | Local files only, size-capped, @import/remote url() blocked; view renders in its own webview so it cannot restyle or impersonate Neuron UI |
| Unsafe URLs / unrestricted navigation | CSP + main-process will-navigate guard pinned to the view origin; window.open denied |
| Remote exfiltration | No network capability exists; networkPolicy accepts only "none"; CSP blocks all remote loads |
| Request floods / accidental htmx loops | Per-session token bucket (burst 30, ~15 rps) → 429; body ≤ 1 MB; file reads ≤ 2 MB; listings ≤ 500; walk budget 20 000; search capped |
| Hostile configuration files | variables.json/manifests validated with versioned schemas; invalid config fails closed with diagnostics |
| DNS rebinding | Exact Host-header check against the loopback origin |
| Manifest self-escalation | Manifests only request; every grant requires user approval bound to the manifest hash; approvals live in app settings, not the workspace |
| View impersonation after edit | Manifest hash change invalidates prior approval; source/manifest changes reload the tab with a fresh session |
Known limitations (deliberate, documented): no remote-content capability, no
commands.execute/actions bridge, search is a bounded workspace scan rather
than an index, and the request inspector devtool is not built — the standard
webview devtools plus server error codes cover debugging today.
Scripts and runtime assets
Every .html view may include inline CSS and JavaScript. Neuron always injects
the bundled htmx runtime and neuron.css; an author who does not use them needs
no opt-out mode. Authored JavaScript can call the same /api/v1 surface as
htmx, gated by the same manifest and approval.
The single CSP permits scripts with script-src 'self' 'unsafe-inline', while
connect-src stays 'self'. That distinction is the safety boundary: a view
can call Neuron's loopback API but cannot reach the internet. Remote images and
other loads remain blocked, popups are denied, and navigation is pinned to the
view origin. Every view also keeps its own sandboxed <webview> and per-session
partition, with no preload and no Node.
The bundled scripted example is examples/demo-repo/Custom dashboard.html.