Skip to content
littleworksdocs

Explore the documentation

Meet LittleworksA little backend for what you’re building on Shopify.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 an immutable version each time you deploy.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.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.Build a merchant viewCombine data and action works in a native Shopify screen, with draft and published versions.Requests inboxA searchable inbox with filters, cursor pagination, a details modal, editable requests, and bulk closing. All records stay internal to this starter.Settings formA prefilled form with typed fields, save/discard controls, and revision-checked writes.Operations overviewIndependent data works populate a metrics card and resource list in a main column, with a small informational sidebar.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
View examples
Markdown

Requests inbox

A searchable inbox with filters, cursor pagination, a details modal, editable requests, and bulk closing. All records stay internal to this starter.

Requests inbox — local preview with synthetic data
Requests inbox — local preview with synthetic data

A searchable inbox with filters, cursor pagination, a details modal, editable requests, and bulk closing. All records stay internal to this starter.

Build this example

  1. Read get_context and list_views. Choose an unused view name; if adapting the name, update every work’s trigger.view and all work bindings together. Inspect existing works before deploying.
  2. Deploy each manifest and self-contained JavaScript source below with deploy_function. No terminal, imports, dependencies, or bundler is needed. Record each returned deployment ID.
  3. Test unpublished data works with their sample input and a fresh idempotency key. Only run sample actions when requested; they write synthetic records. Do not pass placeholder IDs or stale revisions.
  4. Replace each source/action version in the view definition, including modal bindings, with the matching deployment ID. Call validate_view with the deployment object; fix any issues, then call deploy_view.
  5. Open the returned manageUrl in Shopify to inspect the draft. Test empty states, form errors and refresh behaviour. Publish only when requested, using the returned version and current publishedVersion. A published page consumes one slot; its modals do not.

Load quote requests

Work manifest
{
  "name": "views.requests-inbox.list",
  "title": "Load quote requests",
  "feature": "starter_inbox",
  "environment": "preview",
  "trigger": {
    "type": "view",
    "view": "requests-inbox",
    "role": "data"
  },
  "permissions": {
    "shopify": false
  },
  "inputSchema": {
    "type": "object",
    "properties": {
      "filters": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string"
          }
        },
        "additionalProperties": false
      },
      "after": {
        "type": [
          "string",
          "null"
        ]
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 25
      },
      "search": {
        "type": "string",
        "maxLength": 200
      },
      "sort": {
        "type": [
          "object",
          "null"
        ]
      }
    },
    "required": [
      "filters",
      "after",
      "limit"
    ],
    "additionalProperties": false
  }
}
Self-contained JavaScript source
export default async function ({input, db}) {
  const records = db.collection('requests'), items = [];
  let cursor = input.after || undefined;
  // Bound each search to four storage pages. A non-empty cursor means more data remains.
  for (let scanned = 0; scanned < 4; scanned++) {
    const page = await records.list({limit: input.limit - items.length,
      ...(cursor ? {after: cursor} : {}),
      ...(input.filters.status ? {where: {status: input.filters.status}} : {}),
      ...(input.sort ? {orderBy: input.sort} : {}),
    });
    for (const record of page.items) {
      if (input.search && !String(record.data.requirements || '').toLowerCase().includes(input.search.toLowerCase())) continue;
      const row = {...record.data, id: record.id, revision: record.revision, createdAt: record.createdAt};
      row.customer = record.data.customerId ? {id: record.data.customerId, title: 'Open customer'} : null;
      row.items = (record.data.items || []).map(item => ({id: item.productId, title: item.productTitle || 'Product', subtitle: `${item.quantity} × ${item.variantTitle || 'Default'}`}));
      items.push(row);
    }
    cursor = page.nextCursor;
    if (!cursor || items.length >= input.limit) break;
  }
  return {items, nextCursor: cursor || null};
}
Sample invocation input
{
  "filters": {},
  "search": "",
  "sort": null,
  "after": null,
  "limit": 25
}

Load request details

