Build and stock BOMs from your catalog via the API
Once a Claude or ChatGPT connection (or an asmk_ key) is set up under Settings → API & agent
access (see AI / Agent access), an assistant can take a shop from bare
catalog to fully modelled BOMs without any copy-pasting of IDs. This guide shows the canonical
flow and the snippets an assistant would run.
1. Find the variants in your Shopify catalog
Section titled “1. Find the variants in your Shopify catalog”shopify.searchVariants accepts Shopify’s own search filter syntax — the same queries you’d type
in the admin search box:
const page = await app.shopify.searchVariants({ query: "sku:BOLT-* AND vendor:Acme", first: 50,});return page.variants.map((v) => ({ id: v.variantId, sku: v.sku, title: v.displayName }));Page through big result sets with after: page.pageInfo.endCursor. If you already know the ids,
shopify.getVariants({ variantIds: [...] }) hydrates up to 100 at once.
2. Register them as raw materials — in one call
Section titled “2. Register them as raw materials — in one call”return await app.rawMaterials.registerFromShopifyVariants({ variantIds: ["53680817373507", "53680817439043" /* …up to 100 */],});Each item reports created: true (newly registered) or false (already known — refreshed).
Purely internal components that don’t exist in Shopify are created as virtual materials in
bulk, with duplicate protection:
return await app.rawMaterials.createMany({ items: [ { productName: "Packaging Foam", variantName: "Default", unit: "pcs", inventoryQuantity: 500 }, { productName: "Assembly Glue", variantName: "50ml", sku: "GLUE-50" }, ], skipExisting: true, // SKU first, (productName, variantName) fallback dryRun: true, // preview first — flip off to commit});3. Create the BOMs
Section titled “3. Create the BOMs”billOfMaterials.createMany takes up to 25 BOMs per call; unknown Shopify component references
are auto-registered along the way, and skipExisting: true skips variants that already have a BOM:
return await app.billOfMaterials.createMany({ items: [ { productId: "15330758263107", variantId: "53680816652611", productName: "Gift Box Deluxe", variantName: "Default", rawMaterialReferences: [ { nodeId: "53680817373507", quantity: 2 }, { nodeId: "VMAT-VAR-…", quantity: 1 }, ], }, ], skipExisting: true,});Every batch method returns per-item results — check results[] / failedCount rather than
assuming the whole batch landed; failed items can be resent alone.
4. Change many BOMs at once, or copy one
Section titled “4. Change many BOMs at once, or copy one”billOfMaterials.updateMany applies one settings patch to a whole selection — either explicit
ids (up to 25) or every BOM matching a query, which the server resolves itself so you never have
to page through ids:
// Activate every inactive BOM whose name mentions "Gift box", except onereturn await app.billOfMaterials.updateMany({ selection: { mode: "all-matching", query: { q: "Gift box", filters: [{ field: "status", op: "in", value: ["inactive"] }] }, excludedIds: ["BOM-keep-this-one"], }, patch: { status: "active" },});The patch may set status, dynamicAdjustmentEnabled, maintainSameInventoryLevel,
autoGenerateMaterialList (Enhanced plan), onReturnKeepAssembled and tags. It is per-row commit:
every row comes back with its id, its name and — if it failed — its error.
billOfMaterials.duplicate copies a BOM onto a different Shopify variant in one atomic step —
components, sub-assemblies, tags, additional costs, the automation link and all five behaviour
settings come across, and the copy is always created inactive with no pre-assembled stock:
return await app.billOfMaterials.duplicate({ id: "BOM-source-id", name: "Gift Box Deluxe — Red", variant: { productId: "15330758263107", variantId: "53680816652611" }, bomId: "BOM-my-own-id", // mint it yourself, re-send the SAME one on a retry});A BOM belongs to exactly one variant, so duplicating onto a variant that already has one (or leaving
variant out, which means the source’s own) fails with CONFLICT. Passing bomId makes a retry
safe: the second call answers deduplicated: true with the copy that already exists instead of
creating a second one.
4b. Work through your sub-assemblies
Section titled “4b. Work through your sub-assemblies”Sub-assemblies (assembly bills) have the same four calls as BOMs. assemblyBills.query returns
flat rows — one line per bill with its costs, its component counts, how many recipes use it and
the derived unused / empty / lowShelf flags — instead of the full component tree
assemblyBills.list hydrates:
return await app.assemblyBills.query({ filters: [{ field: "unused", op: "is", value: true }], pageSize: 25 });Filters are AND-combined, q searches name, SKU and description, and sort.columnId takes any
sortable field (default: most recently updated first). assemblyBills.list still works and still
returns the hydrated tree, but query is the one to reach for when you want a list.
assemblyBills.usage answers the follow-up question — who consumes one bill — as two flat lists,
the finished goods and the other bills, each with the quantity consumed:
return await app.assemblyBills.usage({ id: "ASM-…" });assemblyBills.updateMany applies one settings patch — onReturnKeepAssembled and/or
onlyConsumePreassembled — to explicit ids or to every bill matching a query, resolved server-side:
return await app.assemblyBills.updateMany({ selection: { mode: "all-matching", query: { filters: [{ field: "lowShelf", op: "is", value: true }] } }, patch: { onlyConsumePreassembled: false },});Every row comes back with its id, its name and — if it failed — its error, and
changedAssemblyIds names the rows that were actually written.
assemblyBills.duplicate copies a bill’s recipe onto a new id in one atomic step: component
materials, nested bills, additional costs and both behaviour settings come across, and the copy
starts with no pre-assembled stock:
return await app.assemblyBills.duplicate({ sourceId: "ASM-source-id", assemblyId: "ASM-my-own-id", // mint it yourself, re-send the SAME one on a retry name: "Frame assembly — Mk II", sku: "FRAME-MK2",});Passing assemblyId makes a retry safe: the second call answers deduplicated: true with the copy
that already exists instead of creating a second one.
Editing a bill’s components
Section titled “Editing a bill’s components”Three calls change what a bill is made of. assemblyBills.updateMaterialQuantity sets one
component’s quantity — a raw material by default, or a nested bill with scope: "subAssembly":
await app.assemblyBills.updateMaterialQuantity({ assemblyId: "ASM-…", nodeId: "45812…", quantity: 4 });assemblyBills.addSubAssembly nests other bills inside this one, all at the same quantity, and
assemblyBills.removeSubAssembly takes one back out. Nesting is all-or-nothing: if any id is already
referenced, nothing is added.
await app.assemblyBills.addSubAssembly({ assemblyId: "ASM-…", subAssemblyIds: ["ASM-frame", "ASM-lid"], quantity: 1 });await app.assemblyBills.removeSubAssembly({ assemblyId: "ASM-…", nodeId: "ASM-lid" });Removing a nested bill only removes the reference — the nested bill itself stays in your catalogue. Each of these recalculates the parent bill’s cost once.
Deleting a bill that has reservations
Section titled “Deleting a bill that has reservations”assemblyBills.delete and assemblyBills.deleteMany refuse a bill that is still used in a recipe or
that carries active safety-stock reservations. Releasing those reservations is an explicit choice,
never something a delete does quietly:
await app.assemblyBills.delete({ assemblyId: "ASM-…", overrides: { releaseReservations: true } });The reservations are released first, and if any release fails nothing is deleted.
5. Ask what you can build
Section titled “5. Ask what you can build”billOfMaterials.buildable computes the same number the app’s Max Buildable column shows —
live Shopify component levels, virtual levels and nested pre-assembled stock included:
const { items } = await app.billOfMaterials.buildable({ ids: ["BOM-…"] });return items.map((i) => ({ bom: i.bomId, perLocation: i.perLocation, total: i.totals.maxBuildable }));Real per-location levels for any mix of materials come from inventory.levels, and
shopify.listLocations is the canonical way to resolve location ids and names.
6. Record what’s already on the shelf
Section titled “6. Record what’s already on the shelf”When finished goods are physically assembled (or counted during a stock-take), adjust the pre-assembled quantity directly — no work order needed:
// Stock-take: SET the level (retry-safe — prefer absolute over delta for counts)await app.billOfMaterials.adjustPreassembled({ id: "BOM-…", locationId: "101176672579", absolute: 12,});
// Or record 3 freshly built unitsawait app.billOfMaterials.adjustPreassembled({ id: "BOM-…", locationId: "101176672579", delta: 3 });adjustPreassembledMany batches up to 25 adjustments for a full stock-take, and
assemblyBills.adjustPreassembled does the same for sub-assemblies. A BOM with Only sell
pre-assembled enabled automatically re-anchors its Shopify availability to the new pre-assembled
quantity.
7. Switch finished goods to “only sell pre-assembled”
Section titled “7. Switch finished goods to “only sell pre-assembled””billOfMaterials.setOnlySellPreassembled turns the setting on or off for a whole selection in one
call. It needs the Enhanced plan, and switching it on always requires you to say what should
happen to the stock numbers — there is no default:
adoptShopify— rebase each BOM’s pre-assembled stock to what Shopify is selling right now.pushPreassembled— keep the pre-assembled numbers and re-anchor Shopify to them.
await app.billOfMaterials.setOnlySellPreassembled({ selection: { mode: "explicit", ids: ["BOM-…", "BOM-…"] }, enabled: true, stockDirection: "adoptShopify",});
// Or every finished good matching a search, minus a couple you excluded in the UIawait app.billOfMaterials.setOnlySellPreassembled({ selection: { mode: "all-matching", query: { q: "gift box" }, excludedIds: ["BOM-…"] }, enabled: false,});Three things worth knowing:
- The Shopify re-anchor is queued, not immediate.
results[]tells you the app-side write landed; the Shopify side finishes in the background. Read the BOM’ssyncState(queued,failed, or empty when it is done) to find out how it ended — the same badge the finished-goods list shows. - Enabling clears the other two stock behaviours. Only sell pre-assembled, dynamic inventory adjustment and maintain-same-inventory-level are mutually exclusive, so turning this one on turns the other two off. Turning it off changes nothing else.
- A finished good with no usable Shopify inventory item is reported as failed and is not
switched — check
results[]before assuming every row landed.