Skip to content

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.

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 scope

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

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
}),
);
});

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",
});
});

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);
});

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.
  1. Batch: one api.get per render, never per item — the badge slot’s map shape exists precisely for this.
  2. Return null when you have nothing to say; empty chrome is worse than none.
  3. Stay inside your budget: renders are timeboxed at 1 s hard — fetch one summary, not a waterfall.
  4. Write for both themes: ctx.api.theme.mode is "light" | "dark", but prefer tones over any mode-specific choice.