diff --git a/typescript/packages/common-charm/src/index.ts b/typescript/packages/common-charm/src/index.ts
index aa35b48a8..0c9337cfc 100644
--- a/typescript/packages/common-charm/src/index.ts
+++ b/typescript/packages/common-charm/src/index.ts
@@ -1 +1,11 @@
-export { runPersistent, type Charm, addCharms, removeCharm, storage, syncCharm, charms } from "./charm.js";
+export {
+ runPersistent,
+ type Charm,
+ addCharms,
+ removeCharm,
+ storage,
+ syncCharm,
+ charms,
+} from "./charm.js";
+export { syncRecipe, saveRecipe } from "./syncRecipe.js";
+export { buildRecipe, tsToExports } from "./localBuild.js";
diff --git a/typescript/packages/lookslike-high-level/src/localBuild.ts b/typescript/packages/common-charm/src/localBuild.ts
similarity index 100%
rename from typescript/packages/lookslike-high-level/src/localBuild.ts
rename to typescript/packages/common-charm/src/localBuild.ts
diff --git a/typescript/packages/common-charm/src/syncRecipe.ts b/typescript/packages/common-charm/src/syncRecipe.ts
new file mode 100644
index 000000000..b2b661f59
--- /dev/null
+++ b/typescript/packages/common-charm/src/syncRecipe.ts
@@ -0,0 +1,83 @@
+import {
+ addRecipe,
+ getRecipe,
+ getRecipeParents,
+ getRecipeSrc,
+ getRecipeSpec,
+ getRecipeName,
+} from "@commontools/runner";
+import { buildRecipe } from "./localBuild.js";
+
+export const BLOBBY_SERVER_URL =
+ typeof window !== "undefined"
+ ? window.location.protocol + "//" + window.location.host + "/api/storage/blobby"
+ : "//api/storage/blobby";
+
+const recipesKnownToStorage = new Set();
+
+export async function syncRecipe(id: string) {
+ if (getRecipe(id)) {
+ if (recipesKnownToStorage.has(id)) return;
+ const src = getRecipeSrc(id);
+ const spec = getRecipeSpec(id);
+ const parents = getRecipeParents(id);
+ if (src) saveRecipe(id, src, spec, parents);
+ return;
+ }
+
+ const response = await fetch(`${BLOBBY_SERVER_URL}/spell-${id}`);
+ let src: string;
+ let spec: string;
+ let parents: string[];
+ try {
+ const resp = await response.json();
+ src = resp.src;
+ spec = resp.spec;
+ parents = resp.parents || [];
+ } catch (e) {
+ src = await response.text();
+ spec = "";
+ parents = [];
+ }
+
+ const { recipe, errors } = await buildRecipe(src);
+ if (errors) throw new Error(errors);
+
+ const recipeId = addRecipe(recipe!, src, spec, parents);
+ if (id !== recipeId) {
+ throw new Error(`Recipe ID mismatch: ${id} !== ${recipeId}`);
+ }
+ recipesKnownToStorage.add(recipeId);
+}
+
+export async function saveRecipe(
+ id: string,
+ src: string,
+ spec?: string,
+ parents?: string[],
+ spellbookTitle?: string,
+ spellbookTags?: string[],
+) {
+ // If the recipe is already known to storage, we don't need to save it again,
+ // unless the user is trying to attach a spellbook title or tags.
+ if (recipesKnownToStorage.has(id) && !spellbookTitle) return;
+ recipesKnownToStorage.add(id);
+
+ console.log("Saving recipe", id);
+ const response = await fetch(`${BLOBBY_SERVER_URL}/spell-${id}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ src,
+ recipe: JSON.parse(JSON.stringify(getRecipe(id))),
+ spec,
+ parents,
+ recipeName: getRecipeName(id),
+ spellbookTitle,
+ spellbookTags,
+ }),
+ });
+ return response.ok;
+}
diff --git a/typescript/packages/lookslike-high-level/test/storage.test.ts b/typescript/packages/common-charm/test/storage.test.ts
similarity index 100%
rename from typescript/packages/lookslike-high-level/test/storage.test.ts
rename to typescript/packages/common-charm/test/storage.test.ts
diff --git a/typescript/packages/common-os-ui/src/components/os-tab-bar.ts b/typescript/packages/common-os-ui/src/components/os-tab-bar.ts
index dd52df6b7..7297198a7 100644
--- a/typescript/packages/common-os-ui/src/components/os-tab-bar.ts
+++ b/typescript/packages/common-os-ui/src/components/os-tab-bar.ts
@@ -115,7 +115,7 @@ export class OsTabBar extends LitElement {
return html`
- ${this.items.map(
+ ${this.items?.map(
(item) => html`