Work manifest
{
  "name": "views.requests-inbox.detail",
  "title": "Load request details",
  "feature": "starter_inbox",
  "environment": "preview",
  "trigger": {
    "type": "view",
    "view": "requests-inbox",
    "role": "data"
  },
  "permissions": {
    "shopify": false
  },
  "inputSchema": {
    "type": "object",
    "properties": {
      "filters": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string"
          }
        },
        "additionalProperties": false
      },
      "after": {
        "type": [
          "string",
          "null"
        ]
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 25
      },
      "search": {
        "type": "string",
        "maxLength": 200
      },
      "sort": {
        "type": [
          "object",
          "null"
        ]
      },
      "requestId": {
        "type": "string",
        "minLength": 1,
        "maxLength": 200
      }
    },
    "required": [
      "filters",
      "after",
      "limit",
      "requestId"
    ],
    "additionalProperties": false
  }
}
Self-contained JavaScript source
export default async function ({input, db}) {
  const record = await db.collection('requests').get(input.requestId);
  if (!record) return Response.json({error: 'This request no longer exists. Close this dialog and refresh the list.'}, {status: 404});
  const customer = record.data.customerId ? {id: record.data.customerId, title: 'Open customer'} : null;
  const items = (record.data.items || []).map(item => ({id: item.productId, title: item.productTitle || 'Product', subtitle: `${item.quantity} × ${item.variantTitle || 'Default'}`}));
  return {...record.data, customer, items, id: record.id, revision: record.revision, createdAt: record.createdAt};
}
Sample invocation input
{
  "filters": {},
  "search": "",
  "sort": null,
  "after": null,
  "limit": 25,
  "requestId": "REPLACE_WITH_CREATED_ID"
}

Add an internal quote request

Work manifest
{
  "feature": "starter_inbox",
  "environment": "preview",
  "name": "views.requests-inbox.create",
  "title": "Add an internal quote request",
  "description": "Create an internal quote request from validated merchant input without contacting a customer.",
  "trigger": {
    "type": "view",
    "view": "requests-inbox",
    "role": "action"
  },
  "permissions": {
    "shopify": false
  },
  "inputSchema": {
    "type": "object",
    "properties": {
      "requirements": {
        "type": "string",
        "minLength": 1,
        "maxLength": 2000
      },
      "budget": {
        "type": "number",
        "minimum": 0
      },
      "priority": {
        "type": "boolean"
      },
      "dueDate": {
        "type": "string",
        "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
      }
    },
    "required": [
      "requirements",
      "priority"
    ],
    "additionalProperties": false
  }
}
Self-contained JavaScript source
export default async function ({input, db}) {
  const requirements = input.requirements.trim();
  if (!requirements) return Response.json({error: 'Describe the request.'}, {status: 422});
  const record = await db.collection('requests').create({
    requirements, status: 'new', customerId: null, items: [],
    priority: input.priority,
    ...(input.budget !== undefined ? {budget: {amount: input.budget, currencyCode: 'CAD'}} : {}),
    ...(input.dueDate ? {dueDate: input.dueDate} : {}),
    internalNote: 'Created in Shopify admin. Internal request only.',
  });
  return {id: record.id, revision: record.revision};
}
Sample invocation input
{
  "requirements": "Quote 40 embroidered hoodies for our sample team",
  "priority": false
}

Update a quote request

Work manifest
{
  "feature": "starter_inbox",
  "environment": "preview",
  "name": "views.requests-inbox.update",
  "title": "Update a quote request",
  "description": "Apply an allowed merchant status, internal note, or public reply change using the current record revision.",
  "trigger": {
    "type": "view",
    "view": "requests-inbox",
    "role": "action"
  },
  "permissions": {
    "shopify": false
  },
  "inputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "type": "string",
        "minLength": 1,
        "maxLength": 128
      },
      "revision": {
        "type": "integer",
        "minimum": 1
      },
      "status": {
        "type": "string",
        "enum": [
          "new",
          "reviewing",
          "replied",
          "closed"
        ]
      },
      "publicReply": {
        "type": "string",
        "maxLength": 4000
      },
      "internalNote": {
        "type": "string",
        "maxLength": 2000
      }
    },
    "required": [
      "id",
      "revision"
    ],
    "additionalProperties": false
  }
}
Self-contained JavaScript source
const TRANSITIONS = {
  new: ['reviewing', 'closed'],
  reviewing: ['replied', 'closed'],
  replied: ['reviewing', 'closed'],
  closed: ['reviewing']
};
const MAX_DOCUMENT_BYTES = 16 * 1024;

