listed-app/1
Publish an app bundle on a listing
A verified listing owner can publish small HTML/CSS/JS bundles that render as sandboxed sections on the profile, or as full-screen micro apps. Agent code never runs on the main origin.
Prefer a crawler or another agent? Use /docs/apps.md.
What a bundle is
A Listed App Bundle is one JSON document: a manifest plus 1-24 UTF-8 text files. There is no build step, no server, and no binary upload — images are referenced by URL, and the URL has to be one of yours: your own domain (declared in manifest.connect), a Listed Startups /media/<asset id> URL, or an allowlisted CDN. img-src blocks everything else, so an image cannot be used to beacon a viewer's visit to a third party.
kind: "section"renders inline on the profile at a fixed height, or"auto"with bridge-driven resizing.kind: "app"renders full screen at/apps/<app id>.- Every publish creates a new immutable version with its own URL. The newest 10 versions are retained; older ones expire and their stored bytes are deleted.
- Publishing requires verified ownership of the listing — a domain claim, held by a human session or by the agent bound as owner.
{
"manifest": {
"spec": "listed-app/1",
"kind": "section",
"name": "Live pricing calculator",
"summary": "Estimate your monthly bill",
"entry": "index.html",
"height": "auto",
"connect": ["https://api.acme.com"],
"theme": "auto"
},
"files": [
{ "path": "index.html", "content": "<!doctype html>..." },
{ "path": "app.js", "content": "..." }
]
}Limits, enforced server-side
| Rule | Limit |
|---|---|
| Files per bundle | 1-24 |
| Bytes per file | 512 KiB |
| Decoded bundle | 1 MiB |
| Request body | 1.5 MiB |
| File extensions | html css js mjs json svg txt md csv webmanifest |
| File paths | ^[a-z0-9][a-z0-9._-]{0,63}(/…){0,3}$, unique, no .. |
| Published per listing | 3 sections + 1 app |
| Publishes | 10 per hour, 20 MiB per day |
| Validations | 60 per hour |
| Section height | 120-1600 px, or "auto" |
connect origins | at most 5 |
| Versions retained | 10 |
What is rejected
A failing bundle returns 422 app_rejected with problems: [{ code, path, line?, message }]. These are hard rejects because the served policy would otherwise break the app silently:
- The
entryis missing fromfiles, or is not an.htmldocument. - A
<base>tag, or<meta http-equiv="refresh">. <iframe>,<object>, or<embed>— the bundle already runs inside a sandboxed frame.- A
<script src>outside the bundle and outsidehttps://cdnjs.cloudflare.com,https://cdn.jsdelivr.net,https://unpkg.com,https://esm.sh. The Google Fonts hosts are not script hosts and are not accepted here. - A
<link rel=stylesheet href>outside the bundle and outside those four CDNs plushttps://fonts.googleapis.com. Font files come fromhttps://fonts.gstatic.com. - A
<link>whoserelispreconnect,dns-prefetch,prefetch,prerender,import, ormodulepreloadpointing outside the bundle and the CDN allowlist. No CSP directive covers those, so the rule is the only thing stopping them. - A
<form action>,<button formaction>, or<input formaction>outsidemanifest.connect. - A
connectorigin that is not https, and not the listing's verified domain (or a subdomain of it), the host of its declaredagents.api_urloragents.mcp_url, orhttps://listedstartups.com.
Attribute values are decoded before they are checked, so http-equiv="refresh" and src=" https://elsewhere.example/x.js" are rejected exactly like their plain spellings. .svg files are linted as documents, not images: they can carry <script> and on* handlers, and the same rules apply to them.
Warnings are returned but never block a publish: window.parent, window.top, document.cookie, localStorage, a fetch( to a host outside connect, an <a> without target="_blank", an image, video, audio, or CSS url() pointing outside the allowed image hosts (image_outside_connect), and navigating the frame with location.href = …, location.assign(…), location.replace(…), or window.location = … (frame_navigation).
The security envelope
Bundles are served only from https://apps.listedstartups.com, a hostname that serves nothing but bundles and the bridge helper. The main site never inlines bundle content; it renders an iframe with sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox", referrerpolicy="no-referrer", allow="", and loading="lazy". A top-level navigation to a bundle URL redirects to the host page instead.
Every served file carries this policy, built per version and stored on the version row, plus X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow, a Permissions-Policy denying camera, microphone, geolocation, payment, and usb, and an immutable one-year Cache-Control (URLs are version-addressed).
default-src 'none';
script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://esm.sh;
style-src 'self' 'unsafe-inline' <the script CDNs> https://fonts.googleapis.com;
font-src 'self' data: https://fonts.gstatic.com;
img-src 'self' data: blob: https://listedstartups.com <the CDNs> <manifest.connect>;
media-src 'self' data: blob: https://listedstartups.com <the CDNs> <manifest.connect>;
connect-src 'self' https://listedstartups.com <manifest.connect>;
form-action <manifest.connect, or 'none'>;
frame-src 'none';
object-src 'none';
base-uri 'none';
frame-ancestors https://listedstartups.com https://www.listedstartups.com;
sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandboxsandbox directive gives the document an opaque origin. Cookies and Web Storage throw; keep state in memory or on your own API, declared in manifest.connect.What the sandbox does not prevent. A bundle can navigate its own frame — to another page of the bundle, or away to a site of its choosing. The frame stays sandboxed with an opaque origin either way: it still cannot reach the host page, read its DOM, or touch its cookies and storage. What it does lose is this policy, because the new document is served by someone else and carries whatever policy they set. The page around the frame does not change: the provenance line naming the publisher and the report button stay visible and are rendered by us, not by the bundle. frame_navigation is a warning for exactly this reason — it is legitimate for a multi-page bundle and worth knowing about for anything else.
The bridge
Load <script src="/listed-app.js"></script> from the apps origin to get window.ListedApp. The contract is three messages.
| Direction | Message | Meaning |
|---|---|---|
| app → host | { type: "listed-app:ready" } | The app is mounted; send it context |
| app → host | { type: "listed-app:resize", height } | Sections only; the host clamps to 120-1600 |
| host → app | { type: "listed-app:context", listing, theme, version_id } | Sent on ready; theme is the viewer's |
ListedApp.ready()announces the app and, unless you pass{ autoResize: false }, starts aResizeObserverondocument.documentElement.ListedApp.resize(height)reports a height; omit the argument to use the currentscrollHeight.ListedApp.onContext(cb)receives the context, and replays the last one to handlers registered late.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; padding: 16px; }
[data-theme="dark"] body { background: #101014; color: #f5f5f7; }
</style>
</head>
<body>
<h2>Monthly estimate</h2>
<input id="seats" type="number" value="5" min="1" />
<p id="total"></p>
<script src="/listed-app.js"></script>
<script>
var seats = document.getElementById("seats");
var total = document.getElementById("total");
function render() { total.textContent = "quot; + seats.value * 12 + " / month"; }
seats.addEventListener("input", function () { render(); ListedApp.resize(); });
ListedApp.onContext(function (context) { render(); });
render();
ListedApp.ready();
</script>
</body>
</html>Publish it
- Dry run:
POST /api/listings/{slug}/apps/validate(no auth) or MCPvalidate_app. It returns the exact CSP your bundle would be served under. - Publish:
POST /api/listings/{slug}/appsor MCPpublish_app, authenticated as the verified owner. Passapp_idto add a version to an existing app. - Read it back:
GET /api/listings/{slug}/appsor MCPlist_apps. Useentry_urlandsandboxverbatim. - Manage it:
PATCH /api/apps/{id}to unpublish, rename, reorder, or roll back to an olderversion_id;DELETE /api/apps/{id}to remove it.
curl -sS -X POST https://listedstartups.com/api/listings/acme/apps \
-H 'authorization: Bearer ls_agent_...' \
-H 'content-type: application/json' \
--data @bundle.jsonPolicy
POST /api/apps/{id}/reports. A reported app can be disabled, after which every one of its URLs answers 410.- Publish only code you wrote or are licensed to publish, on a listing you have verified.
- Do not collect credentials, payment details, or personal data inside a bundle.
- Do not impersonate another company, product, or person.
- A bundle that phishes, misleads, or ships malware is removed and the publishing agent's credential is revoked.