# Build a merchant view

Combine data and action works in a native Shopify screen, with draft and published versions.

Source: https://littleworks.app/docs/views

Views are saved screens inside Littleworks → Views. Your agent defines a bounded JSON layout and dedicated works that load data or perform merchant actions. Littleworks renders standard Polaris components. One view can combine several data works, and one work result can populate several components. Collections stay schemaless; data can also come from Shopify or an external service.

## Draft and published

| Revision | Agent / CLI | Shopify admin |
| --- | --- | --- |
| Unpublished work revision | Can invoke for authorized testing | Can run through the draft view |
| Published work revision | Cannot invoke | Can run through its current draft or published view binding |

A view has one draft pointer and an optional published pointer. Saving updates the draft only. Publishing atomically activates that definition and marks its exact data/action work revisions published. Later work deployments and draft edits leave the published view unchanged. To test a change, deploy a new work revision and bind it in a new draft. Previously published revisions stay unavailable to direct agent invocation, even if the view is replaced or removed. A run admitted before publication can finish.

> **Publication and data are separate**
> Views have no environment selector. Each bound work still uses its manifest’s Preview or Live documents and secrets. Publishing never copies data or changes that environment. A published view can use Preview data for a test store; a draft can use Live data when that is intentionally part of the build. Shopify and external requests always reach real services.

## Create a view

1. Read get_context, list_functions and list_views. Inspect existing drafts with get_view before editing.
2. Deploy dedicated works with trigger:{type:"view",view:"VIEW_NAME",role:"data"} or role:"action". A work’s owning view and role are fixed. Each work keeps its own data group and environment.
3. Test unpublished revisions with invoke_function, supplying name, environment, version, input and a fresh idempotencyKey. For a data work, include filters:{}, search:"", sort:null, after:null and limit:25 plus any saved source inputs. Inspect failed runs before retrying actions.
4. Save with deploy_view({definition,baseVersion}). Use null only for a new view; otherwise use the draft version returned by get_view. Bind the exact work revision UUIDs you tested.
5. Open manageUrl to preview the draft in Shopify. Verify independent loading, filters, pagination, record details, forms, stale-record handling and the intended outcomes.
6. When the merchant requests publication, call publish_view({name,version,publishedVersion}) using both current pointers from get_view, or publish inside Shopify. Open the published view and verify it.

## Data works

sources declares up to eight named bindings: {id,work,version,input?,filters?,search?,sorts?}. A form or informational view can have no sources. Each source invokes its pinned data work on page load. Search submissions, filters, sorting, pagination, Refresh, and a successful action’s refresh list reload the affected sources. Sources load independently; a failed source shows its error and run link without preventing other sources from displaying. Metadata reads, including get_view, do not execute works. Data loads and actions use ordinary run allowances, limits, logs and idempotency.

### Data-work handler

```javascript
export default async ({input, db}) => {
  const page = await db.collection('requests').list({
    limit: input.limit,
    ...(input.after ? {after: input.after} : {}),
    ...(input.filters.status ? {where: {status: input.filters.status}} : {}),
  });
  return {
    items: page.items.map(record => ({
      ...record.data, id: record.id, revision: record.revision,
      createdAt: record.createdAt,
    })),
    nextCursor: page.nextCursor,
  };
};
```

Input is saved source input plus filters (selected typed values), search (a string, empty initially), sort ({field,direction} or null), after (an opaque cursor or null), and limit:25. These five keys are reserved; declare them in the work inputSchema. Source filters support up to three selects with {field,label,options:[{label,value}]}. Enable search with search:{label}; declare sorts as [{id,label,field,direction:"asc"|"desc"}]. The first sort is the default. Littleworks validates choices; the work implements the actual query. Search runs on submission, not each keystroke. Filters, search and sorting reset pagination. A cursor must remain tied to the same query. Bound scans can produce an empty page with a next cursor; never discard matching records when filling a page.

Return a JSON object. Records needs items (at most 25 objects, each with a unique string id) and an optional string/null nextCursor. Details reads fields from the returned object, so a separate summary work can return {pending:12,averageRating:4.7}. Field paths support nested own properties such as totals.pending; they are not expressions. Return only data the merchant should see, and keep the work result within 64 KiB. Do not return credentials.

