Docs/Developer/Atlas API - Compile and Query
DeveloperAPI

Atlas API - Compile and Query

A domain's ontology and instance maps are files in git. They are not queryable in that form.

Updated 12 Aug 2026Timebase Docs

Overview#

A domain's ontology and instance maps are files in git. They are not queryable in that form. Compiling a domain turns those files into a World: an in-memory graph of entities, edges and archetypes held in the Atlas process. Querying runs a Cypher statement against a compiled World and returns a columnar result.

This article documents the four routes that make up that loop:

Route Purpose
GET /api/v1/worlds Discover which worlds are compiled and queryable right now
PUT /api/v1/branches/{branch}/domains/{domain}/world Compile (or recompile) a domain
GET /api/v1/branches/{branch}/domains/{domain}/world Read compile status and staleness without recompiling
POST /api/v1/branches/{branch}/domains/{domain}/queries Run a Cypher query

This article is written for developers building an integration against Atlas.

Scope note. This is the transport reference: routes, request bodies, response shapes, status codes. For the language itself, its supported clauses and its several conventions that shape a result, see Querying with Cypher and Cypher Query Patterns.

Read the API overview first#

The Atlas API Overview article is canonical for the base address, authentication, the full /api/v1 route table, the JSON serialization rules and the RFC-9457 error envelope. This article does not restate them. Three points from it that you cannot skip here:

  1. Authentication is on by default. Every /api/v1 route takes a bearer-only policy while Pulse:Auth:Enabled is true. Send Authorization: Bearer <token> on every call, with Atlas in the token's aud claim. A session cookie does not clear this gate, which is why every example below sends a bearer.
  2. A missing token may return HTML rather than a JSON 401. An unauthenticated /api/v1 read can return 302 to the Pulse sign-in page. Disable redirect-following on your HTTP client so you see the status code rather than a page of HTML.
  3. A wrong route returns 200 text/html rather than 404. Atlas serves its single-page application for any URL outside the API surface, so a 200 whose Content-Type is text/html means the URL is wrong. Compile is PUT .../world and query is POST .../queries.

Discover compiled worlds#

GET /api/v1/worlds

Lists every (branch, domain) pair currently held in the world cache. Only compiled worlds appear, so a client that picks a target from this list will never hit Query.NotCompiled on its first query.

Query parameter Type Description
branch string Optional. Case-sensitive exact match on the branch name.
domain string Optional. Case-sensitive exact match on the domain name.
curl -H "Authorization: Bearer $TOKEN" \
  "https://<your-atlas-server>:4552/api/v1/worlds?branch=main"

Response - 200 OK

{
  "items": [
    {
      "branch": "main",
      "domain": "lonestar",
      "compiledAt": "2026-08-03T09:41:07.221Z",
      "entityCount": 634,
      "edgeCount": 73,
      "archetypeCount": 42,
      "isStale": false,
      "compiledAtSha": "7f3d0884",
      "currentSha": "7f3d0884"
    }
  ]
}

Each entry carries the same statistics as GET .../world plus the branch and domain that address it.

Note

despite the CompiledWorldsResponse summary calling itself "the paginated list", this endpoint accepts no page, pageSize, limit or cursor parameter and returns no cursor. The only filters are branch and domain.

The list is per-process and in-memory. It reflects WorldCache, a plain ConcurrentDictionary. Restarting Atlas empties it. It refills on its own because Atlas recompiles every recorded (branch, domain) pair at boot - see Compiled worlds and restarts.


Compile a domain#

PUT /api/v1/branches/{branch}/domains/{domain}/world

Builds the World from the domain's ontology and instance maps and stores it in the cache. No request body is required.

The call is idempotent and always a full rebuild. There is no incremental path and no "nothing changed, skipping" fast path: every call re-reads the files, re-validates, rebuilds the graph and replaces whatever the cache held.

It is also synchronous. The World is built inside the request and the report comes back in the same response. There is no job id and no polling endpoint.

curl -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  "https://<your-atlas-server>:4552/api/v1/branches/main/domains/lonestar/world"

Response - 200 OK

{
  "entityCount": 634,
  "edgeCount": 73,
  "archetypeCount": 42,
  "compiledAt": "2026-08-07T14:22:31.482Z",
  "functionErrors": []
}
Field Type Description
entityCount integer Entity instances in the compiled World
edgeCount integer A-Box edges between those entities
archetypeCount integer Archetypes (T-Box classes) the World defines
compiledAt string (UTC date-time) When this compile completed
functionErrors array Ontology functions that failed to compile. Empty on a clean build

