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.
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:
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).
The mould for each kind of matter.
Where: Manage → Configuration → Case types
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./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.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.
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.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.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.
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.
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.
{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.
| Type (as you pick it) | Holds | Settings |
|---|---|---|
| Text | Free text — names, notes, references. | Optional max length (1–10,000). |
| Whole number | An integer, no decimals. | Optional min / max. |
| Decimal | A number with decimals — money, rates, measurements. | Min / max; decimal places (0–10, default 2). |
| Date | A calendar date, with a picker. | Allow past / future; default fixed or TODAY±N. |
| Date & time | A date plus a time of day. | As Date, plus an optional @HH:mm default. |
| Time | A time of day on its own. | Optional default time. |
| Yes / No dropdown | Three states: blank, Yes, or No. | Optional default. |
| Checkbox | Two states — ticked or not. | Default checked / unchecked. |
| Dropdown | A 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 link | A link to a case of another type; its fields are reachable in templates & scripts. | The linked case type. |
| Correspondent | A "holder" pointing at one correspondent of a chosen type. | The linked correspondent type. |
| Table | A repeating sub-table on the case — you define its columns. | Member columns (see below). |
| Time record | A start/stop time log, with optional extra columns. | Member columns (see below). |
| Embedded document | A document slot on the case, with a template document. | File uploaded in Data manager. |
| Signature | A 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. |
A default is one string on any scalar field, top-level or table column, shaped by the type:
30, 99.5.True or False.HH:mm.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.
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.
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.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).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.put() are
silently dropped and recomputed. A broken expression yields a blank cell, never a
failed save.billable, hourlyRate,
reason) — templates can sum these for invoicing.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.
{field} token
resolves it, silently. Always deactivate rather than delete.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}).{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.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.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)
case.value.not_a_case_link. Templates and scripts "walk
through" it to read the linked case's own fields.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.
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.
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.
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.Dates are entered and shown dd/MM/yyyy platform-wide; a blank date box saved over a stored date clears it.
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.
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.
A tile authored shorter than its default clips its contents, so the defaults are worth knowing. Heights are in 8px rows:
| Tile | Default height |
|---|---|
| Field — scalar | 5 rows |
| Field — long text (max length > 200) | 11 rows |
| Embedded document | 30 rows |
| Signature | 15 rows |
| Label / Button / Divider | 3 / 4 / 1 rows |
| Correspondent attribute, Case control, Assignment, Global | 5 rows |
| Table (and Time record) | 25 rows |
| Table view, SQL viewer, Web viewer | 30 rows |
| Image viewer | 20 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).
| Tile | Places |
|---|---|
| Field | A 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. |
| Label | Static text — with a chosen size and colour. |
| Button | A button that runs an automation. Style: Primary / Secondary / Danger. |
| Table | A sub-table (add / edit / delete rows). Also the tile used to place a Time record's start/stop log. |
| Web viewer | An embedded web page (an http(s) URL). |
| Image viewer | An uploaded image — optionally overridable per case. |
| Correspondent attribute | One detail of a linked correspondent (name, email, address…). Every tile naming the same holder forms one block. |
| Global | An organisation-wide global variable — editable (saving writes back to the global for everyone). |
| Case control | A system property (reference, status, dates…) — always read-only. |
| Assignment | A detail of the user in an assignment slot — always read-only. |
| SQL viewer | A saved list rendered as a read-only grid on the case. |
| Table view | A 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(). |
| Divider | A 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.
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.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 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.
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.Placing a Table (or Time record) tile gives it its own settings dialog, independent of the field definition.
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.screens.open('CODE') call naming that screen is left
dangling. Search for the code before you delete it.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.
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.
parentScreenCode in the screen's file — declarative, so an
absent key moves the screen back to top level.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.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.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.
A pinned summary, and the built-in properties every case has.
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.{assignment:slot.attribute};
to-dos can be assigned to a slot so they follow whoever holds it.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}.
{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.Injury claim - {client_surname} case reads Injury claim
until the surname arrives.cases.create({ title }), and rows imported with a Title
column. What the author wrote wins, permanently.titleTemplate on
case-type.json, and Where-used on a field lists the case
title, so renaming a field rewrites it.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:
| Trigger | Fires | Can block? |
|---|---|---|
| When a case is being created | Right 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 opened | When 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 Save | Before 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 Save | After 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 Close | When 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 dead | Whenever 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 paid | When 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 |
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 label | Enum (logs, events) | Design-as-Code key |
|---|---|---|
| When a case is being created | OnCreate | onCreate |
| When a case is opened | OnOpen | onOpen |
| Before Save | OnUpdate | beforeSave |
| After Save | AfterSave | afterSave |
| When the user clicks Close | OnCloseRequested | onCloseRequested |
| Before marking the case as dead | OnClosed | onClosed |
| On accounts document paid | AccountsDocPaid | onAccountsDocPaid |
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
}
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.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.
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".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:
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.)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.One click of Save runs these in a fixed order, and a refusal in any of the first three stops everything after it:
stop() the save.PUT of values, tables and time records.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.
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.
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.Three ways a binding looks fine and does nothing, none of them surfaced in the UI:
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.)So before binding, confirm the automation is Active, on this case type, not Library, and has a default for every parameter.
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.An automation is a small JavaScript program that reads and changes a case.
Where: Manage → Configuration → Playbooks → the case type → Automation tab
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.
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.put() and then get() reads
back the new value. (Whole tables are the exception — see the next section.)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.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.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:
| Token | What it addresses | Read / 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.
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:
_id are updated in place._id are inserted.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.
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.
{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.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.
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.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.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.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.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.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.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.
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.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.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.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.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.accounts.* operations per run.With Rangeen Accounts, an accounts.* family lets scripts raise
invoices, bills and credit notes — see section 27.
Many ways to fire the same script.
| Fired by | How you set it up | Interactive? |
|---|---|---|
| A case-type trigger | Bind it on the case type — the seven bindings of section 6. | When a person drove it |
| A field's on-change | Bind 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 change | Bind 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 button | Place 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-open | Bind 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 task | Create a task of kind "Run Automation"; actioning it runs the script. | Yes |
| A workflow template's diary follow-up | Give 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 hook | Bind it on a template; Before can cancel the action (stop), After is best-effort. | Inherits the caller's — see below |
| Client Hub | Nothing 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-action | Give 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 routine | An 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 Run | The 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 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.
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.
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.
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.
trigger globalScripts 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.
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.
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.
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.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.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.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).
The editor writes code for you — and never lets you save broken code.
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…).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.^[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-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.cases.bulk). Bulk runs are
quiet — an ask.* there fails that case.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
}
}
// 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}');
// 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');
// 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');
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.
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.
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:
upper, lower, title,
sentence_case, trim, initials,
first_word.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.time_24h, time_12h,
datetime_uk_slash, datetime_long_uk.value_with_commas,
value_in_words, value_2dp, currency_gbp
(or pounds), pounds_in_words,
currency_usd, currency_eur.duration_hhmm,
duration_hours_decimal, yes_no, ticked.raw (alias value) — hand the stored
string back exactly as it is held.{status|code} / {status|description}
/ a custom column name picks which column shows (Description is the default).{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.
A token is classified by trying a fixed list of roots in order; the first match takes it:
->.view: — the Table View family of section 14.case., then system., then global., then
assignment:.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.{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.
{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>
{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.{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.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).{{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.
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.
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:
=, !=, >,
<, >=, <=.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'.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.
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.
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 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).
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 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.
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:
| Token | Gives |
|---|---|
{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.col | The 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.
{#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.{#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.{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.{view:Monthly Expenses.sum.amount},
{view:Monthly Expenses[].cost}. Codes are 1–50 characters of
letters, digits, spaces, hyphens and underscores.One template library per case type — the Playbooks tabs.
| Tab | Backed by |
|---|---|
| Memos | Text, Word (.docx), Excel (.xlsx), a PDF form or a PDF bundle — picked on the Type tiles. Usually an internal note-to-file. |
| Letters | A 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. |
| Emails | Rich-text / HTML body, sent through the user's Outlook (or a shared mailbox). |
| Letter heads | A Word file used as the masthead on Word letters and Word memos — never on Excel, PDF or email output. |
| Phone calls | No body — records a call note (incoming / outgoing / both). |
| Forms | An uploaded fixed PDF a Letter fills in (see next section). |
| Incoming Post | A 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. |
| Automation | The case type's automations (sections 7–11). |
| Table views | The reusable row-set definitions of section 14. |
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:
| Kind | Type tiles offered | Names a script uses |
|---|---|---|
| Memo | Text · Word · Excel · PDF · PDF bundle | None, MsWord, Excel, Pdf, PdfBundle |
| Letter | Word · Excel · PDF (no Text tile) | MsWord, Excel, Pdf |
| — always HTML | Html | |
| Letter head | — always Word | MsWord |
| Phone call | — no body at design time | PhoneNotes |
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.
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.
pdf_bundle.too_many).pdf_bundle.nothing_converted, 422).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.
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.
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).
embedInto overrides stay allowed —
they are designer-authored).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.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.
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.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.
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.
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).
Firm branding on a Word document, and case data into a fixed PDF.
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".
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.
A Form is a fixed PDF (a court or insurer form) you fill from the case. Two ways to fill it:
{claim_amount}); at send the tokens resolve and
the fields fill.Either way the case data does the filling, and the finished PDF lands on the case.
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.
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.
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.
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-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".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.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'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).
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.
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.
^[A-Za-z0-9_-]([A-Za-z0-9 _-]{0,29}[A-Za-z0-9_-])?$ — letters, digits, spaces, hyphens, underscores; no leading or trailing space.OPEN and open can both exist on one case type.^[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).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:
[case.ref], [global.vat_rate],
[client.email], [assignment:supervisor.email],
[Claim Type|description].today, now, me,
current_user, true, false,
null, empty. A field actually called one of those
must be bracketed.=, !=, <>,
>, <, >=, <=,
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 typed condition
[Review Date] <= today + 7
and [assignment:supervisor.email] = me.email
and not ([Claim Type] in ('PI','CN'))
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.
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:
| Form | Meaning |
|---|---|
case.ref (or case.reference), case.title, case.id, case.casetype, case.status, case.created, case.createdby, case.modified, case.closed | The built-in case controls. The older spellings Reference, Title, Status, CreatedAt and ModifiedAt still work as aliases. |
assignment:slot.attr | The user in an assignment slot — name, email, job_title, phone, id, or any custom user field. |
Holder.attr | A correspondent attribute through a correspondent-link (holder) field — client.city, solicitor.postcode… |
Field|facet | A 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.key | An organisation-wide global (section 17). |
{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.query.named(…, { caseType }) below.| Smart value | Resolves to |
|---|---|
@today, @today+N, @today-N | Today's date (± N days). |
@now, @now+N, @now-N | The 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:Name | A question the runner answers (typed :name in the text view). |
@param:Name.attr | An attribute of the user a User-typed question named — @param:who.email. This is why a question name can never contain a dot. |
@field:Name | Another 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.
@-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.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.
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".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 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".
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.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.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.
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 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.
| Surface | Rows |
|---|---|
| Interactive run page | 1–500 per page (default 50) |
| CSV / XLSX export | 1–10,000 — the run page asks for 10,000, Simple mode takes the 5,000 default |
| Bulk edit from a list | 10,000 |
| Run automation from a list | 200 per run (400 above that) |
| Insight max rows | 1–50,000 (default 5,000) |
| Insight preview | 1–500 (default 50; the run page draws the first 100) |
query.named | Default 500, clamped to 10,000 |
| Auto-routine | 10,000 |
| Shared executor ceiling | 50,000 |
| Interactive scan refusal | More 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.
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".
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.
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.@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.
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.
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.
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:
@me the same user as
the list did.(blank).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.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.
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.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.
# 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.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.
@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.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).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):
job.dryrun.unsupported.@today and
@today-N work).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."
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.
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.
| Status | What it means |
|---|---|
| Queued | Claimed, waiting for a server lane to come free. |
| Running | Executing on a lane now. |
| Succeeded | The executor returned without an error — read the note below before trusting it. |
| Failed | The executor reported an error, every case failed, the run timed out, or a service restart interrupted it. |
| Cancelled | Stopped by a person, or skipped because the job was disabled or deleted while it queued. |
stop('reason') is counted on its own — not a failure — and appears in the
log as "Stopped: reason" with its own icon.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."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.
ask.* prompt fails that case with
"Auto-routines can't show prompts — use ask.* only in manually-run
automations." (section 10)@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.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.
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).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.
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.
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.
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."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.
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.
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 says | Key in tokens, exports and scripts |
|---|---|
| Forename | first_name |
| Surname | last_name |
| Company name | organisation_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_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.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.
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.
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.
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.
@me.hourly_rate, assignment:slot.branch) and
reporting.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.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.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.
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.
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.
Permissions are enforced on the server for every sensitive operation — the menu hiding you see is just convenience on top of that.
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.
| Group | Keys |
|---|---|
| Cases | cases.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 |
| Workflow | workflow.action.email, workflow.action.letter, workflow.action.memo, workflow.action.phone, workflow.action.incoming, workflow.template.manage |
| Correspondents | correspondents.view, correspondents.manage |
| Triage | triage.view, triage.release, triage.archive |
| Tasks | tasks.view, tasks.create, tasks.complete, tasks.reassign, tasks.reschedule, tasks.delete.own, tasks.delete.other |
| Queries & reports | queries.run, queries.manage, reports.run, reports.manage |
| Configuration | casetypes.manage, screens.manage, fields.manage, automations.manage, globals.manage, database.manage, jobs.manage |
| Administration | teams.manage, users.manage, users.activity, roles.manage, settings.manage, collaborators.manage |
| Outlook | outlook.connect, outlook.shared.manage |
| AI Assistant Add-on | ai.use, ai.case, ai.draft, ai.author_templates, ai.author_automations |
| Accounts Add-on | accounts.invoices.view, accounts.invoices.manage, accounts.integration.manage |
| Legal accounts Add-on | cashiering.view, cashiering.post, cashiering.requisition, cashiering.authorise, cashiering.reconcile, cashiering.compliance, cashiering.settings, cashiering.auditor |
| Data Export Add-on | dataexport.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'".
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.
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.
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.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:
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.
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.
/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.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.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.
Organisation-wide switches, in one place.
Where: Manage → Administration → Organisation settings (needs settings.manage)
The page is tabbed:
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.casetypes.manage, not settings.manage — an administrator
holding only settings.manage can open the tab, edit it, and then be
refused on Save.* 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.collaborators.manage held.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.settings.manage and
casetypes.manage, and such a key is an administrator credential —
Design-as-Code applies bypass the grant-escalation ceiling
(section 23).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
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.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.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.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.import.too_large. Split the file.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:
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.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.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:
design-workspace.zip. It is the same content the CLI's pull writes.plan produces, rendered in a dialog: per case type +created · ~updated · −deleted, lint, design-check results, and every planned deletion as a tick box.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).
rangeen-design rollback reaches them.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.
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.
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.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.
code, name or key
inside a file is a delete plus a create.
.design/targets.json in your .gitignore. Keys never
belong in git.
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"
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"
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.
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:
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.
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.
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.
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.
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.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.
A plan carries three kinds of message besides the changes:
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.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.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.
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.currencyMismatch), and the refusal happens before anything
is saved, so the existing connection survives the attempt.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.
SUBMITTED (Awaiting
Approval), so Xero itself will not let it be paid. Dispute is enforced by
status, not by a local flag.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.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.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.Exactly three keys, grouped "Accounts" in the role editor:
accounts.invoices.view, accounts.invoices.manage and
accounts.integration.manage
(section 23).
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.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.*
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.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.
/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.
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.
| Key | Lets the holder… |
|---|---|
cashiering.view | Read every screen in the module. |
cashiering.post | Post receipts, release approved payments, post transfers. |
cashiering.requisition | Raise a payment request (but not approve it). |
cashiering.authorise | Approve or reject a payment request; the only key that can authorise an overdraw. |
cashiering.reconcile | Import statements and run the reconciliation workbench. |
cashiering.compliance | Raise 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.settings | Bootstrap the module, edit the client-money controls, manage the bank-account registry and fiscal periods. |
cashiering.auditor | Read-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.
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.
Every account the firm holds client money in is registered, with a Kind that decides the rules its history was kept under:
| Kind | Postable? | For |
|---|---|---|
GeneralClient | Yes | The general client account. |
DesignatedClient | Yes | A designated deposit account for one matter. |
Joint | No | Recorded for statements and the register only. |
ClientsOwn | No | An account in the client's own name that the firm operates. |
Tpma | No | A third-party managed account. |
OfficeMirror | No | The 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.
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
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.
accounts.period.missing; a closed one is
accounts.period.closed; overlapping periods are refused outright.
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:
| Classification | Effect |
|---|---|
Client | The whole amount posts to the matter's client ledger. |
Mixed | Also posts the whole amount to client — the office element is separated later by a transfer, which is the rule-compliant order. |
Office | Recorded for the trail; nothing lands on the client ledger. |
Paying out is deliberately not one action:
cashiering.requisition or
cashiering.post. Purpose is one of BilledCosts,
Disbursement, ReturnToClient,
SettlementPayment, Other.cashiering.authorise.cashiering.post. Only now does money move.A request moves through Requested → Approved | Rejected → Released, or
Cancelled.
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.
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.
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.
The settings carry a reconciliation cadence (35 days on the UK preset); the compliance job raises the overdue state against it.
Interest row.Low / Medium /
High; status Open / InProgress /
Resolved / Closed.15-exemption-12-2.csv.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.
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.
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.
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.
The case assistant, Code AI and Template AI are the same panel in three modes, and they remember differently:
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.
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.
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.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.External parties see exactly what you share — nothing else.
Where: Manage → Administration → Client Hub (admin) · the Share action on a case
/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.external-profiles.json — and opening and saving that profile in the
app wipes them back to empty.)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.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.
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.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 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.
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.
How the system keeps the right people in and everyone else out.
permission.escalation, while taking privilege away is always
allowed.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.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.Putting it together: a "Debt Recovery" case type from nothing to a working workflow.
Manage → Configuration → Case types → New. Code DEBT, name "Debt Recovery",
multi-user access on.
Manage → Configuration → Data manager, on the DEBT type:
| Field | Type |
|---|---|
| Debtor Name | Text |
| Original Debt | Decimal (2 dp) |
| Interest Rate | Decimal (2 dp, 0–100) |
| Date Instructed | Date (default TODAY) |
| Payment Due Date | Date (default TODAY+30d) |
| Stage | Dropdown — Pre-action / LBA sent / Claim issued / Judgment / Enforcement |
| Debtor | Correspondent (type = Debtor) |
| Payments | Table — columns: Payment Date (Date), Amount (Decimal), Method (Dropdown) |
| Total Paid | Scripted → Decimal (sums the Payments table) |
| Balance Outstanding | Scripted → Decimal (Original Debt − Total Paid) |
| Time On Matter | Time 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}').
Manage → Configuration → Screen studio, on DEBT:
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.
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.
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.
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.
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.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.