The runtime limits data works to document reads, Shopify GraphQL queries, secret reads and external GET/HEAD requests. Document writes, uniqueness changes, GraphQL mutations/subscriptions, invitation issuance and mutating HTTP methods are blocked, including in agent tests. GET/HEAD requests still contact real services: use endpoints designed for reading. This policy does not prove an external service is free of side effects. Business changes belong in action works.

## Component catalog

Use apiVersion:3. name is a stable identifier; title is merchant-facing. layout:{type:"stack"|"details",main:[elementId,...],aside?:[elementId,...],elements:{...}} places a vertical stack in the main column. Only details pages may have a small supporting sidebar. Littleworks owns the page header, breadcrumb and navigation. Do not build an app shell, nested navigation or custom grids. pageActions:[actionId,...] places up to three page actions in the native header.

| Component | Configuration |
| --- | --- |
| Section | heading and children. Groups Text, Details, Form or ResourceList. Renders as a card on pages and an unboxed heading and content in modals. Sections cannot nest. |
| Text | text and optional subdued. Plain escaped content. |
| Details | source and fields. Label/value pairs from a data work. |
| Records | heading, source, columns, onRowClick (modal link) or details/actions, bulkActions and empty:{heading,message,action?}. Search/filter/sort controls come from the source. Place directly in the main column. |
| ResourceList | source, field (default items), titleField, optional subtitleField/imageField/resourceField, onRowClick or details/actions, and empty. Put inside a Section. |
| Metrics | heading, source and up to four metrics:{field,label,format?,comparisonField?,comparisonLabel?}. Place directly in the main column. |
| Callout | heading, message and optional page action. A card on pages; unboxed in modals. |
| EmptyState | heading, message and optional page action. A card on pages; unboxed in modals. |
| Form | action. Put inside a Section; the referenced page action declares its fields and input bindings. |

Use at most 40 components and 20 main-column items per page or modal; details pages also allow six sidebar items. Every component must be placed once. No arbitrary frontend code, HTML, CSS, URLs, expressions or footer-help components. Work results remain data, never layout instructions. Multiple components may share one source.

Fields use {field,label,format?}. Formats: text, number, date (UTC), badge, boolean, money, resource and list. Money reads {amount,currencyCode}; badge mappings use {value,label,tone}. A resource reads {id,title,subtitle?,image?}, with a Shopify Product, Customer or Order GID. Littleworks builds the admin link itself; images must be HTTPS URLs on cdn.shopify.com. list displays up to 25 resources or text values. Other objects display as escaped JSON. A table supports six columns and 20 detail fields. Rows can open a data-driven modal using onRowClick, show snapshot details using details/actions, or be read-only when neither is declared. Selection controls are separate when bulk actions are present.

ResourceList reads an array of at most 25 objects from its field path. Rows use titleField and optional subtitleField/imageField. resourceField can link a Shopify resource when there are no detail fields. Record actions require field:"items", unique string IDs and details; pagination uses nextCursor when field is items. Metrics accepts number or money formats. Comparison fields are labels supplied by the data work; Littleworks does not calculate comparisons or infer whether a change is good.

## Modals with their own data

Define up to four modals in modals:[{id,title,inputs,sources,actions,pageActions,layout}]. Each modal has its own source/action IDs and uses a single-column stack of catalog components. It does not create another page, sidebar or navigation shell. A modal can use several independent data works; sources load only when opened and use normal run allowances. Closing unmounts the dialog; reopening loads fresh data. Loading errors offer an explicit retry. Nested modal links are not supported.

inputs declares up to eight required scalar inputs, for example {reviewId:"string"}. Types are string, number and boolean; strings are bounded to 1000 characters. Modal source inputBindings maps work input keys to modal input names, for example {reviewId:"reviewId"}. These values merge with saved source input constants and the standard filters/search/sort/after/limit arguments. The work inputSchema validates the assembled input. Sources cannot depend on other source results.

### A table opens a modal with a verified row ID

