Skip to content

Scan codes

A code printed on a physical object drives the platform from the floor. Scanning it finds the row that stands for the object, checks that the row is in an expected queue and state, and runs a configured action: open it, claim it, resolve it with a canned payload, move it to another queue or cancel it. This page covers the code format, rules, holding an item across scans, badges and station devices.

index.ts
await start({
database: { connectionString: process.env.DATABASE_URL },
features: { scanCodes: true },
scanSchemes: [
{
version: 10,
name: 'Carton',
target_facet: 'cartonId',
encoding: 'delimited',
rules: [
{
category: '1',
name: 'Packed',
steps: [
{
query: { roles: ['packing'], status: 'pending', availability: 'available' },
verb: 'resolve',
params: { resolverPayload: { outcome: 'packed' } },
},
{ verb: 'show-detail' },
],
fallback: { markdown: '**No carton found for this code.**' },
},
],
},
],
});

Print 10:1:ctn-4417 beside the word “Packed”. Scanning it resolves the carton’s pending row in packing. If the row is anywhere else, the second step opens it, so the scan also answers “where is this carton?”.

features.scanCodes: true turns on the dashboard’s scan surfaces. The execute and config APIs work either way.

A code has three parts: version:category:target.

Part Width Selects
version 2 digits (10 to 99) The scheme: which metadata facet the target matches and how the code parses
category 1 digit (0 to 9) The rule: what scanning this code does
target scheme-defined The value matched against the scheme’s target_facet

The label and the row share the facet value, so the scan is the join. In the dashboard, operators name schemes and actions and the numbers are assigned for them.

encoding Parses Use with
delimited (default) Text split on one character, default :, e.g. 10:1:SN-123 Code 128, QR, DataMatrix
fixed Digits only with a target_length, e.g. 1015949975930; a trailing check digit is accepted UPC-A, EAN, ITF
gtin UPC-A, EAN-13, EAN-8, GTIN-14 as printed on products. The target is the 14-digit GTIN and the one rule is category 0 Manufacturer barcodes

A deployment has at most one gtin scheme. While it is enabled, a fixed scheme that would accept an 8, 12, 13 or 14 digit code is refused, so every digits-only code has one reading.

A rule is a friendly name, an ordered list of steps and a fallback:

  • Event: a code arrives.
  • Condition: each step’s query against the queue (role, status, availability, facets).
  • Action: the verb of the first step that matches.
{
"query": {
"roles": ["packing"], // expected queue(s)
"status": "pending", // pending | resolved | cancelled
"availability": "available", // available | claimed | mine | any
"facets": { "state": "open" }, // extra metadata guards
"subtypes": ["carton"] // optional types / subtypes
},
"cardinality": "first", // first | many
"verb": "resolve",
"confirm": { "prompt": "Close this carton?" },
"params": { "resolverPayload": { "outcome": "closed" } }
}
Verb Effect Params
show-detail Open the row’s detail page
show-list Open the list filtered to all matches
claim, claim-show-detail Atomic claim (and open) durationMinutes, metadata
release Release the caller’s own claim
resolve Atomic claim and resolve with a canned payload resolverPayload, metadata
escalate Create a row in another queue, optionally closing this one targetRole, closeCurrent, escalationType, description, metadata
cancel Claim as a lock, then cancel
accumulate Add the scanned item to an open accumulator row itemKey, resolverPayload, metadata, accumulate
present, hold, fill Offer choices, hold a subject, check off an expected item See below
availability Matches
mine A row under a live claim held by the acting identity
available An unclaimed row, or one whose claim lapsed
claimed A row under anyone’s live claim
any (default) Every row the rest of the query matches

String values in itemKey, resolverPayload and metadata interpolate {scan.target}, {scan.category} and {scan.scannedAt}. {claim.<facet>} reads the acting user’s single live claim, and {item.<facet>} reads the row an item-mode accumulate located. A token that cannot resolve makes the step fall through.

