Skip to content
littleworksdocs

Explore the documentation

Meet LittleworksA little backend for what you’re building on Shopify.Littleworks or your own appWhat it takes to give a Shopify feature a backend: with Littleworks, or with a custom app you build and run yourself.Connect to LittleworksChoose the CLI, a plugin, or a remote MCP connection.CLIConnect from your terminal with Shopify approval and no manual tokens.Claude pluginThe Littleworks MCP connection and agent guidance in one package.OpenAI pluginThe Littleworks MCP connection and agent guidance in one package.MCP with OAuthConnect a compatible client directly, without a marketplace plugin.Build with an agentThe operating guide for agents building on Shopify with Littleworks.Write a workA JavaScript handler, a small manifest, and a version you test and then publish.Keep secretsEncrypted credentials, shared across your store’s works.Call external servicesMake public HTTPS API requests from a work using http.fetch().Store dataDocument collections with a small API, scoped automatically to the work that uses them.Call ShopifyCall Shopify’s Admin and Storefront GraphQL APIs with credentials held by Littleworks.Store filesKeep uploads such as artwork and photos in the merchant’s Shopify Files, uploaded by the browser straight to Shopify.Expose an endpointConnect an existing frontend to a work through a small JSON API.Customer access and invitationsRequire a signed-in shopper or a narrow, expiring invitation before a work runs.RecipesUseful things to build for your store, with the backend already taken care of.Product reviewsCollect customer reviews, verify purchases, and publish approved content directly into your Shopify theme.Customer wishlistsGive signed-in customers a persistent list of products they can revisit across devices.Customer quote requestsCollect a customer’s products, quantities, and requirements without turning a request into an order.Back-in-stock alertsCollect confirmed signups on sold-out variants, email them through your own provider when stock returns, and see every signup and send in your admin.Transactional emailSend email from your works through your own provider, never twice for the same thing, and see what was sent and what failed.Build a merchant pageDescribe your records and what merchants do with them; Littleworks renders a native Shopify admin page.Run works on their ownRun a work when something happens in the store, on a schedule, or from a Shopify Flow workflow.Run background jobsQueue runs for later or in the background: fan out to thousands of customers, import in pages, follow up days later, and retry what fails.MCP tool referenceTools to inspect, deploy, run, and organize the backend for a connected store.Usage and allowancesSee what your works use, how much capacity remains, and when allowances reset.Inspect and troubleshootFind the relevant run, understand the failure, and make the next change deliberately.Limits and securityThe current runtime boundaries, data isolation model, and execution allowances.
Shopify admin
Build
Markdown

Run background jobs

Queue runs for later or in the background: fan out to thousands of customers, import in pages, follow up days later, and retry what fails.

A run has 10 seconds. Most work fits; some doesn’t: emailing 3,000 people waiting for a product, importing 20,000 stock levels from an ERP, or asking for a review a week after delivery. Background jobs split that work into short runs. Any work can queue a job, now or later, and Littleworks delivers it at least once, retries it when it fails and shows what failed.

BuildWith jobs
Back-in-stock emails to 3,000 subscribersThe event work queues one job that emails 50 people a run and continues: about 60 runs
A nightly stock sync from an ERP that pages 500 records at a timeA schedule queues the first page; each page job continues with the next cursor
“Ask for a review 7 days after delivery”The fulfillment work queues a job with a 7-day delay and the order as its key
Recalculating every product’s review summaryA job processes 250 products a run and continues with a cursor

Job works

A job runs a published work whose trigger is {"type": "job"}. It runs at the work’s current version when the job runs, not when it was queued, like any other real run: same deadline, capabilities, logs and Runs entry. Its input is exactly what was queued, checked against the work’s inputSchema when it is queued and again when it runs. A job work never runs on its own; other works (events, schedules, Flow, pages, URLs, and job works themselves) queue it.

Manifest
{
  "name": "back_in_stock.send",
  "feature": "back_in_stock",
  "title": "Email one batch of back-in-stock signups",
  "trigger": { "type": "job" },
  "inputSchema": {
    "type": "object",
    "properties": { "productId": { "type": "string" } },
    "required": ["productId"]
  }
}