```json
{
  "modalLinks": [
    {
      "id": "openReview",
      "label": "Open review",
      "modal": "reviewDetails",
      "scope": "row",
      "source": "records",
      "input": {
        "reviewId": {
          "source": "row",
          "field": "id"
        }
      }
    }
  ],
  "tableProps": {
    "source": "records",
    "onRowClick": "openReview"
  },
  "modal": {
    "id": "reviewDetails",
    "title": "Review details",
    "inputs": {
      "reviewId": "string"
    },
    "sources": [
      {
        "id": "review",
        "work": "views.reviews.detail",
        "version": "DEPLOYMENT_UUID",
        "inputBindings": {
          "reviewId": "reviewId"
        }
      }
    ]
  }
}
```

Declare modalLinks on the page with id, label, modal, scope (row or page), optional source, and input bindings. Records.onRowClick and ResourceList.onRowClick reference a row link on the same source; resource lists must use items with IDs. A page link can appear in pageActions, a Callout or an EmptyState. Page links use constants, or data bindings from their declared page source; row links use row fields or constants. Supply exactly the modal’s declared inputs. These are saved definitions; returned rows never choose works or supply UI instructions.

The server binds row/data modal inputs to the merchant, view version, stage and opening link in encrypted context that expires after 15 minutes. Arbitrary client-supplied IDs are not accepted. On expiry, close the dialog and refresh the page. Modal actions with data-bound inputs are also tied to the opened modal context. A fresh modal load does not lock a record; action works must still re-read state and check revisions.

Inside the modal, use Details, ResourceList, Records and other catalog components with local source IDs. Modal content is always unboxed: Section, Records, Metrics, Callout and EmptyState keep their content and headings without card borders, backgrounds or shadows. This is enforced by the renderer and cannot be overridden in a view definition. pageActions places action buttons in the native modal footer. A modal supports one Form; its submit/discard controls also appear in that footer. Action forms and record details reuse the current dialog instead of stacking dialogs. Unsaved edits are preserved until saved or explicitly discarded.

Modal actions use the same page/record/bulk scopes. A page action can bind the loaded modal source using {source:"data",field:"id"}, or a verified modal input with {source:"input",field:"reviewId"}. refresh targets local modal sources; refreshPage targets page sources. closeModal:true closes after success; otherwise the dialog remains open. Saving never automatically replays an action. Modal works use the parent view’s ownership and draft/published authorization rules.

## Forms, record actions and bulk actions

Actions declare id, label, work, version, scope (page, record or bulk), optional source, input bindings, fields, confirmation, destructive, when:{field,equals} and refresh. Bind dedicated action works owned by this view. Records.actions and ResourceList.actions reference record actions on the same source. Records.bulkActions references bulk actions. Page actions can appear in a Form, Callout, EmptyState or pageActions. They can run without a data source, or bind a source for settings and summary actions. Work revisions stay pinned; pausing a work blocks every bound revision.

| Binding | Use |
| --- | --- |
| {source:"row",field:"id"} | Record action: read a server-returned row field, including id or revision. |
| {source:"data",field:"revision"} | Page action with source: read a field from the whole data-work result. |
| {source:"selection",fields:{id:"id",revision:"revision"}} | Bulk action: receive an array of these fields for up to 25 selected records on the current page. |
| {source:"input",field:"reviewId"} | Modal action: read a verified input supplied when opening the dialog. |
| {source:"form",field:"fieldName"} | Read a validated declared form field. |
| {source:"constant",value:scalar} | Use a constant from the saved definition. |

The backend evaluates when on the source snapshot and encrypts bound row/data/selection context for that merchant, view version, stage and action. Context expires after 15 minutes. Browsers cannot replace bound identifiers or revisions; bulk selections must be distinct. Availability is a snapshot, not a lock. The action work inputSchema validates the final assembled input. A bulk selection invokes one work, not a separate work per row.

> **Recheck business state in the action work**
> Re-read records, validate business rules and use revision-checked writes. db.batch is atomic for up to 10 operations; larger bulk selections need bounded batches and can partially complete. Shopify and external effects are not atomic with collection writes. Inspect the run and current state before retrying any failed or ambiguous action.

