Configuration & Technical Handbook

This is the complete reference for the people who design and configure Rangeen — every feature you can add, customise and connect, with worked examples. It's written to be read as a manual: skim the contents, or work through it to build a case type from nothing to a finished workflow. If you only use the system day to day, the Everyday User Guide is the friendlier starting point. Everything here reflects how the system works today.

Case types & fields Screen studio The automation language Templates & tokens Lists · Insights · Routines People · Permissions Design workspace Accounts · Client money Add-ons · Security

1 · How the system is built

Everything hangs off the case type.

A case is the record for one matter. A case type is the mould every case of a given kind is stamped from. When you design a system in Rangeen, you are almost always working inside a case type — because a case type owns nearly everything a case can do:

  • Fields — the data a case records (Data manager).
  • Screens — how those fields are laid out (Screen studio).
  • Templates — the letters, emails and memos it can produce (Playbooks).
  • Automations — the scripted routines that run for it (Playbooks → Automation).
  • To-dos & Lists, assignment slots, quick-view config, collaborator profiles, and its trigger bindings.
  • Its My Day tile — whether the type gets a one-click create tile on My Day and where in the row it sits, ordered independently of the configuration list so moving a tile never disturbs the New case dropdown.
  • Its guided journey — the authored tree Simple mode walks in place of the case hub (section 2).
  • Who may create and open cases of it — the per-case-type Create / Open rights granted on roles, teams and users (section 2). That one changes the mental model stated above: a case type is no longer only a mould, it is also an access boundary.

A few things are organisation-wide rather than per case type: the correspondent directory and its types, global variables, People / teams / roles, Organisation settings, and the single organisation-wide case numbering sequence. The typical build order is: create the case type → define its fields → lay out screens → write templates → add automations → wire triggers → add Lists and Insights. Section 32 walks that path end to end.

There is a second way to build all of it: as files. The whole tenant design — case types, fields, screens, templates, automations, globals and saved lists — can be pulled down as a folder of JSON, edited in an editor (or by an AI agent) against the guide and JSON schemas shipped inside the workspace, then planned and applied back, with a plan you review before anything changes. The tool is rangeen-design; section 26 covers it.

Every configuration surface named in this handbook lives in the top bar, under its grouped heading. Entries appear only for people holding the matching permission (section 23).

Rangeen keeps an automatic version of a case type's whole design as you change it, and you can restore an earlier one — so designing is safe to experiment with. See section 25.

2 · Case types

The mould for each kind of matter.

Where: Manage → Configuration → Case types

  • A case type has a Code (short, immutable, unique), a Name, an icon and a description. Opening a case of that type brings all of its screens, templates, automations, to-dos and lists.
  • Multi-user vs single-user (editor label: "Allow multiple users to have the same case open at once", on by default). With it on, several people can have the case open at once: the case bar turns green while a colleague is in it, and the people chip beside the reference lists who and since when. They still cannot edit the same screen at the same time — the first edit on a screen claims it (an amber padlock on your screen, a green one on a colleague's), the screen reads view-only for everyone else until the editor saves, and the server refuses a save into a screen someone else is editing. Switch it off and the case is one person's at a time: whoever opens it second is asked "Case X is in use by Y. Do you wish to open the case in READ ONLY mode?" — Yes gives an amber bar, every field, action and automation disabled (the server refuses them too), with a "try again" when the holder leaves; No leaves the case closed. A seat is released when its holder closes the case, switches away without unsaved edits, or their browser falls silent for a minute and a half.
  • Enable / disable: a disabled case type disappears from the new-case and List pickers but keeps its existing cases and history.
  • Deleting asks you to type the case type's exact Name back, and is refused while it still owns any field, screen or case, or any legacy template, workflow template, automation or saved List — or while a Case link field on another case type points at it (the error names them). Its design snapshots go with it. In practice a case type with any history cannot be deleted at all, and the error says so: duplicate it and leave the old one disabled.
  • Numbering is organisation-wide by default. Every case, whatever its type, takes the next number from one shared sequence, six digits wide unless you change the width (see Organisation settings). If you would rather each case type counted on its own, switch on Number each case type separately in Organisation settings and give each type its next reference — Patient 001, Solicitor S1, Doctor D1. Only one case type may count in plain numbers; every other one needs its own letters in front, and no two may share the same letters, because otherwise they would eventually reach the same reference. Cases that already exist keep the numbers they have. Matters are numbered under their root as 000123.001.
  • The case-type editor is also where you bind the seven trigger automations (section 6), configure Quick View and assignment slots (section 5) and keep collaborator profiles (section 30).

The rest of what the editor holds

Title patternThe derived case title. Nobody ever types a case title: it is rendered from this pattern on create and re-rendered on every save — see section 5. Cleared, cases fall back to the case type's name.
My Day tilesShow on My Day plus a My Day order, edited from the "My Day tiles" dialog on the Case types page. The order is deliberately independent of the case type's list order, so rearranging tiles never disturbs the New case dropdown.
Intake screen after createNames, by screen code, the screen guided creation lands on instead of the case hub; "— none (land on the case hub) —" is the blank option. A code rather than an id, so the value ports across tenants in a design; a code that no longer resolves quietly falls back to the hub.
Guided journeyAn authored tree for Simple mode, edited at /configure/case-types/:id/journey: at most 200 nodes, at most 4 deep, node kinds group (the only kind that may have children), screen, automation and builtin. Screen and automation codes are not validated, so a renamed target silently drops its node at render time — and a journey can only be cleared by deleting the file, because a file holding an empty node list is a read error rather than a clear.
Client fieldWhich top-level Correspondent holder names the client this kind of matter is for. Shown only with the legal-accounts add-on, and it must be a top-level Correspondent holder on the same case type.
The Edit case type dialog cannot clear a description. Empty the box and save: the dialog reports success and the old text is silently kept, because an empty string is sent as "leave unchanged". The title pattern does clear.

Who can create and open cases of this type

A case type is not only a mould — it is also an access boundary. Each role, team and user can be granted, per case type, the right to raise cases of it and the right to open them, on a Case types tab in that subject's editor. The two verbs are independent: "may open a Rehab Supplier case, never raises one" is the shape it exists for.

The first tick is the switch. While nobody anywhere in the organisation holds a tick for a verb, that verb is unrestricted for everyone. The first box ticked anywhere turns it into an organisation-wide allow-list and every other non-administrator instantly drops to nothing. Read the permission reference before you tick the first box — that is where the whole feature, its three layers and its gaps are set out.
Deleting cases is permission-gated (cases.delete) and deliberately heavy: the confirm dialog requires typing the case's reference, and deleting the root of a family warns, names every matter, and then deletes the whole family together — rows, history and every stored file, permanently. There is no recycle bin.
Changed — and still visible in exported designs. Earlier versions gave each case type its own reference prefix, digit count and start number. case-type.json still writes that reference block and an apply still stores it, but nothing reads it: no UI exposes it and the effective-prefix helper has no call sites anywhere in the solution, so editing it changes no reference anyone will ever see. The real control is caseReferenceStartFrom in Organisation settings (paired with caseReferenceDigits, the minimum width — six unless changed; in the portal the two are one box, Next case number: what you type is the next case and its length is the width). Setting it to N makes the next case N+1, and the box works in both directions — typing a number the sequence has already passed sends it back, after a confirmation, and minting then skips any number an existing case already holds (so a sequence sent back to 30000 with cases at 100400 counts 30000, 30001 … 100399 and then hands out 100414). A design apply never moves a live counter — it only sets the floor, and a lower value is refused with a warning, so a workspace travelling between organisations can never renumber a live one. One consequence for upgraded organisations: the switch seeded the shared counter from the highest purely numeric existing reference and renumbered nothing, so an upgraded tenant shows a permanent mixture of shapes — legacy prefixed references (PAT000005, PI0002.009) beside new bare numeric ones.

3 · Fields & data types

The data model is yours — defined field by field in Data manager.

Where: Manage → Configuration → Data manager (needs database.manage + fields.manage)

Every field has a Code (its internal short name — it must start with a letter and then hold only letters, digits, underscores and spaces, 1–50 characters. Hyphens, apostrophes, brackets and every other punctuation mark are refused, so put those in the label instead, which has no charset rule. Immutable once created), a display label (editable, and overridable per screen), and a data type (also immutable).

Any field can bind an on-change automation (section 6), set from the field's edit dialog.

Two flags that look useful and are not wired up yet. A field carries isAudited and isGdprSearchable, and Design-as-Code round-trips them faithfully — but nothing reads either one, and no field dialog even exposes them (they can only be set through the Fields JSON import, the API or a design apply). The GDPR subject search covers correspondent names, emails and phone numbers plus case-note bodies regardless of the flag, and there is no per-field value audit behind isAudited. Don't rely on either for compliance today.
There is no field-level "required". Required is a property of a tile, set per screen, so the same field can be mandatory on the intake screen and optional everywhere else — see the Screen studio. Looking for it in Data manager is the most common wasted five minutes on this page. Two neighbours of the same kind: there is no per-field disable or archive either (only option values can be soft-disabled — a field can only be deleted, and only when no screen places it), and an assignment slot's Required tick shows a red hint but does not block case creation.

Names: uniqueness, token form and the shadowing trap

Field names are unique per case type case-insensitively, and the namespace covers table columns too: a column may not take a top-level field's name, and a new top-level field may not take any existing column's name (409 field.name.duplicate). Two different tables may each have a column of the same name — a column's name lives inside its own table.

Every name also has a token form: lowercased, spaces and hyphens become underscores, everything else is dropped. "Date Of Birth" is {date_of_birth} in a template and row.date_of_birth in a calculated column, and lookups accept either the raw name or the token form, in any case.

Nothing stops you shadowing a case column. There is no reserved-name list and no warning. Name a field Status, Title or Reference and it quietly wins, because rule and condition evaluation resolves the case's own field bag first and only falls back to Reference / Title / Status / Created / Modified when nothing matched. A bare {status} then means your field; the case's own value is always {case.status}.

The field number shown in the list comes from one organisation-wide database sequence, so numbers are unique across the whole tenant rather than per case type, and a number freed by a deleted field is never reused.

The fifteen field data types

Type (as you pick it)HoldsSettings
TextFree text — names, notes, references.Optional max length (1–10,000).
Whole numberAn integer, no decimals.Optional min / max.
DecimalA number with decimals — money, rates, measurements.Min / max; decimal places (0–10, default 2).
DateA calendar date, with a picker.Allow past / future; default fixed or TODAY±N.
Date & timeA date plus a time of day.As Date, plus an optional @HH:mm default.
TimeA time of day on its own.Optional default time.
Yes / No dropdownThree states: blank, Yes, or No.Optional default.
CheckboxTwo states — ticked or not.Default checked / unchecked.
DropdownA list of predefined values (each a Code + Description, plus any extra columns).Up to 20 extra option columns; values managed separately (below).
Scripted (computed)A read-only value calculated from other fields.A script expression + a declared return type.
Case linkA link to a case of another type; its fields are reachable in templates & scripts.The linked case type.
CorrespondentA "holder" pointing at one correspondent of a chosen type.The linked correspondent type.
TableA repeating sub-table on the case — you define its columns.Member columns (see below).
Time recordA start/stop time log, with optional extra columns.Member columns (see below).
Embedded documentA document slot on the case, with a template document.File uploaded in Data manager.
SignatureA signature drawn on the case screen (mouse, touch or pen), stored as a picture with who signed and when.Drawn on the screen tile; letters, emails and PDF forms can print it.

Default values, and what constraints really do

A default is one string on any scalar field, top-level or table column, shaped by the type:

Textthe text itself.
Whole number / Decimalplain digits — 30, 99.5.
Yes/No, CheckboxTrue or False.
Dropdownan option value Code.
Time24-hour HH:mm.
Date, Date & timea fixed ISO date (2026-05-20) or a TODAY expression — TODAY, TODAY+30d, TODAY-2m, TODAY+2m@09:00; the unit letters are d / w / m / y.

The @HH:mm suffix is honoured only on a Date & time field — a Date ignores it, and a Date & time with no suffix takes the wall-clock time at case create. An unrecognised unit or an unparseable number (TODAY+5x, TODAY+abcd) silently resolves to plain today, with no warning anywhere.

Timing. A top-level field's default is resolved once, server-side, at case creation — adding a default to an existing field changes nothing on cases that already exist. A table column's default works differently: the row editor applies it to every new row you add, for the life of the table, TODAY expressions included. That seeding is browser-side only, so a row created by the REST API, an automation or an import gets no column defaults at all. Scripted, Table and Case link never get a default. Validation: a default is re-checked against the field's own constraints on create and on update (400 field.default.invalid), and a Decimal default is silently rounded to the field's decimal places.

Constraints (max length, min/max, decimal places, allowed past/future dates) can be relaxed or tightened later; the field's Code and data type stay fixed for the life of the field. Tightening a constraint does not retro-validate what is already stored — out-of-range values stay on their cases and keep rendering; only the default is re-checked, and only future writes are held to the new rule. The past/future date rules are enforced server-side on the value-write path, not just in the picker, so a script, the API or an import writing a forbidden date gets a 400 (case.value.past_disallowed / case.value.future_disallowed) — and a Date & time field with "allow past" switched off will reject a value the user picked seconds ago, because the clock moves while the form is open.

Tables (iteration tables)

  • A Table field holds no value of its own — it's a repeating sub-table. You open it and add columns, which are ordinary fields. Twelve of the fifteen data types may be a column: the nine scalars (Text, Whole number, Decimal, Date, Date & time, Time, Yes/No, Checkbox, Dropdown) plus three that are allowed on a Table only — Document (a per-row file), Correspondent (each row links its own) and Calculated (a Scripted column that computes from its own row).
  • You can't nest a Table, a Case link or a Time record inside a table — those three are refused as columns.
  • You can add a Document column, so each row holds its own file. The grid stays readable: a row shows a small icon only when it actually has a document, and clicking the icon previews it. Upload, replace and remove live in the add/edit-row dialog and stage like every other cell — they work on brand-new unsaved rows too, apply when you save the case, and Cancel undoes them.
  • A document attached to the column is a starting point, not a fallback: rows begin empty, and a row gets its own copy only when someone uses Start from template in the row dialog or an automation puts one there. Each row's copy is independent afterwards — changing the column's template never rewrites rows already made from it. The copy is the file as-is; tokens inside it are not merged from the row.
  • Deleting a row deletes the file that row held, and replacing a row's document deletes the file it replaced once the save commits — the case keeps only what its rows actually point at. Document columns are iteration tables only — not Time records.
  • On a case it renders as a grid with a row count and an Add row button. Each column has an Editable toggle in the tile's table settings: switched off, the column shows greyed-out with a lock in the add/edit-row dialog — people can't type it, but field defaults, automations and the API still fill it. Use it for machine-written columns like a computed rate or a stamped date.

Calculated columns — a Scripted column inside a table

A Scripted field used as a table column is a different animal from a top-level Scripted field, and the column-type picker labels it Calculated. It is allowed on an iteration Table only, never on a Time record.

  • Its expression sees exactly one thing: row, whose keys are the token-normalised names of the same row's other columns — a column called "Amount Net" is row.amount_net, so row.amount_net * 1.2 is a complete expression.
  • There is no get('{…}'), no reach into the case's own fields, no index and no rows, and scripted columns never see each other (the editor's one-click insert list filters scripted siblings out). A cross-row aggregate belongs in a Table View computed column instead (section 14).
  • Unlike a top-level Scripted field the value is materialised into the row's stored values on every row write — which is what lets screen tiles, row dialogs, repeating template tokens, Table Views, exports, tables.list() and the Client Hub portal all render it with no extra plumbing, and why a stored value can never go stale between writes.
  • It is read-only everywhere: the add/edit-row dialog omits it, and writes to it through the REST API, a staged save or an automation's put() are silently dropped and recomputed. A broken expression yields a blank cell, never a failed save.
Editing the expression re-materialises every row — except through Design-as-Code. Change a calculated column in the app and every existing row is swept and rewritten after the commit; adding one to a table that already has rows backfills them too. A design apply does not backfill — it changes the script and stops, leaving every existing row on its old value with nobody told. After editing a live calculated column through the design workspace, plan a re-save or a one-off automation across the table.

Time records

  • A Time record field logs work time. Each entry is one Start / Stop run, carrying start, end, a computed duration, who recorded it, and a note.
  • You can add extra columns (e.g. billable, hourlyRate, reason) — templates can sum these for invoicing.
  • A Time record is always a top-level field. It cannot be a column inside a Table (nor a Table inside it), so a table row can't own its own start/stop log. Its own extra columns are narrower than a Table's: the nine scalar types only — Document, Correspondent and Calculated columns are iteration tables only.

Dropdown option values

A Dropdown field's values are edited in their own dialog, with columns for sort order, Code, Description, any extra option columns you defined, and an Active tick.

  • The Code is the identity actually stored on every case, and it is immutable. Afterwards only the description, the custom column values, the sort order and the Active flag can change. Shape: 1–50 characters of letters, digits, spaces, hyphens and underscores, with no leading or trailing space.
  • To retire a value, untick Active: it disappears from new pickers while historical cases keep it and keep rendering it.
  • The implicit Code + Description pair is always there. List either of those two names as an extra column and it is silently dropped, as is a case-insensitive duplicate; the cap is 20 extra columns of 60 characters each.
  • Removing a name from the extra option-columns list leaves the stale values behind on every existing value row — nothing cleans up.
Deleting an option value is not guarded. The handler removes it unconditionally, whether or not cases still hold it — and every case still holding that code then renders an empty string wherever a {field} token resolves it, silently. Always deactivate rather than delete.

Scripted (computed) fields

  • A top-level Scripted field's value is never stored — it's recalculated every time the case is read, so it's always current and read-only. You give it a script expression and declare what type it returns. (A Scripted field used as a table column works the other way round — see Calculated columns above: it is computed and written into the row on every row save.)
  • It reads other fields with get('{FieldName}'); a field named with spaces uses its exact name, e.g. get('{Original Debt}'). It can also read {global.key} globals, case controls ({case.ref}, {case.status}), {system.today} / {system.now}, correspondent-holder attributes ({Client.email}), assignment slots ({assignment:slot.attr}), whole tables ({Payments[]}) and their dotted aggregations ({Payments.sum.amount}).
  • A Scripted field can read another Scripted field: the runner evaluates the referenced one on demand, recursively, memoised per read, and cycle-guarded — a self-read, any loop (A → B → A) or a chain deeper than eight yields blank rather than hanging. Older guidance shipped in the downloadable Fields JSON example says never to do this because it "evaluates blank"; that advice is stale.
  • What a scripted expression cannot use: drilled link walks ({link->field}), |formatter pipes (the identity |value is stripped and does work), and ask.*. None of these raise an error — the field simply goes blank, which is the single most confusing thing about scripted fields. To reach a linked case's data, use a List or an automation instead.
  • ui.* and actions.* are different: they do not blank the field, they are simply inert — the expression still computes its value and the message, viewer or queued action is dropped on the floor.
  • user.* does work: a calculated field runs as the signed-in user, so user.name, user.email, user.id and user.isSignedIn are real. Its role, team and permission members (user.hasRole, user.hasPermission, user.securityLevel…) deliberately stay false or empty — a value materialised once on write must not vary by who is looking at it.
  • Return type. The picker offers everything except Scripted, Dropdown and Case link — including four that are not scalars. Two of those, Table and Embedded document, are refused at create with 400 field.invalid; Correspondent and Time record are accepted and stored even though nothing renders them. Editing an existing scripted field skips the check entirely, so a return type the create path refuses can still be switched in later. The eight that actually behave are the scalars — Text, Whole number, Decimal, Date, Date & time, Time, Yes/No, Checkbox — exactly the set the Fields JSON sanitiser allows. Treat that as the real list.
  • A Scripted field can produce its own value but can't write to other fields, and it never breaks a case read — a broken formula just yields blank. A syntax error is refused at save with 400 field.script.syntax, but only when the expression is actually changing: a label-only edit to a field whose expression was already broken saves happily. The field editor has a Preview button to try a formula against a real case. Scripted fields also work in Lists, templates and Quick View.

Example — a "Balance Outstanding" scripted field (return type Decimal)

get('{Original Debt}') - get('{Total Paid}')

Example — a "Total Paid" scripted field summing a table's rows

(get('{Payments[]}') || []).reduce(function (sum, row) {
  return sum + (Number(row['Amount']) || 0);
}, 0)

Links & embedded documents

  • A Case link accepts either the linked case's reference (what the picker writes) or its internal id, and stores whichever it was given, so one field's cells can legitimately hold a mix; anything that is neither is refused with 400 case.value.not_a_case_link. Templates and scripts "walk through" it to read the linked case's own fields.
  • A Correspondent field is a holder pointing at one correspondent of a chosen type (a "Main Doctor" holder accepting any Doctor). Its details merge into templates and are reachable in scripts.
  • An Embedded document field carries a template document that renders on every case of the type, and can hold a per-case document.

Deleting a field — what it takes, and what it leaves behind

There is exactly one hard block on deleting a field: placement on a screen. A direct placement returns 409 field.in_use.screens, a table tile pointing at the parent returns field.in_use.screen_tables, and a member column sitting on a screen returns field.in_use.member_screens. Nothing else blocks — a merge token in a template, a get() in an automation, a saved List column or a workflow reference all delete cleanly and break silently afterwards, so run Where-used first.

The delete then cascades everything the field owns, and cannot be undone: a Dropdown takes its option values; a Table takes its columns, every row on every case of the type and every document those rows held; a Time record takes its columns and every recorded entry. Template blobs and released row-document blobs are purged after the commit.

