Skip to content

Agents

The canvas manifest (critlab.canvas.json)

The file a publishing agent writes so the canvas shows every screen and state of a prototype with the right names.

critlab draws every version of a prototype as a canvas: each screen a frame, one section per breakpoint (Desktop, Tablet, Mobile) when the prototype is responsive, joined by the links between them. Where the screens come from is a file the publishing agent writes, critlab.canvas.json. Without it, critlab follows the prototype's links and guesses the names.

The requirement

Whenever an agent publishes a prototype that has more than one screen, or any designed state (loading, empty, error, success…), it writes or updates critlab.canvas.json at the package root, next to index.html, before it publishes:

  • List every screen with route (root-relative, e.g. /checkout/review), label (the name reviewers see, e.g. "Review order") and group (the section it belongs to, e.g. "Checkout").
  • Make every designed state reachable from a URL query, e.g. /checkout/review?state=error, and list it as its own screen named after the state ("Review order · Error") with state set. The canvas never clicks or types, so a state that needs a click to appear cannot be shown.
  • Add edges for flow steps that no link shows.
  • Keep the file in sync on every new version: add new screens, remove deleted ones.

The MCP server sends this requirement to every connected agent, and critlab_publish and crit publish --help repeat it. The canvas tools (critlab_publish, critlab_get_canvas, critlab_rebuild_canvas) are all in the default core tool group.

Example

json
{
  "$schema": "https://critlab.app/schemas/canvas-v1.json",
  "version": 1,
  "entry": "/",
  "breakpoints": ["mobile", "desktop"],
  "groups": [
    { "id": "home", "label": "Home & assistant" },
    { "id": "transfer", "label": "Transfer" },
    { "id": "errors", "label": "Errors" }
  ],
  "screens": [
    { "route": "/", "label": "Home", "group": "home" },
    { "route": "/assistant", "label": "Assistant", "group": "home" },
    { "route": "/assistant?state=thinking", "label": "Assistant · Thinking", "group": "home", "state": "loading" },
    { "route": "/transfer/amount", "label": "Amount", "group": "transfer" },
    { "route": "/transfer/review", "label": "Review", "group": "transfer" },
    { "route": "/transfer/review?state=declined", "label": "Card declined", "group": "errors", "state": "error" }
  ],
  "edges": [
    { "from": "/transfer/review", "to": "/transfer/review?state=declined" }
  ]
}

Fields

FieldRule
versionRequired, exactly 1.
entryOptional, default "/". Must be one of the screens' routes.
breakpointsOptional: some of "desktop", "tablet", "mobile". When present it replaces detection.
groupsOptional, up to 40. id: 1–40 lowercase letters, digits and hyphens. label: 1–60 characters.
screensRequired, 1–200. route: root-relative, no spaces, backslashes or .., a page the package serves, each page once. label: 1–120 characters, plain text. group: a declared group id; a screen without one is in "Screens". state: 1–40 lowercase letters, digits and hyphens.
edgesOptional, up to 1,000. from and to are routes of listed screens, never the same screen.

The file is at most 256 KiB of JSON. Unknown fields are refused at every level, and $schema is informational: critlab never fetches it. The same rules as a JSON Schema: /schemas/canvas-v1.json.