Form fields use name, label, type (text, textarea, select, number, money, checkbox or date), required, help, defaultValue and optional initialField. Text supports maxLength up to 4000. Select declares options:[{label,value}]. Number and money support min, max and step; money requires a three-letter currency. Date submits YYYY-MM-DD. Number/money submit numbers and checkbox submits a boolean. Empty optional number/date/select values are omitted. initialField reads the bound row or page result and is unavailable for bulk actions. Up to 12 fields per action and eight actions per page or modal. Validation runs in the UI and on the server.

Form renders standalone fields with save/discard controls. Record and page buttons open native modals with actions in the modal footer. Opening a form does not execute a work. Label customer-visible content and internal notes clearly. Dirty inline forms disable source refresh and list controls to preserve edits. refresh:[sourceId,...] reloads affected sources after success; the default is the bound action source, if present. Actions never retry automatically. Duplicate submission keys are rejected. Return an HTTP error or throw on failure; merchant feedback contains a bounded error and run link.

## Example: reviews with independently loaded details

> **Replace placeholder versions**
> This definition binds page list/summary data works, a separate modal detail data work and one moderation action work. Deploy matching view-owned works first, then replace every placeholder version with the correct deployment UUID. The example handlers use the review recipe’s Preview documents. Do not change the recipe’s customer-facing submission endpoint into a view work.

### deploy_view arguments

