Skip to main content
Formulas are an early-access feature. This page is unlinked from the public docs index for now. Reach out to your Seal contact if you’d like to use them.
A formula is a small Python expression attached to one writable target — a field’s value, the entity’s title, or its status tag. It computes that target from the rest of the entity, the entities it references, and a few helpers.
The editor gives you autocomplete on entity[...], field, seal.*, and the rest of the formula API as you type — no need to memorise field names or helper signatures. You don’t write triggers either. Seal derives them from your code: reading entity["Density"] adds a watch on Density, so the formula re-runs whenever Density changes. You can also press Recompute in the Fields tab summary bar to run every formula on the entity on demand. Inside the formula modal, Preview shows what the formula would produce against the current entity state without persisting or recomputing the rest of the entity — see Preview and commit below.

Concise syntax

A formula is a single Python expression. Three equivalent forms:
Multi-statement formulas must end with field.value = ... or return ... — the bare-expression form is one-liner only.

Formula context vs scripts

Formulas use Python syntax, but they do not receive the same raw entity JSON object as scripts. They receive a formula context that is already focused on field values:
This differs from scripts, where authors usually work with the entity JSON shape and access field data through the fields object. In formulas, entity["Field name"] is intentionally the shorthand for the field’s current value. Field names must match the UI name exactly, including spaces, punctuation, and units. Entity metadata is exposed as attributes, not JSON keys:
Use entity.fields["Name"] only when you need the Field object itself (.value, .type, .warning, and so on). Formula code cannot mutate arbitrary entity fields or properties; the writable target is the target formula slot: field.value / field.warning for a field formula, title for a title formula, and status_tag for a status-tag formula. Read fields by exact UI name. Bracket notation is the common value-read form:
Use entity.fields["Name"] when you need the full Field object (.value, .type, .warning, and so on). entity.fields.get("Name") returns the same Field object, or None when the field is missing:

Single vs multi cardinality

The shape of a field read is decided by the field’s Allow multiple setting:
  • Allow multiple offentity["X"] returns one value: an Entity (REFERENCE), str (SELECT, STRING), datetime (DATETIME), int/float (NUMBER), bool (BOOLEAN), and so on. Read it directly — do not index with [0].
  • Allow multiple onentity["X"] returns a list of those values. Iterate, index, or aggregate over it.
Indexing a single-cardinality read with [0] is a common mistake. It fails differently depending on type — and one mode is silent:
The save-time linter flags entity["X"][0] on single-cardinality fields and refuses the save with a clear error, so the REFERENCE-style mistake is caught before it ships. The SELECT-style silent truncation on cross-entity reads (backref["Decision"][0]) currently still saves cleanly — when in doubt, check the source field’s Allow multiple setting. Writes go the other way: the value you assign must match the target field’s cardinality. Writing a list to a single-cardinality field (or a scalar to a multi field) fails at run time — “Field X does not allow multiple references, but the formula returned 2” — and the stored value is left untouched. The most common cause is forwarding a value from a multi field on a referenced entity into a single field on this one:
Bail out without writing anything:
Mark a value out of spec — auto-clears next run. Read the target field by name (entity["..."]); a bare field.value read in a warning-only formula is rejected because it leaves older-version instances pinned to stale triggers.
Signatures:

group_by= — bucketed aggregation

Pass a field name to group_by to get a dict keyed by that field’s distinct values instead of a single number. One SQL query, one result.
group_by is not supported on seal.pluck, seal.filter, or seal.only. Like where=, the field name must be a literal string.

seal.filter — filtered entity list

seal.filter returns the matching entities as Entity proxy objects, not a scalar. Use it when you need to read multiple fields from each result — pick the first matching item, build a summary list, etc.
seal.filter supports the same where= dict as all other seal.* calls. It does not accept field= or group_by= — those are for scalar aggregates.

seal.only — single-match entity, fails loud on duplicates