The record is CompileResult and is returned verbatim.

There is no tripleCount, no warnings array and no durationMs on this response. Read warnings from GET .../validation.

functionErrors#

Each entry is { "functionUri": "...", "diagnostics": ["..."] }. A function whose body reports a compiler error is set aside rather than stopping the build: the World compiles, the response is 200, and the function is listed here. Treat a non-empty functionErrors as a 200 that still needs your attention, because any calculated property depending on that function will not evaluate.

Compile failures#

Status Type URI When
404 https://atlas.timebase.io/errors/not-found No domain of that name exists on that branch. Detail: Domain '{domain}' not found
422 https://atlas.timebase.io/errors/validation-failed Validation found at least one Error-severity violation
500 https://atlas.timebase.io/errors/internal-error Build threw. Detail is always the generic "An internal error occurred.", never a stack trace

Compilation aborts; it does not degrade. On an Error-severity violation, ValidateAndThrowIfInvalidAsync throws before the World is built. Atlas does not skip the offending types and compile the rest. No World is produced and the previously cached World, if any, is left untouched.

Response - 422 Unprocessable Entity (application/problem+json)

{
  "type": "https://atlas.timebase.io/errors/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "detail": "2 violation(s) found in lonestar.",
  "violations": [
    {
      "ruleId": "I-B2",
      "severity": "Error",
      "filePath": "instances/equipment.map.json",
      "jsonPointer": "/instances/3/properties",
      "instanceId": "urn:atlas:instance:lonestar:pump-101",
      "message": "...",
      "suggestion": "..."
    }
  ]
}

Notes on this body, which is serialized directly rather than through the generic error mapper:

  • It carries exactly type, title, status, detail and violations. There is no traceId and no code extension on a compile 422, and no groups either: grouping is applied to the validation read and to the publish refusal, not to this one.
  • Each violation carries occurrences, how many violations that entry stands for. Almost always 1. detail reports the sum of occurrences, so it can exceed the length of violations when a rule summarised a fan-out rather than listing it.
  • severity is emitted as the PascalCase enum name, "Error" or "Warning", not camelCase.
  • violations[] contains every violation from the pass, including Warning-severity ones. The 422 itself is triggered only by the presence of at least one Error. Warnings never block anything on their own, and there is no way for a warning alone to produce a non-200 compile.
  • Rule ids come from the fixed families O-A*, O-B*, I-A* and I-B*. See the Validation Rules Reference article.

To see the violations without attempting a compile, call GET /api/v1/branches/{branch}/domains/{domain}/validation.

Compiling while you hold a draft session#

Compile is treated as a read, so draft read-routing applies. If you own a draft session that is ahead of the target branch, your compile builds your draft and leaves the shared result in the shared cache**. The rationale is in that comment: the cache is keyed on (domain, branch) and everyone else queries through it, so storing one author's unpublished work there would leak it to every other caller.

You still get your full CompileResult with real counts, and you still get your 422 if validation fails. What you do not get is a change anyone else can query, including a subsequent GET .../world.

Combine that with the save path, where an ontology write carrying violations returns 2xx but lands in your private draft rather than advancing the branch, and the result is two successful responses and no visible change. If that happens:

  1. Check the Atlas-Validation-Violations response header on the write.
  2. GET /api/v1/branches/{branch}/draft to see what is sitting in your session.
  3. POST /api/v1/branches/{branch}/draft/publish to promote it.
  4. Compile again. Now that the draft is the branch, the compile caches normally.

Check compile status#

GET /api/v1/branches/{branch}/domains/{domain}/world

Returns the cached World's statistics and, crucially, whether the branch has moved since that compile. It never triggers a recompile.

curl -H "Authorization: Bearer $TOKEN" \
  "https://<your-atlas-server>:4552/api/v1/branches/main/domains/lonestar/world"

Response - 200 OK

{
  "entityCount": 634,
  "edgeCount": 73,
  "archetypeCount": 42,
  "compiledAt": "2026-08-03T09:41:07.221Z",
  "isStale": false,
  "compiledAtSha": "7f3d0884",
  "currentSha": "7f3d0884"
}

The first four fields match PUT .../world. The three extra fields are the staleness triple:

Field Type Description
isStale boolean true when the branch has advanced past compiledAtSha in a way that touches this domain's own files or an imported library. Computed on demand from git object ids on every read, never a stored bit
compiledAtSha string The commit the compile actually read from. Blank when the World was compiled before this was tracked, in which case isStale is always true because cleanliness cannot be proven
currentSha string The branch head at the moment of the read. Blank when the branch could not be resolved, in which case isStale defaults to true

