Stela
A self-hosted home for agent-built HTML artifacts: any MCP-capable agent publishes to a stable URL behind your own login — no accounts, no vendor — shared in three tiers and reviewed in place with version-pinned comments that loop back to the agent.
[latest2026-07-19]Stela 1.0 ships — MIT, public on GitHub, complete and stable.
Any agent can generate a self-contained app. Stela is where it goes to be shared — behind your own login, reviewed in place, and kept version by version.
The idea
Agents are good at generating self-contained things — a working prototype, a dashboard, a one-off internal tool — all in a single HTML file. Every vendor now hosts those in its own silo, shared on its own terms: usually a fully public link for the whole internet, or a seat inside the same walled garden. Neither one is "show this to my team, behind our own login" — and each vendor's gallery only holds its own agent's work.
Stela is that missing middle, and stays small being it: one Node process, one SQLite file, no accounts to manage, no cloud dependency. Any agent that speaks MCP can publish; the artifact lands at a stable URL behind whatever login you already run, shareable with exactly who you choose: just you, everyone behind the gate, or a named few. They open it in a browser — no AI seat required — comment on it in place, and the author revises it version by version. The shape of the idea is inspired by Claude's artifacts — it's a good idea, and good ideas are worth generalizing past any single vendor.
What it is
A small, self-hosted platform with three jobs: store artifacts immutably, serve them sandboxed, and close a review loop. An agent publishes over MCP — the open protocol, so it isn't tied to any one tool — and the HTML lands at a stable URL behind your own gate. A viewer opens it in a locked-down iframe that runs the artifact's own scripts but can't phone home. A reviewer pins comments to a spot on the exact version they're looking at, and those comments read back to the authoring agent — so it edits, re-publishes at the same URL, and the loop closes. Every version's HTML and every comment stay pinned to their snapshot, forever.
The sixty-second version
The whole project in five lines — each links to its long form.
- One home for agent-built artifacts — architecture: any MCP-capable agent publishes; the result is served at a stable URL.
- Behind your own login, not a vendor's — security: Stela has no account system; an identity-aware proxy in front decides who gets in, and each artifact is private, shared with everyone behind the gate, or restricted to named people.
- Immutable by version — architecture: one immutable snapshot per version; re-publishing appends, never overwrites; byte-identical HTML dedups. SQLite by default, Azure optional — one conformance suite over both drivers.
- Two MCP surfaces, one contract — architecture: a CLI stdio server and an HTTP remote endpoint share a single set of Zod tool contracts, so they can't drift.
- An artifact can't phone home — security: a strict no-network CSP and an opaque-origin sandbox; the one egress point is SSRF-hardened.
Architecture
Publish over MCP, store immutably by version, serve sandboxed, close the loop.
Stela has a deliberately small job and does it end to end: take a self-contained HTML artifact from any agent that speaks MCP, store it so it never rots, serve it so it can't escape, and route review back to the author. Four nouns carry the system.
- Artifact the unit
A self-contained HTML document — all CSS and JavaScript inline — addressed by a stable id and served at its own URL.
- Version immutable
A snapshot of an artifact's HTML. Re-publishing appends a new version; a version deep-link resolves any past snapshot forever.
- Anchor review
Where a comment is pinned — a text quote and selector that track the element itself, with normalized coordinates always underneath as the fallback.
- Visibility access
Private, everyone, or restricted — enforced server-side on every read, and owner-gated to change.
The lifecycle
One round trip, from an authoring agent out to a reviewer and back — the loop drawn at the top of this dossier. Say an agent builds a pricing calculator — a single HTML file with the form, the math, and the styling all inline. It calls publish_artifact; Stela writes the HTML as an immutable snapshot at {artifactId}/v1.html, records the metadata, and hands back a stable URL, /a/{artifactId}. The agent drops that link in a channel.
A colleague opens it — no agent, no AI seat, just a browser and the same login they use for everything else — and sees the calculator running live. They pin a comment on the discount field: "this should cap at 40%." The agent reads that back with read_comments, fixes the cap, and re-publishes. The link is unchanged; it now serves v2, while the v1 comment stays pinned to v1.
Nothing in that loop overwrites history. Re-publishing mints a new version beside the old one at the same URL; a reviewer who commented on v1 keeps commenting on v1 even after v3 ships.
Storage: immutable by version, pluggable by driver
Storage is a seam, not a commitment. Everything persistent goes through one Store interface, and the interface has two full implementations. SQLite is the default — Node's built-in node:sqlite, one file in the data directory, WAL and STRICT tables and real transactions, where backup means copying the file. Azure Tables + Blobs is the second driver, with ETag concurrency, for the deployment that already lives in that cloud. One conformance suite runs every contract test against both, so the abstraction is load-bearing, not decorative — a driver that drifts fails CI, not production.
Whatever the driver, the shape is the same: artifact HTML is one immutable snapshot per version, addressed as {artifactId}/v{n}.html, and small metadata records track the rest — the artifact (owner, title, visibility, current version, content hash, high-water mark), its versions, and its comments. A stable /a/{artifactId} URL serves the current version, and ?v=2 deep-links a specific one forever, returned with an immutable cache header because that pair never changes.
Two details earn their keep. Re-publishing byte-identical HTML is deduped — a sha256 compare against the stored content hash returns the current version instead of minting a no-op one, so an agent that re-runs and produces the same output doesn't litter the history. And a monotonic high-water mark means a version number is never reused, even if a version is later deleted — so ?v=2 can never silently resolve to different content than it did yesterday.
// @stela/shared — one schema, shared by the API, the UI, and both MCP surfaces
export const Visibility = z.enum(["private", "everyone", "restricted"]);
export const Anchor = z.object({
version: z.number().int().positive(), // pinned to a snapshot, never "latest"
xNorm: z.number().min(0).max(1), // the always-present coordinate fallback
yNorm: z.number().min(0).max(1),
viewKey: z.string().optional(), // page-scope in a multi-page artifact
dom: DomAnchor.optional(), // text quote + selector — tracks the element
// …scroll position, render width, human page label
});Sharing and the review loop
Sharing and review are the point — the parts that turn a one-off render into something a team can work on.
Visibility comes in three tiers. Private is owner-only. Everyone is anyone your gate signs in — the default for "here's the thing, team." Restricted is the owner plus named people, matched by user id or email, for the artifact that should reach two managers and no one else. All three are enforced server-side on every read; only the owner can publish new versions, change sharing, or delete. Revoke someone and the next read re-checks and turns up empty.
Comments are pinned to a version, not to "latest." A comment carries the version it was written against and stays attached to that snapshot after the author moves on. Its pin is a hybrid anchor: a text quote and CSS selector track the element itself, so the pin follows the content as the artifact scrolls and reflows, and normalized coordinates sit underneath as the always-present fallback — a DOM-anchored pin is never worse than a plain one. On a multi-page artifact the pin also carries a page key, so a comment on screen three doesn't show up on screen one. Threaded, resolvable, with a who-and-when audit trail — and a comment doesn't need a pin at all: one without an anchor is general discussion, threading and resolving the same way.
The loop closes over MCP. read_comments pulls the feedback back to the authoring agent; it edits, re-publishes at the same URL, and the old version and its comments stay pinned. Publish → review in place → read back → revise — that round trip is the whole product.
Two MCP surfaces, one contract
Agents reach Stela two ways: a CLI stdio server (@stela/mcp) for an agent on a developer's machine — it publishes by file path and auto-versions repeat publishes of the same file — and an HTTP remote endpoint (/mcp) inside the app for hosted connectors: claude.ai, ChatGPT, Grok, Copilot Studio, anything that takes a remote MCP server. For that second door, Stela is its own OAuth 2.1 authorization server — PKCE, dynamic client registration against an allowlist of client hosts — so a connector pairs itself without a hand-issued key.
Both surfaces register the same tool names and validate against the same Zod contracts from @stela/shared, with parity tests asserting the two can't drift. Nine tools cover it — among them publish_artifact (which returns the stable URL and the new version number, and takes a validate flag to dry-run the self-containment check before committing), read_comments, set_sharing, and get_design_guide, which hands the agent the rules for authoring a CSP-clean, self-contained artifact before it writes one, so the first publish renders instead of coming back blank. That single-source discipline is a theme: the tool contracts, the schemas, and the security policy each live in exactly one place and get imported everywhere they're needed.
Shipping it
The whole thing is one Node process (Node ≥ 22.5 — new enough for node:sqlite), built from a pnpm monorepo: @stela/shared holds the contracts, @stela/app is the deployable — UI, REST API, remote MCP endpoint, and the OAuth server in one SvelteKit 2 app on Svelte 5 runes — and @stela/mcp is the CLI server. Deployment is a Dockerfile plus a compose skeleton that deliberately doesn't publish Stela's port — traffic must come through your identity proxy — with a demo profile that stamps a static identity for kicking the tires. CI gates every push on a Svelte-check, a strict type-check, and the full test suite, storage conformance legs included, before the build. MIT-licensed and public on GitHub, shipped as 1.0: complete and stable, shared as-is.
Security
A no-network sandbox, SSRF-hardened ingest, your own gate in front — and no accounts to steal.
Stela runs arbitrary, model-generated HTML and shows it to other people. That makes containment the product: an artifact has to be powerful enough to be useful — it runs its own JavaScript — and boxed in tightly enough that it can't reach anything it shouldn't.
The artifact sandbox
Every artifact is served under one strict Content-Security-Policy:
default-src 'none';
script-src 'unsafe-inline' 'unsafe-eval' blob:;
style-src 'unsafe-inline';
img-src data: blob:;
font-src data: blob:;
media-src data: blob:;
frame-ancestors 'self';
base-uri 'none';
form-action 'none';
sandbox allow-scriptsInline scripts and styles run, because artifacts are self-contained by design — an artifact can chart data, run a simulation, take input, animate. But there is no connect-src, no remote script source, and no external stylesheet, so the moment it tries to fetch an API, pull a script off a CDN, or open a websocket, the browser refuses. Images, fonts, and media are confined to data: and blob:. And the viewer iframe carries sandbox="allow-scripts", which puts the artifact at an opaque origin: reach for localStorage, a cookie, or the parent page and the call throws.
The effect is that the threat model collapses to almost nothing. A malicious or buggy artifact can misbehave inside its own box — and that's the whole blast radius. It can't exfiltrate what it renders, can't read another artifact's storage, can't be re-framed on a phishing page (frame-ancestors 'self'), and can't post a form anywhere (form-action 'none').
That policy is defined once, in shared code, and imported by the route that sets the header, the design guide that authors write against, and the validate dry-run agents can call before publishing — so the classic "publishes clean, renders blank" drift between what's allowed and what's documented simply can't happen.
The one door out
Stela makes exactly one kind of outbound request: when a hosted connector can't inline the HTML, publish_artifact accepts a short-lived fileUrl that Stela fetches — from a host on a configured allowlist. That single egress path is hardened against SSRF as if it were hostile input — because it is.
It's HTTPS-only, port 443, against a host allowlist. Before it connects it pins DNS: it resolves the hostname, rejects the request if any resolved address is private, loopback, link-local, or carrier-grade-NAT, then pins the socket to the validated IP — which defeats DNS-rebinding, where a name resolves public on the first lookup and internal on the second. No redirects are followed, a hard byte cap and timeout bound the fetch — the same 10 MB limit the publish schema puts on inline HTML — and error messages never echo the resolved IP back to the caller, so the endpoint can't be turned into a network probe.
Identity and authorization
Stela has no account system, on purpose — identity is delegated to whatever gate you put in front of it. An identity-aware proxy — Cloudflare Access, oauth2-proxy, Authelia, Azure Easy Auth as a preset — signs people in and injects a trusted header; if a request arrives with that header, the identity is a user, and first sight is enrollment. The gate decides who can get in; the header decides who they are; Stela does only the authorization: who owns what, who a thing is shared with. In production it refuses to boot until an auth mode is configured, and logs exactly which headers it trusts — fail loud beats fail open.
The three visibility tiers are checked server-side on every read; creating versions, changing sharing, and deleting are owner-only, keyed off the stable id string the proxy provides — never a display name. Revoking someone's access re-checks at read time, so a removed collaborator's next read turns up empty rather than leaking a title or a snippet after the fact. The API itself is token-only: an admin key for CI, or per-user bearer tokens minted through the OAuth and CLI-pairing consent flows — issued only to someone who first got through the gate, and stored as SHA-256 hashes. Cookie-authenticated mutations carry a hand-placed same-origin guard on every mutating route, with a coverage test that fails CI if a future route forgets one; publishing is rate-limited per user (60 a minute, 100 a day) and per IP.