seal.only returns the single matching entity from a multi-REFERENCE or backlink set, or None if nothing matches. If the where= clause matches more than one row, the formula fails with a clear error — it never silently picks the first result.
Use seal.only when at-most-one is the actual contract — the cardinality is part of your data model and a duplicate is a bug. Reach for seal.filter instead when many matches are legitimate, or for plain entity["Ref"][0] when “first arbitrary match” is genuinely fine. seal.only does not accept field= or group_by=. Read fields off the returned entity directly: seal.only(...)["FieldName"]. Cap: seal.filter is capped at 500 entities and 512KB of returned entity JSON. If the filter matches too many rows or too much data, the formula run fails with a clear error. Use seal.count, a scalar aggregate, or a tighter where= filter when you do not need full entity objects. Sub-reference fields: Returned entities have the same read contract as entity.referenced_by items — you can read scalar fields, id, title, kind, status. REFERENCE fields return thin proxies whose .id and .version are reliable but where reading a field (results[0]["Donor"]["Name"]) raises KeyError — nested blobs are not pre-materialised. USER fields resolve only when the user is already loaded into the parent formula’s context (typically because they appear on the host entity); otherwise they read as None. If you need fields from entities one hop deeper, use seal.pluck (a separate call) to flatten the values you need. source is either entity["RefField"] (a multi-REFERENCE field) or entity.referenced_by (the backlink set). Anything else — a Python list comprehension, a sliced ref list, a value bound to a variable through a function — is rejected at save time, because the source has to be statically resolvable for the server to know what to precompute.

where= — filter dict

where= is a dict that mirrors the entity surface you already read. Top-level keys are entity properties; nested fields carries field-equality clauses on the aggregated entity. All clauses are AND’d. Values match the pristine form — the same string entity.kind / entity.status reads back — so writing the filter is a direct transcription of the Python you’d use to inspect a single entity.
Supported keys (the equality-friendly subset of the entity surface): Names in "template": "Lot Record" and "type": "Sample" resolve server-side at run time, the same way Neil’s listEntities tool resolves them — pass a name (preferred) or a UUID. If a name doesn’t match anything, or matches more than one entity, the formula run fails with a clear error. The dict has to be a literal — where=variable and where={**partial} are rejected at save time, because the server pre-resolves the filter before the formula runs and needs to see it statically. Why these keys and not “anything in the entity”? Each supported key has a clear equality semantic and a single SQL path, so the filter compiles to one JSONB clause without surprises. Other entity properties (created_at, editors, tags, etc.) need richer operators — date ranges, list containment, comparisons — that aren’t supported here yet. Reach for those? Iterate in Python over a small ref set, or use a script.

When to use seal.* vs iterate

entity.referenced_by materialises at most 100 entity blobs into the runner — meant for display-style iteration (titles, ids), not summing fields across hundreds of related records. The aggregate path doesn’t load any of them; it queries the GIN-indexed backref column directly. Iterating in Python over a backref set with thousands of entries will silently truncate at 100 — seal.* is the only way to span the whole set.

Worked example — equipment usage

A piece of equipment is referenced by every experiment that used it. To compute total hours logged across all experiments where the equipment was actually used:

Worked example — forward a parent ref

A child template inheriting from a parent (via “Create from”) wants to copy the parent’s Donor reference. The formula sits on the child’s Donor REFERENCE field:
For a multi-ref forward (e.g. all materials), the read shape returns a list and the same formula handles it:
Hierarchy hops (entity.created_from, entity.template, entity.type) populate the formula’s value but do not generate auto-rerun triggers — the linter only watches same-entity field reads (entity["X"]) and REFERENCE-field hops (entity["RefField"]["X"]). If you need the child to track later changes to the parent’s Donor automatically, give the child a REFERENCE field pointing at the parent and read through it (entity["Parent"]["Donor"]) instead — that produces an onReferenceChange trigger.

Dates and times

datetime and timedelta are pre-loaded as globals. No importsimport is blocked.
DATE fields read as datetime.date; DATETIME fields read as datetime.datetime (UTC, tz-aware). Writes accept either Python objects or ISO strings.

Entity properties

entity.X (read-only). Any of these can read None when not applicable.

Identity & lifecycle

Timestamps & people

Hierarchy

Relationships

System

Type-only

User

entity.created_by, entity["Reviewer"], etc. return a User: User instances compare equal by id and are hashable — safe to put in sets and dict keys.