A route is a page of the package when critlab would serve an HTML document for it: an HTML file in the package (/pricing/ for pricing/index.html, /about.html), or an extensionless route of a single-page app that has a root index.html (/checkout/review), or a hash route (/#/cart). A route to a file the package does not have, or to a file that is not HTML, is refused.

Checking the file

crit canvas check <dir> validates the file locally with the server's own rules, with no network. crit publish and critlab_publish check it again before uploading, and the server checks it before storing anything: a broken file refuses the whole publish, naming the field and the fix, for example:

text
critlab.canvas.json: screens[3].route "/pricing.html" is not a page in this package (no such HTML file). Hint: use a route the package serves, e.g. "/pricing/" for pricing/index.html, or remove the screen.

crit publish --canvas <file> publishes another file as the package's critlab.canvas.json.

Without the file

Publishing a package with two or more HTML pages and no critlab.canvas.json prints a note saying so. critlab then builds the canvas by following the links it finds in the rendered pages:

  • It opens each page in the same sandbox reviewers use and reads the href of every link. It never clicks, types or submits, so a screen that only a button's script reaches is invisible to it — list that screen in the file.
  • Only links into the prototype itself count. It stops at 40 found screens, 4 links deep, 120 snapshots and 4 minutes, and says when it stopped early.
  • A screen's name is its page title (a site suffix shared by every page removed), else its first heading, else its route. Its group is the route's first path segment.
  • Pages that are not part of the flow go under "Other screens", the last group. Three kinds: a page no other page links to (critlab looks at every HTML file in the package, so it finds pages nothing points at), a not-found page (its title or first heading says so, in English, German, Croatian, Spanish, French and a dozen other languages, or its file is 404.html or 404/index.html; it goes there even when something links to it), and a page that renders blank at desktop size, such as a Netlify forms stub, when nothing links to it. Every one is still a screen you can open and comment on, and nothing is dropped. A page that renders blank at desktop size is not rendered again at tablet and phone sizes: it keeps a "Blank page" frame in every column. A page something links to stays in its group whatever it is called, so an "Offline" or "Card declined" page the flow reaches is a normal screen. The entry is never moved.

When the file exists, the crawl still runs, but only to find what the file is missing: routes it finds that the file does not list are shown under "Other screens" and reported, never dropped. The file wins everywhere else: a screen it lists keeps the group, the name and the renders the file gave it, whatever the page is (a listed 404 page stays in the group you put it in).

Breakpoints

Desktop is always a section. Without breakpoints in the file, critlab opens the entry page and up to three more pages (the first ones its crawl visited, blank pages skipped) at phone (390 × 844) and tablet (820 × 1180) sizes, compares each with that same page at desktop (1280), and adds a section when at least one of those pages qualifies and at least half of the measured pages do:

  • Mobile. A page qualifies when it declares width=device-width in a viewport meta, does not scroll sideways because of the phone, and its @media rules or layout change at 390.
  • Tablet. A page qualifies when it does not scroll sideways because of the tablet width and its @media rules at 820 are neither its desktop rules nor its phone rules: a layout of its own.

Sideways scrolling counts against a page only when the narrow width causes it (it scrolls sideways at 390 or 820 but not at 1280) and only when a reader could see it: the page does not clip it itself (overflow-x: hidden or clip on html or body), and visible content reaches past the right edge. An off-canvas menu that is hidden, fixed to the window or clipped by an ancestor does not count. Because the pages vote, one redirect stub or one odd page cannot decide for the canvas, while a desktop page shrunk onto a phone on most of its pages never gets a Mobile section. The extra pages count against the 120-snapshot limit.

Detection says why. crit canvas <slug> and critlab_get_canvas print one line per breakpoint under the list of sections:

text
Breakpoints: Desktop, Tablet
  Desktop: always shown
  Tablet: detected — 3 of 4 measured pages have a layout of their own at 820 px
  Mobile: not detected — none of the 3 measured pages fit a 390 px phone and change layout (at least half must). /, /about.html, /contact.html: no width=device-width viewport meta
  To show a breakpoint detection missed, list it in critlab.canvas.json: "breakpoints": ["desktop", "tablet", "mobile"] (the file's list replaces detection).

The API sends the same facts in detection (reasons has one sentence for each breakpoint that is missing; decisions has the counts and each measured page's verdict). To get a section detection does not give, list it in the file's breakpoints: that replaces detection completely.

Reading the canvas

crit canvas <slug> and the critlab_get_canvas tool list the sections and why each breakpoint is or is not there, every screen with its route, group and open comments per breakpoint, and the flow. A page the crawl moved under "Other screens" says why in brackets, and the closing note repeats it:

text
Other screens (2 screens)
  9. Page not found  /404.html  [not-found page]
     Desktop: 0 open · Mobile: 0 open  · id 0acab775-…
  10. /netlify-forms.html  /netlify-forms.html  [blank page, rendered at Desktop only]
     Desktop: 0 open, blank page · Mobile: 0 open, blank page  · id 87ea91be-…

2 pages are under "Other screens", out of the flow: 1 not-found page, 1 blank page (rendered at Desktop only). Every page is still a screen. List a page in critlab.canvas.json to put it in a group.

The API sends the same facts in report.otherScreens (route and why: unlinked, not_found or blank) and report.skippedRenders. Every screen and every section also carries other (true for a page under "Other screens", including a route the crawl found that your file does not list; false for everything else, even a group you named "Other screens" yourself), so crit canvas <slug> --json tells you which screens are out of the flow without reading names. critlab_get_canvas with includeImages: true returns the screens' snapshots too, so an agent sees what reviewers see. crit canvas rebuild <slug> and critlab_rebuild_canvas build it again.

Screenshots of a native app

A team building a native app (SwiftUI, Kotlin, React Native, Flutter) publishes the screens themselves: a folder of PNG, JPEG or WebP screenshots and a critlab.canvas.json of the screens flavour next to them. critlab compiles the set into a prototype reviewers click through like a clickable mockup, and draws its canvas straight from the set, ready the moment it is published. The flavour is told apart by its top-level device; everything else about the file (one name, 256 KiB at most, unknown fields refused, the JSON Schema's $defs.screens) is as above.

bash
crit publish --screenshots ./shots           # the folder holds the images and critlab.canvas.json, no index.html
crit canvas check ./shots                    # the server's rules, locally, plus warnings about the flow

The MCP tool is critlab_publish with screenshots: true. The publishing agent writes the file, exactly as for an HTML prototype: the flow and the device are the agent's to say.

A screens manifest

json
{
  "$schema": "https://critlab.app/schemas/canvas-v1.json",
  "version": 1,
  "device": "mobile",
  "platform": "ios",
  "groups": [{ "id": "onboarding", "label": "Onboarding" }],
  "screens": [
    { "id": "welcome", "image": "01-welcome.png", "label": "Welcome", "group": "onboarding", "source": "Sources/WelcomeView.swift" },
    { "id": "signup", "image": "02-signup.png", "label": "Sign up", "group": "onboarding" },
    { "id": "video", "image": "03-video.png", "label": "Product video", "orientation": "landscape" },
    { "id": "signup", "image": "ipad/02-signup.png", "device": "tablet" }
  ],
  "entry": "welcome",
  "edges": [
    { "from": "welcome", "to": "signup", "label": "Tap Get started", "primary": true,
      "hotspot": { "x": 0.08, "y": 0.86, "w": 0.84, "h": 0.07 }, "device": "mobile" },
    { "from": "signup", "to": "video", "label": "Tap Continue", "device": "mobile" }
  ]
}

Fields of the screens flavour

FieldRule
deviceRequired: "mobile", "tablet" or "desktop", the set's own device. A screen's own device overrides it.
platformOptional, at the top or per screen: ios, android, macos, windows or web.
orientationOptional, at the top or per screen: portrait or landscape; without it, the image's aspect decides. A landscape phone or tablet screen is framed by the device turned sideways (844 × 390, 1180 × 820).
scaleOptional: image pixels per point, 0.5 to 4 (an @2x / @3x file name says it too).
screensRequired, 1 to 200 per set. id: 1 to 40 lowercase letters, digits and hyphens, unique per device. image: a PNG, JPEG or WebP in the folder. label, group and source (a relative path in the app's repo, the file that draws the screen) as their names say. The same id once more with another device is that screen's variant on the other device: it lists only id, image and device (and optionally source, platform, orientation, scale) and takes its label and group from the first entry.
entryOptional, a screen id; default the first screen.
edgesThe flow. from, to: screen ids. label: optional, what the step is ("Tap Continue"), shown on the canvas arrow and in the bundle. hotspot: optional, { x, y, w, h } as fractions (0 to 1) of the screen IMAGE, the region a tap opens; on a long capture, of the whole image. primary: true: the screen's "next" step, which a click anywhere else on the screen (and the → key) follows; at most one per screen and device. device: optional, limits the edge to one device. Without it the edge applies on every device that has both screens, and in a set with more than one device an edge with a hotspot must name its device.

A screen's only outgoing edge is its primary when it has no hotspot. An edge with a hotspot is a tap on that region only, unless primary: true. An edge with neither is flow only: drawn on the canvas and in the bundle, nothing to click. edges left out chains the screens in file order, per device; edges: [] is an unlinked gallery (scroll and comment only).

Where the tap is. The agent knows where the control it tapped is: its frame from the simulator's accessibility tree (an XCUITest element's frame, the bounds in adb shell uiautomator dump) or the UI test that took the screenshot, divided by the screenshot's size in points. For a 390 × 844 pt iPhone screen and a button at { x: 32, y: 726, width: 326, height: 60 } that is { "x": 0.082, "y": 0.860, "w": 0.836, "h": 0.071 }.

Without the file, the screens are chained in file order (a folder or an NN- prefix is the group, the file name the label), the device is inferred from the pixel size, and the publish says both: Note [canvas_manifest_missing] and Note [device_inferred].

Checking a set

crit canvas check <dir> refuses a broken set with the server's own message, naming the field: an image that is not in the folder, an unknown device, a source that is not a relative path, a hotspot that is empty or outside the image, a second primary from one screen. A valid set can still be hard to click through, and the check then warns (the publish still goes through):

text
Warning [flow_only_edge]: The edge review → signup on mobile is flow only: it is drawn on the canvas, but there is nothing to click. Give it a hotspot, or primary: true.
Warning [no_primary]: The screen review on mobile has 2 edges and no primary: a click outside its hotspots only flashes them. Mark the obvious next step primary: true.
Warning [overlapping_hotspots]: On signup (mobile) the hotspots to review and welcome overlap: where they meet only one of them can be tapped.
Warning [small_hotspot]: The hotspot of review → welcome on mobile is 8 × 17 px at the 390px frame, under the 24 px a tap target needs. Make it at least 24 px each way.

The size check measures each hotspot at the width its screen is shown at: Mobile 390 px, Tablet 820 px, Desktop 1,280 px, and 844 px or 1,180 px for a landscape phone or tablet screen.

The canvas of a set

A set's canvas needs no crawl: one column per device the set has (Desktop, Tablet, Mobile), one band per group in the set's order, each screen in the column of its device (a mixed set shows the same screen in two columns), every frame the screenshot itself, and an arrow for each edge, named by its label where the gap between two frames has room. A share link of a set with two or more screens opens on the canvas; a click on a frame opens that screen live; a set of one screen opens the screen. In the live viewer the bar's Screens button lists the set's screens by device and section and opens any of them.

crit canvas <slug> and critlab_get_canvas print the set's devices, each screen with its own id, its page and open comments per device, and the flow per device:

text
Canvas of "a8f3k2" v2: ready · a screenshot set · 3 screens on Tablet + Mobile (iOS) · from critlab.canvas.json, the flow as written

Onboarding (2 screens)
  1. Welcome [welcome]  (entry)
     Mobile: / · 2 open
  2. Sign up [signup]
     Tablet: /tablet/signup/ · 0 open
     Mobile: /mobile/signup/ · 0 open

Flow:
  Mobile:
    Welcome [welcome] → Sign up [signup]  "Tap Get started"  (primary, tap on a hotspot)
    Sign up [signup] → Product video [video]  "Tap Continue"  (primary, click anywhere)

The API's canvas of a set carries format: "screens", screensSet (its device, devices, platform and flow), each screen's screenKey (its id in the file), each frame's own route, orientation and kind: "screenshot", and each edge's breakpoint, label, primary and tap. There is nothing to rebuild: crit canvas rebuild answers ready and changes nothing; publish a new version to change the set.