diff --git a/docs/embed-sdk.md b/docs/embed-sdk.md
new file mode 100644
index 0000000..fb37891
--- /dev/null
+++ b/docs/embed-sdk.md
@@ -0,0 +1,155 @@
+# atmo.social Embed SDK
+
+Build interactive embeds for your atproto app that render natively inside atmo.social.
+
+## How it works
+
+When a user posts a link to your app on Bluesky (e.g. `https://yourapp.com/p/actor/item/abc`), atmo.social renders it as an iframe embed instead of a generic link card. Your embed page receives the host app's theme and the logged-in user's DID, and can create/delete AT Protocol records on their behalf.
+
+## Setup
+
+### 1. Register your app
+
+Your app needs to be added to the embed registry in atmo.social. Contact us or submit a PR adding your app to `src/lib/components/embed/special/embed-registry.ts`:
+
+```ts
+{
+ domain: 'yourapp.com',
+ match: (href) => /^https?:\/\/(www\.)?yourapp\.com\/p\//.test(href),
+ embedUrl: (href) => {
+ const url = new URL(href);
+ url.pathname = url.pathname.replace(/\/$/, '') + '/embed';
+ url.search = '';
+ return url.toString();
+ },
+ allowedCollections: ['your.lexicon.collection'],
+ aspectRatio: { width: 2, height: 1 }
+}
+```
+
+- **`domain`**: Your domain, used to validate `postMessage` origins
+- **`match`**: Function that returns `true` for URLs that should be embedded
+- **`embedUrl`**: Transforms the original URL into your embed endpoint URL
+- **`allowedCollections`**: AT Protocol collections your embed can write to (scoped permissions)
+- **`aspectRatio`**: Aspect ratio (`width`/`height`) — embed fills full width and maintains this ratio
+
+### 2. Create your embed page
+
+For a URL like `https://yourapp.com/p/actor/item/abc`, serve an embed page at `https://yourapp.com/p/actor/item/abc/embed`.
+
+Include the SDK:
+
+```html
+
+```
+
+### 3. Read parameters
+
+The host app passes theme and auth info as URL search params:
+
+```js
+const { base, accent, dark, did } = AtmoEmbed.getParams();
+```
+
+| Param | Type | Description |
+|-------|------|-------------|
+| `base` | `string` | Base color name (e.g. `mauve`, `slate`, `zinc`) |
+| `accent` | `string` | Accent color name (e.g. `fuchsia`, `blue`, `rose`) |
+| `dark` | `boolean` | Whether dark mode is active |
+| `did` | `string \| null` | The logged-in user's DID, or `null` if not logged in |
+
+Use these to match your embed's theme to the host app. If you use [@foxui/core](https://flo-bit.dev/ui-kit) or Tailwind with the same color system, just add the `base`, `accent`, and optionally `dark` classes to your `` element.
+
+### 4. Create records
+
+Create an AT Protocol record on behalf of the logged-in user:
+
+```js
+const result = await AtmoEmbed.createRecord({
+ collection: 'your.lexicon.collection',
+ rkey: 'optional-record-key', // omitted = auto-generated
+ record: {
+ $type: 'your.lexicon.collection',
+ // ... your record fields
+ createdAt: new Date().toISOString()
+ }
+});
+
+console.log(result.uri); // at://did:plc:.../your.lexicon.collection/...
+```
+
+### 5. Delete records
+
+```js
+await AtmoEmbed.deleteRecord({
+ collection: 'your.lexicon.collection',
+ rkey: 'record-key-to-delete'
+});
+```
+
+## Security
+
+- Your embed runs in a sandboxed iframe (`allow-scripts allow-same-origin`)
+- `postMessage` origins are validated against your registered domain
+- Record operations are scoped to your `allowedCollections` — attempts to write to other collections are rejected
+- The user's auth session is never exposed to the iframe — all writes go through the host app's server
+
+## Example
+
+Here's a minimal embed page for an RSVP button:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Full URL example
+
+Original link posted on Bluesky:
+```
+https://atmo.rsvp/p/did:plc:lysqukqdu6hsrhet5v2brjgo/e/3mhqxpdvmw25h
+```
+
+Embed URL loaded in iframe:
+```
+https://atmo.rsvp/embed/p/did:plc:lysqukqdu6hsrhet5v2brjgo/e/3mhqxpdvmw25h?base=mauve&accent=fuchsia&dark=1&did=did:plc:abc123
+```
diff --git a/src/lib/components/embed/Embed.svelte b/src/lib/components/embed/Embed.svelte
index 31e7dfa..300efca 100644
--- a/src/lib/components/embed/Embed.svelte
+++ b/src/lib/components/embed/Embed.svelte
@@ -21,7 +21,7 @@
{#if embed.type === 'images'}
{:else if embed.type === 'external' && embed.external && specialEmbed}
-
+
{:else if embed.type === 'external' && embed.external}
{:else if embed.type === 'video' && embed.video}
diff --git a/src/lib/components/embed/special/AppEmbed.svelte b/src/lib/components/embed/special/AppEmbed.svelte
new file mode 100644
index 0000000..3346590
--- /dev/null
+++ b/src/lib/components/embed/special/AppEmbed.svelte
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/lib/components/embed/special/IframeEmbed.svelte b/src/lib/components/embed/special/IframeEmbed.svelte
new file mode 100644
index 0000000..2ee7485
--- /dev/null
+++ b/src/lib/components/embed/special/IframeEmbed.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+
diff --git a/src/lib/components/embed/special/embed-registry.ts b/src/lib/components/embed/special/embed-registry.ts
new file mode 100644
index 0000000..5e32e27
--- /dev/null
+++ b/src/lib/components/embed/special/embed-registry.ts
@@ -0,0 +1,47 @@
+/**
+ * Registry of third-party atproto apps that support embedding.
+ *
+ * Each entry defines:
+ * - match: which external links should use the embed
+ * - embedUrl: transforms the original URL into the embed URL
+ * - allowedCollections: which AT Protocol collections the embed can create/delete records in
+ * - dimensions: fixed aspect ratio to prevent layout shift
+ */
+
+export interface EmbedAppConfig {
+ /** Domain identifier for postMessage origin validation */
+ domain: string;
+ /** Match function: returns true if this external link should be embedded */
+ match: (href: string) => boolean;
+ /** Transform the original URL into the embed URL */
+ embedUrl: (href: string) => string;
+ /** Collections the embed is allowed to create/delete records in */
+ allowedCollections: string[];
+ /** Aspect ratio for the embed to prevent layout shift */
+ aspectRatio: { width: number; height: number };
+}
+
+export const embedApps: EmbedAppConfig[] = [
+ {
+ domain: 'atmo.rsvp',
+ match: (href) => /^https?:\/\/(www\.)?atmo\.rsvp\/p\/[^/]+\/e\/[^/]+\/?$/.test(href),
+ embedUrl: (href) => {
+ const url = new URL(href);
+ // /p/actor/e/rkey -> /embed/p/actor/e/rkey
+ url.pathname = '/embed' + url.pathname.replace(/\/$/, '');
+ url.search = '';
+ return url.toString();
+ },
+ allowedCollections: [
+ 'community.lexicon.calendar.rsvp'
+ ],
+ aspectRatio: { width: 2, height: 1 }
+ }
+];
+
+/**
+ * Find an embed app config for a given URL.
+ */
+export function findEmbedApp(href: string): EmbedAppConfig | undefined {
+ return embedApps.find((app) => app.match(href));
+}
diff --git a/src/lib/components/embed/special/index.ts b/src/lib/components/embed/special/index.ts
index ed618cf..9e3777c 100644
--- a/src/lib/components/embed/special/index.ts
+++ b/src/lib/components/embed/special/index.ts
@@ -2,11 +2,14 @@ import type { Component } from 'svelte';
import type { EmbedExternalData } from '../types';
import YouTubeEmbed from './YouTubeEmbed.svelte';
import TenorEmbed from './TenorEmbed.svelte';
-import AtmoRsvpEmbed from './AtmoRsvpEmbed.svelte';
+import AppEmbed from './AppEmbed.svelte';
+import { findEmbedApp, type EmbedAppConfig } from './embed-registry';
export type SpecialEmbed = {
match: (data: EmbedExternalData) => boolean;
- component: Component<{ data: EmbedExternalData }>;
+ component: Component<{ data: EmbedExternalData; config?: EmbedAppConfig }>;
+ /** If set, this is an app embed with iframe + postMessage support */
+ appConfig?: EmbedAppConfig;
};
export const specialEmbeds: SpecialEmbed[] = [
@@ -23,14 +26,24 @@ export const specialEmbeds: SpecialEmbed[] = [
},
component: TenorEmbed
},
- {
- match: (data) => {
- return /^https?:\/\/(www\.)?atmo\.rsvp\/p\//.test(data.external.href);
- },
- component: AtmoRsvpEmbed
- }
];
export function findSpecialEmbed(data: EmbedExternalData): SpecialEmbed | undefined {
- return specialEmbeds.find((e) => e.match(data));
+ // Check hardcoded special embeds first
+ const special = specialEmbeds.find((e) => e.match(data));
+ if (special) return special;
+
+ // Check app embed registry
+ const appConfig = findEmbedApp(data.external.href);
+ if (appConfig) {
+ return {
+ match: () => true,
+ component: AppEmbed,
+ appConfig
+ };
+ }
+
+ return undefined;
}
+
+export { findEmbedApp, type EmbedAppConfig } from './embed-registry';
diff --git a/static/embed-sdk.js b/static/embed-sdk.js
new file mode 100644
index 0000000..d7f303d
--- /dev/null
+++ b/static/embed-sdk.js
@@ -0,0 +1,103 @@
+/**
+ * atmo.social Embed SDK
+ *
+ * Include this script in your embed page to communicate with the host app.
+ *
+ * Usage:
+ *