Sibling collection

entity.siblings returns a small collection of summaries. Sibling entries are deliberately not full Entity objects — they don’t load the per-entity blob.
Each entry exposes:

Content

entity.content returns a Content for entities that carry a body (templates, instances). The type field discriminates which value is non-None — only one of page / script_code / file_id / vega is populated per entity.

What’s available

Standard Python built-ins work: len, max, min, sum, sorted, range, abs, round, str, int, float, bool, list, dict, set, tuple, enumerate, zip, any, all, …

What’s not available

  • import statements — including from x import y. No third-party libraries.
  • __import__, exec, eval, compile, open, breakpoint, input, exit, quit — blocked builtins.
  • Network and file system.
  • Entity mutation outside the target. entity["X"] = ..., entity.title = ..., and entity.status_tag = ... are rejected at lint time. Title and status tag have their own pseudo formulas.
If you need any of the above, you’re outside formula territory — use a script.

Title and status tag formulas

Title and status tag are pseudo-fields — they don’t appear in the field list, but you can still attach a formula that owns them. Where to set one: open the entity (template or instance), expand Info in the right-side panel, hover the Title or Status tag row, click the menu, and pick Add formula (or Edit formula when one already exists). Pseudo-field formulas write to a bare global — title = ... or status_tag = ... — not field.value. The bare-expression and return sugars work too; both compile down to the same write. While a title formula is set, the title in the header reads as Automated and is no longer manually editable — every save and every dependency change recomputes it. Same for the status tag picker.

Template pseudo-field formulas run on instances

Regular field formulas compute on the template and on every instance. Template pseudo-field formulas are different: title and status_tag formulas authored on a template always compute on instances only. The template’s own title and status tag stay manually editable so the template can keep a meaningful name and status. You’ll still see the fx affordance next to the template title or status-tag cell because the formula is configured there; the formula result is applied when instances are created or recomputed.
Types don’t carry formulas. Title formulas supersede the legacy template-level computed title expression ({{ field.X }} syntax) on entities where the formula is set; the expression keeps working everywhere else and is still the right tool when you want every instance of a template to share a templated title without authoring a formula.
Intent is one of "none", "primary", "success", "warning", "danger". The bare-expression sugar and explicit return work for pseudo-fields too — both compile to the same write:
When a title formula is authored on a template, it runs on instances. Instance indexes such as entity.template_index are therefore None while you are looking at the template itself; they resolve when the formula is evaluated for an instance.

Triggers (auto-derived)

You don’t manage triggers — they’re inferred from the AST every time you save (saving is automatic, ~300 ms after the last keystroke). The linter records:
  • onSpecificFieldChange — for every entity["X"] read
  • onReferenceChange — for every REFERENCE field whose contents are reached (entity["Ref"]["X"] or seal.*(entity["Ref"], …))
  • onReferencedByChange — when entity.referenced_by is read
onCreate and onManualRun are always present. The formula re-runs whenever a tracked dependency changes; press Recompute in the Fields tab summary bar to force a run on demand. Inside the formula modal, Preview evaluates the current draft against the entity without dispatching a real run — see Preview and commit below. Lifecycle triggers (onSchedule, onPublish, status changes, etc.) aren’t available for formulas — use a script when you need scheduled or status-driven work.

Preview and commit

While editing a formula in the modal, Preview evaluates that one formula against the entity’s current state and shows the result directly in the field cell — the same renderer used for real computed values. It’s a dry run: no trigger_runs row is recorded, no downstream formulas re-execute, and the entity blob is not touched. Use it to iterate on a draft quickly without paying the cost of a whole-entity recompute. Saving is implicit. The formula source auto-saves as you type (~300 ms after the last keystroke). When you close the modal, Seal checks whether anything changed compared to when you opened it; if it did, the entity recomputes automatically. Closing without edits (pure inspection, or type-and-revert) does nothing — the preview cell state clears and the entity is left untouched. Notes:
  • Preview reads sibling field values from their persisted state. If another field on the same entity also has an unsaved formula edit in another modal, Preview won’t see those unsaved changes — it sees what the server has right now.
  • Title and status-tag pseudo-fields preview into the title / status-tag area at the top of the entity. Errors there surface as a toast (no field.warning slot to render into).
  • The Fields-tab Recompute button is still the explicit “run every formula on this entity now” gesture. Use it when you want to force a fresh run without opening the editor.