Every mutating verb stamps scanScheme, scanCategory, scanActionName and scannedAt on the row, so scan-driven changes stay queryable.

Claim, resolve and cancel run as single by-metadata statements, and the caller’s role scope folds into the same SQL filter. Two people scanning the same code produce exactly one transition. The second scan reports conflict or falls through to the locator step.

A step with confirm locates instead of acting. The scan answers confirm_required, the dashboard opens the row and shows the prompt, and confirming calls the same per-id endpoint every surface uses.

When no step matches, the response carries the rule’s fallback: markdown for the operator and optionally a route to open.

Some objects carry one code for life, and the right action depends on where the object is. A present step locates the row, states its facts and returns a labeled choice set:

{
"query": { "roles": ["packing", "shipping", "returns"] },
"verb": "present",
"facts": [
{ "label": "Order", "value": "{item.orderId}" },
{ "label": "Bin", "value": "{item.binCode}" },
{ "label": "Ship by", "value": "{item.releaseAt}", "format": "date" }
],
"choices": [
{ "label": "Claim and start", "verb": "claim", "requireActingIdentity": true, "code": "CLAIM" },
{ "label": "Complete", "verb": "resolve", "requireActingIdentity": true,
"confirm": { "prompt": "Mark this item complete?" },
"params": { "resolverPayload": { "outcome": "complete" } } },
{ "label": "View details", "verb": "show-detail" }
]
}
  • facts takes up to 12 labeled templates rendered on the server. format is date, age or ago.
  • A choice’s code lets a worker scan the object, then scan an action card.
  • Picking a choice calls POST /api/scan-codes/execute-choice with a pointer. The server re-reads config, re-locates the row, re-checks identity and writes the exact row that was shown. A row that moved on answers conflict.
  • autoSelectSingle: true on a step with one confirm-less choice runs it directly: scan the item, it is claimed, the form is on screen.
  • A choice with roles runs only for a person holding one of them. A badge without the role is refused with the choice’s refuse copy and the badge is not spent. That is the manager override.

At a bench where items go into containers, the motion is three scans: the item, your badge, the container. The container scan is the act.

hold is the item scan. It writes nothing and answers held with a subject the station keeps in memory and sends back with the next scans:

{
"query": { "roles": ["packing"] },
"verb": "hold",
"params": { "hold": {
"ttlSeconds": 300,
"label": "{scan.target}",
"headline": "{item.binCode}",
"expect": { "schemes": [14], "prompt": "Walk to this bin and scan it." }
} }
}

The station shows headline large, with subline and the prompt under it. hold.show names a roster field of the held row’s form and renders it under the hold card. The subject lapses after ttlSeconds (default 45).

Subject steps run only while a subject from subject.schemes is held, and read it with {subject.<facet>}:

Step The container scan
accumulate with accumulate.from: "subject" Adds the held item to the accumulator the scan names. The held row is written as the reciprocal in the same statement.
accumulate with accumulate.into: "subject" The held row collects the scanned code. With accumulate.item, the scanned item’s own pending row is written as the reciprocal too.
fill with fill.into: "subject" Checks off one expected code on the held batch row. The answer carries progress: { filled, total, remaining }.

match.target lists templates the scanned target must equal (["{subject.binCode}"]). match.facets lists facets the container must share with the held row. A miss answers refused with refuse.markdown rendered, and nothing is written:

"match": { "target": ["{subject.binCode}"] },
"refuse": {
"markdown": "That is bin {container.binCode}. This item goes in **{subject.binCode}**.",
"conflict": "That bin just closed. Scan the item again."
},
"done": { "markdown": "Place it in **{container.binCode}**. All good." }

When the write loses a race, the answer is refused with refuse.conflict. Of two racing acts that name the same row, exactly one wins: the reciprocal statement writes both rows or neither. The server re-reads the subject’s row on every request and answers subject_stale when it moved on.