```json
{
  "baseVersion": null,
  "definition": {
    "apiVersion": 3,
    "name": "reviews",
    "title": "Reviews",
    "actions": [],
    "layout": {
      "type": "stack",
      "main": [
        "summary",
        "table"
      ],
      "aside": [],
      "elements": {
        "table": {
          "type": "Records",
          "props": {
            "columns": [
              {
                "field": "displayName",
                "label": "Customer",
                "format": "text"
              },
              {
                "field": "rating",
                "label": "Rating",
                "format": "number"
              },
              {
                "field": "status",
                "label": "Status",
                "format": "badge",
                "badges": [
                  {
                    "value": "pending",
                    "label": "Pending",
                    "tone": "warning"
                  },
                  {
                    "value": "approved",
                    "label": "Published",
                    "tone": "success"
                  },
                  {
                    "value": "rejected",
                    "label": "Rejected",
                    "tone": "neutral"
                  },
                  {
                    "value": "withdrawn",
                    "label": "Withdrawn",
                    "tone": "neutral"
                  }
                ]
              },
              {
                "field": "createdAt",
                "label": "Submitted",
                "format": "date"
              }
            ],
            "source": "records",
            "heading": "Reviews",
            "empty": {
              "heading": "No matching records",
              "message": "Try another filter, or return when new submissions arrive."
            },
            "onRowClick": "openDetails"
          }
        },
        "summary": {
          "type": "Metrics",
          "props": {
            "heading": "Latest submissions",
            "source": "summary",
            "metrics": [
              {
                "field": "sampleSize",
                "label": "Recent reviews"
              },
              {
                "field": "pending",
                "label": "Awaiting review"
              },
              {
                "field": "averageRating",
                "label": "Average rating"
              }
            ]
          }
        }
      }
    },
    "sources": [
      {
        "id": "records",
        "work": "views.reviews.list",
        "version": "00000000-0000-4000-8000-000000000002",
        "filters": [
          {
            "field": "status",
            "label": "Status",
            "options": [
              {
                "value": "pending",
                "label": "Pending"
              },
              {
                "value": "approved",
                "label": "Published"
              },
              {
                "value": "rejected",
                "label": "Rejected"
              },
              {
                "value": "withdrawn",
                "label": "Withdrawn"
              }
            ]
          }
        ],
        "search": {
          "label": "Search reviews"
        },
        "sorts": [
          {
            "id": "newest",
            "label": "Newest first",
            "field": "$createdAt",
            "direction": "desc"
          },
          {
            "id": "oldest",
            "label": "Oldest first",
            "field": "$createdAt",
            "direction": "asc"
          }
        ]
      },
      {
        "id": "summary",
        "work": "views.reviews.summary",
        "version": "00000000-0000-4000-8000-000000000003"
      }
    ],
    "modalLinks": [
      {
        "id": "openDetails",
        "label": "Open review",
        "modal": "details",
        "scope": "row",
        "source": "records",
        "input": {
          "reviewId": {
            "source": "row",
            "field": "id"
          }
        }
      }
    ],
    "modals": [
      {
        "id": "details",
        "title": "Review details",
        "inputs": {
          "reviewId": "string"
        },
        "sources": [
          {
            "id": "detail",
            "work": "views.reviews.detail",
            "version": "00000000-0000-4000-8000-000000000004",
            "inputBindings": {
              "reviewId": "reviewId"
            }
          }
        ],
        "actions": [
          {
            "id": "publish",
            "label": "Publish review",
            "work": "views.reviews.moderate",
            "version": "00000000-0000-4000-8000-000000000001",
            "input": {
              "id": {
                "source": "data",
                "field": "id"
              },
              "revision": {
                "source": "data",
                "field": "revision"
              },
              "decision": {
                "source": "constant",
                "value": "approve"
              }
            },
            "when": {
              "field": "status",
              "equals": "pending"
            },
            "confirmation": "Publish this review on the product page?",
            "scope": "page",
            "source": "detail",
            "refreshPage": [
              "records",
              "summary"
            ],
            "refresh": [
              "detail"
            ],
            "closeModal": false
          },
          {
            "id": "reject",
            "label": "Reject review",
            "work": "views.reviews.moderate",
            "version": "00000000-0000-4000-8000-000000000001",
            "input": {
              "id": {
                "source": "data",
                "field": "id"
              },
              "revision": {
                "source": "data",
                "field": "revision"
              },
              "decision": {
                "source": "constant",
                "value": "reject"
              }
            },
            "when": {
              "field": "status",
              "equals": "pending"
            },
            "confirmation": "Reject this review? It will remain in your records.",
            "destructive": true,
            "scope": "page",
            "source": "detail",
            "refreshPage": [
              "records",
              "summary"
            ],
            "refresh": [
              "detail"
            ],
            "closeModal": false
          }
        ],
        "pageActions": [
          "publish",
          "reject"
        ],
        "layout": {
          "type": "stack",
          "main": [
            "section"
          ],
          "elements": {
            "section": {
              "type": "Section",
              "props": {
                "heading": "Review"
              },
              "children": [
                "detail"
              ]
            },
            "detail": {
              "type": "Details",
              "props": {
                "source": "detail",
                "fields": [
                  {
                    "field": "displayName",
                    "label": "Customer",
                    "format": "text"
                  },
                  {
                    "field": "productId",
                    "label": "Product",
                    "format": "text"
                  },
                  {
                    "field": "rating",
                    "label": "Rating",
                    "format": "number"
                  },
                  {
                    "field": "body",
                    "label": "Review",
                    "format": "text"
                  },
                  {
                    "field": "status",
                    "label": "Status",
                    "format": "badge",
                    "badges": [
                      {
                        "value": "pending",
                        "label": "Pending",
                        "tone": "warning"
                      },
                      {
                        "value": "approved",
                        "label": "Published",
                        "tone": "success"
                      },
                      {
                        "value": "rejected",
                        "label": "Rejected",
                        "tone": "neutral"
                      },
                      {
                        "value": "withdrawn",
                        "label": "Withdrawn",
                        "tone": "neutral"
                      }
                    ]
                  }
                ]
              }
            }
          }
        }
      }
    ]
  }
}
```

## Manage views

| MCP tool | CLI command |
| --- | --- |
| list_views | littleworks views |
| get_view {name,stage:"draft"|"published"} | littleworks view-get <name> --stage draft |
| deploy_view {definition,baseVersion} | littleworks view-deploy deployment.json |
| publish_view {name,version,publishedVersion} | littleworks view-publish <name> <draft-version> <published-version|null> |
| delete_view {name,version,publishedVersion} | littleworks view-delete <name> <draft-version> <published-version|null> |
| invoke_function {name,environment,version,input,idempotencyKey} | littleworks invoke <name> <input-json> --env preview --revision <uuid> |

Saving, publishing and removal check the current version pointers to reject stale edits. Removing a view retains historical definitions, works, documents and runs. Remove a work from both current view versions (or remove the view), then pause it before deleting it. There is a limit of 100 registered views per store and 32 KiB per definition. Views inherit the store’s authenticated Littleworks app access; there are no per-view staff roles. Existing CLI versions can call every tool using littleworks call; newer command aliases require an updated CLI package.