It does not delete the values already stored on cases. Case values live in a jsonb bag keyed by field name with no foreign key, so they are simply orphaned — which means creating a new field with the same name later resurrects every old value on every case that had one. If you delete a field to start clean, do not reuse its name. This matters doubly because Code and data type are immutable: the only way to "rename" or "retype" a field is create-new plus delete-old, which walks straight into the trap. (The bulk-delete dialog's warning that deleting "removes all case data stored against it on every case of this type" overstates what actually happens.)

Bulk delete. "Delete fields…" on the Data manager page applies the same rules per field in one transaction, but resiliently: a field blocked by a screen is skipped with a reason instead of failing the batch, and the result reports what was deleted and what was skipped. A member column can't be targeted directly ("It's a column of 'X' — delete the table to remove it"), and one design snapshot is taken for the whole batch rather than one per field, so the restore point is coarse.

Fields JSON — building or copying a field set in bulk

The Data manager page carries a Fields JSON export and import — the fastest way to stand up a field set, or to copy one between case types or environments. Needs fields.manage. The envelope is { "version": 1, "caseTypeCode": "PI", "fields": [ … ] }, up to 1,000,000 characters on import.

  • Validation runs first and writes nothing. You review the plan, tick what you want, and the client then creates each item through the ordinary endpoints — so field validation, permissions and audit all still apply.
  • Caps: 300 fields, 20 columns per table, 500 option values per field, 20 option columns.
  • Friendly type aliases are accepted and mapped to the real types: text/string → Text, integer or "whole number" → Whole number, money/currency/percentage → Decimal, "date & time" → Date & time, boolean → Yes/No, dropdown/select → Dropdown, computed/calculated/formula → Scripted, plus "case link", correspondent, table, "time record" and document.
The import is not atomic. It is one HTTP call per field, per option value and per column, applied in sequence with scripted fields last so their dependencies exist, so a failure part-way leaves everything created so far in place (the client does retry rate-limit responses for roughly two minutes). A field whose name breaks the charset rule is skipped with a reason while everything around it lands.

Dates are entered and shown dd/MM/yyyy platform-wide; a blank date box saved over a stored date clears it.

4 · Screen studio

Lay the fields out the way the work reads.

Where: Manage → Configuration → Screen studio (needs screens.manage)

A screen is a data-entry form bound to one case type; a case type can have many, listed in the case workspace's Screens rail. You build a screen by dragging tiles onto a canvas.

Every screen and placement route sits behind screens.manage — except the reads the case workspace itself makes (list, get, published layout, image download), which are overridden to cases.view, so an ordinary case worker renders screens without being able to design them. Running a screen Button on a case is case-scoped and needs cases.edit.

The canvas

There is effectively one canvas: 192 columns wide, with rows 8 pixels tall. The designer no longer exposes the grid knobs, so every screen the app creates uses it. A tile's x and width are columns (0–191); its y and height are rows of 8px. Only the REST API and Design-as-Code can store anything else (the stored bounds are 4–240 columns and 4–200px rows), and a Design-as-Code screen declaring a different grid is converted to 192×8 — tiles rescaled, proportions kept — with a warning; nothing is rejected. A screen left on a coarser grid can never be nudged back: its author ends up dragging in chunks of a twelfth of the width.

Two silent behaviours on the write path. A tile that would run off the right edge is shortened, never moved — the width is clamped to the canvas, x to the last column, then the width again to what is left — so a too-wide tile quietly comes back narrower with no error. And tiles are absolutely positioned, so two tiles sharing cells render on top of each other: nothing on the write path prevents it, and Design-as-Code only reports overlaps as warnings (Label and Divider overlaps are not warned about at all — a coloured Label banding a group is legitimate).

A tile authored shorter than its default clips its contents, so the defaults are worth knowing. Heights are in 8px rows:

TileDefault height
Field — scalar5 rows
Field — long text (max length > 200)11 rows
Embedded document30 rows
Signature15 rows
Label / Button / Divider3 / 4 / 1 rows
Correspondent attribute, Case control, Assignment, Global5 rows
Table (and Time record)25 rows
Table view, SQL viewer, Web viewer30 rows
Image viewer20 rows

In the designer a tile's creation default is also its resize floor — a tile can be made taller but never shorter than it was born. Six are allowed to shrink: an Embedded document (240px down to a 36px compact card), a table (down to 88px, header plus one row), a SQL viewer and a Table view (both 240px down to that same 88px), a long-text Text field (88px down to 36px), and a Signature (120px down to 72px, the smallest strip a person can still sign in).

The tile kinds

TilePlaces
FieldA case field as an editable input. Also used for linked (cross-case-type) fields and embedded documents. Not for a correspondent holder — the API refuses that placement, and Design-as-Code deletes such a tile on import; place Correspondent attribute tiles instead.
LabelStatic text — with a chosen size and colour.
ButtonA button that runs an automation. Style: Primary / Secondary / Danger.
TableA sub-table (add / edit / delete rows). Also the tile used to place a Time record's start/stop log.
Web viewerAn embedded web page (an http(s) URL).
Image viewerAn uploaded image — optionally overridable per case.
Correspondent attributeOne detail of a linked correspondent (name, email, address…). Every tile naming the same holder forms one block.
GlobalAn organisation-wide global variable — editable (saving writes back to the global for everyone).
Case controlA system property (reference, status, dates…) — always read-only.
AssignmentA detail of the user in an assignment slot — always read-only.
SQL viewerA saved list rendered as a read-only grid on the case.
Table viewA saved Table View rendered read-only, 25 rows a page, with a Refresh button. Rows of a table-sourced view are selectable and feed a screen button's selection.rows().
DividerA rule across or down the canvas — orientation, colour, opacity and thickness.

Eight of those come off the designer's tool strip directly — Label, Button, Divider, Table, SQL viewer, Table view, Image viewer and Web viewer — plus a dedicated Place an embedded document button. Field, Correspondent attribute, Global, Case control and Assignment tiles all come from the field selector, which searches every field, link and correspondent on the case type and routes each picked token to the right kind. Tokens it cannot place are skipped with a count only — "N selections skipped — that kind can't be placed on a screen." — never a name, so check what actually landed.

Draft & publish

  • Editing is a cycle: click Edit to put the screen into draft, lay it out, then Save & publish. Until you click Edit the canvas is read-only — you can select a tile to inspect it, but every drag, resize, add, delete and settings action is blocked.
  • While a screen is in draft, everyone working cases keeps seeing the last published layout — your work-in-progress never leaks onto live cases. This is the commonest "I changed it and nothing happened": if a change looks like it did nothing, check the screen is published.
  • A brand-new screen is live immediately, not in draft — an empty new screen shows on cases the moment it is created. Put it into draft (Edit) before laying it out if that matters.
  • Save & publish makes the new layout live; Reset rebuilds the screen from the last published snapshot.
Reset is destructive, and cannot be undone. It does not rewind your edits — it re-creates every tile from the snapshot, so every placement gets a new identity. Per-case image rows hang off placement ids, so every per-case image a user uploaded onto an Image viewer tile on this screen is discarded for every case, tenant-wide, and a tile added during the draft loses its designer image. Reset is available only while the screen is in draft, and the snapshot it restores is the one taken when you pressed Edit — not when you last published — so on a never-published screen it rewinds to whatever Edit captured, possibly nothing. It is refused outside draft, and on a snapshot that will not parse.

Per-screen field settings

  • Label override, read-only on this screen, required on this screen — set per placement, so the same field can behave differently on different screens.
  • Hide the label — the value renders with no caption, for tightly packed rows where a nearby text label already names the field.
  • Display format — for Date / Date & time / Number / Decimal, picking a format renders the value as read-only formatted text; "None" keeps it editable. For Dropdown fields it picks which column the list shows.
  • Multi-line — turn a Text field into a wrapping text area. The textarea appears if any of three things is true: the designer ticked Multi-line, the field's max length is over 500, or the tile is at least 110px tall.
  • Placeholder (max 200 characters, offered on Text / Whole number / Decimal only) and Help text (max 500 characters, shown as an info icon beside the tile). Both are hidden for Embedded document tiles and for linked (cross-case-type) tiles.
  • Allow download (default on) and Allow upload (default off) on an Embedded document tile. With upload on, a user can drop a document straight into the field from the case screen, replacing what that tile shows for that case only. Unlike a table row's document, that upload is not staged: the file is written and the field value set immediately by a separate request, bypassing Save, and the field's on-change automation runs at once. The empty-state line changes with the setting — with upload on it invites a file for this case, with it off it points the user at Data manager.
  • On change automation — an automation that runs the moment a user has changed this field and moved on (tabs or clicks away, saves, switches screen or closes the case) — never per keystroke, and only for edits typed on a screen. It runs before anything is saved: get() reads the value just typed, and stop('why') rejects it — the value stays on screen outlined red with the reason, and Save waits until it is changed. Use it for checks like "the phone number must have 11 digits". Separate from the field's own on-change automation (Data manager), which runs when the case is saved.
  • Visible from level / Editable from level — per placed field (and per table tile and correspondent block): users below the "visible from" level get an empty space where the tile would be; users below the "editable from" level see it read-only. Same 0–99 scale as a screen's authorisation level, checked against the user's security level; blank = everyone (0 collapses to blank, so "no gate" has one representation). Client Hub count as level 0, and super-admin or system contexts are never gated.
Three ways an On change automation silently does nothing. (1) A hook added or changed during a draft does not fire — while a screen is in draft the server resolves a tile's binding from the published snapshot, not the live tile row, so your edit runs as last published, and a tile added in the draft runs nothing at all. Publish before testing. (2) Ticking read-only on this screen on that tile stops it: the commit is refused with a 403. The binding exists only on Field tiles whose value a user can actually type, so linked (cross-case-type) tiles never fire it either, the designer hides the picker on Scripted fields, and a tile whose edit level gates the viewer cannot commit from it. (3) The bound automation must live on the screen's own case type and must not be a library automation — a library automation can only be called from other scripts via automation.run(), so it cannot be bound to a tile. A deactivated automation is accepted and simply does not fire. The run is posted under a pseudo-trigger naming the placement, and the server resolves which automation to run from the tile, so the caller — a collaborator included — can never name one.

Three more facts about the level gates. They are accepted only on Field, Table and Correspondent attribute tiles — the API refuses them elsewhere, even though the gate itself would honour them anywhere. A view-gated tile leaves an empty hole in the layout: tiles are absolutely positioned, nothing reflows, and there is no padlock, message or count, so a user at a low level simply sees a sparse screen and is never told why. And you cannot preview a gate from the designer — it is applied on the runtime projections only (the case workspace bundle, the published-screen read, a scripted screens.open() popup and the collaborator projection) and deliberately never on the designer's own reads, which always show an author every tile. To test a level, sign in as a lower-level user.

Correspondent blocks

Correspondent attribute tiles are the only way a correspondent belongs on a screen. Each tile binds the holder field plus one attribute, and every tile naming the same holder forms one visual block, drawn in a dashed zone with a label-bar header. The attribute inputs are always read-only at runtime — clicking or typing into one does nothing but make the relevant header button glow for a moment.

  • The header carries up to four buttons — Add (create a new correspondent and use them), Select (search existing), Edit (open the selected correspondent's record) and Clear (unlink; the record itself is kept) — each switched on or off per screen in the block's "<Holder> — block buttons" dialog. Turn all four off and the block is display-only: the correspondent can then only change through automations. Edit is disabled until a correspondent resolves; Clear is enabled whenever an id is stored, even a dead link.
  • Edit opens the tenant-wide directory record, so a save there changes that correspondent on every case that uses them. The dialog looks like a per-case form and is not.
  • The block's settings are written to every attribute tile of the holder, and readers take the first non-null bag in (y, x, id) order — so editing the block from any one of its tiles is the same edit.
  • An edit-gated block keeps its read-only attribute tiles but is delivered with its buttons blanked, losing Add / Select / Edit / Clear entirely.
  • A correspondent tile can render 22 built-in attributes plus any per-type custom field, including four composed address forms (address_block, full_address, street_address, city_line). Three built-ins display under renamed labels — first_name as "Forename", last_name as "Surname", organisation_name as "Company name"; the stored keys are unchanged.
"Hidden system fields" is not a screen setting. It lives on the correspondent type and is tenant-wide: hiding a built-in only removes it from that type's add and edit dialogs. The stored value is untouched, and a screen tile bound to it still displays it. Seventeen keys are hideable; the display name is not, because it is derived.

Table tiles and the column designer

Placing a Table (or Time record) tile gives it its own settings dialog, independent of the field definition.

  • Behaviour toggles, each of which removes the control rather than disabling it: show the add-row button, allow editing rows, allow deleting rows, allow moving rows up / down, and — on a Time record — show the Start / Stop / Manual buttons. With all of them off, an automation-managed table shows no row chrome at all. Rows can still be selected, because selection feeds screen-button automations rather than edits.
  • Allow selecting multiple rows picks the selection mode. Multi-select (the default) always reserves a slim checkbox column, so starting a selection never shifts the data columns sideways; single-select renders no checkbox column — clicking a row highlights it, clicking again clears it.
  • Columns — drag to arrange is the column designer, with a 1:1 preview strip you drag. Per column: a pixel Width (honoured only between 24 and 2000px — anything outside reverts that column to automatic), a Header label override (max 200 characters), a Display column (max 60), an Editable toggle, and two independent visibility toggles, In table and In dialog.
  • In table off removes the column from the grid but the add/edit-row dialog can still ask for it; In dialog off stops the dialog asking and the stored value survives untouched, because both save lanes merge by key. Hiding a column from both is refused — "A column can't be hidden from both the table and the add/edit dialog — remove it instead." — but that is an app-only rule: a Design-as-Code apply lets it through.
  • When every visible column has a pixel width, their sum becomes the table's minimum width and the grid scrolls sideways inside the tile. One automatic column anywhere in the visible set disables that entirely, and widths fall back to percentages or equal shares.
  • Display column is per member kind: on a Dropdown member it names which option column the cell shows (code / description / a custom column; blank falls back to the raw code); on a Correspondent member it names the attribute the cell shows (Forename / Surname / Email / Number / a custom field key), falling back to the display name. Both are tile-only — documents and Table Views keep the default.
  • A calculated (Scripted) column never appears in the add/edit-row dialog, whatever you set — there is no setting that puts one there.

Image viewer, Divider, SQL viewer and Table view

  • Image viewer accepts PNG, JPEG, GIF, WebP and SVG up to 10 MB, uploaded in one shot with the placement. Its settings are Allow download (default on) and Allow upload (default off); with upload on, a user can attach an image for one case only and every other case keeps the designer's default. The server re-checks the setting, so a client that skips the UI gate is still refused, and uploads are refused on a case that is not Open. Duplicating a screen clones the tile but shares the source's image blob, so deleting one copy does not blank the other.
  • Divider — orientation (horizontal / vertical, stored explicitly so a resize cannot flip it), colour, opacity 0.05–1 and thickness 1–24px; the defaults give a 1px hairline in the theme's border colour. Those bounds live only in the dialog's number inputs — the settings API validates nothing in this section, so an API or Design-as-Code write can store anything.
  • SQL viewer renders a saved list — of any case type — as a read-only grid on the case.
  • Table view renders a saved Table View of this case type, strictly read-only ("Read-only — add or edit rows on the underlying table"), 25 rows a page, with a Refresh button; to add or edit rows a user goes to the underlying table's own tile. Its Choose columns dialog offers tick and reorder only — no rename and no width, which are Table-tile settings — up to 100 columns.
  • Two edges on that dialog: saving it with every column ticked in catalog order stores nothing, so the tile keeps showing all columns and picks up columns added to the view later, whereas any hide or reorder freezes the list; and column names are not validated against a catalog, so a stale name is skipped at render — and if all of them are stale the tile fails safe and shows every column.
  • Rows of a table-sourced Table view are selectable at runtime, and the tile emits the selection keyed by the view's source field, so a screen button's script reads the pick through selection.rows() / selection.ids() as if it had been made on the table itself. Script-sourced views have no row identity and never offer selection.

Designer tools

  • Set layout tidies every tile in one click, and is deterministic rather than clever: tiles are taken in reading order (y then x), Label tiles and full-width tiles get a row to themselves, everything else flows two per row, and every tile keeps its height — only x, y and width change. There is no undo for it; Reset is the only escape hatch.
  • Edit tab order sets the author's own 1-based tab sequence; 0 means the generated row-then-column fallback, and a placement omitted from the list keeps the tab index it had. The generated order walks Field, Correspondent attribute and Global tiles only, and the date picker's calendar icon is deliberately skipped (the picker still opens with F4 / Alt+↓).
  • Group / Ungroup (Ctrl-click two or more tiles first) is designer-only: grouped tiles select and move together on the canvas and have no runtime effect on the case at all.
  • Undo / Redo track geometry — moves and resizes — only. Any structural edit (an add, a delete, a settings change) clears both stacks.

Naming a screen, and deleting one

  • A screen's code is immutable once created: 1–31 characters of letters, digits, underscores and hyphens, with spaces allowed inside but never at either end. The name is capped at 200 characters. Both must be unique within the case type. A new screen lands at the end of the list.
  • Deleting a screen is not reference-checked. A comment in the domain claims deletion is refused when a workflow or automation references the screen; the delete performs no such check — it re-parents children, collects blobs and deletes — so a workflow step or a screens.open('CODE') call naming that screen is left dangling. Search for the code before you delete it.

Who sees a screen

  • Authorisation level — a numeric level a user must meet to see the screen (use it to keep, say, a "Costs" screen to supervisors).
  • Visibility rules — under Hide / show based on case data, build one or more conditions grouped with ALL (and) / ANY (or); the screen shows only when the rule is true, and no conditions means always show. The comparators are equals, does not equal, contains, starts with, ends with, is greater than, is less than, is at least, is at most, is between, is blank, is not blank, is one of, is none of. A value can be a fixed value, another field on the case, today / now, or the current user; runtime prompts are the one thing turned off for screens.
  • Rules are evaluated against what is on screen, not what is saved — a screen can appear or disappear as the user types, before any Save.
A hand-edited rule that will not parse opens the screen to everyone. The app refuses an invalid rule, but the Design-as-Code lane stores visibilityRuleJson verbatim, and at runtime an unparseable rule falls back to the (empty) legacy single-condition columns — which reads as "no rule", i.e. visible to all. Never hand-edit that key.

The Screens rail

On a case, screens are listed in the Screens rail — searchable, nestable, with an "N unsaved" pill in its header and a per-screen count badge (a field placed on two screens bumps both badges, so the badges deliberately do not add up to the header total). The search box has two modes, toggled by the button in its suffix: by default it matches screen names, and the toggle switches it to matching field composition — type a database field's code, name or label and the rail lists every screen containing it, naming the matched tiles as chips, while matched tiles on the open screen are spotlit with a brand outline. That is the fastest answer to "where is this field used on a case?". The Client Hub rail does not wire the field mode up.

Nesting screens

  • A screen's settings can name a parent screen (same case type), nesting it under that screen in the workspace's Screens rail — up to two levels below top (top → child → grandchild). The parent stays a normal, fillable screen; nesting is grouping, not a folder. The picker only offers parents the two-level limit allows.
  • Or just drag it. In the Screens list, drop a screen onto the middle of another row to nest it inside — the row lights up as you hover. Drop it on a row's top or bottom edge and it lands there instead, beside that row, at that row's level: a line shows exactly where, indented to the level it will land at. That is how a nested screen comes back out — drop it on the edge of any top-level row — and how one nests at a chosen position rather than last. Dragging a nested screen also shows a “move to top level” strip under the list, and the row's menu has Move to top level. A landing spot the two-level limit forbids greys out and is refused, never silently re-ordered.
  • Long lists scroll as you drag. Hold a screen near the top or bottom of the window and the list scrolls, so moving one from 2nd to 90th is a single drag.
  • A hidden parent hides its group: when a parent fails its visibility rule or authorisation level, its nested screens hide with it — one rule on the parent governs the group. A screen that must always show belongs at top level.
  • Client Hub sharing stays per-screen. Ticking a nested screen without its parent still shows it to the collaborator — at top level (or under its nearest shared ancestor). An explicit grant always wins.
  • Deleting a parent promotes its children one level; nothing nested is deleted with it. In Design-as-Code the parent travels as parentScreenCode in the screen's file — declarative, so an absent key moves the screen back to top level.

Screens and Design-as-Code

  • Draft state is invisible to Design-as-Code. The workspace has no draft flag — isDraft always serialises false, and an apply never touches it. A pull taken mid-edit therefore exports an unpublished draft as if it were the design, and an apply to a screen that is in draft rewrites the tiles while the published snapshot keeps rendering: the apply reports success and nothing changes for anyone. If the author then presses Reset, the applied design is gone without trace. If a screen change applies cleanly and nothing moved, suspect a draft first.
  • The apply validates tile settings more loosely than the app, checking only the on-change automation code and the access levels. Width bounds (24–2000px), label / display-column / placeholder / help-text lengths, a settings section on the wrong tile kind, the 100-column Table view cap and the both-hidden column rule are all app-only — a settings bag the workspace accepts can be one the app itself would have refused. Stay inside the app's limits by hand.
  • A tile whose reference cannot be resolved is skipped with a warning, never a hard failure: an unknown field name, an unresolvable linked-field chain, a missing table, holder, global, saved list or table view, an unknown case-control key, an empty label / button text / URL, or an unknown tile kind. A configured table column whose field is missing is dropped while the tile stays. A restore never fails over one tile, so a screen can come back quietly missing tiles — read the warnings. At most five problems of one kind are reported per screen; the rest collapse into a "+N more" tail.
  • tiles[] is diffed as an ordered list, so appending a tile to the end of the array reads as "every tile after its sorted position changed". The export writes them in canonical order — y, then x, then kind, then a per-kind key — so keep them that way.
  • An image tile travels as three keys: imageFile (a path relative to the case-type folder; the file itself lives in the screen's assets folder), imageFileName and imageContentType. Omit the content type and the tile stores application/octet-stream, which the browser downloads instead of rendering. Per-case image overrides are data, and they cascade away if an apply has to recreate the tile — so avoid changing an image tile's identity.

A screen can bind an automation under Screen details → When this screen is opened. It runs every time a user actually switches to this screen on a case (clicking the screen you are already on does nothing), and it also fires inline after an automation opens the screen with screens.open(...) and the user's edits are applied (section 9). Prompts are supported. Note the name: the stored setting and the Design-as-Code key are still called onSubmit for historical reasons — the behaviour is on open. Screen details also carries a Guided-mode category box (free text, max 100 characters): screens sharing a category collapse into one parent box on the Simple mode case hub, and an empty category leaves the screen as a top-level box. The advanced view ignores it.

On phones (<600px) a designed screen re-flows automatically into a single column in designed reading order — top to bottom, then left to right — so you don't design a separate mobile layout. Vertical dividers are hidden in that mode, and a correspondent block loses its backdrop while keeping its button bar.

5 · Quick View & case controls

A pinned summary, and the built-in properties every case has.

  • Quick View is a Label: Value read-out of the fields that matter most, available from every view of a case. Where it sits follows the workspace layout your deployment ships: in the classic layout it is a panel in the case's side rail, under the Screens list, collapsed by default behind a header that reads "Quick View" and a count, so the Screens list keeps the space until an operator expands it; in the case-bar layout the same component rides inside the case bar as a strip of chips that is always open — there is no collapse toggle — and scrolls sideways when there are more chips than fit. Configure it on the case-type detail page.
  • It can hold case fields (including scripted fields and whole tables / time records), global variables, and case controls. It doesn't show correspondents, links or assignments — the rail cannot render those as a simple value, and such a token is skipped with a count when you pick it.
  • How the configuration is stored. Quick View is a flat, ordered list of refs on the case type, each capped at 160 characters, with no cap on how many. A ref is one of three shapes: a bare case-type field name (optionally name|formatter), global:<key>, or control:<key> from the ten case-control keys. Add opens the shared field selector in fields-only multi-pick mode, offering case-type fields (scripted fields and table / time-record parents included) plus globals and case controls. Picking a table-member column resolves up to its parent table, so the rail shows the whole table. The picked formatter is kept for a Dropdown field (the rail then shows that option column instead of the raw stored code) and for an Embedded document field (file name / size / size_human / download_url). Refs are validated on save: an unknown field name, an unknown global key or an unrecognised control key is rejected.
  • Quick View reads the saved case, not what is on screen. It renders the stored row, not the workspace's unsaved draft buffer, so an edit typed on a screen does not appear here until the case is saved. This is the commonest Quick View surprise.
  • How entries render. With no fields configured it renders nothing at all — no empty box, no heading. An empty value shows as a grey em dash. A calculated entry carries a small violet function marker ("Calculated automatically"). A table or time-record entry loads lazily and shows a compact read-only preview of the first three rows across the first three columns plus a "+N more" line ("Loading…" while in flight, "No entries" when there are none) — in the case-bar layout that preview collapses to a single chip carrying the label and the row count.
  • Case controls are the built-in properties every case has, and the set differs slightly by surface. On a screen (a Case control tile) and in Quick View they are Reference, Title, Status, Case type, Created, Last modified, Closed and Case ID — always read-only, the two sets deliberately identical, and anything else is refused ("'X' is not a case-control field the screen can render."). In templates and lists the set is Case number, Case title, Case type, Case status, Created date, Created by, Last modified and Closed date: note that Created by exists only there, and Case ID only on screens and in Quick View.
  • Assignment slots are the case type's named roles (Case Worker, Supervisor…), each a user-picker that can be restricted to a role or team. Templates and scripts read them as {assignment:slot.attribute}; to-dos can be assigned to a slot so they follow whoever holds it.

The case title pattern

Nobody types a case title. Each case type carries a title pattern — set it in the case-type editor under Case title — and every case of that type is titled from it, on create and again on every save. Write plain text and use Insert field to drop in tokens: Injury claim - {client_surname}.

  • A title is re-rendered inside ordinary case saves, so its vocabulary is deliberately narrower than a document's: case fields (with formatters), case controls, {system.today} and correspondent attributes work. Scripted fields, linked-case drills, globals, assignment slots, tables and table views render blank — the picker hides the ones it can.
  • {case.title} is refused: it is the value being built.
  • Leave it blank and cases are titled with the case type's name, so a list never shows an empty row. A pattern whose tokens are all still empty falls back the same way, and a dangling separator is trimmed — a brand-new Injury claim - {client_surname} case reads Injury claim until the surname arrives.
  • Three kinds of case keep a fixed title and never follow the pattern: cases that existed before you set one, cases an automation created with cases.create({ title }), and rows imported with a Title column. What the author wrote wins, permanently.
  • The pattern travels in Design-as-Code as titleTemplate on case-type.json, and Where-used on a field lists the case title, so renaming a field rewrites it.

6 · Case & field triggers

Bind an automation to a moment in a case's life.

Where: the case-type editor (case triggers) · the field editor (on-change) · Manage → Configuration → Screen studio (a tile's own on-change)

A case type has seven trigger bindings, each an optional link to an automation on the same case type. The labels below are exactly what the editor shows, in the order it shows them:

TriggerFiresCan block?
When a case is being createdRight after a new case is created — prefill values, greet the creator. The New-case dialog defers the fire and runs it interactively; every other create path — an import, another script's cases.create, the API — runs it quietly on the server.No
When a case is openedWhen a case genuinely opens — that is, when it newly joins the open-cases rail. Switching between cards already open, or navigating away and back while the case stays open, does not re-fire it; close the card and reopen the case.No
Before SaveBefore a save commits. get() still reads the OLD values; changes.next('{Field}') reads what is about to be written. Fired by the browser only — the staff workspace, Simple mode and Client Hub. A write straight to the API (PUT /api/cases/{id}/values), an import, or another case's script never runs it, so treat this as a user-facing guard, not a data-integrity constraint.Yes — stop('reason') rejects the whole save (in the browser lanes)
After SaveAfter the save has committed — get() now returns the newly committed field and table data, and put() commits a second pass. The workspace and Client Hub send the save with deferAfterSave set and then re-run this trigger themselves through the prompt loop, which is why the same automation can prompt on a browser save and must not on an API one. Re-entrancy is capped at one level, so a save it causes does not re-fire it.No — fail-soft by construction: a stop() or a throw is recorded as a case event but cannot roll the user's save back
When the user clicks CloseWhen the operator closes the case's card in the open-cases rail (the ✕, or Close all) — this is closing the tab, not closing the case.Yes — a failed run, a cancelled prompt or a stop() keeps the card open
Before marking the case as deadWhenever the case is marked dead, however it happens — the three dots, a screen button, a workflow action, a scheduled Auto routine, or another case's script. See the mark-as-dead gate below for what it receives and how it refuses.Yes — a stop() or a throw keeps the case alive, and the reason is shown to whoever tried. A question (ask.*) can only be answered from the case itself, so from a script the close is refused instead.
On accounts document paidWhen a Xero status read-back reports a linked accounting document fully paid (Rangeen Accounts add-on). Always unattended and post-commit; the document's details arrive as input.No

One thing, three spellings

The same seven bindings carry three different names: the label the editor shows, the internal enum that appears in run logs and case events, and the Design-as-Code key. OnUpdate/beforeSave and AfterSave/afterSave are the pair that trips people up — a log line saying OnUpdate is the pre-save gate.

Editor labelEnum (logs, events)Design-as-Code key
When a case is being createdOnCreateonCreate
When a case is openedOnOpenonOpen
Before SaveOnUpdatebeforeSave
After SaveAfterSaveafterSave
When the user clicks CloseOnCloseRequestedonCloseRequested
Before marking the case as deadOnClosedonClosed
On accounts document paidAccountsDocPaidonAccountsDocPaid

case-types/<slug>/case-type.json — the whole set is one object

"triggers": {
  "onCreate": "PREFILL",
  "onOpen": null,
  "beforeSave": "VALIDATE_SAVE",
  "onCloseRequested": null,
  "onClosed": "CLOSING_LETTER",
  "afterSave": null,
  "onAccountsDocPaid": null
}
That block is declarative on apply. A key you leave out is read as null and clears that binding, and if the whole triggers object is missing all seven are unbound. A mis-spelled key therefore does not merely fail to bind the one you meant — it silently clears every trigger you omitted. Edit the exported block; never hand-write a partial one.

A field's own on-change

Any field can bind an automation to run on change — it fires after the save commits, only for the fields whose value actually changed, in field order, one run per field per save pass. A field that already fired does not re-fire in the same pass even if another hook rewrites it, and a save caps out at 50 on-change invocations — past that the remaining hooks are skipped and a failed-run event says so — so chains can't loop forever.

Inside it, stop('reason') reverts that field alone to its pre-edit value, drops it from the audit entry, and lets the rest of the save stand. When a person saved, the hook runs interactively: the workspace and Client Hub suppress the server-side fire and re-run each hook through the same prompt loop the manual Run dialog uses, so ask.* and ui.message appear as live modals. Only a save that arrives straight from the API runs it quietly.

A field's on-change is the only unattended lane whose ui.message text reaches the operator: collected messages ride the save response and appear as a toast. From a server-fired create, After Save, mark-as-dead or accounts-paid run — or from an Auto routine — a ui.message exists only in the run log. That is nearly always the explanation for "the automation ran but the user saw nothing".

A screen tile's own "On field change"

Separately, a placed Field tile in Screen studio can bind its own automation under On field change (Design-as-Code key settings.field.onChangeAutomationCode). This is not the field's on-change above, and the differences matter:

  • It runs before any save, the moment the user moves on from the tile — focus leaves it, they press Save, switch screen, or close the case. Never per keystroke, and never for an automation or API write.
  • It runs against a draft overlay: get('{Field}') returns what the user just typed, every other unsaved edit on the case is visible, changes carries that one field's stored → pending values, and a put() into another field lands at once. The overlay itself is never persisted — nothing is saved until the user saves.
  • stop('reason') rejects the typed value rather than reverting it: the value stays on screen outlined with the reason, and Save is refused until the user changes it. (Contrast the field's own on-change, where stop() reverts.)
  • Collaborators can fire it too — Client Hub posts the tile id, never an automation code, and the server resolves the binding.
Sharp edges. A field with both bindings runs the tile hook at commit time and the field hook at save time — two runs for one edit. The hook is refused on a non-Field tile (400 screen.tile.kind_mismatch) and on a read-only tile or one above the caller's edit level (403 screen.tile.readonly). It reads as unbound on a Scripted (calculated) field. And while a screen is an unpublished draft the server serves the snapshot taken when Edit was clicked, so a hook you are editing in the designer fires as last published until you publish.

The browser's save order

One click of Save runs these in a fixed order, and a refusal in any of the first three stops everything after it:

  1. Pending per-tile On field change commits — an outstanding rejection blocks the save outright.
  2. Before Save — can stop() the save.
  3. The PUT of values, tables and time records.
  4. Field on-change automations, one at a time, interactively.
  5. After Save, interactively.

The mark-as-dead gate

Before marking the case as dead runs while the case is still alive and writable, so it can still read and write case data. Its actions.send drains after the case dies, with closed cases allowed, so "send the closing letter, then mark the case dead" is supported.

On a server-side close the gate is handed input.fileClosedDate (yyyy-MM-dd) and input.source — the door that asked, one of manual, automation, trigger-automation, field-change, screen-button, workflow-automation, workflow-action, auto-routine.

The one path where the operator picks a date is the one path where the gate is not told it. Closing from the case's ⋮ menu runs the gate in the browser (see the receipt below), and that lane passes no input at all — so input.fileClosedDate and input.source are undefined exactly when a human chose the date. Read the date off the case after the close, or don't depend on it.

It refuses three ways, with a code each: a stop() yields case.close.refused carrying the stop reason; a run that errored yields case.close.gate_failed; and a gate that tries to ask.* somewhere nothing can answer yields case.close.gate_needs_answer ("…asks a question, and nothing here can answer it. Mark the case dead from the case itself…"). A gate that could not finish has not agreed to anything, so the case stays alive.

Two places the gate does not run. A bulk close skips it and stamps gate: "skipped" on the history row — one operator closing a thousand cases must not fan out into a thousand automations. And a close cascade is capped at 8 distinct cases in one flow: beyond that the case still closes, but the gate is skipped and the history row records gate: "skipped-depth".

Because only a browser can show the gate's prompts, the case workspace runs it itself and passes the run's history-event id to the close endpoint as a receipt. The receipt is verified, never trusted — same case, event type AutomationRun, within ten minutes, success: true, and an automationId equal to the type's current binding — otherwise the server simply runs the gate again. A run that stopped is deliberately not a valid receipt: that is the gate refusing. There is no revive or reopen trigger, and reopening a case does not restore its collaborator shares.

cases.close() on the current case depends on the lane, not the trigger. When a trigger is fired server-side, a cases.close() with no caseRef is honoured only in Before Save and When a case is opened; in the other five it is silently dropped — no error, no log line a designer will notice. When the same trigger is fired from the browser — which is how OnCreate runs from the New-case dialog, how "clicks Close" runs from the tab ✕, how the mark-as-dead gate runs, and how a deferred After Save runs — there is no such check and the close happens. So the same automation closes the case in one lane and quietly does nothing in the other. Don't rely on either: use cases.close({ caseRef }) against a named case, or move the logic to Before Save.

Bindings that silently never fire

Three ways a binding looks fine and does nothing, none of them surfaced in the UI:

  • If the bound automation is inactive or has been deleted, the server answers the browser with "not bound" and nothing is shown to the operator. Deactivating one automation silently disarms every trigger bound to it, and the case type still displays the binding. (Binding an automation belonging to another case type is a different matter: the editor refuses it outright with 400 trigger.automation.wrong_case_type, and if one ever exists the run fails loudly rather than quietly — for a gating trigger the message is shown to the operator.)
  • If the bound automation is a Library automation, or it declares a required parameter with no default, the trigger fails at runtime, on every case — triggers pass no arguments.
  • Neither rule is checked when the binding is saved, and the Design-as-Code apply does not check them either: the plan is clean, the apply succeeds, and the trigger fails on every case.

So before binding, confirm the automation is Active, on this case type, not Library, and has a default for every parameter.

Whether a trigger can ask questions depends on who started the run, not on the trigger. Runs driven from someone's browser — opening, saving, closing, pressing a button — are interactive: ask.* and ui.message pause and show to that person (including collaborators on shared screens). Unattended runs — scheduled Routines, bulk runs, API saves, server-fired create / After Save / accounts-paid events — can't prompt, and the two verbs behave differently there. A ui.message is degraded: its text is written to the run log as ui.message: … and the script carries on. A real ask.* throws and fails the whole run ("ask.confirm(…) needs a person to answer it, so it can't run in an automation that fires without one"), and so does screens.open(). Guard with user.isSignedIn when one script serves both, and put the interactive half in a separately-run automation.

7 · Automations: the model

An automation is a small JavaScript program that reads and changes a case.

Where: Manage → Configuration → Playbooks → the case type → Automation tab

Code-first. The automation is a JavaScript script. There are wizards and a field picker that write the code for you, and conditions are ordinary JavaScript if / else — but the script is the real thing, and the editor never lets a broken one save. Any older documentation describing a "visual rule builder" inside an automation is out of date.

Each automation belongs to one case type and has a Code (how it's referenced), a Name, an optional description, its Script, declared Parameters (its "function signature" — see section 9), an Active flag (inactive automations never fire, even from a trigger) and a Library flag (section 10).

Parameters in detail: at most 20 per automation, each name matching ^[A-Za-z][A-Za-z0-9_]{0,31}$ and read in the script as args.Name. The types are Text, Number, Date, DateTime, Time, YesNo, Option (which needs the name of an Option field to populate its dropdown) and User. Triggers, field on-change hooks and Auto routines pass no arguments, so only declared defaults apply there — and a required parameter with no default makes a bound trigger fail on every case.

Un-ticking Active is not a soft pause. It silently disarms every trigger, field hook and template hook bound to that automation: the binding is still displayed on the case type, nothing is shown to the operator, and the hook simply stops happening.
  • The script runs in a sandbox. Ordinary JavaScript is available (if, loops, variables, Math, JSON, Date, String…), but there is no file system, no fetch, no require, no browser globals — only the platform verbs described here. Limits, all tunable by the operator under Automation:Limits: 30 seconds per interactive run and 60 per unattended one, 5 million statements, 64 MB of working memory, a stored script of 500,000 characters, and tables up to 10,000 rows per write.
  • A script never changes data directly. Each verb queues what you want to happen (a field write, a task, a letter). When the run finishes successfully, the queue is applied in one go — so a run that fails without ever pausing leaves the case untouched. A run that paused for a prompt is different: everything its checkpoint already committed stays committed even if the rest never runs.
  • Within a run, a field you put() and then get() reads back the new value. (Whole tables are the exception — see the next section.)
  • When a prompt pauses the run, a checkpoint commits part of what is queued: the script's field writes, attachment bundles and any sub-actions already queued. Tasks, task cancels, cross-case writes, correspondent operations, global variables, case creates, accounting operations and closes are never checkpointed — they only ever apply when the run finishes. On answer the script re-runs from the top with earlier answers and inline results (http.* calls, minted case references) replayed, so a question is not asked twice and a webhook is not called twice. A prompt's replay id folds a hash of its label, so if you edit a prompt's wording between the pause and the resume, the operator is asked again.
Never try/catch around a prompt or a stop(). A prompt (ask.*, screens.open, a send preview) and stop() work by throwing a halt the runner catches. If your own catch swallows it the run is aborted with "A prompt (ask/screen/preview) or stop() was started but its halt was caught — automations must not try/catch around prompts/stop." Wrap http.* and the other catchable failures; never a prompt or a stop.
A pause lives only in the tab. The answers, the replay journal and the pause itself are held in memory in the browser — the server keeps no paused-run state. A page refresh, navigating away for good, or a deploy (new bundle → reload) loses the pause exactly as if the operator had pressed Stop: the checkpointed steps stay committed, the rest of the script never runs, and nothing warns anyone. A paused run is also pinned to the case it started on, so a resume can never retarget its remaining effects at a different case.

8 · Reading & writing data (get / put)

Two verbs do all of it, keyed by a token in braces.

get('{token}')          // read a value  (an array for a {…[]} token)
put(value, '{token}')   // write a value (queued until the run ends)

The token in braces is the same field-picker token you see in templates. Reads come back properly typed — a number is a number, a Yes/No is a boolean. Here are the token forms:

TokenWhat it addressesRead / write
{field}A field on this case (the token is its name).read + write
{table[]}A whole Table, as an array of row objects.read + write
{bucket[]}A whole Time record log, as rows.read + write
{view:code[]}A table view's computed rows.read only
{Holder.attr}A detail (email, name, address…) of the correspondent in a link field.read only *
{global.key}An organisation-wide global variable.read + write
{assignment:slot}The user in an assignment slot (the value is their email or id).read + write
{assignment:slot.attr}A detail of whoever holds that slot: .name, .email, .job_title (or .jobtitle), .phone, .id. Any other suffix falls through to that user's custom field values.read only
{case.ref}, {case.status}, {system.today}Case & system controls (also case.title, case.casetype, case.created, case.createdby, case.modified, case.closed, case.id; the longhand case.reference also works).read only
{field|formatter}A field rendered as formatted text.read only †
{link->leaf}Drill through a Case link into the linked case (chainable, up to 10 hops).read + write
{link->automation:CODE}Runs an automation on the linked case — valid only as automation.run's first argument (section 9).neither

* To change who a correspondent holder points at, write the correspondent's id to the bare holder token: put(id, '{Client}'). The suffixes are .id, .number, .display_name, .first_name, .last_name, .organisation, .contact_person, .reference, .email, .phone, .mobile, .website, .address_line_1 / _2 / _3, .city, .region, .postcode, .country, .notes, the composed .address_block, .full_address, .street_address and .city_line, plus that correspondent type's own attributes.
† With one exception, at the foot of this section: a Dropdown target may carry a match column. get() and put() reject an automation: token outright — that is a lint violation, not a silent no-op.

Working with tables

A {table[]} read gives you an array of row objects. Each row is keyed by the column's exact name (spaces and capitals kept) plus a hidden _id. Writing the array back is a full replace:

  • Rows that keep their _id are updated in place.
  • Rows with no _id are inserted.
  • Rows you leave out are deleted. put([], '{table[]}') clears the table. The array order becomes the table order.

So to add a row you read, push, and put back — as in the worked example in section 11.

Columns that don't behave like fields

A Correspondent column's cell is the linked correspondent's id — the same shape a holder field carries. Hydrate it with correspondents.find(cell), or hand it straight to actions.send as correspondentId. Writing one takes a correspondent's id (or a correspondents.find() result, or the cell you read back), and that id must exist or the write fails.

A Calculated (scripted) column is derived per row, so any value you put into it is silently dropped and recomputed server-side — nothing tells you the write was ignored. The upside is that reading a table and putting it straight back round-trips cleanly. A time record's duration_* members behave the same way (recomputed from started_at / ended_at, ignored on write), and recorded_by is host-managed — kept on update, stamped from the run's actor on add.

Writing a Dropdown field accepts the option's code or its description (the canonical code is stored); an unknown value fails with the valid choices listed. put(x, '{status|description}') matches against a specific column.

Client-money tokens read at runtime but block the save. The Solicitor-accounts tokens of section 12 — {ledger.client_balance}, {ledger.uncleared} and the {ledger[]} statement rows — really do resolve inside get(). The linter does not know them: ledger is not part of an automation's design surface, so get('{ledger[]}') reports "'ledger' is not a table or time-record bucket on case type …", a violation that blocks Save even though the call would have run. Until that closes, read ledger figures through a merge token in a template rather than from a script.

9 · The verb reference

Everything a script can do, grouped. The editor's Syntax tab is generated from this same live catalog; the Help tab beside it is a hand-maintained copy.

Read, write & log

get('{token}')Read a value (an array for a {…[]} token).
put(value, '{token}')Write a value; queued until the run ends.
log.info / warn / error(a, b?, c?, d?)Write to the run log (visible in history and the Test Run tab). Up to four parts, joined with a space. console.log / info / warn / error are first-class aliases with the same ceiling — a fifth argument is evaluated and then dropped without a word.

Detecting changes (Before Save / After Save, and a screen tile's On-field-change)

changes.any()Did this save change anything?
changes.has('{token}')Did this field change? For a {table[]} token: was any row added, edited or deleted?
changes.prev('{token}') / changes.next('{token}')The value before / after the save — and the polarity flips by hook. In Before Save, get() still returns the old value and next() is what's being written. In After Save, and in a screen tile's On-field-change, get() already returns the new (typed) value and prev() is the stored one.
changes.fields()The list of changed tokens. Outside those three hooks it is empty, any() and has() are false, and prev() / next() fall back to the current value.

This case & the run context

userWho's running it: user.id (null in unattended runs — the safe gate), .name, .email, .isSignedIn, .isInternalUser, .isExternalUser (a collaborator), .hasRole(...) / .inTeam(...) / .roles() / .teams() (internal only), .externalRole() / .hasExternalRole(...); and their record as set under People — .jobTitle, .phone, .role, .accountType ('TenantAdmin' | 'StandardUser' | 'TechnicalUser' | 'LiteUser'), .securityLevel (the same 0–99 number screen and tile levels are checked against), .isAdmin, .isCaseWorker, .isDeveloper, .hasPermission(key), .field(name) / .fields() for custom user fields.
actor.name / actor.emailShorthand for the person running the automation.
correspondent, templateA template hook is the one place a script sees these: the recipient, and the template being actioned.
phase"Before" or "After". It is defined in every run, not only in a hook, and what it reports depends on the lane rather than the trigger (screen buttons, calculated fields and auto-routines read "Before"; run-on-case, bulk runs and case-type triggers read "After") — so test it only in a template hook, where it genuinely distinguishes the two. See section 10.
args / inputargs.Name reads a declared parameter, typed and defaulted (a missing required parameter fails the run before it starts); input is the raw value a calling automation passed. With no declared parameters args mirrors an object-shaped input (otherwise {}), so args.x is always safe to read.
Two traps in the user object. isInternalUser and isExternalUser are not negations of each other — both are false in an unattended run, and hasRole, inTeam and hasPermission are always false for a collaborator. And user.email is not reliably blank when nobody is watching: a scheduled routine reports scheduler@<tenant>, and mail arriving through the Outlook connector runs as the mailbox owner under the name outlook-webhook — where isSignedIn reads true with no person present. Gate on user.isSignedIn or user.id, never on an empty address.

Talking to the operator (interactive runs only)

ask.confirm(label)Yes/No question → a real boolean. There is no optional form — "No" is the answer.
ask.text(label, {default?, optional?})Free-text prompt → the string, or null if skipped.
ask.number / ask.date / ask.time / ask.datetime(label, {optional?})Number, date ("yyyy-MM-dd"), time ("HH:mm", 24-hour) or date + time ("yyyy-MM-ddTHH:mm") — each returns exactly what put() accepts for the matching field type. optional: true lets the operator continue without answering; the script then gets null.
ask.choice(label, [options], {default?, optional?})Pick one of a list from a dropdown → the chosen string. default pre-selects a row (the operator can still change it).
ask.field('{token}', {label?, optional?})Ask for one field of this case with the field's own control — a dropdown lists its choices, a date shows a calendar, Yes/No is a select. The answer is saved to the field (a dropdown stores the canonical code, exactly as put() would) and returned typed like get(). label replaces the question text (default: the field's label); optional: true lets the operator skip — the field is left unchanged and the call returns null. Top-level text, number, decimal, date, date-time, time, Yes/No, tick-box and dropdown fields only; anything else is refused with a message naming the allowed form. A dropdown can be asked by a column: ask.field('{stage|code}') lists the codes, '{stage|description}' the descriptions (the default), '{stage|Region}' an extra column of the option list — whichever the operator picks from, the field stores the code and the call returns what get() returns for the same token.
ui.message(...parts)Show a note and wait for acknowledgement. Takes any number of values, joined with a space like log.info — ui.message('Run by', user.name) is one line; objects and arrays print as JSON, and a long message scrolls inside the dialog.
ui.viewDocument(attachmentId)Show a case attachment, then resume.
ui.openCase(refOrId)Navigate the operator to a case once the run ends. Last call wins, and a case created in this run is a valid target.
ui.openUrl('https://…')Open a web link in a new browser tab once the run ends — the pattern for a screen button that jumps to an outside system. http / https only (no javascript: or file: smuggling); last call wins.
screens.open(name)Open a screen as a data-entry dialog mid-run → true if saved, false if cancelled. It matches the screen's Name or its Code and renders a generic form of the screen's Field tiles ordered top-to-bottom then left-to-right — not the designed layout, and none of the non-field tiles. Tiles above the acting user's security level are left out. On save, the entered values apply to the case and the screen's on-submit automation runs.
stop('reason')A clean, deliberate halt — not an error — and it stops the whole process, not just the script. Before Save or a lifecycle automation: rejects the save. A field's save-time on-change: reverts just that field. A screen tile's On-field-change: rejects the typed value, which stays on screen outlined red with the reason, and Save is refused until it changes. A template's Before hook: skips the action. In a Routine: skips that one case. The reason shows as a toast and is recorded on the case's Audit & field-changes tab (not History). Work already queued before the stop is not undone; a throw discards the queue and is logged red as a crash.
What "interactive only" actually means — and it turns on who started the run, not on the trigger kind. In an unattended run (routines, case-type triggers, on-change, bulk, server-side API saves, the accounts-paid trigger) a genuine ask.* throws and the run fails with a teaching error; ui.message degrades to a logged notification and the run carries on; ui.viewDocument, ui.openCase and ui.openUrl log and skip. Gate with user.isSignedIn when one automation serves both audiences.

Selected rows — screen buttons

selection.rows('Table Name')The rows the operator ticked in that on-screen table before clicking the screen Button tile — the same objects get('{Table[]}') returns (exact column keys plus _id), only the selected ones, in table order. Works for iteration tables and time logs alike.
selection.ids('Table Name')Just those rows' _id strings.

This is the canonical "tick some rows, press a button, act on exactly those" pattern, and there is no other way to reach the selection. Three sharp edges: it is always an array — empty in every other context (triggers, the Run dialog, bulk, unattended runs) — so guard with .length, never for null; a name that is not an iteration table or time log on this case type throws, deliberately, so that a typo can never read as "nothing selected"; and rows ticked in a table-sourced Table View tile land on the view's source table, so the script names the table, never the view.

Correspondence & tasks

actions.send('CODE', {…})Fire a template (letter / email / memo / call / incoming) exactly like the manual action dialog. Common options: holderField or correspondentId (the recipient), description, detail, direction, at, from, costUnits, preview (halt for operator review — off by default, skipped unattended), attach, embedInto, sendWithFields, attachHistory, email:{ send, to, cc, bcc } (the object's presence turns real Outlook sending on; omit it for record-only), diary (false, or a follow-up override), targetCase, format ('pdf' | 'docx' | 'inline'). subject and body override the rendered template's subject and body and are token-resolved against the case, so {tokens} in them are merged — an HTML body even gets the table-aware pass, so a {#table:…} block expands. (Only values echoed back from the interactive preview modal skip resolution, because that text is already resolved.) attach entries may be attachment ids or document handles (a row's document cell, or get('{Doc Field}')). embedInto takes a top-level field name, or { row, column } to deposit a copy of the rendered document into one table row's document column; a template's "Lock Embed into" setting binds the interactive dialog only, so a scripted override still applies. A memo whose Type is PDF bundle merges everything sent with it into one PDF instead of attaching the files — the bundle is the entry's document and what embedInto deposits. templateFormat states the template's Type on the line ('MsWord', 'PdfBundle', …) so the code reads what it sends.
actions.receiptIncoming('CODE', {…})Record an inbound document under an Incoming-post template.
tasks.create(title, {…})Create a task. Options: dueInDays, actionType ('Generic' | 'Memo' | 'Letter' | 'Email' | 'PhoneCall' | 'RunAutomation' | 'IncomingPost'), template or automation, assignTo, recipientRole (a Correspondent field name), description. There is no priority option and no category option, despite category appearing in query.tasks' result rows.
tasks.cancel({…filters})Delete matching open tasks. Filters: actionType, recipientRole, templateCode, assigneeEmail, assignmentSlot, titleContains, caseRef, caseId — and a typo in one of these is a violation that blocks Save, not a warning, because a filter that silently vanishes widens a hard delete. assignmentSlot matches only tasks bound to that slot; a task assigned to a named user never matches it (use assigneeEmail for those). Always pass a filter — with none it cancels every open task on the case.
assignTo takes a user email or an assignment-slot code — and an unknown code does not error. A known slot code (spacing and case tolerant) binds the task to the slot: it follows whoever holds it, re-routes when the holder changes, and sits unassigned on the case while the slot is empty. An unknown code quietly assigns the task to whoever ran the script — so a typo in assignTo: 'case_worker' is invisible and the task lands on the wrong person. diary.assignTo on actions.send follows the same rule.

Correspondents, other cases & reuse

correspondents.find(numberOrId)Look up a correspondent → its full details (every attribute plus custom) or null.
correspondents.create('Type', {…fields})Create a directory correspondent of that type and get it back — set any attribute (firstName, organisation, email, addressLine1…) plus custom for the type's own, keyed by attribute label. The display name derives from the record (first + last, else organisation / contactPerson / reference), so give at least one of those. This is an immediate write, not a queued one — it lands in the tenant directory the moment the line runs, a later stop() or thrown error does not undo it, and it happens for real in a Test Run too. Search first (below) rather than creating and repenting. Linking to the case stays a separate put(c.id, '{holder}').
correspondents.update(idOrNumber, {…fields})Edit the directory record (every linked case sees it) — immediate, on the same terms as create. Omitted keys stay; null/'' clears; the type never changes. There is no delete.
correspondents.search({type?, name?, email?, …})Find directory correspondents by type, name, email, reference or number — the find-or-create guard before a create. At least one filter is required (an empty search fails rather than dumping the directory); up to limit rows, default 25, max 100.
correspondents.currentId()The triggering correspondent's id — only inside a workflow template's Before / After hooks. It throws in every other context (the Run dialog, triggers, bulk, screen buttons), deliberately, so a step wired to it fails loudly instead of silently sending to nobody or clearing a holder field. It replaces the old correspondent ? correspondent.id : null pattern, which collapsed to null everywhere outside hooks.
cases.open(refOrId)Open another existing case → a handle { id, reference, title, status, caseType, fields.get(name), fields.set(name, value), links.get(name), save() }, or null. Writes buffer locally and only queue when you call save().
cases.create('CaseTypeName', {reference, sibling?})Create a case (or a matter with sibling: true) → its new reference. The case materialises after the run ends, so cases.open can't reach it mid-run — populate it via a Case-link field and drilled put calls (put(newRef, '{link}'), then put(v, '{link->field}')). Those drilled writes land before the case exists, so its title pattern renders against them first time. Pass title only to override the pattern — an authored title is then fixed for the life of the case.
cases.close({caseRef?, date?})Mark this case dead (or another via caseRef); date sets the file-close date, default today. It runs the case type's Before marking this case as dead automation first, exactly as the three-dots menu does — if that automation stop()s or throws, the close is refused and the case stays alive (a matter still holding client money is refused too). Inside a case-type trigger, closing the current case is honoured only from Before Save and on open — from on-create and the close triggers it is silently ignored. Closing another case works from any trigger.
cases.reopen({caseRef?})Mark a dead case alive again — this case or another. A no-op on a case that is already alive.
automation.run('CODE', args?)Run another saved automation inline; the callee reads the values as both input (raw, exactly as sent) and args (validated — coerced to its declared parameter types with defaults applied; a missing required parameter fails the call loud). Chains nest 5 deep, and a whole chain is capped at 100 automation.run calls in total, so a wide loop over query results fails with "chain call limit (100) reached". The drilled form automation.run('{link->automation:CODE}') runs it on the linked case — its writes, tasks and sends land there.

Finding cases (read-only)

query.named('CODE', {take?, params?, caseType?})Run a saved List by code — the way to find cases by their data. params answers its ask-at-run-time questions (declared defaults apply when one is omitted; a missing required one fails the call loud); caseType runs a List defined on another case type. Default 500 rows, cap 10,000. Rows: { caseId, reference, title, status, modifiedAt, values }.
query.tasks / history / attachments({…})Read this case's open tasks, history events, or attachment list.

Files, web, dates & helpers

attachments.bundleIntoZip([ids], name)Zip existing attachments into one new attachment.
http.get / post / put / patch / delete(url, …)Call an outside REST service — the sandbox has no fetch. The host must be on the organisation's allow-list (Organisation settings → Outbound HTTP, one hostname per line; a parent domain also matches its subdomains; an empty list disables outbound HTTP entirely rather than allowing everything). Each distinct request runs exactly once per run — a checkpoint replay reuses the recorded response. The response is { status, body, data, headers, truncated } (data = the body parsed as JSON, or null), and every failure — an allow-list denial, a blocked private address, a timeout — is catchable with try/catch.
dates.today / now / addDays / addMonths / diffDays / format / weekday / yearsBetween / year / month / dayDate maths and formatting. They never throw on a bad date — a malformed argument parses forgivingly to now, so dates.addDays('not a date', 7) quietly returns a week from today and dates.diffDays against an empty field silently compares against now. Guard with helpers.isBlank() before doing date maths on a field that may be empty. dates.yearsBetween(from, to) returns completed whole years — the age calculation, and the one member here you cannot improvise from the others.
helpers.money / pad / upper / lower / trim / isBlankSmall formatting helpers.
The http caps that actually bite. "Once per run" is about replay idempotence, not a budget — nothing caps how many calls a run makes; what stops a runaway loop is the script timeout. The real caps are per call: request body 256 KB (over it throws), response body 1 MB (truncated, with truncated: true on the response), a default timeout of 10s with a hard ceiling of 30s, and at most 5 redirect hops — hosts resolving to loopback, private or internal addresses are blocked on every hop. One edge worth knowing: a Content-Type entry in the headers option is not copied onto the request as a header — but when the body is a string it is read back out and used as that body's content type. So an API that demands a particular content type can be satisfied: serialise the body yourself and set the header. An object body is always application/json and formUrlEncoded: true always application/x-www-form-urlencoded; for those two the header is ignored.
Sandbox limits depend on who started the run. The script timeout is 30 seconds for an interactive run but 60 for an unattended one — so a script that passes comfortably as a nightly routine can time out the first time somebody runs it from a button. The rest: 5,000,000 statements, 64 MB of memory, 500,000 script characters, 10,000 rows per iteration table, and at most 25 accounts.* operations per run.

With Rangeen Accounts, an accounts.* family lets scripts raise invoices, bills and credit notes — see section 27.

10 · When automations run

Many ways to fire the same script.

Fired byHow you set it upInteractive?
A case-type triggerBind it on the case type — the seven bindings of section 6.When a person drove it
A field's on-changeBind it on the field; fires after the save commits, for the fields that actually changed, in field order.When a person saved
A screen tile's On field changeBind it on a placed Field tile in Screen studio; fires before any save, when the user moves on from the tile (section 6).Yes
A screen buttonPlace a Button tile and write its script inline on the tile — a Button does not point at a saved automation; it carries its own script (up to 100,000 characters). It is also the only place selection.rows('Table') / selection.ids('Table') return the rows the operator had ticked.Yes — for staff, and for collaborators on shared screens
A screen's on-openBind it in Screen settings under "When this screen is opened". It fires both when a user switches to that screen on a case and inline after a script's screens.open() submit applies the user's edits. Skipped while another user holds the case lock. On a dead case it is still attempted and refused with 409 case.closed — it is the one browser-fired hook that does not get the closed-case exemption.Yes
A Run Automation taskCreate a task of kind "Run Automation"; actioning it runs the script.Yes
A workflow template's diary follow-upGive a template a diary entry of action Automation with an automationCode; actioning the template schedules it.When the follow-up is actioned
A template Before / After hookBind it on a template; Before can cancel the action (stop), After is best-effort.Inherits the caller's — see below
Client HubNothing extra to set up: a collaborator working a shared screen raises the case type's On open, Before Save and After Save bindings, plus a field's save-time hook and a tile's commit-time hook.Yes — to that collaborator
An Outlook auto-actionGive a workflow template incoming-mail triggers and tick auto-action; releasing a matching Incoming mail entry fires the template, and with it the template's Before / After hooks.No — headless
A scheduled Auto routineAn Auto-routine background job pairs a saved List with an automation on a cron schedule (section 20). Leave the schedule empty for an on-demand routine.No — always quiet
By hand — Run / Bulk run / Test RunThe case's Automation action (needs cases.edit), the editor's Bulk run (needs cases.bulk, capped at 200 cases per call), or the Test Run tab.Run & Test: yes; Bulk: no
The Graph webhook itself runs no automation — it only lands mail in Incoming mail. And a marker release, where the operator has already picked an incoming-post template, skips trigger evaluation so the action is not fired twice.

Interactive vs quiet runs

The rule is simple: if a signed-in person's browser drove the run, prompts work; if nothing did (a schedule, a bulk run, an API call, a server-fired trigger), they don't — and the two verbs then behave differently. A ui.message is downgraded to a run-log line, while an ask.* or a screens.open() fails the run with a clear error. Write quiet automations to decide everything from the data, and gate mixed-audience scripts with user.isSignedIn / user.isExternalUser.

One lane is not quiet even though it sounds like it should be: a field's on-change after a person saved. The workspace and Client Hub suppress the server-side fire and re-run each hook through the same prompt loop the manual Run dialog uses, so prompts and messages appear as live modals. Only a save that arrives straight from the API runs those hooks quietly.

Who the script thinks is running it, when nobody is

Every background-job run — including a manual Run now, which only enqueues onto the job-server lane — executes under a synthetic identity: user.id null, user.name "scheduler", user.email "scheduler@<tenant-slug>", user.isSignedIn false, and every profile member (job title, phone, security level, custom fields) null.

The Outlook lanes are the trap. The Graph webhook sets the mailbox owner's user id with both name and email literally outlook-webhook (the inbox poller and the triage re-ranker use inbox-poller / triage-reranker the same way), so user.isSignedIn reads true with nobody watching, profile lookups resolve against the mailbox owner, and user.email is not an address. Never detect an unattended run by testing whether user.email looks empty — use user.isSignedIn, and remember the Outlook lane defeats even that.

A calculated (Scripted) field goes through the same runner but with a deliberately db-less actor: user.id, .name, .email and .isSignedIn are honest, but every profile member, role, team and permission check reads null or false — the value is materialised once on write and must not differ by viewer. So if (!user.hasPermission('x')) inside a scripted field always takes the deny branch.

A collaborator-driven run reports a different user object

In a Client Hub run: user.isExternalUser is true; user.isInternalUser is false but is not the negation of it — both read false in an unattended run, so test the one you mean; user.hasRole, user.inTeam, user.roles() and user.teams() are always false or empty; user.externalRole() gives their portal role; and user.securityLevel is hard-coded to 0, so any screen or tile level above 0 is denied. Outbound correspondence never goes out as the external party — the sender resolver returns null for an external caller, so a send with no other resolvable sender fails loudly rather than impersonating them.

There is no trigger global

Scripts often try to branch on which trigger started them. There is no trigger global at all. The only context global is phase, and it returns just "Before" or "After": every case-type lifecycle trigger, the field on-change runner, the manual run, the bulk run and the test run all report "After", while screen buttons, Auto routines and calculated fields report "Before". Branching on phase to tell a Before Save run from an After Save run does not work — both are "After". If a script must behave differently per trigger, bind a different automation or pass the distinction through the data.

Permissions and closed cases differ by lane

POST /api/cases/{id}/run-trigger needs only cases.view, so a view-only user can fire Before Save, After Save and When-the-user-clicks-Close automations and every side effect they queue. The manual run (POST /api/cases/{id}/automations/{id}/run) and a screen button (POST /api/cases/{caseId}/buttons/{screenFieldId}/execute) both need cases.edit; Bulk run needs cases.bulk.

A dead (closed) case refuses the manual run endpoint with 409 case.closed and refuses a values save with a 409 too — but lifecycle triggers and the per-tile hook are deliberately run with closed cases allowed, so When-a-case-is-opened and the mark-as-dead gate still work. A screen's on-open automation is the exception: it is skipped while another user holds the case lock, and on a dead case it is refused with 409 case.closed — the one browser-fired hook that does not get the closed-case exemption.

Client Hub narrows things further: a collaborator can raise only On open, Before Save and After Save (plus the field and tile hooks). On create, When-the-user-clicks-Close, Before-marking-dead and accounts-paid are refused with 403; save-time hooks are refused for a read-only share ("Save-time automations only run for collaborators who can edit the case"); and any pending value, table or tile outside the share's editable screens is refused.

Auto routines: the one lane with no closed-case guard

A routine is a background job of type Auto routine carrying a saved List, an automation code and a dry-run flag, an NCrontab schedule (leave it empty for on-demand only), an optional pin to a specific job server and an optional "send as" user for unattended email. A dry run records every match and executes nothing.

It runs the saved List with that List's own Include closed setting and a hard cap of 10,000 matched cases, then runs the automation per case with no closed-case guard. So if the List includes closed cases, the routine will write to dead ones — an asymmetry worth naming, because a bulk run refuses a closed case outright and the manual run endpoint returns 409 case.closed, while the routine lane does neither and nothing in the routine editor says so.

A routine cannot prompt. A run that calls ask.* is counted as a failure for that case, with the log line "Script error: ask.confirm(…) needs a person to answer it, so it can't run in an automation that fires without one (on-change, triggers, routines, bulk). Move interactive steps to a manually-run automation." A stop() aborts only that one case and is counted separately from failures; the loop continues and each case commits on its own, so a later failure cannot roll back earlier wins.

Template Before / After hooks

  • A bound, Active automation with a non-empty script shadows the legacy inline script stored on the template; anything else falls back to that inline code. So deactivating the bound automation does not disable the hook — it silently resurrects the old inline script.
  • The Before hook's stop('reason') refuses the whole action: no render, no send, no history row, and the After hook does not run either — only an audit-only stopped event with source template-before. A thrown error aborts with automation.before.failed. The After hook can never undo the action; a stop() there only records the reason.
  • Template hooks inherit the caller's interactivity. A manual action or a screen button runs them interactively (a hook's ui.message / ask.* pauses and resumes); an unattended parent — a trigger, an on-change, an Auto routine, a bulk run, an Outlook auto-action — runs them unattended, so the same hook prompts or fails depending on who started the action.

Library automations

Tick Library to make an automation a reusable helper. A library automation can only be called from another script via automation.run('CODE') — it's hidden from run buttons, task pickers and bulk run. Use it to share logic across several automations on the case type. Binding one to a trigger is a configuration error that fails at runtime on every case (section 6).

11 · Writing, testing & examples

The editor writes code for you — and never lets you save broken code.

The editor

  • Add a step opens a palette of platform actions — 26 step kinds in eleven categories: Correspondence (6), Case data (the single "Put a value" step that covers every field, table, global, assignment, correspondent and other-case write), Correspondents (3 — find, add, update), The signed-in user (2 — "If the signed-in user…" and "Use a user value"), To-dos & diary (2), Cases (4 — create, open, mark dead, mark alive), Lists (1), Accounts (2), User interaction (3), Reuse (1) and Flow control (1). Each wizard writes the exact code at your cursor — you can then edit it freely. The list looks short on purpose: flow control, variables, comments and logging are just JavaScript now, so the palette holds only the platform actions you couldn't type yourself.
  • The field picker inserts a get('{token}') for any field, correspondent, global, assignment or whole table; a Snippets menu drops in common patterns (read/put a field, if/else, table read-modify-write, send, a diary date, an HTTP call…).
  • The Syntax tab — the side panel's four tabs are Details, Syntax, Help and Test Run — lists every command the engine actually has, grouped by root, one short cheat-sheet line each, searchable, and clicking one inserts the call skeleton at your cursor. It reads the live catalogue (the same source the linter and the editor's typings use), so it can never offer a verb the engine does not have. Help opens the full reference; Syntax is the sheet you scan when you half-remember a verb.
  • Check runs the parser and the linter against your case type's real design. Anything that would break — an unknown field token, a template code that isn't on this case type, a tasks.cancel with a typo'd filter key — is a violation that blocks Save, exactly like a syntax error. Softer issues are warnings and never block. Only literal strings are checked, so runtime-built values can't false-positive.
  • Parameters (Details tab) are the automation's function signature — at most 20, each named to match ^[A-Za-z][A-Za-z0-9_]{0,31}$; a blank or duplicate name (compared case-insensitively) is silently dropped rather than rejected, so two parameters differing only in case means the second one just disappears. Triggers, screen buttons and Run-automation tasks invoke an automation with no arguments, so adding a required parameter with no default to an automation already bound to one of those breaks every one of those runs — they fail before the first statement executes, with no design-time error. The editor warns as soon as you declare one; mark parameterised helpers as Library.
  • Test Run runs the script against a real case reference (or a synthetic TEST-000000 case) with prompts and previews behaving exactly as live, and the result panel shows the log and the fields it would set. Nothing is committed — but three things are not queued at all: they happen the moment the line runs and a test run does not undo them. http.* calls go out for real; correspondents.create and correspondents.update really write to the tenant directory (so testing a find-or-create script can litter it with duplicates); and sequence numbers are really drawn. phase reads "After", as in every live standalone run. A cases.create that would fail live is validated and reported as "cases.create would fail in a live run: …" rather than sailing through. The newest 20 test runs are kept per automation, newest first.
  • Bulk run runs it across up to 200 cases of the type per call, committing each case independently (needs cases.bulk). Bulk runs are quiet — an ask.* there fails that case.
  • Find & Automation search — the editor's find bar highlights matches in the open script. To search across automations, the Automation search entry in the Manage menu scans every automation's code case-insensitively, lists per-automation match counts, and opens the editor with the matches highlighted.
  • Where used — available on automations (and on templates, saved Lists, screens, global variables and correspondent types) — lists every place that references the thing: screen buttons, triggers, other automations, templates, Routines… each with click-through. Check it before renaming or deleting anything.
  • Code AI Add-on — with Rangeen AI, an assistant in the editor writes and explains automation script against the real API. You still read, test and save it yourself.

Example — escalate when a value crosses a threshold (bind to After Save)

if (changes.has('{claim_value}')) {
  var v = changes.next('{claim_value}');
  if (v > 25000) {
    put('supervisor@yourfirm.co.uk', '{assignment:supervisor}');
    tasks.create('Review high-value claim', {
      dueInDays: 2,
      actionType: 'Generic',
      assignTo: 'supervisor'          // an assignment-slot code — a typo here
    });                               // silently assigns to whoever ran it
  }
}

Example — a scheduled chase letter (run by an Auto routine)

// Quiet run: no prompts. stop() skips just this case in the routine.
var due = get('{report_due_date}');
if (helpers.isBlank(due) || dates.diffDays(due, dates.today()) < 7) {
  stop('Not yet 7 days overdue.');   // isBlank first: dates.* parse a bad
}                                    // date as NOW rather than throwing
actions.send('LTR-CHASE', { actionKind: 'Letter', holderField: 'Client' });
put(dates.today(), '{last_chased}');

Example — updating a table and logging time

// Apply a 5% uplift to every non-void row of the Disbursements table,
// drop voided rows, add a new line, then record the time spent doing it.

// --- table: read, change the array in JavaScript, put the FINAL array back ---
var rows = get('{disbursements[]}') || [];
var kept = [];
for (var i = 0; i < rows.length; i++) {
  var row = rows[i];
  if (row['Status'] === 'Void') continue;                 // left out = deleted on put
  row['Net Amount'] = (row['Net Amount'] || 0) * 1.05;    // EXACT column name
  kept.push(row);                                         // keeps _id = update in place
}
kept.push({ 'Description': 'Admin fee', 'Net Amount': 25 }); // no _id = insert
put(kept, '{disbursements[]}');

// --- time record: append one entry to the Attendance log ---
var att = get('{attendance[]}') || [];
att.push({ started_at: dates.now(), ended_at: dates.now(), note: 'Fee uplift run' });
put(att, '{attendance[]}');

log.info('Uplifted', kept.length, 'disbursement rows');

Example — acting on the rows the operator ticked (a screen Button tile)

// selection.rows is ALWAYS an array — empty unless a screen button started
// the run — so guard with .length, never for null.
var picked = selection.rows('Disbursements');
if (!picked.length) { stop('Tick the rows you want to mark as paid first.'); }

var all = get('{disbursements[]}') || [];
var chosen = {};
for (var i = 0; i < picked.length; i++) { chosen[picked[i]._id] = true; }
for (var j = 0; j < all.length; j++) {
  if (chosen[all[j]._id]) { all[j]['Status'] = 'Paid'; }
}
put(all, '{disbursements[]}');
ui.message('Marked', picked.length, 'rows as paid');

Example — calling a webhook (host must be allow-listed)

try {
  var res = http.post('https://hooks.example.com/case-events', {
    reference: get('{case.ref}'),
    status: get('{case.status}')
  });
  if (res.status >= 300) { log.warn('Webhook responded ' + res.status); }
} catch (e) {
  log.error('Webhook failed: ' + e.message);
}

Token names such as disbursements, Status, Client and LTR-CHASE are examples — they must match your case type's real field / column names and template codes, which is exactly what Check verifies.

12 · Merge tokens & formatters

Placeholders that fill themselves from the case.

Where: Manage → Configuration → Playbooks → the case type → Memos / Letters / Emails…

Templates (letters, emails, memos) merge case data through single-brace tokens. Every editor has an Insert field button so you rarely type tokens by hand. Token matching is case-insensitive and ignores spaces inside the braces; a field named with spaces uses its snake-case token (Date Of Birth → {date_of_birth} — though {Date Of Birth} and {DATE of birth} reach the same field). An unknown token renders as nothing — never as literal braces.

To merge…Write
A case field{field_name}
A global variable{global.key} — e.g. {global.firm_name}
A case property (control){case.ref}, {case.title}, {case.status}, {case.created}…
A correspondent's detail{Holder.attribute} — e.g. {Client.email}, {Client.address_block}
Through a case link{linkedMatter->claim_amount} (chainable, up to 10 hops)
An assignment slot's user{assignment:case_worker.name}
A table / time-record total{timesheet.count}, {timesheet.sum.hours}, {timesheet.latest.note}
The client-money ledger (Solicitor accounts){ledger.client_balance}, {ledger.uncleared}; statement rows {ledger[].date} / .detail / .dr / .cr / .balance
An embedded document's details{contract|filename}, {contract|size_human}, {contract|download_url}
Today / now / the sender{system.today}, {system.now}, {system.user.name}

Correspondent attributes are 23 built-ins accepted under 38 spellings: id, displayName (aliases name, display_name), firstName, lastName (alias surname), organisationName (alias organisation), contactPerson (alias contact), email, phone, mobile, website, reference, addressLine1, addressLine2, addressLine3, city, region, postcode, country, notes, plus four composed forms — address_block (a multi-line postal block), fullAddress, streetAddress and cityLine. camelCase and snake_case spell every one of them. Anything else falls through to the correspondent type's own custom fields, matched on the raw key or on the token form of the label — and an attribute that matches nothing at all renders blank; it never falls back to the display name.

Formatters

Append a pipe to format a value: {token|formatter}. Formatters fold left to right, so {name|trim|upper} is upper(trim(name)). A large selection is built in, including:

  • Text: upper, lower, title, sentence_case, trim, initials, first_word.
  • Dates: date_uk_slash (dd/MM/yyyy), date_long_uk (1 January 2026), date_with_day_name, month_year, date_iso, and many more; plus date-part formatters (day_ordinal, month_name, year_4). A stored Date/DateTime field with no formatter (and no format picked on the field itself) renders dd/MM/yyyy — the UK default everywhere on the platform.
  • Times & date-times: time_24h, time_12h, datetime_uk_slash, datetime_long_uk.
  • Numbers & money: value_with_commas, value_in_words, value_2dp, currency_gbp (or pounds), pounds_in_words, currency_usd, currency_eur.
  • Durations & booleans: duration_hhmm, duration_hours_decimal, yes_no, ticked.
  • Verbatim: raw (alias value) — hand the stored string back exactly as it is held.
  • For a Dropdown field, {status|code} / {status|description} / a custom column name picks which column shows (Description is the default).
Two things sit outside the dd/MM/yyyy default. The system date tokens are not stored field values: {system.today} prints 2026-09-17 and {system.now} a full ISO-8601 timestamp, both in UTC — so always pipe them when they face a client ({system.today|date_uk_slash}, {system.now|datetime_uk_slash}). The rule also bites the other way: any text value that merely looks ISO-shaped is reformatted against the author's intent, so add |raw to a reference or a code you want verbatim.

Which root wins

A token is classified by trying a fixed list of roots in order; the first match takes it:

  1. A link chain — anything containing ->.
  2. view: — the Table View family of section 14.
  3. case., then system., then global., then assignment:.
  4. ledger. — but only when the Legal-accounts module supplied a ledger snapshot for this render. Without the module, ledger is not reserved and falls through like any other name.
  5. A dotted name — Time-record field first, then iteration-table field, then CorrespondentLink holder.
  6. A bare name — the case's own field, matched case-insensitively on either the raw field name or its snake form.
A correspondent holder and a table field share one namespace, and the table wins. Name a CorrespondentLink holder the same as a Table or Time-record field and {Holder.email} is read as a table aggregation — which has no email — so it renders blank. Keep holder names and table names distinct.

The two built-in roots are closed lists. case.* is exactly ref, reference (a permanent alias for ref), title, id, casetype, created, createdby, modified, closed, status. system.* is exactly today, now, user.name, user.email, tenant.slug. Anything else under those roots renders blank.

The three silent failures

  1. A token cannot span a line break. The matcher rejects carriage returns and newlines inside the braces, so a token that Word or the rich-text editor has wrapped across two lines never matches and stays in the finished document as literal text — the one case where braces do reach the recipient.
  2. An unresolvable token becomes the empty string. A brace you typed on purpose, a mistyped field name and a token for a deleted field all vanish from the produced document without trace.
  3. An unknown formatter is not an error at all. The value passes through unchanged, so {fee|currency_aud} quietly prints the raw number. A typo in a formatter name never blanks a letter and never fails a send; only the design check notices it, and only as a warning.

The remedy in all three cases is the same — render the template against a real case before you ship it:

Preview a template against a live case

rangeen-design preview <case-type> <template> --case <ref>

Other behaviours worth knowing

  • A bare holder token on a table is a count, not a blank: {bills} on an iteration table is the row count, and on a Time record field it is the count of stopped entries — running and discarded entries are excluded from every document.
  • {doc|download_url} resolves to /api/attachments/{id}/download, an in-app authenticated route needing cases.view. Putting it in an outgoing email hands an external recipient a link they cannot open.
  • A signature field merges as the picture itself: a bare {sig} prints the drawn signature in letters and emails (and nothing at all in a plain-text render), while {sig|signed_by}, {sig|signed_at} (dd/MM/yyyy HH:mm) and {sig|filename} print who signed, when, and the stored file name.
  • Scripted (calculated) fields are evaluated once and overlaid at render, and the overlay wins over any stored copy — so a template renders them exactly as the screen shows them. A Before hook's field writes also land before the body renders, so a hook can shape what goes out (section 15).
  • The template's Description is itself merged. It is a token-bearing string resolved per case, so a description reading Chase {Client.displayName} re {case.ref} reads as real names in the action dialog's template dropdown and letter-head menu, and the resolved text becomes the history row's description unless the operator edits it (GET /api/cases/{caseId}/workflow-actions/resolved-descriptions).
Where the old double-brace grammar still runs. The legacy {{token}} resolver is not merely historical. It still serves the old Templates entity and, in live use, the Description of the ad-hoc correspondence route (/api/cases/{id}/correspondence) behind a custom one-off email, phone call or incoming post. It is a much smaller language: it needs at least two dot-parts, it never snake-normalises (so a multi-word field name cannot be referenced at all), it supports only case. / correspondent. / system. plus one case-link hop, and it has no formatters, no views, no conditionals, no globals and no assignments. The two grammars do not understand each other — a single-brace {case.ref} typed into that Description is left in the output as literal text.

13 · Conditional blocks

One template that adapts to the case's data.

Wrap text in an inline condition so it only appears when the case matches. Blocks can nest, and the first matching branch wins:

{#if claim_amount >= 10000}
   …multi-track paragraph…
{#elseif claim_amount >= 1000}
   …fast-track paragraph…
{#else}
   …small-claims paragraph…
{#endif}

A marker on its own line is removed cleanly (no blank line left behind), and this works in plain text, HTML email/letter bodies, and Word documents — where a block may span whole paragraphs or table rows and the winning branch keeps its formatting — and in Excel cells.

The expression language

A condition is an SQL-like expression (keywords are case-insensitive). Reference a field by name ([Date Of Birth] in brackets if it has spaces), and combine with and / or / not and parentheses:

  • Comparisons: =, !=, >, <, >=, <=.
  • field is empty / field is not empty.
  • field in ('a','b') / field not in (…); field between x and y.
  • field contains 'x' / starts with 'x' / ends with 'x'.
  • Right-hand values: quoted text, numbers, true/false, empty, relative dates today/now (with +N/-N), the current user me, a runtime parameter :name, or another field.

This is the same condition language used by screen visibility rules and List criteria — learn it once, use it in all three places.

Conditions see one case — they are not merge tokens

This is the biggest authoring trap in the chapter. A condition is evaluated against one case: a holder walk or a link chain inside a condition degrades to null rather than resolving. So {#if Client.email is not empty} does not do what {Client.email} does in a token — it simply never matches — and {#if linkedMatter->amount > 0} never matches either. A condition sees the case's own fields plus the stable columns (Reference, Title, Status, CreatedAt, ModifiedAt), and nothing else.

Within that, a custom field shadows the stable column of the same name: a custom field called "status" or "title" wins over the case's own Status or Title. That is deliberate, and it mirrors the token resolver, where bare {status} is the custom field and {case.status} is the stable one.

If you need a correspondent's detail or a linked case's value in a condition, copy it onto the case first — with a Before hook or a calculated field — and test that field instead.

Two marker forms

The inline form — {#if age >= 10 and age < 20} … {#elseif …} … {#else} … {#endif} — is what the editor emits today, and the expression lives in the body text itself. The legacy id form — {#if:c1} … {#elseif:c2} … {#endif}, ids drawn from [A-Za-z0-9_-] — carries no expression in the marker; it resolves against a per-version branch map stored beside the body.

A Word save-back drops legacy id-form rules. Editing a Word template in Office creates the new version with a null branch map, silently — the browser upload path carries the map forward, the Office save path does not. A branch whose id has no stored rule evaluates false, so the letter renders with that section quietly missing. Inline conditions live in the body text and survive a Word round-trip.

What is checked, and what is not

A malformed marker is left as inert text rather than throwing, and a broken expression evaluates to false (so dubious content is simply not shown). Save-time validation flags the first bad expression — but only in a text body (a memo, an email, a HTML-authored letter): it checks that every {#if} is closed, that every inline expression parses and that every referenced rule id exists (workflow.conditions.unbalanced / …invalid_expression / …missing_branch / …too_large, the branch map being capped at 200,000 characters).

Nothing checks a condition inside a Word or Excel template. The markers live inside the file, so the save-time validator never sees them — and Design-as-Code validate / plan / apply does not check them either. A mistyped condition in a .docx saves without complaint, applies cleanly, and silently suppresses its branch on real letters going to real people. Preview against a real case before you ship one (section 12).

One reassurance for anyone who inspects the underlying HTML: the rich-text editor rewrites every space inside a marker as &nbsp; the moment a body is edited, and the marker matcher deliberately accepts any whitespace or an encoded no-break space and HTML-decodes what it captures — so {#if [Claim Value] > 10000}, {#table:Monthly Expenses} and spaced Table View codes keep working after an edit.

14 · Tables in documents

Turn a case's rows into a table in a letter or email — including running totals.

Where: Manage → Configuration → Playbooks → the case type → Table views

A Table View is a named, reusable definition scoped to a case type. It reads a case's real rows (read-only) — from a Table or Time record field, filtered, sorted and with extra computed columns, or from a small script that returns rows. Reference it with the {view:code…} token family:

TokenGives
{view:code} or {view:code.count}The row count.
{view:code.sum.col} / .avg. / .min. / .max.An aggregate over a column.
{view:code.first.col} / .last.colThe first / last row's cell.
{view:code[N].col}The Nth row's cell (1-based).
{view:code[].col}Repeating — put this in one table row in Word/Excel and the engine clones it per row.
{#table:code}Email/HTML bodies: expands into a complete, styled HTML table. {#table:code|colB,colA} picks and orders columns.

Example — a billable-items table in a Word letter

| Date                                           | Description                        | Amount                                     |
| {view:billable[].work_date|date_uk_slash}      | {view:billable[].description}      | {view:billable[].amount|currency_gbp}      |

Items: {view:billable.count}     Total: {view:billable.sum.amount|currency_gbp}

Put the {view:billable[].…} tokens in a single template row inside the Word table; the engine repeats that row for every row in the view. In an email body, {#table:billable} drops the whole table in one token. Rendering order is fixed: conditionals expand first, then tables, then tokens — so a table inside a losing {#if} branch never renders. A view can set what happens when it's empty (keep the header, remove the table, or drop in a fallback paragraph — the fallback may itself contain tokens), and has a row cap (up to 500) that fails loudly rather than truncating silently.

Sharp edges

  • {#table:code} is HTML-only — Text and HTML emails, and HTML-authored Word letter/memo bodies. In a plain-text memo the marker is eaten by the token pass and the table simply vanishes, with no error. Inside a real .docx or .xlsx file, use the repeating {view:code[].col} row instead.
  • Name precedence for {#table:name} is Table View code first, then an iteration-table field, then a Time-record field; an unknown name expands to nothing. A column list that matches nothing fails safe to all columns rather than to an empty table.
  • {view:code[].col} renders blank anywhere except a Word/Excel table row — the substitutor expands it before the token pass ever sees it.
  • Two forms not in the table above: {view:code[last].col} (the last row, with no index arithmetic) and {view:code.count.col} (the number of rows whose cell in that column is non-empty). Row indexes are 1-based, and an out-of-range index renders blank rather than erroring.
  • A code may contain spaces and is matched case-insensitively — in the repeating form too: {view:Monthly Expenses.sum.amount}, {view:Monthly Expenses[].cost}. Codes are 1–50 characters of letters, digits, spaces, hyphens and underscores.
  • The row cap fails the whole document. A view that yields more than 500 rows, or whose script throws, fails the entire generation — so one over-broad view breaks every letter that references it, including letters sent unattended by an automation or by Routines (section 20). A view can also set its own lower cap.

15 · Template kinds, versions & hooks

One template library per case type — the Playbooks tabs.

TabBacked by
MemosText, Word (.docx), Excel (.xlsx), a PDF form or a PDF bundle — picked on the Type tiles. Usually an internal note-to-file.
LettersA Word (.docx), Excel (.xlsx) or PDF-form file, rendered per case — the Type tiles for a Letter are Word / Excel / PDF; plain Text is not offered.
EmailsRich-text / HTML body, sent through the user's Outlook (or a shared mailbox).
Letter headsA Word file used as the masthead on Word letters and Word memos — never on Excel, PDF or email output.
Phone callsNo body — records a call note (incoming / outgoing / both).
FormsAn uploaded fixed PDF a Letter fills in (see next section).
Incoming PostA named inbound correspondence type — no post text; the description says what arrived and the dropped documents are the content. Recording one can fire automation hooks.
AutomationThe case type's automations (sections 7–11).
Table viewsThe reusable row-set definitions of section 14.

Kind and Type

Every template has a kind — the tab it lives on, fixed at creation, with no way to change it afterwards — and a Type, which says what the template actually produces. Type is a row of icon tiles in the designer:

KindType tiles offeredNames a script uses
MemoText · Word · Excel · PDF · PDF bundleNone, MsWord, Excel, Pdf, PdfBundle
LetterWord · Excel · PDF (no Text tile)MsWord, Excel, Pdf
Email— always HTMLHtml
Letter head— always WordMsWord
Phone call— no body at design timePhoneNotes
Changing a live template's Type breaks every script line that still states the old one. actions.send's templateFormat is enforced against the template's Type at run time (automation.action.format_mismatch) and the linter blocks the save, so the scripts have to move with the template.

The PDF bundle

A PDF bundle is a memo that authors no body. Actioning it merges, in this order, the documents in its Sends-with embedded-document fields, then the history documents picked at send time (or a script's attach: ids), then any document-less history picks rendered as text pages — into one PDF with a bookmark per document. That merged PDF is the history row's document, and it is what "Embed into" deposits. The compose offers a single Preview PDF button; there is nothing else to author.

  • A bundle holds at most 60 documents (pdf_bundle.too_many).
  • Anything that cannot be converted is left out and named with its reason on the entry rather than blocking the send; only a bundle where nothing converts fails (pdf_bundle.nothing_converted, 422).
  • The bundler takes PDFs as they are, fits images onto A4, lays an email out with its envelope above the body, and pushes everything Office-shaped through headless LibreOffice (section 16).
  • It rides on the ordinary Produce memos permission — there is no separate bundle permission.

Versioning

Every save mints a new immutable version; the template points at the current one, and you can revert to any earlier version with no data loss. Crucially, a generated document keeps the version it was made from — editing a template later never alters documents already produced.

Binary templates (Word/Excel) are edited live: Edit in Word / Edit in Excel opens the file over WebDAV and every save from Office lands a new version by itself (each stamped "Saved from Word"), so a long sitting in Word produces several versions. Uploading a replacement file also lands a version.

A live edit session's token lasts 60 minutes. Every save inside that window lands as a new version immediately, so the only thing an expiry costs you is whatever is still unsaved in Word at that moment — after it, Word's save is refused and the text has to be copied out by hand. An app restart does not end a template edit session: the token carries its own expiry and the session is recreated on the next save (the version is then attributed to word-dav). Both the 60 minutes and a restart do kill a case-action live edit, whose working copy is a scratch blob held only in memory — see the drafts note.

Two things about "current". A send always renders the template's current version — there is no per-send version picker, no "send version 3", and no scheduled or effective-dated version. And Revert to version is a pure pointer flip to an older version row: no content is copied, no new version is created, the version list does not grow, and an accidental revert is undone simply by reverting again. Version numbers are 1-based and unique per template.

Identity, limits and copying

A template's identity is (case type, kind, code) and a unique index enforces it: a Memo and a Letter may share a code, two Memos may not, and a duplicate answers 409 workflow.code.duplicate. A code is 1–60 characters of letters, digits, spaces, hyphens and underscores, with no leading or trailing space.

Copy clones everything — wiring, diary, Before/After hooks, the embed and send-with lists, and the current version's body or blob bytes together with its conditional branches — but only within the same case type. There is no portal path to copy a template to another case type; cross-type distribution is Design-as-Code (section 26).

Description30,000 characters
Inline automation code100,000 characters
Version body text100,000 characters
Version notes1,000 characters
Uploaded file50 MB
Conditional-branch JSON200,000 characters

Send-with, embed & diary

  • Send-with documents — attach an Embedded-document field's file alongside the correspondence (using the case's per-case copy where present).
  • Embed into — drop the rendered output into an Embedded-document field on the case as well as into history. Tick Lock "Embed into" on the template to fix that destination: the send dialog then shows it read-only and the server refuses a change (automation embedInto overrides stay allowed — they are designer-authored).
  • Diary follow-up — any kind can schedule a follow-up task on action: a number of days out (0–36,500) plus an action (Generic to-do, Send Memo / Letter / Email, Make Call, Run Automation, Incoming post), a correspondent type, a template, an assignee and a note. The days must be set — a follow-up with no days is inert and emits nothing at all, silently. The action must agree with the referenced template's kind, and Run Automation needs an automation named. The send dialog lets the user adjust or suppress it for that one send; a Custom one-off has no follow-up section at all.
  • Save as draft — mid-compose, any action (memo, letter, email, call, incoming post) can be parked with one click and resumed later — even after signing out, and even mid-Word-edit (the edited document is kept). Drafts are personal; they live on the case's Drafts panel and on the global Drafts page, and are deleted automatically once sent.

Before / After hooks

A template can run an automation before it's actioned and another after. Point each at a saved automation. These hooks are the one place a script sees the action's correspondent, template and phase.

  • Before runs prior to rendering and prior to the correspondence entry being recorded, so its field writes shape what goes out — and a stop('reason') there abandons the send: nothing is rendered, no memo/letter/email row is written, and the After hook never runs. Two things do survive, so a validation stop is not a clean rollback: any field writes the hook already made are kept, and an audit-only "Automation stopped" entry recording the reason is written to the case.
  • After runs only once the entry is committed, so a failure — or the operator cancelling a question it asks — never rolls the send back ("After-automation cancelled — the action itself was already recorded"). It is best-effort: the failure is logged.
  • Either hook can pause and ask the operator a question mid-send; answering resumes from where it paused rather than re-committing.
Deactivating a bound automation silently disables the hook. The resolver prefers the assigned saved automation (the Run before / Run after pickers) and filters on active; it falls back to the template's legacy inline code only when the assigned one resolves to nothing. So deactivating an automation a template points at turns that hook off with no warning anywhere on the template — and can quietly re-enable stale inline code in its place.

Custom one-offs are hidden templates

A Custom one-off memo or letter is a real but hidden template: the app mints one with a random code of the form 1off-xxxxxxxx, actions it through the ordinary pipeline, then deletes it. That is why a one-off behaves exactly like a template, Type tiles included (Memo offers Text / Word / Excel / PDF; Letter offers Word / Excel / PDF). Abandoning the compose without saving a draft deletes the hidden template with it.

A one-off is gated on the channel permission of its kind — workflow.action.memo / .letter / .email / .phone — and never on workflow.template.manage, so a user who may produce memos but may not build the library can still send a custom memo. A custom one-off email, phone call or incoming post is different machinery: those compose inline and post to the ad-hoc correspondence route. Only Memo and Letter build a hidden template.

Incoming post

An Incoming post carries no post text: the Description says what arrived, the dropped documents are the content, and a body handed over by an older client or script is discarded on the way in. It is also the one kind with an Attachment to embed picker — where several files were captured you choose which single one is deposited into the Embedded-document field, and a dropped .eml / .msg is stored verbatim, tagged "original" and offered first. An incoming post can never be resent (the Resend row action is disabled: "An incoming post was received, not sent, so it cannot be resent"). Like every other kind it can carry Before/After automation and a diary follow-up, which is what makes it a trigger point.

Senders

Email templates deliberately carry no send-from setting — the sender is chosen at send time: the operator's own connected mailbox or a shared mailbox they can send from. For unattended sends by Routines, the sender comes from the job server's Sends email as setting (section 20).

16 · Letter heads & PDF forms

Firm branding on a Word document, and case data into a fixed PDF.

Letter heads

A Letter head is a Word document holding your firm's header and footer (logo, address bar, page setup). Any Word letter or Word memo template can pick one — letter heads are Word-only and are never applied to Excel, PDF or email output (the Email designer discards a letter head on save). At render time Rangeen transplants the letter head's header/footer onto the document and takes its page size and margins with them, leaving your authored body paragraphs untouched — "apply the branding, keep my content".

That transplant replaces whatever header, footer and page setup the document already carried. Only the body paragraphs survive.

Swapping one, and pulling changes in

  • Per send — a Letterhead: button appears in the compose for a Word letter or Word memo (and a Word one-off). Its first entry is No letter head, and whatever is chosen overrides the template's configured head for that one send.
  • Per template — in the designer, a Word letter built on a letter head gets an Apply letter head button that refreshes the template's document with the current head: the head's header, footer and page setup are transplanted in and your authored body is kept. (A letter with no document yet simply adopts the head as its starter.) That is how you pull a letter-head change into templates already built from it. The button also appears on a Word memo, but the server refuses it there — 400 workflow.letter_head.invalid_target, "Only Word Letters can have a letter head applied."

Authoring style decides the mechanism: an HTML-authored Word letter gets the head's HTML prefixed, while a Word file template has its header/footer parts swapped before token substitution — which is why tokens inside the letter head resolve too.

PDF forms

A Form is a fixed PDF (a court or insurer form) you fill from the case. Two ways to fill it:

  • AcroForm fields — if the PDF has form fields, map each one to a token (e.g. a field to {claim_amount}); at send the tokens resolve and the fields fill.
  • Overlay boxes — drop styled text boxes onto specific pages, each bound to a token or literal, with font size, bold, italic, left/centre/right alignment and an optional multi-line wrap (there is no font-family picker). At send, every box is editable in a preview so the operator can adjust before committing. Boxes placed by an older design as a checkbox, select or date now render as plain text — Forms v2 collapses every placement to one styled text box.

Either way the case data does the filling, and the finished PDF lands on the case.

How a fill fails — quietly

  • AcroForm field names are extracted from the uploaded PDF automatically (distinct, alphabetically sorted). A corrupt, encrypted or form-less PDF returns an empty list rather than an error, and the designer then hand-adds the rows.
  • A mapped name that is not present in the PDF is ignored silently.
  • On an owner-password-protected PDF the AcroForm fill is dropped entirely and only the drawn overlay boxes survive — the letter still sends, just with the form fields empty.

Who owns what

The Form template owns the box placements — coordinates in PDF points measured from each page's top-left. A PDF Letter stores content only, and inherits the geometry:

What a PDF Letter's version body holds

{
  "formTemplateId": "…",
  "content": { "<fieldId>": "Text with {tokens}" }
}

That formTemplateId GUID is rewritten on Design-as-Code import, so it must never be hand-edited in a workspace file. An older stored shape instead holds mappings (acroField → token pairs) and/or its own overlays array.

Convert to PDF

The three-dots menu on any history row offers Convert to PDF. It builds a new history entry holding one PDF of that entry's own document first, then its attachments in the order they were added; the original entry is untouched. Files that could not be converted are named in a warning toast afterwards and the PDF still lands, just incomplete. It records a memo-class row, so it consumes the Produce memos permission (workflow.action.memo); it is refused on a dead case (409 case.closed) and answers 400 history.convert.no_files when the entry carries no document.

The conversion limits behind every PDF the platform makes. All server-side document-to-PDF conversion is headless LibreOffice (soffice --headless --convert-to pdf) with a 30 MB input cap, a 45-second timeout and at most two concurrent conversions — a burst of preview or bundle clicks queues rather than failing.

17 · Global variables

Organisation-wide values, defined once.

Where: Manage → Configuration → Data manager → pick Global variables in the case-type dropdown. Opening the browser needs database.manage; creating, retyping or deleting a global needs globals.manage; changing only a value needs cases.edit — except a secret's value, which needs globals.manage. Reading is open to every signed-in user.

  • A global is an organisation-wide value — a standard rate, a firm name, a fee amount, an address. Each has a Key (immutable), a label, a type (the same set as case fields, carrying the same per-type config) and a value. Mark one as a secret to mask it in the UI.
  • The Key is 1–60 characters matching ^[A-Za-z][A-Za-z0-9_ ]*$ — a letter first, then letters, digits, underscores and spaces — and is unique across the organisation, compared case-insensitively. It cannot be edited: to rename a global you delete and recreate it, and the value resets. A null value means "the global exists but is unset", which is not the same as "does not exist".
  • There are four ways to read one, and they are not interchangeable: global.key in a list's criteria, columns or sort; {global.key} in a template; get('{global.key}') / put(value, '{global.key}') in an automation; and globals.get('key') inside a Table View column script.
  • Placed on a screen as a Global tile, a global is editable — and saving it changes the value for the whole organisation, not just the case.
A secret hides a value from the screen; it does not protect it. The secret flag is presentation-only: a reader without globals.manage sees the literal mask •••••• and an admin needs a reveal click, but scripts, templates and Lists always resolve the real value. Marking an API key secret keeps it out of the UI — it does not stop a tenant automation from reading it and sending it somewhere.
A global tile rides the same Save button but a different lane. A save where only globals are dirty skips the case write entirely — and with it the case type's Before Save and After Save automations. The global write is also not atomic with the case write, so on a mixed save one can land without the other.

Globals in a design workspace

A global's definition exports — key, label, dataType, per-type config — but its live value never does: values are runtime data, and a secret's value must not leave the tenant. Unusually for the workspace, a global's dataType is mutable in place on apply: the type simply changes and the stored value is left in the old shape, neither re-coerced nor cleared, so plan to fix the value immediately after retyping one. On apply, globals land in the tenant-scope batch that runs before the case types (correspondent types, globals, roles, teams, user fields, collaborator roles), so a list, template or Global tile that references global.key resolves even when the same apply created the global (section 26).

Changed. In automations the old globals.get('key') / globals.set(…) calls are gone — there is no globals object in the automation sandbox at all. Use the token form: get('{global.key}') and put(value, '{global.key}'). The one place globals.get('key') survives is a Table View column script, whose host surface is just case_ plus globals — don't "modernise" one of those into the token form.

18 · Lists

Saved questions over the caseload — build once, run forever.

Where: Manage → Configuration → Manage lists (build, needs queries.manage) · Lists (run, needs queries.run)

A List targets exactly one case type — there is no cross-case-type list, and the run page makes you pick a case type before it shows you anything. It has an immutable Code, a name, criteria, display columns, a multi-field sort, an Include closed cases flag and an Active flag — inactive Lists are hidden from the run page. Criteria combine a field, a comparator and a value, joined with And / Or (And binds tighter), grouped with brackets — the click-builder nests four levels deep and the typed view is unbounded, because a saved List has no depth ceiling (the 12-level cut-off applies to screen visibility rules and insight inline filters, not here) — and any criterion or group can be negated with Not, so shapes like not (A or B) and C are expressible.

Include closed cases is not additive. Unticked, the list is restricted to status Open exactly; ticking it removes the status filter altogether, so Archived cases come back as well. On a case type holding 500 open, 300 closed and 200 archived cases you get 500 or 1,000 — never 800. There is no "open plus closed but not archived" setting.

Naming rules

CodeImmutable identity, 1–31 characters matching ^[A-Za-z0-9_-]([A-Za-z0-9 _-]{0,29}[A-Za-z0-9_-])?$ — letters, digits, spaces, hyphens, underscores; no leading or trailing space.
NameUp to 200 characters; Description up to 1,000.
UniquenessCode and Name must each be unique within the case type — but the check compares with SQL equality, which is case-sensitive on PostgreSQL, so OPEN and open can both exist on one case type.
Question name^[A-Za-z][A-Za-z0-9_]{0,31}$ — a letter first, then letters, digits and underscores, 32 characters at most. It can never contain a dot: the dot after a question name is reserved for the user-attribute pick (@param:who.email).
Two silent normalisations on save. An empty group is dropped — build a group, delete its last row, and the group disappears with no warning — as is any leaf with a blank field name. Duplicate columns and duplicate sort fields are de-duplicated, first occurrence winning.

Writing the criteria

You can author criteria two ways — the click-builder or the typing view — and switch between them freely. The typed grammar is the same condition language as template conditionals and screen visibility rules (section 13), with these list-specific rules:

  • Anything that is not a plain identifier-shaped field name goes in [square brackets] — [case.ref], [global.vat_rate], [client.email], [assignment:supervisor.email], [Claim Type|description].
  • Eight words are reserved as values and can never be read as a bare field name: today, now, me, current_user, true, false, null, empty. A field actually called one of those must be bracketed.
  • Operators are =, !=, <>, >, <, >=, <=, plus is [not] empty, [not] in (…), between v and v, contains, starts with and ends with. not in (…) works; not between is refused by both parsers with "'not between' isn't supported — use a range or 'or'." Wrap it as not ( … between … ) instead.
  • A syntax error blocks saving the list — the editor never silently falls back to the last valid tree.

A typed condition

[Review Date] <= today + 7
  and [assignment:supervisor.email] = me.email
  and not ([Claim Type] in ('PI','CN'))

Comparators

Equals, Not equals, Contains, Starts with, Ends with, Greater than, Less than, Greater-or-equal, Less-or-equal, Between, Blank, Not blank, In list, Not in list. A list is one comma-separated string, matched case-insensitively — so a list value can never itself contain a comma. Ordered comparisons need both sides present: a case with a blank field never matches >= / Between, and a date compared against non-date text is simply no match — never an alphabetical accident.

Not equals and Not in list DO match blank fields, because a blank never equals anything — which catches out everyone who reads "not X" as "has a value, and it isn't X". Blank / Not blank test whitespace too, so a value of only spaces counts as blank.

What you can filter, show and sort on

Beyond the case type's own fields (scripted fields included — evaluated per case at run time), criteria, columns and sort can use an extended vocabulary:

FormMeaning
case.ref (or case.reference), case.title, case.id, case.casetype, case.status, case.created, case.createdby, case.modified, case.closedThe built-in case controls. The older spellings Reference, Title, Status, CreatedAt and ModifiedAt still work as aliases.
assignment:slot.attrThe user in an assignment slot — name, email, job_title, phone, id, or any custom user field.
Holder.attrA correspondent attribute through a correspondent-link (holder) field — client.city, solicitor.postcode…
Field|facetA facet of a field: an Option's code / description / custom option column, an embedded document's metadata, or a display formatter such as date_uk_slash.
global.keyAn organisation-wide global (section 17).

What a list cannot do

  • It cannot drill through a case-link field. {link->field} works in templates and scripts, but the executor has no -> arm: the field picker disables link drilling and any picked path containing -> is discarded. The workaround is a Scripted field on the case type that reads the linked value — the list then filters on that.
  • Table, time-record and embedded-document fields cannot be compared at all — IterationTable, TimeRecord and EmbeddedDocument are excluded from the condition picker outright.
  • There is no cross-case-type list. One list, one case type. To reach another type's cases from a script, use query.named(…, { caseType }) below.

Smart values

Smart valueResolves to
@today, @today+N, @today-NToday's date (± N days).
@now, @now+N, @now-NThe current time (± N minutes).
@me (or @currentuser)Whoever is running the list — and @me.email, @me.name, @me.job_title or a custom user field compare an attribute instead, so one shared "my open cases" list works for everyone.
@param:NameA question the runner answers (typed :name in the text view).
@param:Name.attrAn attribute of the user a User-typed question named — @param:who.email. This is why a question name can never contain a dot.
@field:NameAnother value on the same case — a field-vs-field comparison. It speaks the full extended vocabulary, so @field:case.created and @field:client.email are valid right-hand sides.

Spelling is forgiving on the date forms: spaces are fine (@today - 90), and on ordered date comparisons the bare today ± N without the @ resolves too. Under Equals or Contains the literal word "today" stays a five-letter string; the @-prefixed form resolves under every comparator. A question's supplied value or default is itself re-resolved through the date-sentinel logic (a Date question defaulted to @today compares as today), but only date sentinels nest — an @param or @field inside a parameter value stays literal, so one question can never reference another.

A mistyped smart value fails silently. An unrecognised @-sentinel is returned verbatim, so @tday is compared as those five literal characters: the list runs cleanly, returns nothing, and says nothing anywhere. Same family — a ±N offset larger than a 32-bit integer parses as 0, and every offset is clamped to ±500,000 days or minutes, so an absurd hand-typed offset degrades quietly instead of failing.

Questions asked at run time

Declare typed questions so one list serves every variation — answer types are Text, Number, Date, Date & time, Time, Yes/No, Option (a dropdown drawn from an Option field) and User; each carries the wording shown to the runner, a required flag and an optional default. In the questions panel a Detect (N) button auto-declares every :key the criteria already reference, and typing a :name into the condition text creates a question stub — not required, with a label humanised from the key and the answer type inferred from the field it is compared against (a Date field gives a Date question, a Dropdown gives an Option question, anything else Text) — but never overwrites a declaration that already exists, so a question you have already set to Date and required survives every edit and every switch between the two views.

Optional-question semantics — worth knowing exactly. A required question left blank refuses to run. An optional question left blank drops its criterion from the list before any case is tested — it doesn't match-all or match-nothing, it simply isn't used to filter. A Between with one bound answered degrades to the one-sided comparison and is dropped only when both bounds are blank; a group whose every child dropped is dropped whole (so a negated group can never become NOT(nothing)); if everything drops, the list runs unfiltered and returns the whole case type. And when a dropped criterion was the start of an Or segment, the next survivor inherits that Or — "A or B and C" does not silently collapse to "A and C".

How results sort

A column a tenant fills freely can hold a mixture, so sorting uses one typed key with a total order that cannot throw. What that looks like on screen: blanks sort last in both directions; unlike kinds group as numbers → dates → yes/no → text; text compares naturally, so PI/9 sorts before PI/10 and ABC-99 before ABC-100234; and case references and titles are always typed as text, so a numeric-looking reference still groups with the rest. With no sort configured at all, results come back newest-modified-first. Table views keep their own comparer and none of this applies to them.

The run page

The run page shows three fixed columns — Case key (the case reference), Title and Status — then the list's own columns, extended ones rendered as a friendly path ("Supervisor · Email"). Every header sorts: ascending, descending, then back to the list's saved order. Page size is 25 / 50 / 100 / 250 / 500, seeded from the organisation's default case-list page size rounded down to one of those buckets; changing it re-runs from page one and throws away both your ticks and your clicked sort. Selection is by checkbox, with a "Select all N matches" jumper that pages the list in 500-case chunks. While a required question is blank, paging and sorting are silent no-ops and the action buttons carry the tooltip "Fill required parameter(s) first".

Results are gated by permission, not per case. The read gate accepts either cases.view or queries.run: hold one of them and you get every case the list matched, including cases of a case type you are barred from opening. Case-type access rights are enforced on the path — opening such a case answers 403 casetype.open.denied — and deliberately do not hide rows from case lists, search, Lists or Insights. Never use case-type access rights to keep sensitive rows or columns out of a list or its export. Insights skip even this gate: reports.run on its own is enough to read case data through a insight.
A list refuses to run on a case type bigger than 25,000 cases. Criteria are evaluated in memory, so the whole case type has to be loaded; an interactive run is therefore refused with 400 query.too_broad — "This query would have to examine more than 25,000 cases of this type, which is too much work to run safely. Narrow it down…". The counter-intuitive part: the refusal is about the size of the case type, not the size of the answer, so adding criteria does not help once the type itself is over the line. The cap covers the run page and CSV / XLSX export. It does not cover bulk update, Insights, auto-routines or query.named — those load the whole case type with no ceiling (a bulk update still writes at most 10,000 cases, but it is never refused for scanning too many). It does not cover Insights, auto-routines or query.named — those load the whole case type with no ceiling.

Exporting

Export to Excel or CSV, all matches or just the ticked rows. The server caps every export at 10,000 rows and nothing tells you when the cap bit — the menu still reads "Excel (all 42,000)" and you silently receive the first 10,000. The export also re-runs the stored list, so the file arrives in the list's saved sort, not the column you clicked on screen, and "export selected" only keeps ticks that fall inside those first 10,000 rows. Every file is prefixed with four fixed columns — Reference, Title, Status, ModifiedAt — before the list's own; CSV is UTF-8 with a byte-order mark and dates are rewritten UK-style. Check the row count in the file before reconciling anything against it.

Bulk edit

A Bulk edit writes one field straight into every ticked case or — when nothing is ticked — into every matching case, up to 10,000. An empty value clears the field. There is no undo and no per-case confirmation, only the count on the "Apply to N" button. Calculated, table, case-link, correspondent-link and embedded-document fields are not offered. The button is visible to everyone; the call needs cases.bulk, and a user without it only finds out at Apply.

Run automation

Run automation takes the opposite fallback to the Bulk edit sitting beside it: with nothing ticked it runs against the rows on the page you are looking at, not every match, and the server refuses any batch over 200 cases ("Bulk runs are capped at 200 cases per call."), so a 500-row page has to be narrowed first. Only active automations on that case type are listed. Same screen, same empty selection, two different scopes — read the count on the confirm button.

Row caps, by surface

SurfaceRows
Interactive run page1–500 per page (default 50)
CSV / XLSX export1–10,000 — the run page asks for 10,000, Simple mode takes the 5,000 default
Bulk edit from a list10,000
Run automation from a list200 per run (400 above that)
Insight max rows1–50,000 (default 5,000)
Insight preview1–500 (default 50; the run page draws the first 100)
query.namedDefault 500, clamped to 10,000
Auto-routine10,000
Shared executor ceiling50,000
Interactive scan refusalMore than 25,000 cases in the case type

Simple mode also names the exported file after the list's Code where the full Lists page names it after the list's name — so the same list exported two ways gives two different filenames and two different row counts.

Turning one off, and deleting one

Clearing Active hides a list from the run page and stops query.named finding it — with no "disabled" marker, users just see an absence. It does not stop an insight or an auto-routine that references it by id; both look it up with no active check and keep running. Deactivating is not a kill switch for scheduled consumers.

Nothing blocks a delete either: the only guard is the type-the-code confirmation in the dialog. Afterwards a bound insight fails at run with report.query.notfound, and an auto-routine logs "Saved query {id} not found." Always run Where used first — and note that it deliberately opens tenant-wide, because another case type's script, a job, an insight or a SQL-viewer screen tile can depend on the list and a single-type scan would wrongly answer "not used anywhere".

Running a list from somewhere else

An automation reaches the same engine with query.named('CODE', { take?, params?, caseType? }) (section 9), which returns rows of { caseId, reference, title, status, modifiedAt, values }. values is keyed by the list's output column names exactly as configured, so a column named Claim Type|description is read as r.values['Claim Type|description']. take defaults to 500 and clamps to 10,000. Omitting caseType runs a list on this case type; passing it (the other type's name or code, matched case-insensitively) runs one defined elsewhere. It applies the list's declared defaults and fails loud on a missing required question.

The list Code is matched case-sensitively. query.named('overdue') will not find a list coded OVERDUE — and an unknown code is a script error, not an empty result. A scripted (calculated) field can never call query.named at all: its evaluation context carries no named-query resolver, so there is no recursion path back into the list engine.
An auto-routine runs your list with no parameters and no user. A background auto-routine (a saved list paired with an automation run per matched case) calls the executor with neither parameters nor a current user. Every @param: criterion is therefore dropped — not defaulted, not refused — so the routine matches a broader set than the run page does, and the required flag is not enforced on this path. Every @me / @me.attr criterion resolves to null and matches nothing. Never drive a routine from a list whose safety relies on a required question, and use the routine's dry-run first: it records the matches without executing the automation. The same no-user rule applies to a scheduled insight and to the design CLI's run command.

A saved list (or an insight) can also be pinned to My Day as a tile: a list tile shows the match count and the top five rows, an insight tile the grand totals plus a corner of the preview. A dashboard tile sends no answers to the list's questions — required ones make the tile read "Could not load query." and optional ones are dropped — so the tile can show a large, unfiltered count that disagrees with the same list run by hand. The tile's Open button goes to the Lists page, not to that list.

Designing My Day. Pinning is personal. To put a list on everybody's My Day, design the page: Configuration → Home page (permission home.design, its own key — laying out the page the whole tenant opens first is deliberately not a ride-along on screens.manage). It is the same canvas as Screen studio — 192 columns of 8-pixel rows, drag to move, drag an edge to resize — and the same draft / publish rhythm, so nobody watches My Day rebuild itself while you work. Until somebody publishes a design, every tenant sees the standard My Day and nothing changes.

A box can be a count from a list ("cases whose Type of instruction is Pagination") or the cases that list finds; an insight; one of the tenant-wide totals (total / open / closed / archived / opened by me today, which count across every case type and so cannot be a saved list); one case type's count, or a strip of them all; the quick-create buttons; Recent, Starred, This week, your-week chart, the to-do summary; a heading, a line, a link. Each box picks which component draws it — the same number can be a framed box, a small chip, a big figure or a bar meter — plus a themed colour, or literal hex if a token will not do.

Three things worth knowing. A box can be hidden from people who lack a permission or a security level, and that is enforced on the server: the box is not sent to the people it excludes, so it is a real control, not a curtain. At most 12 boxes may run a list or an insight, because each one searches a whole case type every time anybody opens My Day (boxes on the same case type share one search, so six on one type cost about what one does). And personal pinning survives as a box: place My pinned tiles and everybody gets their own corner to pin into; leave it out and nobody has personal tiles. The page-level Customise button disappears once a design is published — one page, one way to change it.

A design travels through the design workspace as tenant/home.json, naming every list by code rather than id so it applies to another tenant. A tenant on the standard My Day has no such file, and an absent file means "not described here" — never "delete the design".

The design CLI can run a saved list live and read-only — rangeen-design run <case-type> <query> [--param k=v] [--top N] [--skip N], clamped to 1–500 rows, with the full result written to .design/last-query.json (section 26). It runs with no current user, so @me matches nothing there either.

There is no private list, and no sharing. A saved list carries no owner and no visibility column: every list on a case type is tenant-wide, and every holder of queries.run sees all of them. Create one and it is instantly on everyone's shelf. The queries.manage permission is described as "Create, edit, share, and delete saved queries", but there is no sharing mechanism anywhere — read "share" as "publish to the organisation". The same goes for Insights. And because the case read gate accepts cases.view or queries.run, granting a read-only analyst queries.run also hands them case rows through Lists and exports.

Example — "Overdue high-value matters for a fee earner"

One User question (owner) and one Number question (threshold, default 50,000), reading:

Assignee     equals        @param:owner
AND Target Date   less than    @today
AND Claim Value   >=          @param:threshold
AND ( Status equals 'Open'  OR  Status equals 'On hold' )

Sort by Target Date ascending; show Reference, Title, Claim Value, Target Date, Assignee. Leave threshold optional and a blank answer simply stops filtering by value.

19 · Insights

A saved list, presented properly.

Where: Manage → Configuration → Manage insights (build, needs reports.manage) · Insights (run, needs reports.run)

Every Insight binds one saved list, and that binding is fixed at creation — there is no "change the list" control anywhere, so pointing a insight at a different list means deleting and recreating it, which loses its Code and takes its schedules with it. On top of the list it adds presentation:

  • Columns to project, and an optional inline filter that narrows the list without forking it (always AND-combined — it can never widen the result). The filter's Answer operands read the same merged parameter map, so an inline condition can reference one of the list's questions by key, and Today/Now use the same clock and @me the same user as the list did.
  • Grouping — one or more levels, declared outermost-first, each with its own Label and a Descending flag. Rows are sorted by the group fields left-to-right and a band header is emitted whenever any level's key changes. The per-bucket count and the subtotal row are per-level opt-ins, not automatic. A blank group key renders as the literal (blank).
  • Roll-ups — Count, Sum, Average, Min or Max on chosen fields, shown as a grand total (and as the per-group subtotals). All five ignore blank and non-numeric cells, and Average divides by the numeric cells only. Count counts the cells that parsed as a number, not the rows, so a Count on a text column reports 0 with no error — to count rows, roll up a numeric column (or a scripted field that returns 1). A chart's Count is the other way round: it really is a row count per category.
  • A chart — Bar, Pie or Line, computed over every matched row (up to the max-rows cap), not just the rows drawn on screen, so it never disagrees with the totals. The picture, though, exists only in the on-screen Preview: the PDF prints the chart as a two-column table of categories and values, and CSV and Excel leave it out altogether.
  • Output — a PDF (portrait or landscape, with a subtitle, header and footer), an Excel workbook, or a CSV; plus a max-rows cap (default 5,000, clamped 1–50,000). Rows past the cap are dropped silently — from the row count, the file, the subtotals, the grand totals and the chart alike — so a truncated insight's totals are totals of the truncated set.
An insight can only see what its list already asked for. The runner builds its field resolver from the linked list's criteria, columns and sort, and preloads correspondents, assignment slots, users, option values and globals for those names only. So a global.key, assignment:slot.attr, Holder.attr or Field|facet that appears only in the insight's own columns, groupings, roll-ups or chart resolves against an unloaded lookup and renders blank — no error, no warning, just an empty column or a band of (blank). Scripted (calculated) fields are the one exception: they are materialised insight-side, including names used only by the insight. The fix is to add the same name to the list's columns as well, even when you don't want it on the grid.

Running one

Whoever runs it answers the list's questions — though unlike the Lists page, Insights does not park on a blank required question: it attempts the run and shows the server's error. The Format drop-down overrides the insight's stored default for that run. An insight with no columns configured falls back to Reference, Title and Status plus the first distinct value keys it finds, stopping at 30 columns.

An insight sidesteps two of the Lists page's guards. It never applies the case-read filter, so reports.run on its own reads case data (section 18); and it is exempt from the 25,000-case scan refusal — it loads the whole case type into memory, which makes an insight on a very large case type the one place that cost is not bounded.

The output files

A downloaded file is named <report code>-yyyyMMdd-HHmm with the format's extension — AGED-DEBT-20260917-0930.pdf — the timestamp in UTC. The PDF prints the insight name, the subtitle, "Generated <date> UTC · N row(s)", the parameter values actually used, the author's header and footer text and "Page X of Y". The on-screen Preview draws only the first 100 rows ("N rows · showing first M") while the totals, subtotals and chart are computed over the full matched set — so the figures on the preview are right even though the rows are a sample.

Insight CSV is not a clean flat table. Group headings and subtotals are interleaved as lines beginning with # and a space, and the grand totals are appended after a blank line as # total: …, so a grouped insight's CSV will not load cleanly into Excel or Power Query. Use the Excel output, or an insight with no groupings, when a machine has to read it.

Scheduling

A schedule takes a name, a Code, a standard five-field cron expression evaluated in UTC, a Format, comma-separated Recipients and an optional subject; an invalid cron is refused at save with 400 schedule.cron.invalid. The rendered file is emailed as one attachment — the first recipient in To, the rest blind-copied so the list never leaks — with a default subject of "Rangeen report: <filename>". Schedules are stored as background job rows (job type ScheduledReport), so they also appear on the Routines page (section 20) and their run history is the job's run history.

Deactivating an insight does not stop it running. The Active flag filters the list only; the run and preview handlers never check it, and a cron pointing at the insight keeps firing. To stop a scheduled insight, disable or delete the schedule.
An unattended run has no user and no answers. @me has no meaning without a signed-in user, so a scheduled insight built on a "my cases" list matches nothing. And the schedule form has no parameter inputs at all — it sends none — so a default value declared on the list's question is the only way to fix a value for an unattended run. For the same reason an insight whose list has a required question with no default fails on every scheduled run ("Render failed: Required parameter …") even though the same insight runs perfectly by hand. Only schedule Insights whose questions are all optional or carry defaults; nothing in the UI warns you.

20 · Routines & job servers

Scheduled work, defined and watched in the open.

Where: Manage → Operations → Routines (needs jobs.manage) — two tabs: Jobs and Servers

jobs.manage is all or nothing. The whole page — reading included — sits behind that single permission. There is no read-only view of jobs or of run history, so letting someone check whether last night's routine ran necessarily gives them create, edit, delete and Run now on every job (section 23).

The Jobs tab

Each row shows a status dot; the name with its code and type beneath it; the schedule (the plain-English reading, the raw cron and "UTC", or "On demand"); the last run (a status badge and a relative time, or "Never run"); the next run; an Enabled toggle; and the row actions — Run now, Dry run (Auto routines only), Run history, Edit and Delete. The search box filters on code or name.

You can create three kinds of job (the editor offers them exactly so):

  • Auto routine — saved list + automation: on each run, execute a saved List and run an automation on every matching case. The per-row Dry run button lists the matched cases without executing — a one-off override that is never saved onto the job. The editor's Dry-run by default checkbox is a different thing: it is stored on the job, so every scheduled run becomes a no-op until you untick it. A job left in that state looks perfectly healthy — it runs on time and the history shows the matched cases — while nothing is ever actually done, and it will save even with no automation chosen. Dry run exists only for Auto routines: a "dry" insight or digest would email people for real, so the server refuses it with job.dryrun.unsupported.
  • Scheduled insight — render and email: render an Insight and email the file (report default / PDF / Excel / CSV) to a comma-separated recipient list, with an optional subject and, when the underlying List declares questions, an optional JSON block of answers (sentinels like @today and @today-N work).
  • To-do digest — daily to-do email: the "here's what's due" email. The per-recipient scope matches each recipient address to a case worker and sends only their own to-dos; firm-wide scope sends everyone the same list, optionally including unassigned to-dos. The buckets are Overdue / Today / This week / Later.

Every job has a Code — 1–31 characters of letters, digits, spaces, hyphens and underscores, not starting or ending with a space, fixed at creation (the field is disabled when you edit) and unique (a duplicate is refused with job.code.duplicate) — plus a name, an enabled toggle and a cron schedule (five fields, 120 characters at most, always evaluated in UTC). Six quick-picks are offered: every 15 minutes, every hour, daily 08:00, weekdays 08:00, weekly Mon 09:00, monthly 1st 06:00. Under the box the editor prints a live plain-English reading, but it recognises only six shapes (every N minutes, hourly at :MM, daily, weekdays, named weekdays, monthly on day N) — anything else, including any expression that uses the month field, simply echoes "Custom cron — checked when you save. Times are UTC." That is not a warning; the expression is still validated on save. A blank schedule reads "No schedule — the job runs only when you press Run now."

UTC, and the next run is measured from the finish. There is no seconds field and no timezone picker, so a British firm's 0 8 * * * fires at 09:00 local right through BST. The next run is recomputed from the moment the run finished, not from the slot it was meant to occupy, so a run that overruns its interval skips slots — a 20-minute run on */15 * * * * lands on the first quarter-hour after it finished, i.e. every 30 minutes in practice. Editing any field on a job also recomputes its next run from now.

Run now does not execute inline: it queues the run on the free on-demand lane and returns immediately, toasting "name: queued — it will run on the next free slot", then the outcome ("3 matched, 3 succeeded, 0 failed", or "N case(s) matched — nothing executed" for a dry run) once it finishes. The page watches for about three minutes and then stops reporting; the run itself carries on regardless. Delete asks you to type the job's Code to confirm and takes the entire run history with it — there is no undo.

Run history

The history icon on a row opens that job's run history — the newest 50 runs, showing started, trigger (scheduled or manual), which server it ran on, duration, status and match / success / failure counts. Each run expands to a per-item log: for a routine, every case it touched with the fields set, to-dos created and letter or email steps queued, or the error that stopped it; for an insight or a digest, the recipient. The dialog re-reads itself every five seconds while it is open, so a run you have just triggered settles in place.

StatusWhat it means
QueuedClaimed, waiting for a server lane to come free.
RunningExecuting on a lane now.
SucceededThe executor returned without an error — read the note below before trusting it.
FailedThe executor reported an error, every case failed, the run timed out, or a service restart interrupted it.
CancelledStopped by a person, or skipped because the job was disabled or deleted while it queued.
A green run is not necessarily a clean run. A run is marked Failed only when the executor itself reported an error, or when every single case failed (failures > 0 and successes = 0). A routine in which 90 of 100 cases failed is reported Succeeded. The only way to see per-case failures is to open the run in the history dialog and expand it. A case whose automation calls stop('reason') is counted on its own — not a failure — and appears in the log as "Stopped: reason" with its own icon.

Timeout, cancel and restart

  • Every run executes under a 60-minute timeout (an operator setting, BackgroundJobs:MaxRunMinutes). A timed-out run is recorded Failed with "Timed out after N minutes and was stopped so the job server could move on."
  • You can stop a run yourself from the Servers tab. A running job's stop button fires the coordinator's cancellation token and the worker records the outcome — the row may still read Running for a moment, the page polls. Cancelling a queued run flips it to Cancelled at once and puts the job's next-run time back (the claim had cleared it). Cancelled is its own status, distinct from Failed.
  • On a service restart, any run left Queued or Running from before the restart is marked Failed with "Interrupted by a service restart before it could finish." and the job is rescheduled from its cron.

How much history is kept

Only the newest 200 runs per job exist at all: older rows are removed by a set-based prune at the end of every run (live Queued/Running rows are never touched), with a 180-day sweep as a backstop. A routine on */10 * * * * therefore keeps roughly 33 hours of history and anything older is gone permanently — if you need a longer record, write it into the case history from the automation itself.

Nothing tells you when a scheduled job fails. There is no email, no alert and no notification of any kind when a routine, insight or digest fails. A failed run is visible only by opening this page or its run history. If you need to be told, build the check into the work itself — a to-do the routine creates, or a digest recipient who will notice its absence.

What a routine can and cannot do to the caseload

  • A routine processes at most 10,000 matched cases per run. Case 10,001 onward is silently skipped: the run still reports a match count of 10,000 and still looks successful.
  • An automation that calls any ask.* prompt fails that case with "Auto-routines can't show prompts — use ask.* only in manually-run automations." (section 10)
  • Each matched case is committed on its own, so a later failure never rolls back earlier successes, and a case that throws has its half-applied work discarded rather than riding along on the next case's save.
  • There is no dead-case guard. A routine acts on whatever its List returns, and marking a case as dead sets its status to Closed — so a List with Include closed left off (the default) will not feed dead cases to a routine, but tick it and the routine will write to cases that have been marked as dead, with nothing in the run log to flag it. Don't point a routine at a List you built for closed-case reporting.

Sharp edges on scheduled insights and digests

  • A scheduled insight runs with no signed-in user, so @me (and @me.email / @me.name) in the underlying List resolves to null and matches nothing. Schedule a per-person job, or pass a fixed user id as a parameter instead.
  • There is no safe rehearsal: pressing Run now on an insight or a digest sends real email to the real recipient list, and dry run is refused for these types.
  • Deleting an Insight deletes its schedules with it — the job rows and their whole run history go in the same action, with no confirmation beyond the insight's own. The nightly maintenance sweep is only a backstop for schedules left pointing at an insight that vanished some other way: it disables (never deletes) any enabled scheduled insight whose insight is missing, so the row stays visible on this page instead of firing and failing forever.

Jobs your add-ons own

Three job types are created and owned by an add-on rather than by you: Accounting sync — the Xero auto-push (xero-auto-push, every 10 minutes) and the Xero status poll (xero-status-poll, hourly) — Data export (data-export, on the interval preset in its settings), and Legal accounts compliance check (legal-accounts-compliance, daily at 06:00 UTC: the reconciliation-cadence, dormant-balance, mixed-element, disbursement and AR1-deadline tick). The editor lets you rename, reschedule and enable/disable them and nothing more — their payload belongs to the add-on and is never sent back. Be aware that the owning add-on re-applies its own configuration whenever its settings are saved: it re-enables the job, and Data export rewrites the cron from its interval preset, so a reschedule or a disable made here is not necessarily permanent. These jobs always ride the free on-demand lane and never occupy one of your job servers.

The Servers tab

  • Scheduled jobs run on your organisation's job servers — capacity that comes with your plan, not something you can add from inside the product. Each server runs one job at a time; anything else queues behind it. The On-demand lane card is always there: Run-now clicks and system jobs ride it for free and never occupy a server.
  • With no servers on the plan, scheduled jobs simply do not run. Nothing fails and nothing is logged — the job keeps showing a next-run time that quietly slips into the past, and Run now still works, which makes the page look healthy. The only signal is the banner at the top of the Jobs tab: "No background job server. Scheduled jobs will not run — contact us to add one to your plan." Add a server and everything waiting is picked up on the next tick.
  • Each server card shows what is running (with elapsed time and a stop button), what is queued (with position and a cancel), and the mailbox it sends as. Its settings dialog holds a Display name ("Compliance server", 60 characters, shown instead of "Server N" everywhere) and Sends email as — the Outlook mailbox unattended email steps deliver through on that lane. Candidates are enabled users who have connected Outlook on their own profile; anyone else is refused (job.server.sender_not_connected). A dedicated account (e.g. automations@yourfirm) keeps unattended mail out of personal mailboxes, and such an account is an ordinary licensed user (section 22).
  • The settings dialog is a full save, not a patch — clear the name box and the display name is removed. If the designated sender later disconnects Outlook, nothing is cleared: the card still shows the name, warns "— Outlook no longer connected", and every unattended email on that lane fails.
  • A job can pin to a server with the editor's Runs on picker, or take Next available server (the least-loaded lane at claim time). Pinning to a server number above your current plan is refused; a pin that goes out of range later because the plan shrank silently falls back to next-available instead. Server settings survive a lowered count, and a lane above the count that still holds work is shown as "No longer in your plan — finishing its queue".

Which mailbox unattended email comes from

An Auto routine's email steps resolve their sender in this order: the mailbox on the server the run actually executed on, then the mailbox on the job's pinned server, then the mailbox on the lowest-numbered server that has one configured. That last fallback matters — a manual Run now executes on the on-demand lane, so it still sends as Server 1's mailbox rather than your own. That is deliberate: the send authenticates as the designated user, so a From of your address would be rejected by Microsoft's send-as check. With no sender configured on any server, a scheduled run fails its email steps loudly, per case (automation.action.email_no_sender), rather than recording undeliverable rows — but a Run now quietly falls back to your own connected mailbox, so a routine that works when you test it can still fail on its schedule.

What travels with a design workspace

Only Auto routines are part of Design-as-Code (the design workspace). They export to tenant/jobs/<slug>.json with the code, name, description, job type, schedule, enabled flag and an autoRoutine block holding a { caseTypeCode, code } reference to the saved List, the automation code, the stored dry-run flag and a legacy send-as user id; the delete token is job:{code}, and applying a delete takes the run history with it. Scheduled insights and to-do digests are invisible to the workspace in both directions — never exported, and never planned for deletion despite the "absent means delete" rule — so they must be recreated by hand in every tenant. A job's server pin, next-run time and run counters are runtime infrastructure: not exported, not diffed, and recomputed from the cron on apply, so a routine distributed to another tenant lands unpinned.

21 · Correspondents & types

The organisation-wide directory of everyone cases deal with.

Where: Manage → Contacts → Correspondents (needs correspondents.view) · Contacts → Correspondent types (needs correspondents.manage)

Creating or editing a correspondent always needs correspondents.manage; picking one inside a case needs neither key. A correspondent is not a sign-in identity: the record carries no user id, no password and no link to a staff user or a collaborator account. Adding someone here gives them nothing — portal access is granted separately through Client Hub (section 30). Inside the portal a client can only select an existing correspondent (there is deliberately no Add), and the list they see carries name, reference and company only, never contact details.

Correspondent types

  • Correspondent types are yours to define — Solicitor, Hospital, Expert, Court, Insurer, Client — each with a Code (1–31 characters of letters, digits, spaces, hyphens or underscores, fixed once the type exists), a name, an icon, up to 30 custom fields, a Hidden fields list and any Sharing groups. There's no fixed built-in list.
  • Custom fields use seven data types only — Text, Whole number, Decimal, Date, Date & time, Yes/No and Checkbox. Names are unique within the type and capped at 60 characters. Renaming one keeps every value already saved against it (values hang off an opaque key, not the label); removing one takes it off the forms without erasing the stored data.
  • A type can't be deleted while any correspondent still uses it, nor while any case type has a Correspondent field pointing at it. Both refusals name what is blocking, and a Where used view shows it.

A correspondent record

  • A correspondent belongs to one type and carries a standard set of details — Reference, Forename, Surname, Company name, Contact person, Address lines 1–3, City, Region / County, Postcode, Country, Email, Phone, Mobile, Website, Notes — plus that type's custom fields. Each gets an organisation-wide number (#1, #2, …), which is how scripts and grids address one record and how two look-alikes are told apart.
  • There is no Name box. The display name is derived on every save: Forename + Surname if either is filled, else Company name, else Contact person, else Reference, truncated at 200 characters. Clear the Forename and Surname on an existing person and the record silently renames itself to the Company name. Saving with none of those five filled is refused.
  • A record's type can never be changed — it is part of identity and keys the custom values. The only way to move one is to delete and re-create it, which mints a new id and a new #number, so every case holder pointing at the old record has to be re-pointed by hand.
  • Email is stored trimmed and lower-case, so lookups and duplicate checks are case-insensitive.
A script's correspondents.create() derives the name the same way. A supplied displayName survives only when nothing else is derivable — it is kept purely so old displayName-only scripts keep working, and the moment a real name part is set it wins. A create with none of firstName, lastName, organisation, contactPerson, reference or displayName is refused with "give firstName / lastName or organisation — the display name derives from them."

Sharing groups — one pooled directory for several types

A sharing group is just a name typed on a correspondent type. Any two types listing the same name (matched case-insensitively) draw from one pooled directory: a record created under either is selectable in holders of both. This is the answer to Passenger 1…5 and Defendant 1…3. A type may join at most 20 groups, each name at most 60 characters; a group exists by being listed and dissolves when the last type drops it.

Pooling changes exactly three things — which records a holder picker offers, what a holder link accepts on validation, and how custom values resolve across types. It does not change the Correspondents page's Type filter, which stays an exact single-type filter, so filtering by Passenger 2 hides a pooled Passenger 1 record. Each type also keeps its own template catalogue.

A correspondent always keeps its home type. Its custom values are stored under that type's keys and are aliased into another type's matching field by label, so two types whose custom fields are spelled differently ("SRA Number" vs "SRA no.") carry nothing across and the tile shows blank. Holder-picker rows deliberately don't print a record's home type.

Sharing groups are a completely different feature from correspondent groups — the free-form tags ("London hospitals") you multi-select in the Edit dialog. The two share the word and never interact.

Hidden fields hide, they don't delete

A type can hide any of seventeen built-in field keys from its add/edit forms: reference, first_name, last_name, organisation_name, contact_person, address_line_1, address_line_2, address_line_3, city, region, postcode, country, email, phone, mobile, website, notes. Hiding is cosmetic: hidden controls are blanked in the create form and left out of the edit payload, so a value already stored is never wiped and keeps resolving in tokens, screens, templates and exports. A letter template can therefore go on printing an address the form no longer shows — and you can't clear that value without unhiding the field first.

On-screen labels are not the token keys

Three built-in fields were renamed on screen while their keys stayed frozen. Tokens, exports, the field catalog and scripts all still use the old snake_case key.

What the form saysKey in tokens, exports and scripts
Forenamefirst_name
Surnamelast_name
Company nameorganisation_name
Region / County (forms) · Region (field catalog, email matcher)region
Line 3 (dialogs) · Address line 3 (everywhere else)address_line_3

So to print a client's company on a letter you write {Client.organisation_name}, even though the box you filled in said Company name; {Client.company_name} prints nothing.

Four composed address attributes are offered to screens, templates and Lists alongside the raw parts:

address_blockthe postal address as stacked lines, for the top of a letter
full_addressthe whole address on one line
street_addressaddress lines 1–3 only, on one line
city_linecity, region and postcode on one line
address_block is newline-separated in Word but re-joined with spaces and commas when it lands on a single-line screen tile. That is by design.

The correspondent block on a case screen

Cases point at correspondents through Correspondent fields (holders) — that's how a case "addresses" a letter or email, and how a correspondent's details merge into templates. A holder cannot be dropped on a screen as an ordinary field control; the API refuses it with "A correspondent holder cannot be placed as a field. Add correspondent attribute tiles instead." Screens carry correspondent attribute tiles instead, grouped under a labelled header, and those tiles are always read-only — clicking or typing in one only makes the block's buttons glow, and nothing typed is ever saved.

Addcreate a new correspondent of this type and use them
Selectsearch the existing directory
Editchange the selected correspondent's details — greyed out until one is selected
Clearunlink from this case; the directory record is kept

Screen studio can hide any of the four per screen. With all four off the block is display-only and the correspondent can then be changed only by an automation — and because the setting is per screen, not per user, the same holder may still be changeable from a different screen.

The asymmetry that catches people. Add, Select and Clear only stage the change in the case's unsaved draft and are lost if you leave without saving. Edit writes to the shared directory record immediately — live for everyone the moment you press Save in that dialog. There is no per-case copy and no per-case override of a correspondent's details anywhere in the product, so correcting an address "just for this case" changes it for every case that uses that record.

A table (iteration table) on a case can also carry a Correspondent column, so each row links its own correspondent of the chosen type — picked or created per row, and writable by actions and automations. The designer chooses which attribute the grid cell shows (Display name by default, or Forename, Surname, Email, Number, or any of the type's custom fields), falling back to the display name when the chosen attribute is blank. A Correspondent column is allowed on a Table only, never on a Time record.

Finding, copying and deleting

  • The directory page is paged 50 rows at a time and sorted server-side across the whole result set (Name ascending by default), with columns #, Type, Name, Contact, Location and Modified.
  • Free-text search matches only display name, forename, surname, company name, email, city, postcode, phone and mobile. Reference, contact person, notes, the address lines and every custom field are not searched — even though the holder picker's box is labelled "Search by any field". If your Hospital type has an NHS code custom field, typing that code into the picker returns nothing.
  • Copy opens the create dialog pre-filled with everything except the Reference. Nothing is suffixed, so unless you change a name part the copy's derived name is identical to the original and only the # number tells them apart.
  • There are no bulk actions, no merge or de-duplicate, and no archive state: create, edit and delete are the whole lifecycle.
  • A correspondent's page has a Cases card — read it carefully. It lists only cases where something was actually sent or recorded to them (a letter, email, memo, phone note or incoming post). Cases that merely hold them in a correspondent field never appear, so "Not used on any cases yet" does not mean unused.
  • Deleting follows the same line. If any correspondence has been recorded against them the delete is refused and the message names up to three of the cases. If not, the delete goes through — and every case slot holding them goes blank (tiles empty, Edit greys out, only Clear still works). The confirmation dialog says the name will read "Unknown"; nothing in the product renders that word. There is no undo.

22 · Users, teams & roles

Who's in, how they're grouped, what they may do.

Where: Manage → Administration → People / Teams / Roles (needs users.manage / teams.manage / roles.manage — each entry appears only if you hold its key)

The same Administration group holds Activity, Protected cases, Organisation settings, Client Hub and AI usage & credits.

People

  • Create a user with an email, name and exactly one role (plus optional job title, case-worker flag, and any custom user fields your organisation defined). A role is mandatory — a create without one is refused. They receive a branded invitation with a single-use set-your-password link (valid 7 days) and finish their own setup — the invitation never carries a password. The link demands at least 8 characters with an upper-case letter, a lower-case letter and a digit.
  • New accounts are created unlicensed and enabled, and the create itself is uncapped; only "Assign a licence now" runs inside the seat-cap transaction and can come back as "all licences are in use". If one team is flagged as the default and is enabled, the new user joins it automatically.
  • One Microsoft directory backs every organisation a person belongs to, so the same person uses one credential across workspaces. Reset password emails a 24-hour set-password link and signs the user out everywhere; their existing password keeps working until the link is redeemed.
  • One licence type. An account either holds a licence or it doesn't — there are no tiers, and creating an account never consumes a seat by itself. The People page band reads "Licenses: X of Y in use · Z available"; assigning is blocked when nothing's available ("All licences are in use. Ask your IT team to arrange additional licences."). Every licensed account counts — including an account that exists only to run scheduled Routines; the old seat-exempt "background worker" licence was retired and can no longer be issued. An account without a licence can't sign in and is refused on every request. A licence marked "not charged" was granted by the platform operator (support access, for example): it sits outside your pool, the band counts it separately, and it never appears on an invoice. You can remove it; only the operator can grant one.
  • Users are never deleted (their name is needed in history) — they're disabled, which ends their sessions on the very next request. Disabling does not free the licence; unassign it to free the seat. You can't disable your own account or the last enabled administrator. If more licences are assigned than the plan includes (it can happen after plan changes), the availability shows negative and the band turns amber — nothing breaks, but new assignments wait until the pool grows.
  • Clone starts a fresh name and email from an existing account. It copies the role, teams, licence type, security level, job title, the case-worker flag and the per-user extra grants and revokes. It does not copy the phone number (deliberate — personal), the custom user-field values, the per-case-type access rows or any task shares. If the second step (teams and overrides) fails the dialog still closes, leaving a clone with only the base fields applied.
  • Custom user fields (Organisation settings → User fields) add your own attributes to every user — hourly rate, branch — available to automations, Lists (@me.hourly_rate, assignment:slot.branch) and reporting.
  • Task visibility is administrator-controlled, on the user dialog's Task visibility tab (needs users.manage) — people cannot share their own to-dos or take a share back; they can only read who has been given sight of theirs. A row is one grantor → grantee pair, and a bulk "add a whole team" action gives a team leader sight of their team in one go. Only two of the ticks gate anything: View (listing someone else's to-dos — refused with "{name} has not shared their to-dos with you") and Delete (bulk-deleting them). Action and Reschedule round-trip and show as ticks but enforce nothing — completing or rescheduling anyone's to-do is governed by the tasks.complete / tasks.reschedule keys alone, with no ownership test. Changes on this tab save immediately.
  • Two flags change what a user sees but grant nothing. Case worker means cases can be assigned to them and they appear in the case-worker picker. Developer means they see automation run-report toasts — diagnostics only. Simple mode is likewise two independent levers, both UX-only: the user's own preference, and an administrator lock on People → Main → Interface (refused with a 400 when the add-on is off). Guided and advanced call the same permission-checked APIs.
The Manage dialog saves one tab at a time. Its five tabs — Main, Teams, Authorisations, Case types, Task visibility — each save separately through their own footer button ("Save main", "Save teams", "Save authorisations", "Save case types"); only Task visibility saves as you click. Switching tabs without pressing that tab's Save discards its edits, with no warning. The Authorisations tab shows provenance rather than a bare matrix: the role baseline named by role, each team's contribution named by team, per-user extras, per-user revokes, and the resulting effective set (for an administrator the wildcard is expanded into every catalog key, for display only).
When a new user can't be provisioned. If the Microsoft directory is configured but provisioning fails, no invitation is sent and the response carries signInReady: false: the row exists but that person can never sign in. The dialog currently advises deleting and re-creating the user — that isn't possible, because there is no delete-user endpoint anywhere in the product, and a retry hits the duplicate-email conflict. Contact support to have the account cleared.

Activity — the working-time audit

Manage → Administration → Activity, gated on its own users.activity key — deliberately split out of users.manage so that sight of colleagues' working patterns can be granted, or withheld, separately. It shows a live Right now presence grid (active / idle / offline per person, an in-document marker, the case reference they are on, and active seconds today) plus per-day history with a CSV export.

Teams

A team groups users. A team can carry its own permission grants, which are added to each member's permissions; one team can be the default (new users auto-join); and a case type's assignment slots can restrict who's pickable to certain roles or teams. Teams don't hide cases — visibility is a permission matter. Team grants flow only from enabled teams: disabling a team withdraws its permissions — and its per-case-type rights — from every member on the next request while leaving the memberships in place, so re-enabling restores them.

Roles & how permissions combine

A user's effective permissions are: their one role's grants, plus the grants of every enabled team they're in, plus any extra permissions granted to them individually, minus any revoked from them (a revoke always wins). There are no hidden defaults — the role is the only baseline.

  • Three roles are seeded, all of them renameable and editable: Tenant Administrator (a wildcard — every permission, now and in future releases; it can never be deleted and at least one enabled user must always hold it), Standard User (day-to-day case work) and Read-only User (view-only: see cases, correspondents, Incoming mail, to-dos, and run Lists and Insights — change nothing).
  • Only the administrator role is undeletable. Standard User and Read-only User can be deleted, and doing so silently unassigns everyone still holding them — those users keep only their team grants and per-user extras — and sweeps that role's per-case-type rows.
  • Delegated administration is escalation-proof: someone with Manage users who isn't an administrator can't assign the administrator role, can't grant a permission they don't hold themselves, and can't raise anyone's security level above their own. The exact rules, and the three places the bounds stop, are in section 23.

Permissions are enforced on the server for every sensitive operation — the menu hiding you see is just convenience on top of that.

23 · Permission reference

The keys you assign to roles and teams.

Sixty-eight keys in thirteen groups. The group headings below are the catalog's own and are printed verbatim in the authorisations matrix — they are not renamed by your organisation's vocabulary, so the matrix says Triage, Tasks, Workflow, "Queries & reports" and "AI Assistant" wherever the rest of the app says Incoming mail, To-dos, Playbooks, Lists / Insights and Rangeen AI.

GroupKeys
Casescases.view, cases.create, cases.edit, cases.assign, cases.close, cases.reopen, cases.delete, cases.bulk, cases.share, cases.protect, cases.protect.manage, cases.history.amend, cases.history.remove
Workflowworkflow.action.email, workflow.action.letter, workflow.action.memo, workflow.action.phone, workflow.action.incoming, workflow.template.manage
Correspondentscorrespondents.view, correspondents.manage
Triagetriage.view, triage.release, triage.archive
Taskstasks.view, tasks.create, tasks.complete, tasks.reassign, tasks.reschedule, tasks.delete.own, tasks.delete.other
Queries & reportsqueries.run, queries.manage, reports.run, reports.manage
Configurationcasetypes.manage, screens.manage, fields.manage, automations.manage, globals.manage, database.manage, jobs.manage
Administrationteams.manage, users.manage, users.activity, roles.manage, settings.manage, collaborators.manage
Outlookoutlook.connect, outlook.shared.manage
AI Assistant Add-onai.use, ai.case, ai.draft, ai.author_templates, ai.author_automations
Accounts Add-onaccounts.invoices.view, accounts.invoices.manage, accounts.integration.manage
Legal accounts Add-oncashiering.view, cashiering.post, cashiering.requisition, cashiering.authorise, cashiering.reconcile, cashiering.compliance, cashiering.settings, cashiering.auditor
Data Export Add-ondataexport.run, dataexport.manage

A few notes: cases.assign lets someone fill assignment slots without broader edit rights; cases.share lets a case worker share the case in front of them through Client Hub without being a Hub administrator (that's collaborators.manage); users.activity is sight of colleagues' working patterns, split out of users.manage so it can be withheld; rescheduling a to-do is its own key, separate from creating one; and to-do deletion is split into "own" and "others'".

All 68 keys in all 13 groups are always shown and always tickable — including AI Assistant, Accounts, Legal accounts and Data Export. The matrix is not filtered by your add-ons. Granting one of those keys on an organisation without the add-on is harmless and does nothing: the nav entry stays hidden and the endpoints keep refusing. Enabling an add-on is a plan matter, not a setting.

Case passwords come as a pair: cases.protect is the lock action inside a case — set a password, change or remove it from in there. Everyone (the setter included) then enters that password each time they open the case, and the server refuses the case's contents until they do. cases.protect.manage is the oversight power: the Protected cases screen listing every protected case with who set its password and when, able to reveal, change or remove any of them without knowing the current one — the recovery path when a password is forgotten. Passwords are deliberately rule-free (whatever was typed, verbatim), stored encrypted, kept out of history entries, and every set / change / removal is written to the case's audit trail.

The wildcard is not a key you can grant

Administrator status comes only from a role's admin role flag, which the resolver expands into * at resolve time. The seeded administrator role's stored permission list is deliberately empty for that reason, so editing its ticks is a no-op. * is not in the catalog: the sanitiser silently drops it — and any other unrecognised key — from a role, team or per-user grant set, and a save carrying it succeeds with the key quietly discarded.

Grant lists are replaced wholesale on every save. One save through a stale client can therefore strip keys, and keys retired in a later release (the old ai.ask / ai.summarize / ai.prefill family, the retired internal-ledger accounts.* keys) vanish the next time that role, team or user's grants are saved.

How grants are bounded — and where the bounds stop

The escalation ceiling fires on creating a user, changing a user's role, setting per-user extras, setting a user's teams, editing a role's permissions and editing a team's permissions, refusing with 403 permission.escalation — "You can't grant permissions you don't have yourself." Revokes and removals are never checked: taking privilege away is always allowed. Only an existing wildcard holder may put someone into the administrator role. Marking a role as an admin role is not available to anyone: the flag belongs to the single Tenant Administrator role seeded when the tenant is created, no API writes it, and a design apply that claims it on a new role is ignored with a warning. Security level (0–99) is ceilinged the same way — you can never raise anyone above your own level, but you can always lower it.

Three edges are worth naming:

  1. When editing a role's or a team's key list, only the keys being added are checked. Keys already on the role are never re-checked, so a delegate can keep saving a role that already carries keys beyond their own ceiling.
  2. A per-user revoke is powerless against an administrator: the resolver removes the revoked keys and then re-adds the wildcard from the role's admin flag, so the save succeeds and changes nothing. To take one permission off an administrator you must move them off the admin role.
  3. Design-as-Code applies are deliberately unbounded by the ceiling — the workspace is the source of truth for role and team grants. A design API key with plan or apply scope should be treated as an administrator credential (section 26).

Per-case-type Create & Open rights

Which case types a person may open, and which they may raise, is not a permission key — it is a row per (subject, case type), where a subject is a role, a team or a user. It is edited on a grid that now appears on a role, a team and a user — a Case types tab on the team and user dialogs, and a Case types this role can reach section at the foot of the role dialog, which is one scrolling form rather than tabbed — gated on whichever key already governs that subject (roles.manage, teams.manage, users.manage). Create and Open are independent — "may open a Rehab Supplier case, may not raise one" is the common shape.

The upgrade law. While no role and no team anywhere holds a tick for a verb, that verb is unrestricted for everyone — which is exactly today's behaviour, and the only safe thing to switch on in a live organisation. The first tick an administrator saves turns that verb into a tenant-wide allow-list for every non-administrator. Two consequences follow: deleting the last role or team holding a tick returns the count to zero and re-opens every case type to everyone; and a case type created after rights exist has no rows, so every non-administrator is refused until it is granted on each role and team.

On a role or a team the value is two-state — ticked means granted, unticked means no row. Only on a user is it tri-state, exactly like per-user permission extras and revokes: ticked is an extra grant, explicitly off is a revoke that beats any role or team grant, and blank is inherit. Only enabled teams count, so disabling a team withdraws its case-type grants at the same moment it withdraws its permissions.

Ticking Can create on a row auto-ticks Can open, because a case you can raise and never look at is a dead end. Un-ticking Open afterwards is allowed and the tab warns about it — and it genuinely produces someone who can raise a case and is shut out of it a second later.

Openenforced by middleware on the path — anything under /api/cases addressing a case, the by-reference lookup included — refusing with 403 casetype.open.denied, "You cannot open {TypeName} cases". The refusal order is 401 → 403 permission → 403 case type → 423 locked.
Createenforced in the create handler with 403 casetype.create.denied, which covers the toolbar +, quick create and the automation host alike. An unattended automation run has no user id and is unrestricted. The CSV importer is the exception: it writes cases straight to the database and never consults the resolver — harmless today only because the import endpoint is administrator-only, and administrators are unrestricted.
Three gaps to know about. Creating a matter is not checked — that handler never consults the resolver, so a user restricted from a case type can still mint one of that type from a family they can already reach, and is refused the moment it opens. My Day's quick-create tiles do not pre-filter either: the tile stays clickable and the refusal arrives as a red toast after the click, where the New-case dialog filters client-side. And Simple mode has no access-denied panel, so a refused case type there falls through to a generic "Could not open this case" with no type named.
It is a door lock, not a curtain. Nothing is hidden by design: quick search, My Day's Recent / Starred / This week, the case stat tiles, list and insight results, to-do and diary lists and the case-type catalog all still show cases and types the user cannot open. Only the door refuses. The one place the UI filters is the advanced New-case dialog; the Simple mode create page lists every type and refuses server-side on click. If you need a case genuinely hidden rather than merely shut, that is password protection (the security model), not this.

An administrator subject cannot be restricted at all: the grid renders read-only and a save against an admin role or an admin user is refused 422 casetype.access.admin. The grid carries its own escalation ceiling (403 casetype.access.escalation) — you may only grant case types you can reach yourself, while revoking is never bounded.

These rows are people-shaped tenant data, like team membership: they do not export with Design-as-Code, a role or team deleted by an apply takes its rows with it, and a tenant rollback does not bring them back.

24 · Organisation settings

Organisation-wide switches, in one place.

Where: Manage → Administration → Organisation settings (needs settings.manage)

The page is tabbed:

  • General — five cards, six with the Simple mode add-on on: the organisation's Tenant name (shell header, browser title, system emails); the default case-list page size (10–500); Case numbering — next case number (what you type is the next case, and its length is the width of every number after it — 1 numbers cases 1, 2, 3…, 0001 numbers them 0001, 0002…; the box works in both directions, a number an existing case already has is skipped, and the page says plainly — and asks you to confirm — when you are sending the sequence back over ground it has already covered); Case actions — "Ask for everything on one form", which collapses the three-step action dialog (correspondent, then source, then compose) into a single form, taking effect on the next page refresh; and Collaborator sharing — "Shareable up to authorisation level" (0–1000), which lets any sharer offer screens up to that level regardless of their own, with blank meaning each sharer is capped at their own level. With the Simple mode add-on on there is also a New users start in the guided interface toggle, a Design the guided home button and a 30-day usage read-out.
  • Email matcher — per case type, the extra fields inbound email is matched on beyond the case reference (including matching on a correspondent's attribute).
  • Quick search — per case type, up to 8 fields the top-bar case search matches in addition to reference and title (which are always matched). Scripted fields and table-member fields can't be chosen. Search covers open and closed cases, never archived ones. Sharp edge: the tab lives on this page but saves through the case-type API, so it needs casetypes.manage, not settings.manage — an administrator holding only settings.manage can open the tab, edit it, and then be refused on Save.
  • User fields — the custom attributes on every user record. Six data types only: Text, Whole number, Decimal, Date, Date & time, Yes / No. A field's code and data type are fixed once it exists; only the label, order, notes, constraints and default can change afterwards. "Required" is cosmetic — it adds a * to the label and nothing else; neither the form nor the API refuses a save with the field empty. Deleting a field removes the definition but leaves each user's stored value in place, so re-creating a field with the same code brings the old values back.
  • Client Hub Add-on — the month-by-month "Collaborator accounts used" view behind your Client Hub invoice line (section 30). Shown only with the add-on on and collaborators.manage held.
  • Outbound HTTP — the hostnames automation scripts may reach with any http.* verb, entered one per line. Empty (the default) means outbound HTTP is off for the whole organisation. Entries containing a space, /, : or a leading or trailing dot are rejected, and private / internal addresses (loopback, 10.x, 172.16–31.x, 192.168.x, 169.254.x including the cloud metadata endpoint, 100.64.x, fc00::/7 and multicast) are always refused even if a name resolves there. Matching is exact or suffix — allow-listing gov.uk also admits api.gov.uk.
  • Design workspace — the API keys for the design CLI (section 26). The plaintext key is shown exactly once and only its hash is stored; keys can be disabled but never deleted. Minting one with plan or apply scope needs settings.manage and casetypes.manage, and such a key is an administrator credential — Design-as-Code applies bypass the grant-escalation ceiling (section 23).
Two things people look for here that are not settings: session length is fixed platform policy (you stay signed in while active; sign-out comes after 4 days of inactivity, with no per-organisation control — any stale idle value is zeroed the next time an administrator presses Save), and the one-signed-in-place-per-account rule is part of your plan, switchable only by us, separately for staff and for Client Hub, with an optional per-account override.

25 · Import, export & design versions

Cases in and out by CSV, and the safety net behind every case type's design.

Where: Manage → Configuration → Case types (needs casetypes.manage) — the list toolbar, and the Design versions and Design check panels on a case type

Bulk cases

  • CSV import creates cases in bulk for one case type: a header row naming the fields (plus an optional Title column), then one case per row, up to 5,000 rows. Unknown columns are skipped and logged. This is an administrator operation via the system API — usually part of onboarding.
  • CSV export produces every case of a case type — a header row of Reference, Title, Status then every field name in alphabetical order, one row per case. Dates come out UK-style (dd/MM/yyyy), yes/no as Yes/No, and the file is named <CODE>-yyyyMMdd-HHmm.csv. The whole file is built in one go, so a case type with more than 50,000 cases is refused outright (export.too_large) rather than truncated — use a saved list with narrower criteria, or a scheduled data export, for anything that big.
  • For everyday extracts, use Lists → Export and Insight outputs — those are self-service.

What a CSV import actually does to a case

  • Columns match top-level field names only, and the match is exact. A table's member columns cannot be imported; an unrecognised header is reported per row and skipped; and a row whose column count does not match the header is skipped whole, never partially imported.
  • A Title column is an explicit title and is pinned against the case type's derived-title pattern, so a later refresh will not overwrite it. With no Title column the title is derived from the pattern as usual and stays template-managed.
  • References come from the same sequence an interactive create uses (at its configured width), honouring Case numbering — next case number. An import targets one case type, so with Number each case type separately on it draws from that type's own counter and carries that type's prefix; with it off, from the one shared sequence. Either way a number an existing case already has is skipped.
  • Value parsing is forgiving to a fault. Yes/No accepts Yes, 1 or true and reads everything else as No; dates accept ISO or UK day-first dd/MM/yyyy (optionally with HH:mm); and a Number or Decimal that will not parse is stored as text rather than rejected — one stray currency symbol silently poisons the column.
  • Two caps, one error code. 5,000 rows and 8 MB of characters both come back as import.too_large. Split the file.
No automations run on an imported case. The importer builds each case and writes its values straight to the record: On-create, the case triggers and everything chained off them never fire. Imported cases are inert — anything On-create would have done (an opening to-do, a first document, a status move) has to be done another way.

Design versions — restore, export & clone a case type

Rangeen keeps automatic versions of a case type's whole design (its fields, screens, templates, automations and settings) as you change it: a "before" image is taken at most once every 6 hours per case type — one per editing session, not one per click — it is skipped when nothing actually changed, and only the last 3 are kept, so the fourth prunes the first. It is an undo, not an audit trail; for real history, keep the design in git with the design workspace (section 26). From the case type you can:

  • List versions — number, when, who, and the note the system stamped on it. Open Restore… on one to see the diff against the live design before you commit to it.
  • Preview and restore an earlier version. The confirmation is per deletion key — you tick the specific things the restore would remove, not one blanket yes — and an unconditional pre-restore (from v{n}) snapshot is taken first, so you can undo the undo. Restoring the oldest retained version is safe: that row is exempt from the keep-3 prune and cannot be evicted mid-restore.
  • Export a version (or the live design) as a zip, and import a zip to clone it into a brand-new case type — a clean way to move a design between environments, or to start a new type from a proven one. The per-case-type zip is bundle.json at the root plus blobs/<hash> for each attachment; a blob that has gone missing since the snapshot is listed in an exportWarnings array inside bundle.json rather than failing the export.
Import has no code or name override in the portal yet: a code collision comes back as a 409 toast, and you rename the source case type or delete the clash and retry. A clean import lands you on the new case type with "Imported design — N items created", plus up to three warnings.

Design-as-Code without the CLI

The whole pull → review → apply loop is also on the Case types list, with no command line, no npm and no API key. Three buttons, all needing casetypes.manage:

Export workspaceDownloads the whole tenant design — every case type, tenant settings, global variables, the built-in guide and the JSON Schemas — as design-workspace.zip. It is the same content the CLI's pull writes.
Review changesUpload an edited workspace zip and get the identical server dry-run the CLI's plan produces, rendered in a dialog: per case type +created · ~updated · −deleted, lint, design-check results, and every planned deletion as a tick box.
ImportA per-case-type design zip clones into a brand-new case type. Pick a whole-tenant workspace zip here by mistake and it does not dead-end — it reroutes you into Review changes with "That zip is a whole-tenant design workspace — reviewing its changes instead."

Apply is offered from the review dialog itself, and only when the plan could apply — nothing blocking, nothing drifted, something to change. It is confirm-gated ("Apply to the live tenant?", spelling out how many deletions you ticked and how many will be skipped) and pinned to the fingerprint of the plan you just read, so an edit between review and apply invalidates the plan instead of slipping through. Uploads are capped at 200 MB. What the CLI adds on top is git, offline editing, scripting and rollback (section 26).

Rollback is not in the portal. An apply does take restore points first, but they live in the CLI's own snapshot series and never appear in the Design versions list above — only rangeen-design rollback reaches them.

Design check

A second panel on the case type, with a Run check button, scans every automation, screen, button, calculated field, template and rule on that case type for references to fields, codes or screens that no longer exist. It reports a count of errors and warnings across N scanned sources, grouped by area, each row naming the source, where in it the reference sits and what failed to resolve — and it only reports things that are certainly broken. It is the natural companion to a restore or an import, both of which can leave dangling references.

The same scan runs automatically after every workspace apply and rollback, returning up to 50 unresolved edges as designCheck { errors, warnings, issues[] }. It reports; it never blocks.

26 · The design workspace

Your whole tenant design as files — pull, edit, review, apply, roll back.

Where: Manage → Administration → Organisation settings → Design workspace (keys) · the rangeen-design command (your machine)

Everything you can design in the portal — case types, fields, screens, templates, automations, Lists, table views, triage matchers, guided journeys, and the whole tenant scope — can also be pulled to your computer as plain files, edited (by you, or by an AI coding assistant), diffed, and applied back. That gives you version control with git, reviewable changes, and a clean way to build a design against a test tenant and release it to production.

The two-key model

  • Keys are minted in Organisation settings → Design workspace. A key looks like rgnd_<slug>_<random> and carries scopes: design.read (pull), design.plan (dry-run) and design.apply (write). The key itself selects the tenant — there is no tenant argument on the wire — and the plaintext is shown once. Copy it then; revoke any key at any time. Minting plan/apply keys additionally needs the Manage case types permission.
  • Work with two keys: a day-to-day key with read + plan (safe to give an AI assistant — it can see and propose, never change), and an apply key that stays with a human and is used only at the moment of applying.

What a pull writes

The workspace has exactly four managed roots — the only paths a pull replaces wholesale. A pull also rewrites the baseline at .design/workspace.json. Everything else is left alone: your .git, your own notes, .design/targets.json and the last-*.json reports.

CLAUDE.md          instructions for an AI assistant working in this folder
guide/             the in-product handbook, exported as markdown + JSON schemas
                   (fields, screens, workflows, automations, queries, tenant scope,
                    permissions, guided mode, automation-api.md, automation.d.ts)
tenant/            the tenant scope — settings, globals, correspondent types, jobs,
                   roles, teams, user fields, external roles, reports, data export
case-types/<slug>/ one folder per case type
.design/           metadata: workspace.json (the baseline), targets.json (your keys),
                   and the last-*.json reports. NOT a managed root.
Filenames are only handles. Every artefact's authoritative key lives inside the file; the filename is a slug of it. Renaming a file changes nothing — but changing a code, name or key inside a file is a delete plus a create.
Put .design/targets.json in your .gitignore. Keys never belong in git.

Setting up

npm install -g <the rangeen-design package we supply>
rangeen-design                              # prints the usage block — proves it is on your PATH

mkdir C:\work\firm-design ; cd C:\work\firm-design

# the tenant you EDIT AGAINST (your test/UAT tenant) — the default target:
rangeen-design init --tenant uat  --url https://api.rangeen.com --key <uat read+plan key>  --save-key
# every tenant you distribute to:
rangeen-design init --tenant prod --url https://api.rangeen.com --key <prod read+plan key> --save-key

rangeen-design pull                         # the design arrives as files
git init
git add -A ; git commit -m "baseline"

The daily loop

rangeen-design pull            # start from the live design
# …edit files (or let your AI assistant propose edits)…
rangeen-design validate        # structural checks, offline
rangeen-design plan            # exact dry-run diff against the tenant — READ IT
rangeen-design test ONB SETSTART --case ONB00012    # try an automation, no writes
rangeen-design apply --key <owner apply key>        # land it
git add -A ; git commit -m "escalation feature"

The twelve commands

initRegister a target tenant (--save-key stores its key locally).
pullFetch the live design into the managed roots.
status"Is my workspace current?" — compares baseline fingerprints with the tenant.
validateOffline structural checks. No network, no tenant.
planThe dry run. Uploads tenant/ + case-types/ + the baseline and returns the exact diff plus a plan fingerprint.
applyWrites. Recomputes the plan server-side first.
rollbackReplays the restore points an apply took.
appliesLists past applies with their ids.
testRuns an automation against a real case without writing.
runRuns a saved list and prints the result.
previewRenders a template against a real case.
mcpServes the same surface over MCP on stdio, so an AI assistant can drive it directly — ten tools: pull, status, validate, plan, apply, rollback, list_applies, test_automation, run_query, preview_template.

guide/ and CLAUDE.md stay on your machine — plan never uploads them.

What apply refuses, and why

apply answers HTTP 200 with refused: true and a list of reasons. It is not an error status, and a script that only checks the status code will think a refused apply succeeded — check the flag. The seven reasons:

  1. the workspace could not be read;
  2. the plan fingerprint is stale — the workspace changed since you planned;
  3. a lint violation remains;
  4. a case type you touched has drifted — someone changed it in the portal;
  5. the tenant scope has drifted;
  6. the workspace baseline is missing while updating existing types;
  7. two folders carry the same case-type code.

Drift and the fingerprint are the two that fire in practice, and both mean the same thing: pull, review the other person's change in git, re-plan, and apply again.

Deletions are opt-in, one at a time — or all at once, on purpose

A plan prints a --confirm "kind:key" token for every deletion it found. Apply executes only the ones you pass back. Nothing is ever deleted because it happened to be in the diff. rangeen-design apply --confirm-all passes back every token of the last reviewed plan in one go: the server still receives one explicit token per deletion and still refuses a stale plan, so nothing can go that was not in the plan you looked at — but everything in it does. It exists for resetting a test tenant to a lane's design in one command, once a person has deep deleted whatever holds data there (next note); on a live tenant, read the plan's delete list before you type it.

But absence is a delete in the tenant scope. For eight of the ten tenant units — globals, correspondent types, jobs, roles, teams, user fields, external roles and reports — a unit that exists live with no file in your workspace is planned as a delete. A missing folder reads as "delete everything in it". Only data export and triage matchers are "absent means unmanaged".
A whole case type is a delete too — design only. A case type on the tenant with no folder in the workspace is planned as case-type:CODE. Its detail line says how many fields, screens, templates, automations, queries and views go. Unticked it is kept. Confirmed, every design piece goes, then the type — and there is no restore point: the design lives only in git. But the workspace never deletes data: a case type that still has cases is blocked, however it is confirmed, and so is a correspondent type that still has correspondents. Two other things block it, both named by the plan: a case-link field on a surviving case type that points at it (removed in the same apply, the delete simply runs after that update), and a family root with child cases of another type.
Deep delete lives in the portal, not in the workspace. To remove a case type with its cases, or a correspondent type with its correspondents, an administrator uses Deep delete on the case type's page or in the Correspondent types list. Each has its own permission (casetypes.deepdelete, correspondenttypes.deepdelete), asks for two confirmations that show every number — cases and documents, or correspondents and the cases that will have their selection emptied — and then runs in steps with live progress. It takes the whole design with it, including case-type fields that point at a deleted correspondent type. There is no undo. Afterwards a rangeen-design pull shows the type gone.
Per-case-type create/open grants do NOT travel with the design. Role and team files carry their permission keys only; the grants are people-shaped tenant data, like team membership. So a design released to another tenant arrives with no case-type access at all, and — on a tenant where rights exist — a confirmed role:<name> or team:<name> delete erases that subject's grants with no rollback. Re-grant them on the Case types tab of the role or team in the target tenant. User-level rows never export either.

Restore points and rollback

Every apply takes a pre-apply restore point for each touched case type and for the tenant scope, keeping the last ten. rollback replays them. Design versions in the portal (the previous section) remain your in-portal safety net.

Read the plan's warnings, not just its diff

A plan carries three kinds of message besides the changes:

ErrorsThe workspace cannot be applied as-is.
Adjusted on readThe server changed your design on the way in — a tile dropped by normalisation, a tile pulled back onto the canvas. These change what applies.
Not fully carriedThe export could not carry some artefact. Anything named here is something this workspace does not fully hold — treat it as a warning that an apply may flatten it.

The round-trip rule

The one structural rule worth understanding. A pull builds the files from the live design, but an apply rebuilds the design from the files. So a design property with nowhere to live in the workspace is invisible to a pull, and is overwritten by the next apply — which is exactly how a design project silently discards somebody's portal edits.

The platform now guards this at start-up: a boot-time check walks eighteen declared shape-to-shape hops and warns about any property that has no counterpart on the workspace record and no declared reason to stay behind. It warns rather than fails, so the guard's job is to make the gap visible before it costs anyone their work. If you hit a property that does not survive a pull/apply round trip, that is a bug worth reporting rather than a limitation to work around.

Cross-tenant releases

  • plan --tenant prod, read the diff (production may legitimately differ), then apply --tenant prod with the production apply key.
  • pull refuses a pull from a different tenant than the folder was pulled from unless you pass --force — otherwise you would silently replace your source design with the target's state.
  • Pull's clobber guard is git status over the managed roots and nothing else. In a folder that is not a git work tree there is no guard at all, and a pull overwrites uncommitted edits without asking. Always work in git.
The CLI never touches case data — it moves design only. Fixed correspondents on templates and designated senders are cleared on cross-tenant applies (they are data); re-pick them in the target tenant's portal once.

From the portal

Four of these routes have portal twins for people who would rather not use a terminal — plan, apply, rollback and the list of applies — all gated on Manage case types, with the plan shown in the same reviewable dialog, warnings included. A fifth exports the workspace as a download.

27 · Rangeen Accounts (Xero) Add-on

Invoices, bills and credit notes — kept beside the cases, posted to Xero.

Where: Manage → Accounts → Documents (Xero) (needs accounts.invoices.view) · Xero settings (needs accounts.integration.manage) — when the add-on is enabled

Xero-only, by design. Rangeen Accounts runs entirely on Xero — there is no built-in ledger to maintain for your office books, and no chart of accounts to keep here. Accounting statements (VAT, P&L, balance sheet, aged reports) live in Xero, where your accountant already works. Firms on the Legal accounts add-on (section 28) are the exception: client money is held in a separate ledger inside the product, and that module does keep its own control accounts and fiscal periods.
  • Connect once under Xero settings, pick your Xero organisation, and it stays connected — the connection is kept alive automatically and won't lapse from a quiet month. An organisation whose base currency differs from the one on your accounts settings is refused (xero.currencyMismatch), and the refusal happens before anything is saved, so the existing connection survives the attempt.
  • Documents: raise invoices (money in) and bills (money out) and credit notes. Each flows Draft → Approve, and can be Disputed / cleared / Voided. Push to Xero, and payment status flows back (amount paid, amount due, Xero status). A case type can bind the On accounts document paid trigger to react when money lands (section 6) — and that is the only accounting trigger in the product: nothing else in either money module fires one.
  • Per-case financials: a money-in / money-out / paid-vs-outstanding rollup for each matter.
  • Mapping: default sales/purchase accounts and tax types, tracking categories, contact matching, and auto-push on approval — configured under Xero settings.

What pushes, and who owns the numbers

The integration is a one-way push with a status read-back. Four things go out — Contact, Invoice (sales and purchase alike), Payment and CreditNote — and nothing is ever pulled in except status and totals. Xero documents are never imported into the product.

  • Only Authorised or Paid documents push. Pushing a Draft is recorded as a Skip — "Approve the document before pushing it to Xero" — not an error, so an auto-push run that meets a draft simply leaves it behind.
  • A line with neither an explicit Xero account code, nor a mapped account, nor a tenant direction-default fails fast with a named message telling you to set a default sales (revenue) or purchase (expense) account.
  • Xero owns the money figures. After a push or a refresh the product adopts Xero's SubTotal, TotalTax and Total, and its AmountPaid / AmountDue — which is what flips the local status between Authorised and Paid.
  • The VAT you see before a push is only an estimate, computed from the cached Xero tax rates. On a tenant that has never connected that cache is empty, so every line estimates zero tax and the subtotal, VAT and total all read wrong until the document reaches Xero.
  • A disputed invoice is held in Xero as SUBMITTED (Awaiting Approval), so Xero itself will not let it be paid. Dispute is enforced by status, not by a local flag.
  • Supplier payments cannot be pushed — only customer receipts. Pay a bill in Xero.

Case linking, numbering and the two sync jobs

  • Every invoice and bill must be linked to a case by default; a tenant can relax that in the mapping config. How the case reference reaches Xero is a three-way choice: as an option under a tracking category (the default, category named "Case"), appended to the Reference field using the template Case {caseRef}, or Off. Xero allows only two tracking categories per organisation — if both slots are already taken, the strategy falls back to Reference on its own.
  • Numbering comes from tenant sequences with fixed prefixes: INV-{n} (sales invoice), BILL-{n} (purchase bill), CN-{n} (sales credit note), SCN-{n} (supplier credit note) and C-{n} (contact). If Xero rejects a number as a duplicate the push retries up to five times, renumbering from the sequence each attempt — and every renumber permanently advances the sequence, so gaps in your numbering are normal and cannot be reclaimed.
  • The Xero settings toggles create and enable two background jobs (section 20): xero-auto-push every ten minutes, which drains the approved-but-unpushed backlog, and xero-status-poll hourly. Each run handles at most 50 documents, so a large backlog clears over several runs.

Permissions the add-on adds

Exactly three keys, grouped "Accounts" in the role editor: accounts.invoices.view, accounts.invoices.manage and accounts.integration.manage (section 23).

Two traps. The retired internal-ledger keys (accounts.view, .manage, .post, .reports, .banking, .tax, .clientmoney, .payments) are silently dropped the next time that role, team or user's grants are saved — a role imported from an older design loses them with no error. And the permission catalog is not filtered by add-on: the Accounts, Legal accounts, Rangeen AI and Data Export groups are always listed and tickable even on a tenant without those modules. The keys simply do nothing until the platform operator enables the module.

Raising documents from an automation

Automations can post accounting documents too, through the same service as the UI — so a script-raised invoice is identical to a hand-raised one:

// Raise a fixed-fee invoice for this case and approve it
var inv = accounts.createInvoice({
  contactName: get('{Client Name}'),
  contactEmail: get('{Client Email}'),
  reference: 'Fixed fee - ' + get('{case.ref}'),
  dueInDays: 30,
  approve: true,
  lines: [
    { description: 'Professional services', amount: 750, taxType: 'OUTPUT2' }
  ]
});
log.info('Raised invoice handle ' + inv);

The accounts.* family also includes createBill, createCreditNote, approve, dispute, clearDispute, void (alias voidDoc, because void is a JavaScript keyword), refresh, updateDraft, deleteDraft, attach, documents() and financials().

Accounts calls are deferred. Every accounts.* call is held back until after the run commits, because the push is external I/O — so a create returns a handle, a promise of a document rather than the document itself. You can pass that handle to approve or attach later in the same run, but you cannot read the invoice back: accounts.documents() is a snapshot taken before the script ran and never contains this run's creates. At most 25 accounts.* operations per run. accounts.void is irreversible, and a paid document cannot be voided — credit it instead.

28 · Legal accounts — client money Add-on

Client money kept to the SRA Accounts Rules, on the same case as the work.

Where: Manage → Legal accounts → Cashiering · Reconciliation · Compliance · Client accounts · Accounts settings

This is a second, separate money module from Rangeen Accounts. Rangeen Accounts is your own money and runs on Xero; Legal accounts is money you hold for a client, and it lives here because the SRA requires a matter-level record that agrees with the bank.

Both add-ons are required. Every /api/legal-accounts/* route answers 403 unless legalaccounts and accounts are both enabled for the tenant. That is not an oversight: the rule-4.3 costs-transfer gate is anchored on a bill that was actually delivered, and bills live in the Xero lane. Only a platform super-admin can switch either on.

The eight permissions

They appear in the role editor under Legal accounts. They are listed whether or not the add-on is on — granting one only starts to matter once a super-admin enables it, because the routes 403 until then.

KeyLets the holder…
cashiering.viewRead every screen in the module.
cashiering.postPost receipts, release approved payments, post transfers.
cashiering.requisitionRaise a payment request (but not approve it).
cashiering.authoriseApprove or reject a payment request; the only key that can authorise an overdraw.
cashiering.reconcileImport statements and run the reconciliation workbench.
cashiering.complianceRaise and work breach-register entries, set the interest policy and credit interest, record 7.2 opt-outs, and dispose of residual balances. Reading the register and the COFA dashboard is cashiering.view.
cashiering.settingsBootstrap the module, edit the client-money controls, manage the bank-account registry and fiscal periods.
cashiering.auditorRead-only, everywhere. Granted on every GET including the full AR1 download, and on no write route at all.

cashiering.auditor is the reporting accountant's key. Give it to your external auditor and they can see and download everything and change nothing — no posting, no deciding, no reconciling, no configuring.

Turning it on

The module must be bootstrapped once, by a cashiering.settings holder. That creates the settings row, applies the packaged UK solicitor preset (controls on, non-negative enforced, a client required on every posting, reconciliation cadence 35 days, a dormant-balance threshold) and seeds the CLIENT-FUNDS control account. It is the only preset that exists.

The individual guards are switchable and, before bootstrapping, off: a master client-money controls switch, then enforce non-negative (rule 5.3) and require a client as separate booleans. A tenant that never bootstraps has the screens but none of the protections.

The client bank-account registry

Every account the firm holds client money in is registered, with a Kind that decides the rules its history was kept under:

KindPostable?For
GeneralClientYesThe general client account.
DesignatedClientYesA designated deposit account for one matter.
JointNoRecorded for statements and the register only.
ClientsOwnNoAn account in the client's own name that the firm operates.
TpmaNoA third-party managed account.
OfficeMirrorNoThe office-side counterpart, used to mirror a costs transfer into Xero.

A posting may only move through an account that is flagged as a bank account, is of fund type client money, and is active. The Kind is immutable once the account exists, and there is no delete — only deactivate.

The matter ledger

One ledger card per case. Every movement writes exactly one balanced journal entry, plus the ledger row or rows it needs — one for a receipt or a payment, two for a transfer (the office-side echo of a costs transfer, or the second matter of an inter-matter move) — under a per-matter lock so two cashiers cannot interleave. A row's type is one of:

ClientReceipt          ClientPayment
OfficeReceipt          OfficePayment
ClientToOfficeTransfer OfficeToClientTransfer
InterMatterTransferIn  InterMatterTransferOut
Interest               WriteOff               Reversal
Corrections are reversal-only, everywhere. A posted row is never edited or deleted — it is contra'd with a Reversal. A row already reversed refuses a second reversal, and a Reversal can never itself be reversed. A manual journal may not touch any client-money account while the controls are on, and a journal written by the posting service cannot be reversed from the journal screen. The typed cashiering routes are the only lane in.
Every posting needs an open fiscal period covering its entry date. No covering period is accounts.period.missing; a closed one is accounts.period.closed; overlapping periods are refused outright.

Money in — receipts

A receipt captures the rule-2.3 promptness trail as three separate stamps — received on, banked on, posted at — plus clearance. Method is one of BankTransfer, Cheque, Cash, Card, Other; Cheque and Other default to uncleared. Category is one of OnAccountOfCosts, Damages, CompletionFunds, LegalAidAgency, TrusteeMoney, BillPayment, Other.

Classification decides where the money lands:

ClassificationEffect
ClientThe whole amount posts to the matter's client ledger.
MixedAlso posts the whole amount to client — the office element is separated later by a transfer, which is the rule-compliant order.
OfficeRecorded for the trail; nothing lands on the client ledger.

Money out — the three-step requisition

Paying out is deliberately not one action:

  1. Create the request — cashiering.requisition or cashiering.post. Purpose is one of BilledCosts, Disbursement, ReturnToClient, SettlementPayment, Other.
  2. Decide it — cashiering.authorise.
  3. Release it — cashiering.post. Only now does money move.

A request moves through Requested → Approved | Rejected → Released, or Cancelled.

The requester can never approve their own request. That separation is enforced in the domain, not in the screen, so it holds however the request was made.
Rule 5.3 has its own door. A release that would overdraw a client ledger is refused. Passing an override flag to the ordinary release route is refused too (cashiering.override.wrong_route) — an authoriser must use the dedicated override route, and every override writes a breach-register entry. Both the block and the breach entry hang off the enforce non-negative control: with it off, an overdraw posts silently — which is why the UK preset turns it on.

Transfers, and the rule-4.3 gate

Four kinds: CostsToOffice, OfficeToClient, InterMatter, MixedOfficeElement.

Taking your costs out of client money is gated on a delivered bill: the invoice must be a sales invoice on this case and neither draft nor voided. The transferable headroom is the bill total, minus any lines flagged anticipated and not yet marked incurred, minus whatever the bill has already recovered (the greater of what earlier costs transfers took and what the client has paid), floored at zero — so money held for a disbursement you have not yet paid stays on client account, and one bill cannot be drawn down twice. Ask the API for the headroom per bill before you transfer:

GET /api/legal-accounts/transfers/bill-headroom?caseId=<caseId>
  -> [{ invoiceId, number, issueDate, total, amountPaid, transferred,
        headroom, status, anticipatedHold }]

If an OfficeMirror account is registered, the transfer also mirrors office-side into Xero as a payment against the bill. With no mirror registered nothing is mirrored and the office side is yours to reconcile.

Agent versus principal disbursement VAT — the Brabners point — is recorded per invoice line as Agent (outside the scope of VAT, recharged at cost) or Principal (a VATable recharge). It is a record of the decision, not a calculation: the module stores what you tell it and reports on it.

Reconciliation

Import a bank statement as CSV. The header reader is tolerant: it needs a Date column plus either Amount, or both Debit and Credit. Dates accept dd/MM/yyyy, yyyy-MM-dd and dd-MM-yyyy.

An import can never create a posting. Statement lines are matched against existing ledger entries, never merged into them. A manual tick must match the amount exactly, and the ledger entry must have moved through the same bank account and not already be ticked elsewhere. The three-way workbench then reconciles bank, cash book and the sum of the matter ledgers — the comparison the rules actually require.

The settings carry a reconciliation cadence (35 days on the UK preset); the compliance job raises the overdue state against it.

Compliance

  • Interest — a policy-driven engine computes what is owed on client balances and posts it as an Interest row.
  • Residual balances — dormant small balances are listed against the dormancy threshold, with a disposal route (including the charity route for an untraceable residual) that is recorded like any other movement.
  • The breach register — every rule-5.3 override lands here automatically, and entries can be raised by hand. Severity Low / Medium / High; status Open / InProgress / Resolved / Closed.
  • The COFA dashboard — the compliance officer's single view: overdrawn ledgers, overdue reconciliations, open breaches, dormant balances.
  • The 12.2 calculator — works out whether the firm qualifies for the accountant's-report exemption from the reconciliation snapshots in the period (average £10,000, maximum £250,000). It recomputes on every run and stores nothing; the arithmetic ships in the AR1 pack as 15-exemption-12-2.csv.
  • A scheduled compliance job stores nothing and refreshes nothing — the COFA dashboard is computed on demand. Once a day per tenant it recomputes five deadline signals (a reconciliation overdue on an active general or designated account, dormant balances past the threshold, mixed-receipt office elements past a 14-day grace, anticipated disbursements billed 60+ days ago and never incurred, and AR1 falling due within six months of the last closed period) and emails administrators a digest when any of them fire. With no settings row it is a quiet no-op.

The report canon and the AR1 pack

Alongside the on-screen reports, one download assembles the whole evidence pack an accountant asks for — a ZIP of seventeen files:

00-COVER.txt              09-breaches.csv
01-client-balances.csv    10-interest-policy.csv
02-overdrawn-ledgers.csv  11-interest-calcs.csv
03-ledger-entries.csv     11b-residual-disposals.csv
04-journals.csv           12-disbursements.csv
05-receipts.csv           13-bill-register.csv
06-payments.csv           14-account-inventory.csv
07-transfers.csv          15-exemption-12-2.csv
08-reconciliations.csv

A cashiering.auditor holder can download it without holding anything else.

Ledger merge tokens

The module reserves the ledger prefix on every case type. Five scalars:

{ledger.client_balance}Held on client account for this matter. Alias clientbalance.
{ledger.office_balance}The office-side balance. Alias officebalance.
{ledger.unbilled_disbursements}Costs on supplier bills recorded against this matter, less what has already been recharged on a bill as a disbursement (floored at zero).
{ledger.uncleared}Of the client balance, what has not cleared. Alias uncleared_funds.
{ledger.count}How many client-side movements are on record — the same rows {ledger[]} repeats over.

And a repeating collection — one Word table body row, repeated per client-side posting, oldest first — with the members date, detail (alias narrative), type, dr (alias debit), cr (alias credit) and balance.

A client-account statement template

CLIENT ACCOUNT STATEMENT
Matter:  {case.ref} — {case.title}
Client:  {client.display_name}

Held on client account:  {ledger.client_balance | currency}
Of which uncleared:      {ledger.uncleared | currency}
Unbilled disbursements:  {ledger.unbilled_disbursements | currency}
Movements on record:     {ledger.count}

(one Word table body row — it repeats per posting)
| {ledger[].date} | {ledger[].detail} | {ledger[].dr} | {ledger[].cr} | {ledger[].balance} |
dr and cr render blank, not 0.00, in the opposite column, so the table reads like a ledger card. Rows are client-side only — the office side surfaces solely through {ledger.office_balance}. A repeating token takes at most one formatter pipe; write a second and no formatting is applied at all — the raw value renders, with a linter warning.
On a tenant without this module the ledger prefix is not reserved, and these tokens render blank. Worse, if the case type has no field actually named ledger, the checker reads {ledger[].date} as a missing iteration table and {ledger.client_balance} as a missing field ("'ledger' is not a field on case type … — the token renders blank") — both errors in the Design check. The template still saves, because the design check reports and never blocks; the tokens simply render blank. Only an automation script is refused outright for a bad token. Don't put ledger tokens in a template on a tenant that doesn't hold client money.

Two boundaries worth knowing

  • No automation verb posts client money. The scripting surface can read the ledger through merge tokens, and nothing more. Every movement goes through the typed routes with their permissions and their separation of duties.
  • A matter holding client money cannot be marked dead. The close is refused before anything else runs, naming the amount: "This matter still holds {amount} of client money. Return it to the client (rule 2.5) — or, for an untraceable residual balance, use the charity disposal — before closing the file." See the lifecycle triggers for where that sits in the close sequence.

29 · Rangeen AI Add-on

Optional writing and answering help — never an actor on its own.

Where: the AI button in the case action bar · Write with AI in the memo / letter / email compose dialog · Template AI in the email template dialog · Code AI in the automation editor · Manage → Administration → AI usage & credits (needs ai.use and the add-on)

Rangeen AI provides focused assistants: a per-case assistant that answers questions about the open case from its own data and history (read-only — it can tell you, never change anything); Write with AI in the memo / letter / email compose dialog on a case, which drafts a one-off from the case's real data and saves nothing as a template; a Template AI chat in the email template dialog and a Word/Excel document generator for letter and memo templates (one permission, ai.author_templates, covers both); and Code AI in the automation editor, which writes and explains automation script against the real API. Everything an assistant writes lands in an editor for a person to review and save — AI can never do anything the signed-in user couldn't do by hand, and each surface has its own permission (section 23). ai.use is the master switch: without it none of the other AI keys does anything.

One panel, three memories

The case assistant, Code AI and Template AI are the same panel in three modes, and they remember differently:

Case assistantA session list per case — start as many conversations as you like and come back to them. Read-only throughout.
Code AIOne persistent thread per user and automation, with no session list; Start fresh archives it and begins again. Every turn carries the live editor buffer, so it edits the script you actually have on screen, not the last saved one.
Template AIThe same single-thread, live-buffer behaviour, pinned to one email template.

Code AI and Template AI hand back a lint-clean proposal as a diff card. Nothing changes until you press Apply, and Apply only replaces the editor contents — saving is still your normal Save button.

Credits: the pool, the caps and the charge

Usage draws on a monthly credit pool, and the AI usage & credits page shows the balance, the recent activity behind every charge, and a Per-user monthly caps table — User / Used this month / Monthly cap — where an administrator who also holds users.manage can set or clear a ceiling for one person. A blank cap means no limit.

  • How the pool is sized. creditsPerLicense × the tenant's licence pool size (the number of licences bought, not the number of people who currently hold a seat) + topUpCredits. All three are set by the platform operator; with no explicit creditsPerLicense the default is 200 credits per licence.
  • It re-sizes mid-month. The grant is recomputed on every AI use or balance read, so an operator change lands on the running month immediately. Credits already spent are never touched; a decrease below what is already used simply floors the remainder at zero.
  • The charge is token-based with a per-task floor — credits = max(floor, ceil((promptTokens + 4 × completionTokens) / 2000)). Output is weighted four times because it costs about that much more. Floors are 1 for a case question, 2 for a draft, 3 for a template and 4 for an automation, so a trivial ask pays the floor while a long Code AI session is charged in proportion to the work — it is not capped at the floor.

30 · Client Hub portal Add-on

External parties see exactly what you share — nothing else.

Where: Manage → Administration → Client Hub (admin) · the Share action on a case

Accounts & invites

  • External users are invited by email and sign in at <workspace>-collaborator.rangeen.com with their own credentials — never with staff accounts. The invite link lets them set their own password (nothing is ever emailed as a password); invites expire after 7 days by default (clamped to 1–30) and can be revoked. Admin tools: suspend, reactivate, reset password — where Reset password only emails a fresh set-password link; nobody, including you, ever sees the password.
  • Invite first, then share — and wait for the acceptance. Issuing an invite creates an invite row and nothing else. The account itself is created at the moment the person accepts and chooses a password, so until then they appear nowhere in the Users tab and cannot be picked in any Share dialog. With no accepted accounts yet the Share dialog simply says "No active external users to pick from — add one on the Client Hub page first".
  • After sending, a one-time Invite issued — copy now dialog shows the accept link; the token is shown once and is never recoverable. Its Copy URL button copies a relative path (/accept-invite?token=…) with no host, so pasting that into an email of your own produces a broken link — the invite email the system sends separately carries the correct absolute one.
  • Groups (Solicitors, Case Managers…) are labels for organising external users — access is always decided per share, never per group.
Suspend is not a pause button. Suspending a collaborator does far more than block sign-in: it revokes every one of their active case shares, with the reason "external user suspended" (the external token is validated cryptographically, so cutting the shares is the only way to cut access immediately). Reactivating lets them sign in again but restores nothing — they land on an empty case list. There is no warning and no undo. If someone is away for three weeks, suspending and reactivating costs you the re-share of their entire caseload; the fastest route back is Users → Manage cases → tick → Share, which re-shares using each case type's default profile.

Shares

  • A share grants one external user access to one case. In the Share dialog you set each screen to Hidden, Read-only or Editable (with Hide all / All read-only / All editable shortcuts), choose how much case history they see, set attachment rights (upload / download / see attachments), an optional expiry, and whether access survives case closure. What the share freezes is the access map — which screens are Hidden / Read-only / Editable, the history setting and the attachment flags — not the screens themselves. Screen layouts are read live, so a field you add to a screen the share already makes Editable reaches the collaborator at once, editable. Re-snapshot only re-reads the case type's collaborator profile into the access map.
  • Case history is a three-way choice: No history at all, Only events flagged "visible to externals" (the default), or All case history. Note that nothing is flagged by default — every event is written private, and staff release individual rows with the eye toggle in the last column of the History tab (tooltip: "Make visible to external collaborators with this case shared"). On the default setting a new collaborator sees little but their own uploads and saves. Even on "All case history" the server strips field changes, locks, status flips, to-do churn and note edits: collaborators only ever get the human-facing Actions timeline.
  • There is no field-level switch — the granularity is strictly per screen. If a screen mixes information you will share with information you won't, build a second, cut-down screen for collaborators and hide the original. (Per-field hidden and read-only lists do exist in the data model and are enforced, but they can only be authored through Design-as-Code, in the case type's external-profiles.json — and opening and saving that profile in the app wipes them back to empty.)
  • A named collaborator profile per case type (case-type detail page) gives you a reusable preset for all of that; the default profile pre-loads in the Share dialog.
  • Who can share: cases.share covers sharing the case in front of you; collaborators.manage owns the whole portal (accounts, invites, every share, and bulk sharing). The "shareable up to authorisation level" setting (section 24) can let sharers offer screens above their own level.
  • Sharing in bulk, from the person's side: Users tab → row menu → Manage cases lists and revokes that person's existing shares and lets you search and tick up to 100 cases to add per batch. A bulk share has no per-screen control — it always uses the case type's default collaborator profile, so a case type with no profile at all produces a share that shows nothing: the case appears in their list with no screens on it. A bulk share never aborts on a bad row; it reports per-case reasons under "n cases were skipped". An expiry entered here is taken as the end of that day, where the Share dialog's own date field uses the raw date.
  • Only one active share can exist per case and collaborator — a second attempt is refused with 409 "This user already has an active share on this case", and re-sharing after a revoke revives the same row with a fresh snapshot rather than creating a second one.
Collaborators are authorisation level 0. They hold no permissions at all, and the projection layer treats them as level zero. So a screen you set to Editable in the Share dialog can still arrive with gaps, or read-only, if individual tiles on it carry a view or edit level above 0: a tile with any view level is dropped, one with any edit level arrives read-only. A screen's own authorisation level works differently — it decides whether the Share dialog offers the screen at all, so a screen above the sharing ceiling is silently left out rather than delivered blank. The Share dialog gives no warning. Build collaborator-facing screens at level 0, and check the levels before concluding a share "isn't working".

Which screens actually reach the portal

  • A screen with no entry in the share's snapshot defaults to Hidden — the model fails closed, and Hidden is stripped from the payload rather than greyed out. So a screen added to the case type after a share was created is simply absent from that share until somebody edits or re-snapshots it.
  • A never-published draft screen is left out of the projection, and a screen published and then re-drafted renders from the frozen published snapshot — so unpublished work can never leak to the portal. The Share dialog marks these with a "Draft" badge.
  • Sharing is per screen even for nested screens: a shared child whose parent is Hidden still shows in the portal, at top level.
  • If a share grants zero visible screens the portal says "No screens defined yet." — which names a design problem when the real cause is the share. And if a collaborator opens a bookmarked case whose share has since been revoked, the page has no error handler and sits on "Loading…" indefinitely.

What closing a case does to a share

Marking a case dead ends every active share on it except those flagged "keep access after the case is closed" — and reviving the case does not bring them back; re-share deliberately. A five-minute background sweeper is the backstop, and also flips shares to Expired once their date passes. On a dead case that was kept, the collaborator can still sign in and read it, but saving a screen is refused with 409 "This case is closed." and so is uploading a case attachment. One inconsistency worth knowing: the document-conversation endpoints carry no dead-case guard, so on a kept-after-close case a collaborator can still upload into the two-way thread even though nothing else can be added.

What collaborators experience

  • They land on Cases shared with you — the case list is the home page; there is no navigation rail. Opening a case gives them the shared screens in a right-hand rail, plus a History icon (absent when the history setting is "No history at all") and an Uploads icon with an unread badge.
  • A case opens into the same screen renderer staff use: editable screens edit for real (tables and time records included), read-only screens wear a "View only" lock, and edits across screens buffer into one draft with one Save — which runs your Before Save / After Save / on-change automations, prompts included, exactly as for staff. Screen buttons you shared run their automations too (user.isExternalUser tells your script who's driving), and a press is logged on the Activity tab as ButtonExecuted. AutomationTriggered is the separate kind for the lifecycle and on-change automations that fire around a collaborator's case open and save, and only when one actually ran.
  • Documents is a two-way file conversation per (case, collaborator): they upload, you exchange, they can remove their own file within 24 hours (25 MB a file, executables refused); your side — the case's Client Hub view — shows one thread per person with unread badges and open/download receipts. Receipts are one-directional: the collaborator is never shown any. The conversation is deliberately kept out of the audit/fields history.
  • They can't: send correspondence, see to-dos or notes, follow case links to other cases, edit globals, share onward, or reach any case or screen you didn't share. Everything they do lands on the case history under their name, and files they upload wear an External badge.
  • Sessions last 8 hours (renewing while active); single-signed-in-place can be enforced on portal accounts as part of your plan.
"Allow downloads" off is not confidentiality. It removes the download button and nothing else — preview always works, so the collaborator can open and read every document in the conversation on screen, and photograph it. Read it as "discourage saving copies", not "prevent reading". Likewise See attachments off means they see only the files they uploaded themselves, plus an allow-list of three things: the document behind a history row they can already see, embedded documents on a screen they can already see, and documents held in the rows of a table on a screen they can already see.
A shared automation is not sandboxed. A script on a collaborator-visible screen that walks CrossCase to a case that was never shared with them still runs and still reads. The share redacts the case in front of them; it does not fence the script. Write collaborator-reachable automations with the outside audience in mind. The reassuring half: a save that tries to write a hidden or read-only field is refused loudly with 403 "Field is not editable for external collaborators." — never dropped silently.

The Activity tab and the daily digest

The Client Hub page has four tabs — Users, Groups, Invites and Activity. Activity is a separate, append-only audit of what outside users did (When / User / Kind / IP) that never mixes with the internal case timeline; the kinds include Login, LoginFailed, CaseOpened, ScreenOpened, ScreenEdited, AttachmentUploaded, AttachmentDownloaded, ButtonExecuted, AutomationTriggered, BulkReadDetected and DigestSent. Separately, a background job emails each collaborator a daily roll-up of the last 24 hours on their shared cases — but only events flagged visible to externals, so on the default history setting that digest is usually empty. Each person is digested at most once per 23 hours.

Billing transparency

Your invoice carries one line for collaborator usage: the number of external accounts that actually signed in and did something during the month — counted from that same Activity log, which is why unused invites and idle accounts cost nothing and failed logins, password resets, magic-link requests, sign-outs, bulk-read flags and digest sends are all explicitly non-billable. Organisation settings → Client Hub itemises exactly who was counted, month by month — the same calculation the invoice uses, so the two can never disagree.

31 · Security model

How the system keeps the right people in and everyone else out.

  • Isolation — each organisation's data lives in its own database schema; separation is structural, and the workspace is resolved from the subdomain on every request.
  • Identity — staff sign in with Microsoft Entra (one directory backing many organisations); collaborators use a separate credential system confined to their portal.
  • Sessions — a licence is required to hold a session (an unlicensed account is refused on every request, not just at sign-in); disabling a user ends their sessions on the next request, everywhere; sign-out in one tab signs out all of them; inactivity past 4 days ends the session; and your plan can enforce one signed-in place per account (staff, Client Hub users, or both). The 4-day limit is fixed and not configurable — the per-tenant "idle lockout minutes" value is inert, and the settings page writes zero to it on every save, so a design import that sets it is zeroed the next time an administrator presses Save. Single-sign-in-place is a platform-operator switch with an optional per-account override, not something a tenant administrator can turn on.
  • Authorisation — role/team permissions are enforced server-side on every sensitive operation; the menu gating you see is convenience only. Delegated admin can never grant beyond what it holds on the Users, Roles and Teams screens: a grant the caller does not hold themselves is refused with permission.escalation, while taking privilege away is always allowed.
  • Case passwords — a password-protected case's contents are refused by the server (not just hidden by the page) until the user enters its password, and the unlock is dropped again when they leave the case. Insights and lists still count such cases; only opening them is gated. Passwords are stored encrypted and are revealable only on the permission-gated Protected cases screen. The gate applies to staff only: an outside collaborator who already holds a share on that case walks straight past it, because the share is treated as their grant. If a case is sensitive enough to password-protect, revoke its shares as well.
  • Accountability — per-field audit and an immutable case history attribute every change to its actor — person, automation or collaborator — and disabled users are retained so their name persists.
  • Hardening — automation outbound HTTP is off by default and host-allow-listed (with private-address guards), CSV export is protected against formula injection, secrets are masked in the UI, and licence caps and last-admin protection are enforced server-side.
  • Transport & hosting — TLS on every address including each organisation's own subdomain; hosted on Microsoft Azure with staged, health-checked releases.
The grant ceiling has one deliberate hole. A Design-as-Code apply (section 26) is exempt from the escalation ceiling on purpose, because the workspace is the source of truth for role and team grants; the only gate is upstream, at key-mint time — creating a key with design.plan or design.apply scope needs both settings.manage and casetypes.manage. Treat an apply-scoped key as an administrator-equivalent credential and keep it off any automated or AI-held key.

What a session refusal actually says

Six 401 codes can end a signed-in session, each with its own sentence on the sign-in page. Being signed out mid-day is therefore almost never the inactivity rule — it is one of these:

session.invalidThe account is no longer part of this workspace.
session.unlicensed"Your account has no licence assigned. Ask your administrator."
session.revokedThe token predates the revocation watermark — "You have been signed out."
session.supersededSingle-session is on and another login owns the account — "You signed in on another device, so this session was ended."
tenant.inactiveThe whole workspace is suspended.
expiryThe token simply ran out, including the 4-day inactivity limit.

32 · Design a case type end to end

Putting it together: a "Debt Recovery" case type from nothing to a working workflow.

Step 1 — Create the case type

Manage → Configuration → Case types → New. Code DEBT, name "Debt Recovery", multi-user access on.

Step 2 — Define the fields

Manage → Configuration → Data manager, on the DEBT type:

FieldType
Debtor NameText
Original DebtDecimal (2 dp)
Interest RateDecimal (2 dp, 0–100)
Date InstructedDate (default TODAY)
Payment Due DateDate (default TODAY+30d)
StageDropdown — Pre-action / LBA sent / Claim issued / Judgment / Enforcement
DebtorCorrespondent (type = Debtor)
PaymentsTable — columns: Payment Date (Date), Amount (Decimal), Method (Dropdown)
Total PaidScripted → Decimal (sums the Payments table)
Balance OutstandingScripted → Decimal (Original Debt − Total Paid)
Time On MatterTime record (extra columns: billable, hourlyRate)

Total Paid = (get('{Payments[]}') || []).reduce(function (s, r) { return s + (Number(r['Amount']) || 0); }, 0); Balance Outstanding = get('{Original Debt}') - get('{Total Paid}').

Step 3 — Lay out the screens

Manage → Configuration → Screen studio, on DEBT:

  • Overview — Case-control tiles (Reference, Status), the debtor & Stage, Original Debt, Balance Outstanding (read-only), Payment Due Date, and an assignment tile for the Case Worker.
  • Payments — a Table tile on Payments, plus Balance Outstanding and a Global tile for the firm's default interest rate.
  • Time & Costs — a Table tile on Time On Matter (its start/stop log), with an authorisation level so only supervisors see it.

Give the Payments screen a visibility rule of Stage is not "Pre-action" so it only appears once action has started. Configure Quick View on the case type — Stage, Balance Outstanding, Payment Due Date — so the numbers ride the case bar.

Step 4 — Add templates

Manage → Configuration → Playbooks → DEBT → Letters: a Letter Before Action (Word letter to the debtor, using {Debtor Name}, {Original Debt|currency_gbp} and a conditional paragraph on {#if [Balance Outstanding] > 5000}), and a Statement of Account letter using a {view:payments…} table view of the Payments table.

Step 5 — Add an automation

Workflow → DEBT → Automation → New, code SEND-LBA. It sends the Letter Before Action to the debtor, sets the stage, and diarises a chase:

actions.send('LTR-LBA', {
  actionKind: 'Letter',
  holderField: 'Debtor',
  description: 'Letter Before Action to {Debtor Name}'
});
put('LBA sent', '{Stage}');
tasks.create('Chase response to LBA', {
  dueInDays: 14,
  actionType: 'PhoneCall',
  assignTo: 'case_worker'
});

Run Check, then Test Run against a real DEBT case, then place a Primary button "Send Letter Before Action" on the Overview screen that runs SEND-LBA.

Step 6 — Wire triggers

On the case type, bind When a case is being created to an automation that sets Stage = "Pre-action", and bind Before marking the case as dead to one that blocks closing while there's a balance. (When the user clicks Close is a different trigger — it fires on the tab ✕, not on closing the case.)

if (Number(get('{Balance Outstanding}')) > 0) {
  stop('Cannot close — ' + get('{Balance Outstanding|currency_gbp}') +
       ' is still outstanding.');
}

Prefer stop('reason') to throw here. Both keep the case open, but a stop refuses with case.close.refused carrying your reason, which is the sentence the user reads; an errored run refuses with the generic case.close.gate_failed instead. The gate runs while the case is still alive and writable, it receives input.fileClosedDate and input.source, and its actions.send drains after the case dies — so "before marking it dead, send the closing letter" is a supported shape.

The gate is not an invariant. A bulk close from the case list deliberately skips it — the history row is stamped gate:"skipped" — so the balance guard can be walked past. Anything you must never bypass belongs in a server-side rule, not in a close gate. Two more ways the binding goes quiet: a bound automation that is inactive, deleted or on another case type reads as unbound and simply never fires, with nothing on the case type showing the binding is dead; and one that is marked library, or that declares a required parameter with no default, refuses the close loudly instead of running.

Step 7 — Add a List, an Insight and a scheduled job

Build a List "Overdue debts" (Payment Due Date < @today AND Balance Outstanding > 0, with an optional Stage question), and a Insight on it grouped by Stage with a Sum of Balance Outstanding and a bar chart. Then add an Auto routine job — Manage → Operations → Routines → New job — that runs "Overdue debts" every morning at 08:00 and fires a "chase" automation on each match. Dry run is a tick-box on the job, not a button you press: tick it, let the job run, read the log to see exactly which cases it would have touched, then untick it. A job with Dry run on and no automation code is perfectly valid, which is exactly how it is meant to be used first.

That's a complete case type — data, screens, letters, automation, lifecycle rules and reporting — built from the pieces in this handbook. Every change you made was captured as a design version you can restore, and the whole design can live as files under git with the design workspace CLI.