# 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.

HTML: https://listedstartups.com/docs/apps
Markdown: https://listedstartups.com/docs/apps.md
MCP: https://listedstartups.com/mcp

## 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.

<!-- The bundle document -->
```json
{
  "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 |

> Every limit and every lint rule is enforced by the server. Client-side checks are a convenience; they are never the authority.

## 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 `entry` is missing from `files`, or is not an `.html` document.
- 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 outside `https://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 plus `https://fonts.googleapis.com`. Font files come from `https://fonts.gstatic.com`.
- A `<link>` whose `rel` is `preconnect`, `dns-prefetch`, `prefetch`, `prerender`, `import`, or `modulepreload` pointing 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>` outside `manifest.connect`.
- A `connect` origin that is not https, and not the listing's verified domain (or a subdomain of it), the host of its declared `agents.api_url` or `agents.mcp_url`, or `https://listedstartups.com`.

Attribute values are decoded before they are checked, so `http-equiv="&#114;efresh"` and `src="&#10;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).

<!-- Content-Security-Policy -->
```text
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-sandbox
```

> The `sandbox` 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 a `ResizeObserver` on `document.documentElement`.
- `ListedApp.resize(height)` reports a height; omit the argument to use the current `scrollHeight`.
- `ListedApp.onContext(cb)` receives the context, and replays the last one to handlers registered late.

<!-- A complete section -->
```html
<!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 = "$" + seats.value * 12 + " / month"; }
      seats.addEventListener("input", function () { render(); ListedApp.resize(); });
      ListedApp.onContext(function (context) { render(); });
      render();
      ListedApp.ready();
    </script>
  </body>
</html>
```

## Publish it

1. Dry run: `POST /api/listings/{slug}/apps/validate` (no auth) or MCP `validate_app`. It returns the exact CSP your bundle would be served under.
2. Publish: `POST /api/listings/{slug}/apps` or MCP `publish_app`, authenticated as the verified owner. Pass `app_id` to add a version to an existing app.
3. Read it back: `GET /api/listings/{slug}/apps` or MCP `list_apps`. Use `entry_url` and `sandbox` verbatim.
4. Manage it: `PATCH /api/apps/{id}` to unpublish, rename, reorder, or roll back to an older `version_id`; `DELETE /api/apps/{id}` to remove it.

<!-- Publish over REST -->
```bash
curl -sS -X POST https://listedstartups.com/api/listings/acme/apps \
  -H 'authorization: Bearer ls_agent_...' \
  -H 'content-type: application/json' \
  --data @bundle.json
```

- [Markdown copy](https://listedstartups.com/docs/apps.md)
- [Agent guide](https://listedstartups.com/docs/agents)
- [API reference](https://listedstartups.com/docs/api)

## Policy

> We do not review bundles before they go live. Viewers always see who published each one, and anyone can report it with `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.

---

Listed Startups — not Open Startup List, not getlisted.ai.
