This is the complete reference for the people who design and configure CaseOnGo — 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 CaseOnGo, 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, Tenant 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 Queries and Reports. Section 31 walks that path end to end.
Every configuration surface named in this handbook lives in the sidebar, under its grouped heading. Entries appear only for people holding the matching permission (section 23).
The mould for each kind of matter.
Where: Sidebar → Configuration → Case types
000123.001.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.The data model is yours — defined field by field in the Database Management.
Where: Sidebar → Database Management (needs database.manage + fields.manage)
Every field has a Code (its internal short name — letters, digits, underscores and spaces; immutable once created), a display label (editable, and overridable per screen), and a data type (also immutable). Fields can be marked audited (every change stamped with who and when) and GDPR-searchable, and any field can bind an on-change automation (section 6).
| 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. |
| 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 the Database Management. |
billable, hourlyRate,
reason) — templates can sum these for invoicing.get('{FieldName}'); a field named with
spaces uses its exact name, e.g. get('{Original Debt}'). It can
also read globals, linked-case fields and table rows.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)
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. 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: Sidebar → Screen Designer (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 panel. You build a screen by dragging tiles onto a grid. The tile kinds are:
| Tile | Places |
|---|---|
| Field | A case field as an editable input. Also used for linked fields, embedded documents and correspondent holders. |
| 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 | A static uploaded image. |
| Correspondent attribute | One detail of a linked correspondent (name, email, address…). |
| 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. |
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 (Database management), which runs when the case is saved.parentScreenCode in the screen's file — declarative, so an
absent key moves the screen back to top level.A screen can also bind an automation to run on submit — used together with
the screens.open(...) verb (section 9).
On phones (<600px) a designed screen re-flows automatically into a single
column in reading order — you don't design a separate mobile layout.
A pinned summary, and the built-in properties every case has.
{assignment:slot.attribute};
tasks 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)
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:
| Trigger | Fires | Can block? |
|---|---|---|
| When a case is being created | Right after a new case is created — prefill values, greet the creator. The interactive New-case path can prompt; other create paths run quietly. | No |
| When a case is opened | When a case is opened in the workspace — once per open (not re-fired while the case stays in the open-cases rail). | No |
| Before Save | Before a save commits. get() still reads the OLD values; changes.next() reads what's being saved. stop('reason') rejects the whole save. | Yes — blocks the save |
| After Save | After the save has committed — get() now returns the new data. Good for follow-on work; it cannot undo the save. | No |
| When the user clicks Close | When the user closes the case's card in the open-cases rail — before it closes. | Yes — a failed run or cancelled prompt keeps it open |
| Before marking the case as dead | When marking the case dead (closing it for good). | Yes — a throw or cancelled prompt prevents it |
| On accounts document paid | When a linked accounting document is paid (CaseOnGo Accounts add-on). Always unattended; the document's details arrive as input. | No |
Separately, any field can bind an automation to run on change — it
fires after the save commits when that field's value changed. Inside it,
stop('reason') reverts that field alone to its pre-edit
value while the rest of the save stands. A field that already fired doesn't
re-fire in the same pass, and a save caps out at 50 on-change invocations — so
chains can't loop forever. And any screen can bind an automation to run
on submit. All of these are just ways to fire an automation; the
automation itself is the same kind of script described next.
ask.* and ui.message pause and show to that person
(including collaborators on shared screens). Unattended runs — scheduled
Background jobs, bulk runs, API saves, accounts-paid events — can't prompt: an
ask.* there is skipped and logged. Guard with
user.isSignedIn when one script serves both.An automation is a small JavaScript program that reads and changes a case.
Where: Sidebar → Workflow → 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).
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: about 30 seconds per interactive run,
5 million statements, tables up to 10,000 rows.put() and then get() reads
back the new value. (Whole tables are the exception — see the next section.)http.* call is never repeated.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. | read + write |
{case.ref}, {case.status}, {system.today} | Case & system controls (also case.title, case.casetype, case.created, 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 |
* To change who a correspondent holder
points at, write the correspondent's id to the bare holder token:
put(id, '{Client}').
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.
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.
Everything a script can do, grouped. The editor's Help tab carries the same reference, generated from the live API.
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(msg)Write to the run log (visible in history and the Test Run tab). console.log(...) works too.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. In Before Save, get() still returns the old value — next() is what's being written.changes.fields()The list of changed tokens. Empty outside the two save triggers.userWho's running it: user.id (null in unattended runs — the safe gate), .name, .email, .isSignedIn, .isInternalUser, .isExternalUser (a collaborator), .hasRole(...) / .inTeam(...) (staff only), .externalRole().actor.name / actor.emailShorthand for the person running the automation.correspondent, template, phaseIn a template hook: the recipient, the template being actioned, and whether this is the "Before" or "After" phase.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.ask.confirm(label)Yes/No question → a boolean.ask.text / number / date(label)Prompt for a value.ask.choice(label, [options])Pick one of a list → the chosen string.ui.message(text)Show a note and wait for acknowledgement.ui.viewDocument(attachmentId)Show a case attachment, then resume.ui.openCase(refOrId)Navigate the operator to a case once the run ends.screens.open(name)Open a screen as a data-entry dialog mid-run → true if saved.stop('reason')A clean, deliberate halt — not an error. Before Save: rejects the save. A field's on-change: reverts just that field. 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 trail. Work already queued before the stop is not undone; a throw is a crash logged red, by contrast.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, 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').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 (a user's email, or an assignment-slot code — a slot binds the task to whoever holds the slot), recipientRole (a Correspondent field name), description.tasks.cancel({…filters})Delete matching open tasks. Filters: actionType, recipientRole, templateCode, assigneeEmail, assignmentSlot, titleContains, caseRef. Always pass a filter — with none it cancels every open task on the case.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 (displayName, email, addressLine1…) plus custom for the type's own. Linking to the case stays a separate put(c.id, '{holder}').correspondents.update(idOrNumber, {…fields})Edit the directory record (every linked case sees it). 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.cases.open(refOrId)Open another existing case → a handle with fields.get/set and save(), or null.cases.create('CaseTypeName', {reference, sibling?})Create a case (or a sibling case 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?})Close this case (or another).automation.run('CODE', args?)Run another saved automation inline (depth cap 5); the callee reads args. 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 questions; 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 — only hosts on the organisation's allow-list (Tenant settings → Outbound HTTP). Each request runs exactly once per run; the response is { status, body, data, headers, truncated }, and failures are catchable with try/catch.dates.today / now / addDays / addMonths / diffDays / format(…)Date maths and formatting.helpers.money / pad / upper / lower / trim / isBlankSmall formatting helpers.With CaseOnGo 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 when the value changed. | When a person saved |
| A screen button | Place a Button tile; it runs the automation you pick — for staff, and for collaborators on shared screens. | Yes |
| A screen's on-submit | Bind it on the screen; runs after a screens.open submit. | — |
| A Run Automation task | Create a task of kind "Run Automation"; actioning it runs the script. | Yes |
| A template Before / After hook | Bind it on a template; Before can cancel the action (stop), After is best-effort. | Yes |
| A scheduled Auto routine | A Routine pairs a saved List with an automation on a cron schedule (section 20). | 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), 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), they don't — an
ask.* there is skipped and logged with a clear error. Write quiet
automations to decide everything from the data, and gate mixed-audience scripts
with user.isSignedIn / user.isExternalUser.
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.
The editor writes code for you — and never lets you save broken code.
if,
loops, comments — is just JavaScript; the palette only covers the platform
actions you couldn't type yourself.)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.cases.bulk).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
});
}
}
// 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.');
}
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');
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: Sidebar → Workflow → 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}). 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 include displayName (alias name),
organisationName, contactPerson, email,
phone, mobile, addressLine1/2,
address_block (a multi-line postal block), city,
postcode, fullAddress, plus any custom field on that
correspondent type.
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 date 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.{status|code} / {status|description}
/ a custom column name picks which column shows (Description is the default).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. 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.
Turn a case's rows into a table in a letter or email — including running totals.
A Table View is a named, reusable definition scoped to a case type
(Workflow → Table views). 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.
One template library per case type — the Workflow tabs.
| Tab | Backed by |
|---|---|
| Memos | Rich text authored inline — an internal note-to-file. |
| Letters | A Word (.docx), Excel (.xlsx) or PDF-form file, rendered per case. |
| 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 letters. |
| 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 — 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 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 round-trip: open locally, save, re-upload to land a new version.
embedInto overrides stay allowed —
they are designer-authored).A template can run an automation before it's actioned (a
stop('reason') there cancels the action — useful for validation) and
after (best-effort — a failure is logged but never rolls back the
correspondence). Point each at a saved automation. These hooks are the one place
a script sees the action's correspondent, template and
phase.
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 Background jobs, the sender comes from the job server's Sends email as setting (section 20).
A Letter head is a Word document holding your firm's header and footer (logo, address bar, page setup). Any letter template can pick one; at render time CaseOnGo transplants the letter head's header/footer onto the letter and inherits its page size and margins, leaving your authored body untouched — "apply the branding, keep my content".
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.
Organisation-wide values, defined once.
Where: Configuration → Global variables (edit needs globals.manage)
{global.key}, and in an
automation as get('{global.key}') /
put(value, '{global.key}'). Queries can filter on
global.key too.Saved questions over the caseload — build once, run forever.
Where: Queries → Manage queries (build, needs queries.manage) · Queries (run, needs queries.run)
A List targets one case type and has an immutable Code, a name, criteria,
display columns, a multi-field sort, an Include closed cases flag (closed
cases are excluded by default) and an Active flag — inactive Queries 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 to
any depth, and any criterion or group can be negated with Not — so shapes
like not (A or B) and C are expressible.
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).
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
(comma-separated, case-insensitive). 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, case.title, case.status, case.casetype, case.created, case.createdby, case.modified, case.closed | The built-in case controls. |
assignment:slot.attr | The user in an assignment slot — name, email, job title, or a custom user field. |
Holder.attr | A correspondent attribute through a holder field — client.city, solicitor.postcode… |
global.key | An organisation-wide global. |
@field:Name | Another field on the same case (field-vs-field comparison). |
| 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). |
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, the literal word "today" stays
literal.
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. A Detect button auto-declares keys the criteria already reference.
The run page shows Case key, Title and Status plus the List's columns —
every header sorts; pages are 25–500 rows. Results are access-filtered: a
List never returns a case the runner isn't allowed to see, and neither do its
exports. Export to Excel or CSV (all matches or the selection, up to
10,000 rows), apply a Bulk edit (set one field across the selection —
needs cases.bulk), or Run automation across the matches.
Automations reach the same engine with query.named
(section 9) — including Queries on other case
types.
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 List, presented properly.
Where: Reports → Manage reports (build, needs reports.manage) · Reports (run, needs reports.run)
An Insight binds a saved List and adds presentation:
Whoever runs it answers the List's questions. An Insight can be scheduled
as a Routine (next section) that renders the file and emails it to a recipient
list — the first recipient in To, the rest blind-copied so the list never leaks.
One caveat for schedules: @me has no meaning in an unattended run
(there's no signed-in user), so a scheduled Insight built on a "my cases" List
matches nothing — give the List a User question instead and fix its value on the
schedule.
Scheduled work, defined and watched in the open.
Where: Sidebar → Background jobs (needs jobs.manage) — two tabs: Jobs and Servers
You can create three kinds of job (the editor offers them exactly so):
@today /
@today-N).Every job has a Code (immutable), name, an enabled toggle, and a cron schedule (five fields, evaluated in UTC, with quick-picks like "Daily at 08:00" and "Weekdays 08:00"). A blank schedule means on-demand only. Run now queues the job immediately on the free on-demand lane and toasts the outcome ("3 matched, 3 succeeded, 0 failed"). Run history shows the newest 50 runs — started, trigger, which server it ran on, duration, status, matched/OK/failed — and each run expands to a per-item log. Deleting a job asks you to type its code and removes its history with it.
Accounting sync and Data export jobs are created by their add-ons — you can rename, reschedule and enable/disable them, nothing more.
automations@yourfirm) so unattended email stays separate
from personal mailboxes. Note that such an account is an ordinary licensed
user (section 22).The organisation-wide directory of everyone cases deal with.
Where: Sidebar → Correspondents · types under Configuration (needs correspondents.manage to edit)
Who's in, how they're grouped, what they may do.
Where: Sidebar → Configuration → Users / Teams / Roles
@me.hourly_rate, assignment:slot.branch) and
reporting.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.
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.
| 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 |
| 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 Inbox | 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, roles.manage, settings.manage, collaborators.manage |
| Outlook | outlook.connect, outlook.shared.manage |
| CaseOnGo AI 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 |
| 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 Collaborate without being a Hub administrator
(that's collaborators.manage); rescheduling a task is its
own key, separate from creating one; task deletion is split into "own"
and "others'". The add-on groups appear in the matrix only when the matching
add-on is enabled for your organisation (enabling add-ons is a plan matter, not
a setting).
Case passwords come as a pair: cases.protect is the lock action
inside a case — set a password, change or remove it from in there. Everyone
(the setter included) then enters that password each time they open the case, and
the server refuses the case's contents until they do. cases.protect.manage
is the oversight power: the Protected cases screen listing every protected
case with who set its password and when, able to reveal, change or remove
any of them without knowing the current one — the recovery path when a password is
forgotten. Passwords are deliberately rule-free (whatever was typed, verbatim),
stored encrypted, kept out of history entries, and every set / change / removal is
written to the case's audit trail.
Organisation-wide switches, in one place.
Where: Configuration → Tenant settings (needs settings.manage)
The page is tabbed:
http.*. Empty (the default) means outbound HTTP is off; internal
/ private addresses are always refused.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.CaseOnGo keeps automatic versions of a case type's whole design (its fields, screens, templates, automations and settings) as you change it. From the case type you can:
Your whole tenant design as files — edit, review, apply, roll back.
Where: Tenant settings → Design workspace (keys) · the caseongo-design command (your machine)
Everything you can design in the portal — case types, fields, screens, templates, automations, Queries, Reports — 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 develop a design against a test tenant and release it to production.
npm install -g <the caseongo-design package we supply>
caseongo-design --version # proves the command 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:
caseongo-design init --tenant uat --url https://api.caseongo.com --key <uat read+plan key> --save-key
# every tenant you distribute to:
caseongo-design init --tenant prod --url https://api.caseongo.com --key <prod read+plan key> --save-key
caseongo-design pull # the design arrives as files
git init
# put the line .design/targets.json in .gitignore — keys never enter git
git add -A ; git commit -m "baseline"
caseongo-design pull # start from the live design
# …edit files (or let your AI assistant propose edits)…
caseongo-design validate # structural checks, offline
caseongo-design plan # exact dry-run diff against the tenant — read it
caseongo-design test ONB SETSTART --case ONB00012 # try an automation, no writes
caseongo-design apply --key <owner apply key> # land it
git add -A ; git commit -m "escalation feature"
plan --tenant prod, read the diff
(prod may legitimately differ), then apply --tenant prod with the
prod apply key. Deletions only run with explicit --confirm tokens
the plan prints.caseongo-design status answers "is my workspace current?"caseongo-design rollback undoes the last apply (per case type or
whole-tenant scope), and design versions (section
25) remain your in-portal safety net.Invoices, bills and credit notes — kept beside the cases, posted to Xero.
Where: Sidebar → Documents (Xero) / Xero settings (when the add-on is enabled)
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,
void, attach and documents().
Optional writing and answering help — never an actor on its own.
CaseOnGo 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 email and letter compose dialogs; a template assistant that drafts letter and email templates in the Workflow editors; a document generator for producing template documents; and Code AI in the automation editor, which writes and explains automation script against the real API. Usage draws on a monthly credit pool, and an AI usage & credits page in the Manage menu shows the balance and recent activity. 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).
External parties see exactly what you share — nothing else.
Where: Sidebar → Collaborators (admin) · the Share action on a case
cases.share covers sharing the case in front
of you; collaborators.manage owns the whole Hub (accounts,
invites, every share). The "shareable up to authorisation level" setting
(section 24) can let sharers offer screens above their
own level.user.isExternalUser tells your script who's driving).Your invoice carries one line for collaborator usage: the number of external accounts that actually signed in and did something during the month. Unused invites and idle accounts cost nothing; failed logins and password resets don't count. Tenant settings → Collaborators 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.
Putting it together: a "Debt Recovery" case type from nothing to a working workflow.
Manage → Case types → New. Code DEBT, name "Debt Recovery",
multi-user access on.
Manage → Database Management, 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 → Screen Designer, 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 → Workflow → 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:
if (Number(get('{Balance Outstanding}')) > 0) {
ui.message('Cannot close — a balance of ' +
get('{Balance Outstanding|currency_gbp}') + ' is still outstanding.');
throw new Error('Balance outstanding'); // a throw here keeps the case open
}
Build a List "Overdue debts" (Payment Due Date < @today AND Balance
Outstanding > 0, with an optional Stage question), and an
Insight on it grouped by Stage with a Sum of Balance Outstanding and a bar chart.
Then add an Auto routine — Manage → Background jobs → New job — that runs
"Overdue debts" every morning at 08:00 and fires a "chase" automation on each
match; press Dry run once to see exactly which cases it would touch.
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.