#Entities & Data Model
Entities are the records your application works with — a vessel, a task, a customer. Every entity belongs to an entity type, which pins its shape to a JSON schema and declares which attributes are queryable. Entities connect to one another through relationship rules (e.g. Project → Task). See Core Concepts for the mental model.
Authenticate with a Personal Access Token (HTTP Basic auth) — enter it once in the Authorize bar at the top of this page and every Try it below uses it. Each example body is pre-filled; the console targets staging by default. Full schemas and field docs are in the linked API reference.
#Build a data model
The flow is always the same: schema → entity type → relationship rule → entities → links.
#1. Define a JSON schema
A schema describes an entity's attributes. Reuse one schema across many types.
POST/schema/schemasRegister a JSON schemaAPI docs ↗Try it
#2. Create an entity type
The type binds a name to a schema and declares config.identifier_key — the
attribute path(s) that derive an entity's stable business key, computed
server-side on every write — and config.indexed_attributes — the
attributes that get a real index, so filtering, sorting, and searching on them
stays fast at scale (see
Entity type config & query capabilities
for the full contract). A type is draft until you activate it; only active
types accept entities.
POST/entity/entity-typesCreate an entity typeAPI docs ↗Try it
#3. Add a relationship rule
A rule declares a directed connection between two types and its cardinality
(max_from_cardinality / max_to_cardinality; null = unlimited). Cardinality
is enforced when links are written.
POST/entity/relationship-rulesCreate a relationship ruleAPI docs ↗Try it
#4. Create entities
attributes are validated against the type's schema. identifier_key is never
sent by the caller — it's computed server-side from config.identifier_key
(here, the key attribute) and returned on every response. A create whose
computed identifier_key collides with a live entity returns 409 — use the
upsert path instead.
POST/entity/entitiesCreate an entityAPI docs ↗Try it
#5. Link entities
Link two existing entities via a rule. on_cardinality_violation chooses what
happens when the link would exceed a rule's cap:
| Value | What happens |
|---|---|
raise |
Default. Refuse the write with 409 "Cardinality constraint violated". |
replace |
Soft-delete the oldest existing link to make room, then write this one. |
ignore |
Skip the write and carry on. A single create answers 422 naming this. |
POST/entity/relationshipsLink two entitiesAPI docs ↗Try it
#Read & query
POST /entity/query is the read surface for anything non-trivial. The body is a
filter tree plus sort, pagination, and relationship includes. Cross-tenant rows
you can see through a share come back tagged source_type: "shared".
POST/entity/queryQuery entitiesAPI docs ↗Try it
- Path conventions:
["name"]/["id"]/["created_at"]/["updated_at"](system fields);["attributes", "x"](an attribute, indexed or not — see below);["relationships", "<TypeName>", "attributes", "x"](filter through a relationship, resolved in either direction);["relationships", "rule", "name"](filter by the connecting rule's name instead of the related type). - Pagination is keyset by default (
cursor, from a previous response'snext_cursor); offset (page/limit) is the escape hatch — the two are mutually exclusive.includeloads one hop of related entities by related type name (the engine resolves the connecting rule(s), in either direction); narrow to a specific rule withinclude_specs.rule_name.
#Filter operators
Every particle filter is { "operator": ..., "path": [...], "value": ... },
combined with a composite
{ "operator": "and" | "or" | "not", "filters": [...] } (nestable up to 25
levels deep).
| Operator | Applies to | Meaning |
|---|---|---|
eq / neq |
any | Exact (in)equality. Text comparison is case-sensitive. |
is |
any | Null check (value: null) or boolean check (value: true/false). |
gt / gte / lt / lte |
numeric, timestamptz, text | Range comparison — numeric/date ordering for those types, lexicographic for text. |
in |
any | Value is one of an array of scalars. |
like / ilike |
text | Pattern match with % wildcards. like is case-sensitive, ilike is not; both are accent-insensitive. |
segment |
text | Matches a value stored as a pipe-delimited string (|seg1|seg2|) — e.g. filtering identifier_key by one segment. |
fuzzy |
text | Typo-tolerant match (word-similarity scoring). Optional threshold (0–1, default 0.6). Needs searchable: true on the attribute for real similarity scoring — see below. |
cs |
text_array | Contains-all: the array attribute contains every value in value. |
ov |
text_array | Contains-any: the array attribute shares at least one value with value. |
exists |
relationship | {"path": ["relationships", "<TypeName>"]} — entity has at least one relationship of that type. |
rel_exists |
relationship | Like exists, but with a nested value filter that every leaf must match on the same related row (a conjoint-row predicate — different from ANDing several ["relationships", ...] filters, which can each match a different related row). |
// "Vessels with an expired Ship Safety certificate" — conjoint on ONE cert row
{
"operator": "and",
"filters": [
{ "operator": "gt", "path": ["attributes", "dwt"], "value": 10000 },
{
"operator": "rel_exists",
"path": ["relationships", "VesselCertificate"],
"value": {
"operator": "and",
"filters": [
{
"operator": "eq",
"path": ["attributes", "type"],
"value": "Ship Safety Certificate"
},
{
"operator": "lt",
"path": ["attributes", "expiry_date"],
"value": "2026-05-25"
}
]
}
}
]
}
#Relationship includes: include vs include_specs
include is the simple form — an array of related entity type names,
loading one hop with no further control. include_specs is the object form for
the same one hop, with per-relationship filter, sort, limit, and optional
rule_name narrowing (when two rules connect the same pair of types):
{
"type_name": "Task",
"include_specs": [
{
"rule_name": "ProjectHasTask",
"type_name": "Project",
"filter": {
"operator": "eq",
"path": ["attributes", "status"],
"value": "active"
},
"limit": 1
}
]
}
/entity/query only hydrates one hop — an include_specs entry with its
own nested include is rejected (422) on this endpoint. For a whole related
subtree in one call, use /entity/query-graph below, whose include accepts
the same per-entry filter/sort/limit plus nesting.
#Distinct values vs. distinct rows
Two different tools, easy to conflate:
distinct_on(a field on/entity/queryand/entity/query-graph) — SELECT-DISTINCT-ON semantics: one representative entity per unique combination of up to 5 attribute paths, e.g.{"paths": [["attributes", "region"], ["attributes", "status"]]}. An optionalsortsub-object picks which row wins each group (default: most recently updated).POST /entity/query/distinct— a separate endpoint returning distinct values of a single attribute ({"type_name", "attribute_name"}), for building filter dropdowns or autocomplete. The attribute must be indexed.
#Deep relationship reads — /query-graph
/entity/query reads one hop deep. For a whole related subtree in a single
call, use POST /entity/query-graph — the same type_name / filter / sort
/ pagination shape, plus a nested include tree (each entry can itself carry
include, filter, sort, limit, to any depth up to a server cap). Instead
of a row-per-parent shape, the response is one deduplicated graph: roots
(matching entity ids), nodes (every reachable entity, keyed by id — a node
shared by two branches is serialized once), and edges. This is the REST-native
way to do a graph-shaped read without GraphQL, and it's the surface most
integrations reach for by default.
POST/entity/query-graphQuery a graph of entities (deep hydration)API docs ↗Try it
If the node budget constrains a deeply-nested branch, page_info.truncated is
true and page_info.truncations reports which branch and at what depth —
narrow that branch's limit, prune it, or paginate the roots and re-issue.
For a GraphQL surface over the same access rules (selective fields, Relay
Connections, codegen) see Querying & GraphQL. The
GraphQL playground also has a converter that turns a query into a workflow
QueryEntity step.
#Entity type config & query capabilities
config.indexed_attributes (set at entity-type creation, patchable later) is an
array of up to 20 entries, each:
{
"path": ["due_date"],
"type": "timestamptz",
"filterable": true,
"sortable": false,
"searchable": false
}
type—text|numeric|timestamptz|boolean|text_array. Governs which operators apply (the table above) and how the value is cast for comparison.filterable(defaulttrue) — gives the attribute a real index for filtering. Filtering itself is not gated on this — any attribute in the type's JSON schema can be used infilter, indexed or not; a non-indexed attribute is matched via a JSONB fallback that's correct but unindexed (slower at scale, and not accelerated by a partial or composite index). Mark an attributefilterable(or just indexed) once you actually query it often.sortable(defaultfalse, max 10 per type) — required for reliablesort. A non-indexed or non-sortable attribute path insortis not rejected outright, but falls back to an unindexed text comparison — numbers and dates sort lexicographically in that fallback ("10"before"9"), not numerically/chronologically. Always mark an attributesortablebefore sorting on it.searchable(defaultfalse, max 10 per type,textonly) — makesfuzzya real typo-tolerant word-similarity match on that attribute (and speeds uplike/segmenttoo). Without it,fuzzyon that attribute silently degrades to a plain substring check (no typo tolerance).- A path not listed in
indexed_attributesat all still works forfilter(JSONB fallback) but not reliably forsort, not forfuzzy's real similarity behavior, and not for/entity/query/distinctorcs/ovfast paths — index anything on the querying hot path.
Alongside indexed_attributes, two type-level flags tune search and sort:
sort_by_updated_at(tri-state boolean) — opt a type into fastsort: [{"path": ["updated_at"], "direction": "desc"}]at scale.trueensures it,falsedrops it, omitted leaves it as-is. Turn it on for types whose UIs list "most recently changed first" over large row counts.name_search(tri-state boolean) — opt a type into fastlike/ilike/fuzzyon the top-levelnamefield (as opposed to an indexedattributes.*). Same tri-state semantics. Turn it on when you search entities by name over large row counts.
Count strategy at scale. count_strategy: "estimate" (on /entity/query
and /entity/query-graph) returns a fast, per-tenant row estimate — it can be
slightly stale but is scoped to your tenant, not the whole store. Use it when a
UI needs an approximate total quickly; use "exact" only for small result sets.
An attribute path that doesn't exist at all (typo, or never in the schema) is not validated away — a filter on it just matches nothing rather than erroring. Double-check spelling before assuming a query is "empty."
#Bulk upsert & import
POST /entity/entities/upsert writes a large batch in one transaction (capped
per request — see the reference). Each row matches an existing entity on its
computed identifier_key (derived from attributes.key per the type's
config above) — never a caller-supplied identifier_key. Useful options:
skip_unresolved_relationships (skip + log an inline relationship whose
target_identifier doesn't resolve, instead of failing the batch),
merge_on_conflict / merge_cardinality (fold a colliding
identifier_key into the surviving entity), and suppress_events.
on_conflict: "merge"still validates what you SEND. The payload is checked against the type's attribute schema before any existing row is read, so every attribute the schema marksrequiredhas to be present in the request — even one you are not changing and the stored row already has. Merging affects what is written, not what is validated. A partial update that cannot restate the required attributes wantsPATCH /entity/entities/{id}/attributesinstead.
POST/entity/entities/upsertBulk upsert entitiesAPI docs ↗Try it
For files, POST /entity/entities/import streams a CSV (an X-Import-Config
header carries the column mapping + on_conflict strategy); …/export streams
the inverse.
#Update, patch & delete
PUT replaces an entity; PATCH …/attributes deep-merges a partial attribute
update; DELETE soft-deletes the entity and cascades to its relationship
instances. Updates accept the same merge_on_conflict / merge_cardinality
options as upsert.
PATCH/entity/entities/{id}/attributesPatch entity attributes (deep-merge)API docs ↗Try it
#Attach files (artifacts)
Files attach to an entity via a two-step flow: request an upload URL, PUT the bytes to it, then confirm. Artifacts are encrypted at rest and access is gated by the same permissions as the entity.
POST/entity/entities/{id}/artifacts/upload-urlRequest an artifact upload URLAPI docs ↗Try it
To hand a file back to a user, request a download URL for an artifact
(POST …/artifacts/{artifactId}/download-url). The link downloads as an
attachment with the artifact's original filename.
#Temp files
When you need somewhere to put bytes that are not a stored artifact — a generated report, an on-the-fly export, a file a user just picked in an app — use a temp file. Where an artifact belongs to an entity and lives until you delete it, a temp file has no owning entity, its lifetime is capped at 24 hours, and it disappears on its own when that lifetime runs out.
Temp files are produced, not POSTed by hand. Two things create them:
- a job's
uploadTempFileaction, which stores a stream, a raw string or a JSON value and returns the file's id together with a download URL; - an app's
fileform field, where the browser uploads straight into a temp file and the field's value becomes its id (see Apps).
Either way you end up holding a temp file id, and there are three things to do with it.
Read the bytes back. From a job, the downloadTempFile action reads a temp
file into the run's stream context — the counterpart of downloadArtifact — and
as: "text" decodes it in memory instead. Directly over the API:
GET/entity/temp-files/{id}Stream a temp file's bytesAPI docs ↗Try it
The recorded name and media type come back in the X-Temp-File-Name and
X-Temp-File-Content-Type headers. The response's own Content-Type is always
application/octet-stream, so a stored type can never drive how a browser
renders it.
Hand it to someone. Mint a link that downloads without any credentials until
it expires — from a job with the createTempFileDownloadURL action, or
directly:
POST/entity/temp-files/{id}/download-urlMint a credential-free download linkAPI docs ↗Try it
ttl is seconds — 900 by default, 7200 at most, and never longer than the temp
file's own remaining life. The URL is a bearer capability: anyone holding it can
download until expires_at, so treat it like a secret and keep the TTL short.
Keep it. If the bytes need to outlive the window, promote them into a
durable artifact on an entity with uploadArtifact. Nothing else preserves a
temp file — when its lifetime ends it is gone.
#Sharing & access
Sharing your data model and rows with other tenants — definition shares, data shares, and grants — is its own topic. See Shares & Grants.
#API reference
Every entity and schema operation, with full request/response schemas and an interactive console, is in the API reference.