Atlas API - Compile and Query
A domain's ontology and instance maps are files in git. They are not queryable in that form.
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:
- Authentication is on by default. Every
/api/v1route takes a bearer-only policy whilePulse:Auth:Enabledistrue. SendAuthorization: Bearer <token>on every call, withAtlasin the token'saudclaim. A session cookie does not clear this gate, which is why every example below sends a bearer. - A missing token may return HTML rather than a JSON 401. An unauthenticated
/api/v1read can return302to the Pulse sign-in page. Disable redirect-following on your HTTP client so you see the status code rather than a page of HTML. - A wrong route returns
200 text/htmlrather than404. Atlas serves its single-page application for any URL outside the API surface, so a200whoseContent-Typeistext/htmlmeans the URL is wrong. Compile isPUT .../worldand query isPOST .../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.
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, nowarningsarray and nodurationMson this response. Read warnings fromGET .../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,detailandviolations. There is notraceIdand nocodeextension on a compile 422, and nogroupseither: 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 always1.detailreports the sum ofoccurrences, so it can exceed the length ofviolationswhen a rule summarised a fan-out rather than listing it. severityis emitted as the PascalCase enum name,"Error"or"Warning", not camelCase.violations[]contains every violation from the pass, includingWarning-severity ones. The422itself is triggered only by the presence of at least oneError. Warnings never block anything on their own, and there is no way for a warning alone to produce a non-200compile.- Rule ids come from the fixed families
O-A*,O-B*,I-A*andI-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:
- Check the
Atlas-Validation-Violationsresponse header on the write. GET /api/v1/branches/{branch}/draftto see what is sitting in your session.POST /api/v1/branches/{branch}/draft/publishto promote it.- 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 is400with"code": "Query.NotCompiled". They are different routes with different failure shapes for the same underlying condition, and the404here carries nocodeextension 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/worldsis the cheapest way to know when your target is ready. - Connector-dependent domains are the exception. A domain whose instance maps use a
RowsetTemplateis 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 manualPUT .../worldis 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, notquery. This is the single most common first-call mistake. BecauseSystem.Text.Jsonignores unknown members,{"query": "MATCH ..."}bindscyphertonulland 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
400carries nocodeextension, 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
EntityRefcells,ArchetypeRefcells (produced byIS TYPEqueries) and the enriched dictionaries the projection operator emits for a bareRETURN n. A query that returns only scalars, such asRETURN p.Label, touches no entity cells and comes back with an emptyclassesTouchedand an emptyinstanceIdsByClass. 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 TYPEqueries add the class with no instances. AnArchetypeRefcontributes 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 .../querieswithprojections: trueandMATCH (n:Plant) RETURN nreturns:"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 nourn:atlas:class:orurn:atlas:instance:prefixes - do not hardcode either.The same query written as
RETURN n.Labelreturned"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 |
Related articles#
- Atlas API Overview - base address, authentication, the full route table, the error envelope
- Querying with Cypher - the supported language subset
- Querying with Cypher - worked patterns, including time-range queries
- Query Troubleshooting - diagnosing wrong or empty results
- Validation Rules Reference - the
O-A*/O-B*/I-A*/I-B*rule families behind a422 - Compiling and Publishing a Domain - the task-level walkthrough
- Atlas API Overview - Instance Maps
- Atlas API Overview - Ontology