context.trigger is "job", and context.job describes the job: {id, key, attempt, maxAttempts, previousRunIds, enqueuedAt, scheduledFor, enqueuedBy, rootId, generation}. rootId and generation place it in a chain of continuations.

Queue jobs

The jobs SDK
// Queue one job. Returns {id, status: "queued" | "duplicate", runAt}.
await jobs.enqueue("reviews.request", { orderId }, { delaySeconds: 7 * 86400, key: `review:${orderId}` });

// Queue up to 50 jobs of one work at once.
await jobs.enqueueMany("stock.notify", batches.map((b, i) => ({ input: b, key: `${event.id}:${i}` })));

// Queue this work again with new input: the next page of a long task.
await jobs.continue({ cursor: page.nextCursor });

// Cancel a queued (not running) job by key or id.
await jobs.cancel("reviews.request", { key: `review:${orderId}` });
OptionMeaning
delaySeconds or atWhen the job is due: now (the default) up to 30 days ahead. at is an ISO time.
keyUp to 128 characters, unique per work while the job is kept (7 days after it finishes). Queuing a key again returns the existing job as duplicate and changes nothing.
maxAttempts1 to 5 (default 5).
  • Kept only if the run completes. Jobs a run queues are held until the run finishes, whatever it returns. If the run throws, times out or is interrupted, they are discarded, and a retry of that run queues them again. A crashed run never leaves half a chain behind.
  • Continue at most once. jobs.continue is enqueue of the current work with the key continue:<job id>, so a job continues once even when its run is retried. Long work is a chain of short runs: do a page, then continue with a cursor.
  • Always key fan-out. Without a key, queuing twice queues two jobs. Key each batch by what it covers, such as the event ID and batch number, so a repeated event or retried run queues nothing twice.
Fan out from an event (two works)
// Event work: when a product is back in stock, queue the emails as a background job.
export default async ({ input, jobs }) => {
  const productId = "gid://shopify/Product/" + input.payload.product_id;
  // Keyed by the event: a repeated delivery or a retried run queues it once.
  return jobs.enqueue("back_in_stock.send", { productId }, { key: `restock:${input.event.id}` });
};

// Job work: email the next 50 waiting signups, mark them, and continue until none are left.
export default async ({ input, db, http, secrets, jobs }) => {
  const signups = db.collection("signups");
  const page = await signups.list({ where: { productId: input.productId, status: "waiting" }, limit: 50 });
  if (!page.items.length) return { done: true };
  await http.fetch("https://api.email.example/batch", {
    method: "POST",
    headers: { Authorization: `Bearer ${await secrets.get("EMAIL_API_KEY")}` },
    body: JSON.stringify({ to: page.items.map((s) => s.data.email), template: "back-in-stock" }),
  });
  // Mark them so the next step moves on to the next 50.
  await db.batch(page.items.map((s) => ({ type: "update", collection: "signups", id: s.id, revision: s.revision, data: { ...s.data, status: "notified" } })));
  await jobs.continue({ productId: input.productId });
  return { emailed: page.items.length };
};
Import in pages
// Job work: import one page of an ERP's stock levels, then continue with the next page.
export default async ({ input, http, secrets, shopify, jobs, context, log }) => {
  const key = await secrets.get("ERP_API_KEY");
  const page = await (await http.fetch(`https://erp.example.com/stock?cursor=${input.cursor ?? ""}`, {
    headers: { Authorization: `Bearer ${key}` },
  })).json();
  // inventorySetQuantities takes up to 250 quantities in one call.
  await shopify.admin.graphql(SET_QUANTITIES, { input: toQuantities(page.items) });
  if (page.nextCursor) await jobs.continue({ cursor: page.nextCursor });
  log.info("Page", context.job.generation + 1, "attempt", context.job.attempt);
  return { imported: page.items.length, more: Boolean(page.nextCursor) };
};