isStale is the signal to poll. A write to the ontology does not recompile, so a compiled World stays queryable and unchanged until PUT .../world is called again or the process restarts. Poll isStale on GET .../world where your client needs to know that the model has moved on.

Response - 404 Not Found when the domain has never been compiled on that branch:

{
  "type": "https://atlas.timebase.io/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Domain 'lonestar' on branch 'main' has not been compiled. Use PUT .../world to build."
}

Watch the asymmetry. Reading world status for an uncompiled domain is 404. Querying an uncompiled domain is 400 with "code": "Query.NotCompiled". They are different routes with different failure shapes for the same underlying condition, and the 404 here carries no code extension because it goes through the generic mapper. Do not write one handler for both.

Compiled worlds and restarts#

Atlas records every successful compile to {DataDirectory}/compiled-worlds.json and a background hosted service recompiles every recorded pair after boot without blocking Kestrel from accepting traffic. Domains flip back to queryable one at a time as each rebuild lands.

Practical consequences:

  • Do not instruct users to recompile after every restart. In the normal case it happens by itself.
  • Do handle a brief not-compiled window right after startup. GET /api/v1/worlds is the cheapest way to know when your target is ready.
  • Connector-dependent domains are the exception. A domain whose instance maps use a RowsetTemplate is deferred at boot and recompiled only once its connector advertises. If that connector never connects, the domain stays uncompiled for the life of the process, and a manual PUT .../world is the fix.
  • There is no time-based retention or pruning on the record store, so the recorded set only shrinks when a world is explicitly invalidated.

Run a query#

POST /api/v1/branches/{branch}/domains/{domain}/queries
Content-Type: application/json

One route serves both the plain and the enriched result: enrichment is a body flag rather than a separate URL.

Request body#

Field Type Required Description
cypher string Yes The Cypher statement to execute
parameters object No Named parameters, referenced in the statement as $paramName
projections boolean No true adds classesTouched and instanceIdsByClass to the response. Absent or false returns the plain shape

The field is cypher, not query. This is the single most common first-call mistake. Because System.Text.Json ignores unknown members, {"query": "MATCH ..."} binds cypher to null and you get:

{
  "type": "https://atlas.timebase.io/errors/invalid-request",
  "title": "Invalid Request",
  "status": 400,
  "detail": "Cypher query string is required."
}

Note that this particular 400 carries no code extension, unlike the query-engine errors below, because it is raised before the request reaches a handler.

Plain result#

curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cypher":"MATCH (p:Plant) RETURN p.Label AS name LIMIT 10"}' \
  "https://<your-atlas-server>:4552/api/v1/branches/main/domains/lonestar/queries"

Response - 200 OK

{
  "columns": ["name"],
  "data": [
    ["Lonestar Beverages Plant"]
  ]
}
Field Type Description
columns string[] Column names in RETURN order. An un-aliased projection yields the expression text itself, for example "p.Label"
data array of arrays One inner array per row, values in columns order

This {columns, data} shape is the response shape.

A null cell means the expression did not resolve, rather than "no live data". Property names are case-sensitive, so p.label where the property is Label returns null. See Querying with Cypher.

Using parameters#

{
  "cypher": "MATCH (p:Pump) WHERE p.Speed.Value > $minSpeed RETURN p.Label, p.Speed.Value",
  "parameters": { "minSpeed": 1000 }
}

JSON values are unwrapped from JsonElement into native CLR types before the engine sees them, with integral numbers preferring 64-bit integers over doubles, and arrays and objects normalized recursively. You do not need to pre-cast.

Parameters are also the supported way to page: SKIP and LIMIT accept a numeric literal or a numeric $parameter, but nothing else. An expression there throws SKIP/LIMIT expression must be a constant integer, and a referenced-but-absent parameter throws SKIP/LIMIT parameter '$name' was not provided. Both surface as 400 with "code": "Query.Unsupported".

Enriched result#

Set "projections": true to have Atlas walk the result cells and report which entity types were touched and which instances matched.

curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cypher":"MATCH (p:Plant) RETURN p","projections":true}' \
  "https://<your-atlas-server>:4552/api/v1/branches/main/domains/lonestar/queries"

Response - 200 OK

