Skip to content
Talk to our solutions team

Remote entities

A remote entity has no table. Every operation against it is turned into exactly one outbound HTTP request, and the response body is mapped back into rows. The engine compiles no SQL for it: joins, ORDER BY and LIMIT belong to the upstream service, and so do the row-level and field-level rules for its data (see Security).

Set materialization: remote on the entity and add a remote: block.

entities:
- name: shipments
materialization: remote
fields:
- name: id
type: string
- name: reference
type: string
- name: status
type: string
access:
actions:
read: { language: expr, expression: "true" }
create: { language: expr, expression: "true" }
remote:
name: carrier
base_url: https://carrier.example.com
timeout: 5s
auth:
type: bearer
token: ${secret.CARRIER_TOKEN}
getlist:
pre:
template:
url: /v1/shipments
method: GET
headers:
X-Account: "{{user.tenant}}"
post:
expression: .data.items
create:
pre:
template:
url: /v1/shipments
method: POST
body: |
{"reference": "{{payload.data.reference}}"}
post:
expression: .data
KeyTypeDefaultMeaning
namestring—Short identifier for the remote system. Logs and metrics only.
protocolrest | graphqlrestgraphql sends every operation as a POST unless its template names a method, and takes request values as variables. See GraphQL.
base_url (alias baseurl)string—Endpoint root. Relative template URLs are joined onto it.
pathstring—Fallback URL used when an operation’s template sets no url.
timeoutduration string10sPer-request wall clock. Unparseable or non-positive values fall back to the default.
authmap—Credential scheme. See Auth.
create get getlist update deletemap—Per-operation request template and response expression.

The block is decoded strictly: any other key, including a misspelling of one of these, fails the load and names the line. materialization: remote and a remote: block go together, and the load fails when either one is declared without the other.

BlockDispatched forDefault method
createCreatePOST
getRead pinned to a single primary-key valueGET
getlistEvery other readGET
updateUpdate, and RestorePUT
deleteDeleteDELETE

An operation block takes pre.template and post.expression. post.expression is compiled at load, so an expression that is not valid jq fails the load. An operation block with neither pre nor post is dropped at load. Attempting an operation whose block is missing fails at request time; a remote entity never falls back to SQL.