How delivery works

  • At least once. In rare cases, such as an instance stopping after Shopify accepted a change but before the run was recorded, a job’s attempt can start again. Use context.job.attempt, previousRunIds and your own records (an emailedAt field, a processed cursor) so repeating it does no harm.
  • Retries. A run that throws, times out or exceeds a resource limit is retried after 1 minute, 10 minutes, 1 hour and 6 hours, up to maxAttempts. A returned 4xx or 5xx response counts as handled and isn’t retried; throw to ask for a retry.
  • Waiting doesn’t use attempts. A busy store waits a minute. When the run allowance is used up, jobs wait up to 24 hours for it, then expire with a notice. Jobs for a paused work wait up to 7 days and run when it is resumed; the work’s page can cancel them instead. Deleting the work or uninstalling the app cancels them.
  • Failures stay visible. A job that fails its last attempt is kept for 7 days with its error and every run, with a notice on the work. Retry it after a fix with retry_job or the Retry button on the work’s page; it gets a fresh set of attempts.
  • Pace. Each store runs at most 2 jobs at a time, in their own lane: an import never delays Shopify events (up to 6 at a time) or storefront traffic, and one store’s queue never delays another store’s. Due jobs run oldest first, without a strict order; a chain of continuations runs one step at a time.
  • Shopify throttling. A job that hits Shopify’s rate limit (SHOPIFY_THROTTLED) is retried a minute later. To go faster, read extensions.cost.throttleStatus from the response and continue with a short delay before the bucket runs dry.

Runs and pricing

Every job attempt that starts a run counts as one run against the store’s allowance, like any other run. Queuing, de-duplicating, cancelling and waiting are free. Jobs usually make big work cheaper in runs, because each run does up to ten seconds of work: 3,000 back-in-stock emails, sent 50 to an API request, take about 60 runs. Usage shows job runs as Background jobs, and the notice for one work taking most of a day’s runs covers runaway chains.

Test and watch

  • Test a job work directly. invoke_function with input and trigger: {job: {attempt?, previousRunIds?, key?}} fills context.job as a queued job would. From the CLI: littleworks invoke back_in_stock.send '{"productId":"gid://shopify/Product/1"}' --job.
  • Jobs from test runs are test jobs. A test run’s jobs run the target’s newest version as test runs, at most 200 a day per store, due within 1 hour, and a chain stops after 20 steps. Like every test run, they use the store’s real Shopify data and call real external services, so test a fan-out with a small list first.
  • Watch. list_jobs (CLI: littleworks jobs) lists jobs with their status, attempts, runs and error; inputs are never returned, only their size. cancel_jobs (littleworks jobs cancel <work>) cancels queued ones and retry_job (littleworks jobs retry <id>) re-queues a failed one. get_function shows a job work’s queued, running, succeeded and failed jobs. In Shopify admin, Works filters by Runs from: Background jobs, and a job work’s page shows its jobs and failed jobs with Retry and Cancel.
  • Locally. With npm run dev:local, queued jobs run from the local maintenance loop. npm run local:tick -- --at <time> runs the jobs due by then, so delayed jobs and retries can be tried without waiting.

Limits

LimitValue
Job input64 KiB; keep larger data in storage and pass its ID
DelayUp to 30 days
Jobs queued per run100; enqueueMany takes up to 50 at once
Waiting jobs per store5,000, and 20 MB of their input; beyond that, JOB_QUEUE_FULL
Chain length10,000 steps from one job; the next continuation fails with JOB_CHAIN_LIMIT and a notice
Attempts1 to 5, retried after 1 minute, 10 minutes, 1 hour and 6 hours
Job runs at a time2 per store
Kept after finishing7 days (succeeded, failed, cancelled and expired)

Jobs reach only the store’s own job works, and only a running work can queue them, so storefront visitors can’t queue jobs directly. A public URL work that queues a job per request should key it, so repeated requests don’t multiply. For bulk Shopify writes, prefer mutations that take many items at once, such as inventorySetQuantities (250 quantities), productVariantsBulkUpdate (a product’s variants) and metafieldsSet (25 metafields), over one mutation per record.