{
  "columns": ["p"],
  "data": [
    [{ "entityId": 12, "typeId": "urn:atlas:ontology:lonestar-plant:Plant", "label": "Lonestar Beverages Plant" }]
  ],
  "classesTouched": ["urn:atlas:ontology:lonestar-plant:Plant"],
  "instanceIdsByClass": {
    "urn:atlas:ontology:lonestar-plant:Plant": [
      "urn:atlas:instance:lonestar:plant-01"
    ]
  }
}
Field Type Description
classesTouched string[] The archetype name of every entity type that appeared in the result, ordinal-sorted
instanceIdsByClass object Map of archetype name to the array of matching durable instance URN strings

instanceIdsByClass values are strings. They are durable URNs resolved through the World's reverse entity index, so type your client to a string array.

Three behaviours to design around:

  • Only cells that carry an entity or type reference contribute. The extraction pass looks for EntityRef cells, ArchetypeRef cells (produced by IS TYPE queries) and the enriched dictionaries the projection operator emits for a bare RETURN n. A query that returns only scalars, such as RETURN p.Label, touches no entity cells and comes back with an empty classesTouched and an empty instanceIdsByClass. If you want enrichment, return the node itself.
  • Entities with no URN are dropped, but the key is kept. Strategy and edge nodes have no durable URI, so they vanish from the array while their class key remains, aligned with classesTouched. An empty array under a populated key is normal.
  • IS TYPE queries add the class with no instances. An ArchetypeRef contributes a key to both structures but no instance URNs.

The map keys are archetype names, which WorldBuilder sets to the archetype URI. On the live lonestar domain, labels() returned full URNs of the form urn:atlas:ontology:{scope}:{LocalName}, which is what the sample above uses.

What this looks like in practice. Against a compiled domain, POST .../queries with projections: true and MATCH (n:Plant) RETURN n returns:

"classesTouched": ["urn:atlas:ontology:lonestar-plant:Plant"],
"instanceIdsByClass": { "urn:atlas:ontology:lonestar-plant:Plant": ["Lonestar-Plant"] }

Note the shape: the key is the full type URN, and the values are instance ids (Lonestar-Plant), not URNs. There are no urn:atlas:class: or urn:atlas:instance: prefixes - do not hardcode either.

The same query written as RETURN n.Label returned "classesTouched": [] and "instanceIdsByClass": {}, confirming that projection enrichment only fires for entity-valued columns. A scalar projection enriches nothing.

There is no time-range parameter#

The request body has exactly three fields. There is no from, no to, no range and no asOf. Time-series windows are expressed inside the Cypher statement, by unwinding a compound property and filtering on its declared time-axis field; the engine pushes that predicate down to the connector and stitches across strategy changeovers. The pattern and its constraints belong to the language, so see Cypher Query Patterns.

There is no server-side row cap or query timeout#

Atlas applies neither, and the request body size is unlimited by default, so set a client timeout and a row budget on your side. An unbounded MATCH (n) RETURN n on a large World will run to completion and serialize everything. Put a LIMIT on exploratory queries and set a client-side timeout.


Errors on the query route#

All failures are RFC-9457 application/problem+json. The overview article has the full type-URI table; what follows is specific to POST .../queries.

Query-engine failures are the one place Atlas gives you a machine-readable error code. Every ErrorType.Invalid failure from the query handlers becomes a 400 carrying its stable error key as a code extension, precisely so a client can tell "compile first" apart from "your Cypher is broken" when both are 400.

{
  "type": "https://atlas.timebase.io/errors/invalid-request",
  "title": "Invalid Request",
  "status": 400,
  "detail": "Domain 'lonestar' on branch 'main' has not been compiled. Call PUT /api/v1/branches/main/domains/lonestar/world first.",
  "code": "Query.NotCompiled",
  "traceId": "00-..."
}
code Status Meaning
Query.NotCompiled 400 No World is cached for this (branch, domain). Call PUT .../world first
Query.SyntaxError 400 The statement did not parse. detail carries the parser message
Query.Unsupported 400 Parsed, but uses a construct this engine does not implement. Includes unsupported functions and non-constant SKIP/LIMIT
Query.SemanticError 400 Parsed and supported, but does not make sense against this World, for example an undefined variable
(none) 500 Query.ExecutionError. The engine threw at run time

Both the plain and the enriched handler emit the same five.

An uncompiled domain returns 400, rather than a 404 or an empty result set. Branch on "code": "Query.NotCompiled" to decide whether to compile.

Query.ExecutionError is the odd one out. It is ErrorType.InternalError, so it does not go through the code-carrying branch. It becomes a plain 500 whose detail is always the fixed string "An internal error occurred." and which has no code extension. You will never get a useful message out of a 500 from this endpoint. Reproduce the statement locally, or read the server log.

