Your first integration
Photo Delivery is the platform’s first integration and the shape most integrations will echo: read something the workspace already has (finished project photos), mirror it somewhere the customer chose (a Drive/Dropbox folder), and show sync state where the customer already looks (photo tiles, the project header, the dashboard).
This page walks its extension bundle end to end. Everything below is the real, shipping code path.
1. The bundle is one file
Section titled “1. The bundle is one file”A bundle imports @homestage/extension-kit (dependency-free by
design), registers a renderer per slot, then starts the runtime:
import { register, startExtensionRuntime, Badge, ProgressRing, Pill, Card, Row, Icon, Text,} from "@homestage/extension-kit";
// …register(...) calls, one per slot (below)…
startExtensionRuntime(self); // `self` = the Worker scopeThe host loads it as a Web Worker. There is no build coupling to the product: the kit emits plain JSON, so bundle however you like as long as the output is a single worker-safe file.
2. Photo-tile badges — the batched slot
Section titled “2. Photo-tile badges — the batched slot”photo-tile.badge is called once per photo grid, not per tile:
the context carries every visible assetId, and you return a map.
register("photo-tile.badge", async (ctx) => { const { statuses } = await ctx.api.get( `/v1/integrations/photo-delivery/asset-status` + `?workspaceId=${ctx.workspaceId}&projectId=${ctx.projectId}`, ); return Object.fromEntries( ctx.assetIds.map((id) => { const s = statuses[id]; if (s === "delivered") return [id, Badge({ shape: "dot", tone: "success", tooltip: "Delivered" })]; if (s === "error") return [id, Badge({ shape: "dot", tone: "danger", tooltip: "Sync error — check integration settings" })]; if (s === "pending") return [id, ProgressRing({ value: null, tone: "pending", tooltip: "Sync pending" })]; return [id, null]; // null = contribute nothing for this tile }), );});3. The project-header pill
Section titled “3. The project-header pill”One Pill summarizing the project. Returning null renders nothing —
do that when there is nothing worth saying.
register("project.header.status", async (ctx) => { const { statuses } = await ctx.api.get( `/v1/integrations/photo-delivery/asset-status` + `?workspaceId=${ctx.workspaceId}&projectId=${ctx.projectId}`, ); const all = Object.values(statuses); if (all.length === 0) return null;
const delivered = all.filter((s) => s === "delivered").length; const anyError = all.includes("error"); return Pill({ icon: "images", text: `Photo Delivery · ${delivered}/${all.length} synced`, tone: anyError ? "danger" : delivered === all.length ? "success" : "pending", });});4. The dashboard card
Section titled “4. The dashboard card”A Card composed from rows, icons, and text — no layout invention,
no colors of your own (tones map to theme tokens host-side).
register("dashboard.card", async (ctx) => { const s = await ctx.api.get( `/v1/integrations/photo-delivery/summary?workspaceId=${ctx.workspaceId}`, ); const children = [ Row({ gap: "sm" }, [ Icon({ name: "images" }), Text({ text: `${s.delivered} photo${s.delivered === 1 ? "" : "s"} delivered` }), ]), ]; if (s.pending > 0) children.push(Text({ text: `${s.pending} pending`, variant: "caption" })); children.push( s.lastError ? Pill({ text: "Sync error — check settings", tone: "danger" }) : Text({ text: s.lastSyncedAt ? `Last synced ${s.lastSyncedAt.slice(0, 10)}` : "Not synced yet", variant: "caption", }), ); return Card({ title: "Photo Delivery" }, children);});5. What the platform does around you
Section titled “5. What the platform does around you”You wrote three renderers. The platform supplies the rest:
- The store listing, install flow, and destination OAuth (the customer picks Google Drive or Dropbox; tokens never touch you).
- An automatic first sync the moment the destination connects, event-driven syncs as photos change, a reconcile schedule, and the customer’s “Sync now”.
- Activity feed rows for each delivering sync, attributed to your app with its catalog icon.
- Failure containment — if your renderer throws or stalls, its slot shows a small error glyph; the page never breaks.
Rules of thumb
Section titled “Rules of thumb”- Batch: one
api.getper render, never per item — the badge slot’s map shape exists precisely for this. - Return
nullwhen you have nothing to say; empty chrome is worse than none. - Stay inside your budget: renders are timeboxed at 1 s hard — fetch one summary, not a waterfall.
- Write for both themes:
ctx.api.theme.modeis"light" | "dark", but prefer tones over any mode-specific choice.