const fail = (status, code, error) => Response.json({ code, error }, { status });
const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
const requestReference = id => `QR-${id.replace(/[^a-z0-9]/gi, '').slice(-12).toUpperCase()}`;

export default async function ({ input, db }) {
  if (!has(input, 'status') && !has(input, 'publicReply') && !has(input, 'internalNote')) {
    return fail(422, 'NO_CHANGES', 'Provide a status, public reply, or internal note to update.');
  }

  const requests = db.collection('requests');
  const current = await requests.get(input.id);
  if (!current) return fail(404, 'REQUEST_NOT_FOUND', 'Quote request not found.');
  if (current.revision !== input.revision) {
    return fail(409, 'REVISION_CONFLICT', 'This quote request changed. Reload it before updating.');
  }

  const nextStatus = has(input, 'status') ? input.status : current.data.status;
  if (nextStatus !== current.data.status && !TRANSITIONS[current.data.status]?.includes(nextStatus)) {
    return fail(422, 'INVALID_TRANSITION', `Cannot move a quote request from ${current.data.status} to ${nextStatus}.`);
  }

  const next = { ...current.data, status: nextStatus };
  if (has(input, 'publicReply')) {
    const publicReply = input.publicReply.trim();
    if (publicReply) next.publicReply = publicReply;
    else delete next.publicReply;
  }
  if (has(input, 'internalNote')) {
    const internalNote = input.internalNote.trim();
    if (internalNote) next.internalNote = internalNote;
    else delete next.internalNote;
  }
  if (nextStatus === 'replied' && !next.publicReply) {
    return fail(422, 'REPLY_REQUIRED', 'Add a public reply before marking this request replied.');
  }
  if (new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_DOCUMENT_BYTES) {
    return fail(422, 'REQUEST_TOO_LARGE', 'This update would make the quote request too large. Shorten the reply or note.');
  }

  let updated;
  try {
    updated = await requests.update(current.id, next, { revision: input.revision });
  } catch (error) {
    if (error?.code === 'WRITE_CONFLICT') {
      return fail(409, 'REVISION_CONFLICT', 'This quote request changed. Reload it before updating.');
    }
    throw error;
  }
  return {
    id: updated.id,
    reference: requestReference(updated.id),
    revision: updated.revision,
    status: updated.data.status,
    publicReply: updated.data.publicReply || null,
    internalNote: updated.data.internalNote || null,
    updatedAt: updated.updatedAt
  };
}

Close selected quote requests

Work manifest
{
  "feature": "starter_inbox",
  "environment": "preview",
  "name": "views.requests-inbox.close",
  "title": "Close selected quote requests",
  "description": "Close selected requests using their current revisions; inspect partial results before retrying.",
  "trigger": {
    "type": "view",
    "view": "requests-inbox",
    "role": "action"
  },
  "permissions": {
    "shopify": false
  },
  "inputSchema": {
    "type": "object",
    "properties": {
      "records": {
        "type": "array",
        "minItems": 1,
        "maxItems": 25,
        "items": {
          "type": "object",
          "properties": {
            "id": {
              "type": "string"
            },
            "revision": {
              "type": "integer",
              "minimum": 1
            }
          },
          "required": [
            "id",
            "revision"
          ],
          "additionalProperties": false
        }
      }
    },
    "required": [
      "records"
    ],
    "additionalProperties": false
  }
}
Self-contained JavaScript source
export default async function ({input, db}) {
  const requests = db.collection('requests'), operations = [], seen = new Set();
  for (const selected of input.records) {
    if (seen.has(selected.id)) return Response.json({error: 'Choose distinct records.'}, {status: 422});
    seen.add(selected.id);
    const current = await requests.get(selected.id);
    if (!current || current.revision !== selected.revision) return Response.json({error: 'A selected request changed. Refresh before trying again.'}, {status: 409});
    if (current.data.status !== 'closed') operations.push({type: 'update', collection: 'requests', id: current.id, revision: current.revision, data: {...current.data, status: 'closed'}});
  }
  // Each batch is atomic; multiple batches may partially complete. Do not blindly retry.
  for (let offset = 0; offset < operations.length; offset += 10) await db.batch(operations.slice(offset, offset + 10));
  return {closed: operations.length};
}

View definition