A documented anti-join pattern currently returns 500. Executed live on 2026-08-07, both of these crash the engine:

MATCH (n) OPTIONAL MATCH (n)-[r:contains]->() WHERE r IS NULL RETURN count(n)
MATCH (n) OPTIONAL MATCH (n)-[r:contains]->() WITH n, r WHERE r IS NULL RETURN count(n)

The second form is the workaround older guidance recommends for the first, and it fails identically. There is no known working formulation of "find nodes with no outgoing relationship" on this engine today. Tracked as B25; see Query Troubleshooting.


The /app twin#

The Atlas single-page application calls its own mirrors of these routes: POST /app/branches/{branch}/domains/{domain}/queries and PUT / GET /app/branches/{branch}/domains/{domain}/world.

If you are reading a browser network tab, those are the calls you will see. They are the same handlers with the same request and response records, so nothing in this article changes for them. What differs is the credential: /app is gated by the cookie-based UI policy and marked .ExcludeFromDescription(), so it does not appear in the OpenAPI documents and a bearer token does not clear it. It is private glue for the user interface. Build against /api/v1.


Troubleshooting#

Symptom Cause What to do
400 "Cypher query string is required." The body field is cypher, and a query field is ignored Rename the field to cypher
400 with "code": "Query.NotCompiled" No World is cached for that branch and domain PUT /api/v1/branches/{branch}/domains/{domain}/world, then retry. Or pick a target from GET /api/v1/worlds, which only ever lists compiled worlds
You expected 404 for an uncompiled domain and got 400 Querying an uncompiled domain is 400. Only GET .../world returns 404 for that condition Branch on the code extension, not the status code
200 text/html instead of JSON The route did not match and the single-page application shell was served Check for POST .../compile, POST .../query, POST .../query/filter or an /api/v2 prefix. Always assert Content-Type: application/json before parsing
302, or an HTML sign-in page No bearer token, and the deployment challenges through the browser flow Send Authorization: Bearer <token> with Atlas in aud. Turn off redirect-following so you see the 302
Compile returns 422 Validation found at least one Error-severity violation and no World was built Read violations[], fix the reported O-A* / O-B* / I-A* / I-B* rules, compile again. Warnings in that array are informational
Compile returns 200 but functionErrors is non-empty One or more ontology functions failed to compile. The World built anyway Read diagnostics[] per functionUri. Any calculated property using that function will not evaluate
Compile returned 200 and a query still sees the previous data Your compile was draft-routed, so it built your copy and left the shared world in place GET /api/v1/branches/{branch}/draft, publish with POST /api/v1/branches/{branch}/draft/publish, then compile again
A write returned 2xx but the compile does not reflect it The save carried violations and went to your private draft session. The branch never advanced Check the Atlas-Validation-Violations response header on the write, then publish the draft
Query results are correct but out of date A write does not recompile, so the compiled world keeps answering until you do Poll isStale on GET .../world, or compare compiledAtSha with currentSha. Recompile when they diverge
isStale is true immediately after a compile compiledAtSha is blank (compiled before SHA tracking) or the branch could not be resolved. Neither can be proven clean, so both default to stale Recompile once on the current build to populate compiledAtSha. If it stays blank, check that the data root really is a git repository
instanceIdsByClass fails to deserialize The values are durable URN strings now, not integers Type the field as Dictionary<string, string[]> or equivalent
classesTouched is empty on a query that clearly matched rows The result contained only scalar cells. Enrichment reads entity and type references, not property values Return the node itself, for example RETURN p rather than RETURN p.Label
A class key in instanceIdsByClass maps to an empty array Those entities have no durable URN, typically strategy or edge nodes. They are dropped while the key is kept Expected. Do not treat an empty array as an error
500 with detail "An internal error occurred." Query.ExecutionError. The detail is a fixed string, so quote the traceId when you contact support Check the server log. If the statement is an OPTIONAL MATCH ... IS NULL anti-join, you have hit the known engine bug above
Query never returns on a large domain There is no server-side row cap and no query timeout Add LIMIT, filter earlier with WHERE, and set a client-side timeout
400 "SKIP/LIMIT expression must be a constant integer" SKIP and LIMIT accept a numeric literal or a numeric $parameter only Pass the value through parameters
Right after a restart, a domain is briefly not compiled Startup auto-recompile runs in the background and domains become queryable one at a time Poll GET /api/v1/worlds until your target appears. If it never appears and the domain uses a RowsetTemplate, its connector has not advertised; compile it manually
esc
↑↓ navigate · ↵ open · ⌘K anywhere