Debugging

print() output is captured into the run’s console output. Click Last run in the summary bar to see the latest run’s status, console output, and per-field outcome. Tracebacks land there too.
Common lint errors:
  • “Dynamic field lookups not allowed”entity[some_var]. Field names must be string literals so the linter can record reads at save time.
  • “Imports are not allowed” — top-level import or from x import y.
  • “Multi-statement formula must end with field.value = ... or return — multi-line script with a trailing bare expression is ambiguous.
  • “CYCLE_DETECTED” — two formulas on the same entity transitively depend on each other. The error message names the cycle.

Common patterns

Guard against missing inputs

Out-of-spec warning

Roll up a child quantity

Days since publication

Forward a reviewer

Share computed state between formulas via a sibling field

Each formula has its own scope — there’s no shared variable across formulas on the same entity. When two formulas need the same intermediate result, compute it once into a dedicated field, then read it from the others. The intermediate field can be hidden from page content if it’s purely internal.
The reader’s entity["Total quantity (mL)"] registers a watch, so the display field recomputes whenever the canonical one does.

Authoring guidance

  • Prefer the bare expression for one-liners. When the whole formula is a single expression, write the expression and drop field.value =. Reserve the explicit form for multi-statement logic — a skip() guard, a field.warning, branching.
  • Keep formulas simple. A formula that needs intricate Python, or in-Python iteration over hundreds of items, is usually working around a missing primitive — a seal.* helper, an entity property, or a richer where= operator. Prefer raising that gap over a clever workaround; formulas should stay readable.
  • Share a value through a field, not duplication. When two formulas need the same intermediate, compute it once into a dedicated field and read it from the others — see Share computed state between formulas.

Migrating legacy formulas and out-of-spec expressions

Per-field formulas replace the older legacy formulas (math.js expressions on the dedicated Formula field type) and out-of-spec expressions (math.js predicates configured on any field). Both surfaces are retired going forward. What you can and can’t do under per-field formulas
  • New legacy formulas and new out-of-spec expressions can no longer be created — the Formula option is removed from the new-field menu, and the “Add out of spec criteria” menu item is hidden on fields that don’t already have one.
  • Existing legacy formulas and out-of-spec expressions on templates and instances are view-only. Opening one shows the original math.js expression for reference; the body cannot be edited.
  • A new Migrate action replaces editing. It’s the only forward-going change you can make to a legacy formula.
Running the migration Migration is a template-level change and follows the standard change-set flow — open a draft on the template, run the migration there, publish.
  1. On the template (in a draft / open change set), open the field’s three-dot menu → Configure field (for legacy Formula fields) or View out of spec criteria (for OOS expressions).
  2. The modal opens read-only with a “no longer supported” banner at the top.
  3. Select the output type. For legacy Formula fields, an icon-row picker just above the footer lets you select the type the migrated field should produce (Number, Text, Checkbox, Date, Time & date, JSON) — defaulted to the type the formula was already producing. Out-of-spec migrations on non-Formula fields keep the field’s existing type — no picker.
  4. Click Migrate in the footer. The legacy expression is cleared and the formula is written in the same change-set entry — atomic, no intermediate state. The new formula seeds with the legacy expression wrapped via legacy_formula(...), so behaviour is preserved out of the box; open it from the formula editor afterwards to rewrite as idiomatic Python whenever you’re ready.
When the change set publishes, new instances created from the new template version pick up the migrated formula and compute their values via onCreate. Instances created from earlier template versions keep their old field shape and their previously-computed values — the migration does not retroactively re-evaluate or rewrite them. About legacy_formula(...) The wrapper is a transitional bridge — it runs your original math.js expression server-side and returns the value to the surrounding Python. You can keep using it indefinitely while you learn the per-field-formula syntax, then rewrite the body in idiomatic Python at any time (the per-field formula editor accepts both forms).
The two produce identical results for the same inputs, so existing instances and dashboards continue to show the same values.