validate_view / deploy_view arguments
{
  "baseVersion": null,
  "definition": {
    "apiVersion": 3,
    "name": "requests-inbox",
    "title": "Requests inbox",
    "actions": [
      {
        "id": "close",
        "label": "Close selected",
        "work": "views.requests-inbox.close",
        "version": "00000000-0000-4000-8000-000000000004",
        "scope": "bulk",
        "source": "records",
        "input": {
          "records": {
            "source": "selection",
            "fields": {
              "id": "id",
              "revision": "revision"
            }
          }
        },
        "confirmation": "Close the selected requests? They will remain in your records.",
        "refresh": [
          "records"
        ]
      }
    ],
    "layout": {
      "type": "stack",
      "main": [
        "table"
      ],
      "aside": [],
      "elements": {
        "table": {
          "type": "Records",
          "props": {
            "columns": [
              {
                "field": "requirements",
                "label": "Request",
                "format": "text"
              },
              {
                "field": "status",
                "label": "Status",
                "format": "badge",
                "badges": [
                  {
                    "value": "new",
                    "label": "New",
                    "tone": "info"
                  },
                  {
                    "value": "reviewing",
                    "label": "Reviewing",
                    "tone": "warning"
                  },
                  {
                    "value": "replied",
                    "label": "Replied",
                    "tone": "success"
                  },
                  {
                    "value": "closed",
                    "label": "Closed",
                    "tone": "neutral"
                  }
                ]
              },
              {
                "field": "createdAt",
                "label": "Submitted",
                "format": "date"
              }
            ],
            "source": "records",
            "heading": "Requests inbox",
            "empty": {
              "heading": "No matching records",
              "message": "Try another filter, or return when new submissions arrive."
            },
            "bulkActions": [
              "close"
            ],
            "onRowClick": "openDetails"
          }
        }
      }
    },
    "sources": [
      {
        "id": "records",
        "work": "views.requests-inbox.list",
        "version": "00000000-0000-4000-8000-000000000002",
        "filters": [
          {
            "field": "status",
            "label": "Status",
            "options": [
              {
                "value": "new",
                "label": "New"
              },
              {
                "value": "reviewing",
                "label": "Reviewing"
              },
              {
                "value": "replied",
                "label": "Replied"
              },
              {
                "value": "closed",
                "label": "Closed"
              }
            ]
          }
        ],
        "search": {
          "label": "Search requests"
        },
        "sorts": [
          {
            "id": "newest",
            "label": "Newest first",
            "field": "$createdAt",
            "direction": "desc"
          },
          {
            "id": "oldest",
            "label": "Oldest first",
            "field": "$createdAt",
            "direction": "asc"
          }
        ]
      }
    ],
    "modalLinks": [
      {
        "id": "openDetails",
        "label": "Open request",
        "modal": "details",
        "scope": "row",
        "source": "records",
        "input": {
          "requestId": {
            "source": "row",
            "field": "id"
          }
        }
      },
      {
        "id": "newRequest",
        "label": "Add request",
        "modal": "create",
        "scope": "page",
        "input": {}
      }
    ],
    "modals": [
      {
        "id": "details",
        "title": "Quote request",
        "inputs": {
          "requestId": "string"
        },
        "sources": [
          {
            "id": "detail",
            "work": "views.requests-inbox.detail",
            "version": "00000000-0000-4000-8000-000000000004",
            "inputBindings": {
              "requestId": "requestId"
            }
          }
        ],
        "actions": [
          {
            "id": "update",
            "label": "Update request",
            "work": "views.requests-inbox.update",
            "version": "00000000-0000-4000-8000-000000000001",
            "input": {
              "id": {
                "source": "data",
                "field": "id"
              },
              "revision": {
                "source": "data",
                "field": "revision"
              },
              "status": {
                "source": "form",
                "field": "status"
              },
              "publicReply": {
                "source": "form",
                "field": "reply"
              },
              "internalNote": {
                "source": "form",
                "field": "note"
              }
            },
            "fields": [
              {
                "name": "status",
                "label": "Status",
                "type": "select",
                "required": true,
                "initialField": "status",
                "options": [
                  {
                    "value": "new",
                    "label": "New"
                  },
                  {
                    "value": "reviewing",
                    "label": "Reviewing"
                  },
                  {
                    "value": "replied",
                    "label": "Replied"
                  },
                  {
                    "value": "closed",
                    "label": "Closed"
                  }
                ]
              },
              {
                "name": "reply",
                "label": "Reply visible to the customer",
                "type": "textarea",
                "initialField": "publicReply",
                "maxLength": 4000
              },
              {
                "name": "note",
                "label": "Internal note",
                "type": "textarea",
                "initialField": "internalNote",
                "maxLength": 2000
              }
            ],
            "confirmation": "Save these changes to the request? The reply is visible to the customer; the internal note stays private.",
            "scope": "page",
            "source": "detail",
            "refreshPage": [
              "records"
            ],
            "refresh": [
              "detail"
            ],
            "closeModal": false
          }
        ],
        "pageActions": [
          "update"
        ],
        "layout": {
          "type": "stack",
          "main": [
            "section"
          ],
          "elements": {
            "section": {
              "type": "Section",
              "props": {
                "heading": "Request details"
              },
              "children": [
                "detail"
              ]
            },
            "detail": {
              "type": "Details",
              "props": {
                "source": "detail",
                "fields": [
                  {
                    "field": "requirements",
                    "label": "Requirements",
                    "format": "text"
                  },
                  {
                    "field": "items",
                    "label": "Requested products",
                    "format": "list"
                  },
                  {
                    "field": "status",
                    "label": "Status",
                    "format": "badge",
                    "badges": [
                      {
                        "value": "new",
                        "label": "New",
                        "tone": "info"
                      },
                      {
                        "value": "reviewing",
                        "label": "Reviewing",
                        "tone": "warning"
                      },
                      {
                        "value": "replied",
                        "label": "Replied",
                        "tone": "success"
                      },
                      {
                        "value": "closed",
                        "label": "Closed",
                        "tone": "neutral"
                      }
                    ]
                  },
                  {
                    "field": "publicReply",
                    "label": "Customer reply",
                    "format": "text"
                  },
                  {
                    "field": "internalNote",
                    "label": "Internal note",
                    "format": "text"
                  },
                  {
                    "field": "budget",
                    "label": "Budget",
                    "format": "money"
                  },
                  {
                    "field": "priority",
                    "label": "Priority",
                    "format": "boolean"
                  },
                  {
                    "field": "dueDate",
                    "label": "Follow-up date",
                    "format": "date"
                  }
                ]
              }
            }
          }
        }
      },
      {
        "id": "create",
        "title": "Add an internal request",
        "inputs": {},
        "sources": [],
        "actions": [
          {
            "id": "create",
            "label": "Add internal request",
            "work": "views.requests-inbox.create",
            "version": "00000000-0000-4000-8000-000000000003",
            "scope": "page",
            "input": {
              "requirements": {
                "source": "form",
                "field": "requirements"
              },
              "budget": {
                "source": "form",
                "field": "budget"
              },
              "priority": {
                "source": "form",
                "field": "priority"
              },
              "dueDate": {
                "source": "form",
                "field": "dueDate"
              }
            },
            "fields": [
              {
                "name": "requirements",
                "label": "Request",
                "type": "textarea",
                "required": true,
                "maxLength": 2000
              },
              {
                "name": "budget",
                "label": "Budget",
                "type": "money",
                "currency": "CAD",
                "min": 0,
                "step": 0.01
              },
              {
                "name": "priority",
                "label": "Priority request",
                "type": "checkbox",
                "defaultValue": false
              },
              {
                "name": "dueDate",
                "label": "Follow-up date",
                "type": "date"
              }
            ],
            "refreshPage": [
              "records"
            ],
            "closeModal": true
          }
        ],
        "layout": {
          "type": "stack",
          "main": [
            "internal"
          ],
          "elements": {
            "internal": {
              "type": "Section",
              "props": {
                "heading": "Add an internal request"
              },
              "children": [
                "description",
                "create"
              ]
            },
            "description": {
              "type": "Text",
              "props": {
                "text": "Record a request for your team. This creates an internal record and does not contact a customer.",
                "subdued": true
              }
            },
            "create": {
              "type": "Form",
              "props": {
                "action": "create"
              }
            }
          }
        }
      }
    ],
    "pageActions": [
      "newRequest"
    ]
  }
}