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.
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: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: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:
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:
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 off →
entity["X"]returns one value: anEntity(REFERENCE),str(SELECT, STRING),datetime(DATETIME),int/float(NUMBER),bool(BOOLEAN), and so on. Read it directly — do not index with[0]. - Allow multiple on →
entity["X"]returns alistof those values. Iterate, index, or aggregate over it.
[0] is a common mistake. It fails differently depending on type — and one mode is silent:
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:
entity["..."]); a bare field.value read in a warning-only formula is rejected because it leaves older-version instances pinned to stale triggers.
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.
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.
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’sDonor reference. The formula sits on the child’s Donor REFERENCE field:
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 imports — import is blocked.
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.
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
importstatements — includingfrom 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 = ..., andentity.status_tag = ...are rejected at lint time. Title and status tag have their own pseudo formulas.
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."none", "primary", "success", "warning", "danger".
The bare-expression sugar and explicit return work for pseudo-fields too — both compile to the same write:
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 everyentity["X"]readonReferenceChange— for every REFERENCE field whose contents are reached (entity["Ref"]["X"]orseal.*(entity["Ref"], …))onReferencedByChange— whenentity.referenced_byis 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: notrigger_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.warningslot 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.
- “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
importorfrom x import y. - “Multi-statement formula must end with
field.value = ...orreturn” — 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.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 — askip()guard, afield.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 richerwhere=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.
- 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).
- The modal opens read-only with a “no longer supported” banner at the top.
- 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.
- 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.
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).