Restore maps onto update; there is no separate restore block. The soft-delete rewrite the SQL path performs does not happen here, so ?purge=true reaches nothing and the upstream service decides what a delete means.

  1. The operation template’s url. A template written as an absolute URL (http://, https://) is used as written; any other template is relative. Whether a URL is absolute is read from the template, so a substituted value never makes it one.
  2. An empty url falls back to remote.path.
  3. A relative result is appended to base_url with its trailing slash trimmed.
auth.typeHeader sent
bearerAuthorization: Bearer <token>
apikey<auth.header>: <token>, header defaulting to X-API-Key
basicAuthorization: Basic <base64(user:password)>
anything else, or absentnone

Matching is case-insensitive. An empty credential sends no header at all rather than an empty one. token, user and password expand ${secret.NAME} only; they are substituted with no bindings attached, so a {{…}} reference inside a credential always renders empty. A secret resolves through the vault in the scope of the requesting tenant, so the same ${secret.CARRIER_TOKEN} is each tenant’s own token when each tenant has one. A secret that cannot be resolved (no vault configured, a lookup error, an empty value) refuses the call rather than sending it without the credential.

url, body and every header value are filled from the request before it is built. A request carrying a non-empty body and no explicit Content-Type header is sent as application/json.

FormResolves to
{{payload.data.<field>}}The mutation payload after the pre-execute hooks have shaped it.
{{payload.id}}The primary-key value the engine was able to recover.
{{filter.<field>}}A value from the request’s filter map.
{{user.id}}The calling user’s id.
{{user.tenant}}The calling tenant’s key.
{{user.roles}}The calling user’s roles, as a JSON array: ["clerk","manager"].
{{entity}}The entity name.
${secret.NAME}The secret from the vault, for the requesting tenant. Bodies, headers and credentials only: a secret in url refuses the call, since URLs are logged.
${env.NAME}Refused. The process environment holds the service’s own secrets; the schema fails to load, naming where the reference sits.

The template’s own text fixes the shape of the request. A value fills a slot in it and is encoded for where that slot sits, so a value can never add a JSON member, a query parameter, a path segment or a header.

SlotEncoding
URL path, before the template’s first ?Escaped as one path segment. A value that is empty, . or .., or that contains a . or .. segment, refuses the call.
URL query, after ?Escaped as one query value.
JSON body, inside a string literal: "{{payload.data.name}}"Escaped as string content.
JSON body, standing alone: {{payload.data.qty}}One whole JSON value of the value’s type: a number, a boolean, a string, an array, an object, or null.
Form body (Content-Type: application/x-www-form-urlencoded)Escaped as one form value.
Header valueInserted as text. A value with a line break refuses the call.

A JSON body template must render to valid JSON; a template that does not refuses the call. A body of any other content type takes secrets only, and a request value in it refuses the call.

A slot whose path resolves to nothing renders as null when it stands alone in a JSON body, and as an empty string in a JSON string, a query value or a header. Test each template with a real payload, and declare a required check at the owning service for any value it depends on.

With protocol: graphql, the query is part of the declaration and request values travel as variables. A request value inside the top-level query member, or inside each query of a batch, refuses the call.

remote:
name: carrier
protocol: graphql
base_url: https://carrier.example.com
get:
pre:
template:
url: /graphql
body: |
{
"query": "query($id: ID!) { shipment(id: $id) { id status } }",
"variables": { "id": "{{payload.id}}" }
}
post:
expression: .data.shipment

A remote entity calls out from inside the platform on a tenant’s behalf, so every call is held to three rules:

  • The destination is the host the entity declares: base_url, or an absolute per-operation url. Request values fill in the path; they cannot change the host. A declaration whose host is itself templated is refused. A redirect may not leave the declared host.
  • Public addresses only, unless the operator names the host. The address a name resolves to is checked when the connection is made. Loopback, private and carrier-grade NAT addresses are refused unless the host is listed in remote.internalhosts; link-local addresses, where cloud instance metadata lives, are refused whatever is listed.
  • Tenant overlays cannot declare remote entities. A remote entity in a tenant’s tenant_extensions rows is dropped with a diagnostic; remote entities are the product’s to declare.
# boot document
remote:
internalhosts: [crm.internal, 10.0.4.12] # hosts that may resolve to internal addresses

See Configuration for remote.internalhosts and the test-only remote.allowprivate.

The body is read with a hard 32 MiB cap and parsed as JSON. The cap truncates rather than errors, so a larger response is cut mid-document and then decodes to nothing. A body that is not JSON, an empty DELETE response, for example, decodes to nothing rather than erroring too.

post.expression is a jq expression applied to the parsed body. An empty expression returns the parsed body unchanged. The result is then normalised:

Expression yieldsRows returned
An objectOne row.
An arrayOne row per element; a non-object element becomes {"value": <element>}.
A scalarOne row, {"value": <scalar>}.
Nothing, or nullNo rows.
Several valuesThe values as a list, normalised as an array.

Whatever the expression yields is what the caller receives, the entity’s declared fields: do not project, coerce, default or mask the response.

The engine dispatches on materialization before it does anything else, then runs a reduced version of the hook pipeline around the HTTP call.

PhaseTable entity, readRemote, readTable entity, writeRemote, write
BeforeValidaterunsrunsrunsruns
AfterValidaterunsskippedrunsruns
compile to SQLrunsskippedrunsskipped
BeforeExecrunsskippedrunsskipped
executedriverHTTP requestdriverHTTP request
AfterExec——runsruns
AfterScanrunsruns——

In place of the compile step, a remote call is checked against the service allow-list and the operation’s action rule before the request is sent, the same checks the compiler makes for a table entity.

Consequences, in the order you meet them:

  • Pointcuts registered at BeforeExec run on table entities only: BeforeCascade and BeforeCacheRead.
  • On reads, AfterValidate is skipped as well, so OnRLSFilter and any read-side AfterValidate work run on table entities only.
  • Payload edits still stop mattering after AfterValidate, exactly as on the SQL path: the payload is snapshotted into the outbound body at that point.
  • The relation eager-load step is part of the SQL read path only, so ?include= resolves nothing on a remote entity.

Declarative machinery that still applies to a remote entity: field defaults, field and entity validations, audit-column stamping, stateflow guards, before_* triggers, Before* pointcuts on create/update/delete, After* pointcuts at AfterExec, the read-side BeforeQuery / BeforeAggregate and AfterQuery / AfterAggregate pointcuts, and read-side computed fields at AfterScan.

Reads reach the upstream service through the filter bindings and nothing else. Dispatch happens before the engine builds a query, and an AST the caller already assembled is discarded unread.

CapabilityAgainst a remote entity
FilteringOnly as {{filter.<field>}} bindings you place in the template yourself, and only from a surface that populates them. Operators, AND/OR nesting and negation are not represented. A binding is a bare value.
SortingNot forwarded. ?order_by= is ignored.
PaginationNot forwarded. ?limit=, ?offset= and cursors are ignored; no cursor is minted and no page block is returned.
ProjectionNot forwarded. ?select= is ignored.
Relations / joinsNot available. ?include= resolves nothing, and an include of a remote entity from a local parent compiles SQL against a table that does not exist.
AggregatesNot available. Aggregate clauses are discarded with the rest of the AST; the response is whatever the template returned.
Row capThe 10,000-row default cap is never applied: dispatch returns before the engine injects it. The upstream response is returned whole, up to the 32 MiB body cap.
Countcount in the response envelope is the number of extracted rows, not an upstream total.

Anything the upstream service should filter, sort or paginate by has to be passed explicitly in the template, from a {{filter.…}} binding or a header.

Which surface you call from decides whether the filter bindings carry anything at all.

Read surfaceFilter bindingsBlock dispatched
GET /rest/{entity}emptygetlist
GET /rest/{entity}/id/{id}emptygetlist
POST /rest/{entity}/searchemptygetlist
GraphQL list query with filter:the filter argumentget when the filter pins the primary key, else getlist
GraphQL by-id queryidget, when the entity’s primary key is named id
PATCH /bulk?entity=…&filter=<k>=<v>the repeated filter pairsget when one pins the primary key, else getlist
data.query, data.find_one, data.find_by_id from a scriptemptygetlist

Writes fare better, but not uniformly:

OperationWhat reaches the template
CreateThe whole request body as {{payload.data.*}}; {{payload.id}} only if the body carried one.
Update{{payload.data.*}} from the body, {{payload.id}} and {{filter.<pk>}} from the path id.
DeleteNothing, the path id travels in the discarded mutation AST, so {{payload.id}} and {{filter.<pk>}} are both empty.
RestoreNothing, for the same reason. The restore route sets neither payload nor filters, so the update block fires with every binding empty.

A remote entity is served by another service. The controls that apply to the call are enforced here; the controls that apply to the upstream’s rows are declared where the data lives.

ControlOn a remote entity
Service allow-list (access.services)Enforced before every call
Per-action rules (access.actions)Enforced before every call, deny-by-default like everywhere else
Row-level security on createEnforced: the create-side rule checks the payload before the call
Row-level security on read, update, deleteDeclared at the owning service. A remote entity that declares one fails the load.
Field-level permissions (access.fields)Declared at the owning service. A remote entity that declares them fails the load.
Compliance masking (compliances:)Declared at the owning service

Each call carries the identity of the caller: {{user.id}}, {{user.tenant}} and {{user.roles}} are the requesting user’s. Declare access.actions here to control who may reach the entity at all, and the row-level and field-level protections on the owning side.

Each request is capped by remote.timeout, defaulting to 10 seconds. The cap is wall-clock for the whole exchange, applied by cloning the shared HTTP client per dispatch, so one entity’s timeout never leaks into another’s. There are no retries and no circuit breaker: one operation is one attempt.

ConditionError
No block for the attempted operationremote: entity "<name>" has no <op> operation
Block present but no pre.templateremote: entity "<name>" <op> op has no template
Upstream status ≥ 400remote: upstream <status>: <first 512 bytes of the body>
Timeout, DNS failure, connection refusedremote: HTTP <method> <url>: <cause>
post.expression invalid or failingremote: extract: <parse|compile|eval> post.expression: <cause>

The engine wraps each of these before the HTTP layer sees it, exec: remote query: … on a read, exec: remote <op>: … on a write. That wrapped form is what the service log shows; the HTTP layer strips the engine’s own prefixes and sends the client the cause alone.

Remote operations never report an affected-row count. The router returns -1 unconditionally, regardless of what the upstream service said. The response envelope’s count is the number of rows the expression produced.

Scripts cannot make outbound HTTP calls, the httpGet, httpPost, httpPut and httpDelete service functions are registered and callable but every one returns call. The same is true of cacheGet/cacheSet/cacheDelete/cacheInvalidate, publish, publishBatch, getState, getTransitions, canTransition, getStateHistory, config, configMap, featureFlag, and the five content* functions.

A remote entity is the supported route. The data.* namespace re-enters the engine, so calling data.query or data.create on a remote entity dispatches through the router and runs the same reduced chain, access rules included. Reads from a script always land on getlist with an empty filter map, so a script that needs to address one upstream record has to reach it through a getlist template that carries the discriminator in a header or a hard-coded path. The same applies to a scripted endpoint: it reaches an upstream service by reading or writing a remote entity, not by calling out itself.

The migration planner skips every entity whose materialization is not table, so a remote entity produces no DDL and no table exists for it. A bulk create on a remote entity sends each row through the router, as a single create would. A relation eager-load into a remote entity compiles SQL against a table that does not exist, so declare relations from a remote entity’s parents only where reads do not include it.