# Embedding in another site

By default no site may frame a Homespun app in an `<iframe>`. A manifest can name the exact third-party origins allowed to, and the app auto-sizes the iframe to fit its own content once framed. Use this for a form or a small tool that lives on someone’s marketing site or blog, backed by a Homespun app doing the work.

## Who can be embedded

[Section titled “Who can be embedded”](#who-can-be-embedded)

**Only a `public` app whose manifest needs no visitor identity.** Visitor identity means a collection admits anonymous create (`write: ["anyone"]`) **and** scopes `update` or `delete` to a row subject such as `creator` or `own`, which needs a cookie to recognize the same visitor across visits. Every usercontent cookie is dropped inside a cross-site frame, so an app that needs one would not actually work embedded, even if it rendered. A plain public form, `write: ["anyone"]` with no row-scoped `update`/`delete`, is the shape that qualifies.

This is checked **on every request**, not once at deploy: flip the app to private, or grow a collection that needs visitor identity, and framing stops on the very next response. There is nothing to revoke.

## Declare the origin

[Section titled “Declare the origin”](#declare-the-origin)

Add the embedder’s exact origin to `x-homespun-manifest.embedAncestors`:

```
"x-homespun-manifest": {
  "embedAncestors": ["https://your-partner-site.example"]
}
```

Exact origins only, no wildcard, ever, with one exception for local development (`http://localhost:PORT` and `http://127.0.0.1:PORT`). See the [manifest reference](/agents/manifest-reference/)’s `embedAncestors` entry for the full grammar. Declaring the field on an app that is not public, or that needs visitor identity, does nothing: it is left declared but inert rather than rejected, so check the app’s own collections before assuming an embed will render.

## The snippet

[Section titled “The snippet”](#the-snippet)

Once at least one origin is declared, the app’s owner shell shows a ready-to-copy snippet on the app’s Settings tab, filled in with the app’s real URL. Hand that to whoever is embedding the app rather than retyping it by hand.

The snippet is an `<iframe>` plus a small `<script>` that listens for a resize message and grows the iframe to match the app’s rendered height, so a fixed-height iframe never scrolls internally or leaves dead space below a short form:

```
<iframe
  id="homespun-embed-your-app"
  src="https://your-app.example-usercontent-domain.com/"
  style="width:100%; border:0;"
  height="600"
  title="Your app"
></iframe>
<script>
(function () {
  var frame = document.getElementById("homespun-embed-your-app");
  var expectedOrigin = "https://your-app.example-usercontent-domain.com";
  window.addEventListener("message", function (event) {
    if (event.origin !== expectedOrigin) return;
    if (!frame || event.source !== frame.contentWindow) return;
    var data = event.data;
    if (!data || data.__homespun !== 1 || data.v !== 2 || data.kind !== "resize") return;
    if (typeof data.height !== "number" || data.height <= 0) return;
    frame.style.height = data.height + "px";
  });
})();
</script>
```

The two checks in that listener are load-bearing, not decoration. `event.origin` must equal the app’s own origin exactly, and `event.source` must be that specific iframe’s own `contentWindow`, not any other frame or script on the embedder’s page. A listener that resizes on any message, from any window, is a defect: any page on the internet can post a message shaped like a resize envelope, and without both checks it would drive the iframe to an arbitrary height on command. Do not simplify either check away when adapting the snippet.

There is nothing to configure on the app side beyond declaring the origin: every app already loads the SDK, and the SDK already posts its rendered height to its parent whenever it detects it is framed. There is no manifest key for auto-resize and nothing to opt into.

### The hosted convenience script

[Section titled “The hosted convenience script”](#the-hosted-convenience-script)

For a simpler paste at the cost of one more request to homespun on every page load, use the hosted `embed.js` instead of hand-maintaining the snippet:

```
<script src="https://<main-domain>/embed.js"
        data-app="https://your-app.example-usercontent-domain.com/"
        data-height="600"
        data-title="Your app"></script>
```

It creates the iframe itself and applies the same two checks as the inline snippet. This is the convenience path; the inline snippet above is the documented default, and it keeps working even if homespun is briefly unreachable after the embedder’s page has loaded, since no homespun-hosted script is involved.

## Abuse guidance for an anonymous-write embed

[Section titled “Abuse guidance for an anonymous-write embed”](#abuse-guidance-for-an-anonymous-write-embed)

**A public, anonymous-write form is a spam target, embedded or not, and embedding it on someone else’s site makes it easier to find.** If the embedded app’s collection admits anonymous writes (`write: ["anyone"]`), declare both of these on that collection:

```
"antiAbuse": "turnstile",
"anonWriteBudget": { "perIpPerDay": 20, "perAppPerDay": 500 }
```

* **`antiAbuse: "turnstile"`** requires a Cloudflare Turnstile token on every anonymous write. This only works when the relay operator has Turnstile configured; if a deploy is refused naming `antiAbuse`, drop the key or ask the operator to enable it. Turnstile **fails open**: if Cloudflare cannot be reached in time, the write proceeds rather than being blocked, so a Cloudflare outage never takes an embedded form down.
* **`anonWriteBudget`** caps how many rows anonymous visitors may add per day, per IP and across the whole app. This has no configuration prerequisite and is what keeps holding even during a Turnstile outage, so declare it regardless of whether Turnstile is available on the relay you are deploying to.

Neither of these gates *reading* an embedded app’s data, only anonymous *writes*: they exist because framing a form makes it reachable from wherever the embedder’s own page gets traffic, which is usually the point, and also usually more traffic than a spammer scanning for open forms would otherwise find on their own.