A scheme with kind: "identity" is the badge layer. Its target_facet names the lt_users.metadata key a badge token matches (for example badge_id). A badge scan answers identity_primed with an acting grant: an ephemeral token minted under the scheme’s policy.

Field Meaning
grant_ttl_seconds How long the grant lives (1 to 86400)
grant_max_uses 0: bound by time. n: covers n acts.
grant_scope action (default): each act spends a use. subject: the grant binds to the held subject and covers that item’s whole motion.

The grant rides later scans as actingToken. Verbs run as the badged person under their own live RBAC. An expired, exhausted or revoked grant answers not_primed, never a silent run as the device. Showing, presenting, holding and refusing spend nothing. A write spends one use, and a write that does not land gives it back.

Steps opt in with requireActingIdentity: true. A role sets its stations’ policy in properties.badge_grant:

"properties": { "kiosk": true, "badge_grant": { "ttl_seconds": 600, "max_uses": 0 } }

Seed badge tokens as long random strings. Unknown badges answer identity_unknown.

A shared device signs in once as a station account: a member with read_scope: 'all' and write_scope: 'none' on the queues it fronts. Reads are free. Every write takes a badge, and write_scope: 'none' means even a misconfigured rule cannot write as the station.

A role with properties.kiosk: true locks the dashboard for a user who is a member of exactly that one role: no left nav, the role’s list (or first portal) is home, and the session stays on the list, detail page, portals and scan screens. On the work form, anyone at the bench edits freely and the badge is asked at submit. Writes record the badged person in assigned_to and resolved_by, with the device in scanStation.

The full-screen experience is /scan/station. With a live grant, the header shows the badged person and the minutes left.

Terminal window
curl -X POST https://lt.acme.dev/api/scan-codes/execute \
-H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \
-d '{ "code": "10:1:ctn-4417" }'

The call runs as the caller under normal RBAC and adds actingToken and subject when the station holds them. Every terminal state is a structured 200:

Outcome Meaning
executed A step matched and its action ran
matched_list A show-list step matched
confirm_required A confirm step located its row
choices, held A present or hold step located its row
refused Understood and not acted on. Nothing written.
no_match_fallback No step matched; fallback included
no_open_container The item was found but no pending container carries its facet
conflict A concurrent actor won the row
forbidden The caller’s roles bar the action
subject_stale The held subject moved on
not_primed The act needs a badge
identity_primed, identity_unknown A badge scan matched a person, or did not
unconfigured, invalid_code Unknown scheme or category, or no scheme parses the string

Anything that produces a string can call it: a barcode scanner, an RFID reader, a camera decode, the execute_scan_code MCP tool, or curl.

With scan surfaces on, the dashboard listens on every page. A keyboard-wedge scanner types its decode and ends with Enter. Capture checks whether the recent keys end in a configured code shape, strips the code back out of any focused field and runs it.

  • Digits-only codes fire only at scanner speed (50 ms per key or faster), so a typed number in a field stays there.
  • A form field with x-lt-scan that accepts the scheme takes the scan first.
  • Keys inside an element marked data-lt-scan-capture="off" reach only that field.
  • A scanner with no Enter suffix fires after 300 ms of quiet at scanner speed.
  • Delimited targets carry a-z A-Z 0-9 . _ - and matching is case-sensitive.

The header bar runs rules without hardware: pick a rule from the type chip and type the target.

Admin > Scan Codes lists schemes. Add a scheme, point it at a facet, then add actions: name it, order the steps, set the fallback. The same CRUD is at PUT/GET/DELETE /api/scan-codes/schemes/:version[/actions/:category] and in the MCP tools list_scan_schemes, upsert_scan_scheme, upsert_scan_rule, delete_scan_rule and execute_scan_code. An incoherent rule fails the write with the exact problem.

Declared scanSchemes follow code-owned configuration: under configSource: 'code' a changed rule is live after the next boot. Per-entry reset covers the scheme and its rules together.