API Authentication & Versioning
Which credential each Atlas route accepts, how the four authorization policies differ, and the route map for the current /api/v1 surface.
Overview#
This article covers the two things to get right before an Atlas integration works: which credential a given route accepts, and which routes make up the current API.
It goes further than Atlas API Overview on authentication. That article carries the full /api/v1 route list; the table below covers the resources a client meets first.
Two facts shape everything below:
- The machine-facing API is
/api/v1. The version lives in the path, and there is no version header or content negotiation. - Atlas has four named authorization policies. Each pins a different credential, so a bearer that works on
/api/v1does not clear/app, and the browser session cookie does not clear the machine plane.
Part 1: The API surface#
The current policy#
| Question | Answer |
|---|---|
| Which path is the machine-facing API? | /api/v1. |
Is there a version request header, an Accept profile, or content negotiation? |
No. The version lives in the path. |
| How do I discover the authoritative current surface? | The OpenAPI documents Atlas serves itself: /api/v1.json and /api/i3x.json, browsable at /api/help. |
Atlas serves its web interface for any URL outside the API surface, so a mistyped or out-of-date route returns 200 text/html rather than a 404. Check the response content type before parsing.
The current route names#
| Purpose | Route |
|---|---|
| Entity types | GET /api/v1/branches/{branch}/domains/{domain}/entity-types |
| Relationship types | GET /api/v1/branches/{branch}/domains/{domain}/relationship-types |
| Data types | GET /api/v1/branches/{branch}/domains/{domain}/data-types |
| Compile a domain | PUT /api/v1/branches/{branch}/domains/{domain}/world |
| Run a query | POST /api/v1/branches/{branch}/domains/{domain}/queries |
| Library imports | `GET |
| Branches | GET /api/v1/branches |
Two things to note when building a client:
- Update verbs differ by resource. Entity types, relationship types and properties update with
PATCH(partial merge). Data types and functions update withPUT(full replacement). data-typescarries theisTimeAxisflag, which is what makes a compound time-segmentable.
Check the content type, not just the status#
Because Atlas serves the interface for any unmatched URL, a client that classifies responses by status code alone reads a wrong route as a success:
# Checks the status only - a wrong route reads as 200.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $ATLAS_TOKEN" \
"https://atlas.example.com:4552/api/v1/branches/main/domains/PlantModel/entity-types"
# -> 200
Rule for any Atlas client: treat a 2xx whose Content-Type is not application/json (or application/problem+json on failure) as a routing problem rather than a result. Assert on the content type once in your HTTP wrapper, and every future typo surfaces immediately.
Building a client against the current surface#
- Pull the authoritative surface. Fetch
/api/v1.jsonfrom your own server and compare your client's route list against it. That document is generated from the running application, so it never drifts. - Use the route names and verbs above, including
PATCHfor entity types, relationship types and properties. - Put the branch in the path. Ontology, imports, instance maps, world, queries, validation and draft routes are all branch-scoped under
/api/v1/branches/{branch}/.... Percent-encode a/inside a branch name:/api/v1/branches/feature%2Fmy-ontology/domains/PlantModel/entity-types. The encoded slash is preserved and decoded by the handler. - Assert on the content type, as above.
- Send a bearer on every call. See Part 2.
Part 2: The four-policy authorization model#
Atlas registers four named policies. Each pins a different set of authentication schemes, and that pin determines both which credential clears the gate and what a failure looks like.
| Policy | Credential accepted | Applies to | Failure shape |
|---|---|---|---|
ApiAccess |
JWT bearer only | /api/v1/**, /i3x/v1/** (except GET /i3x/v1/info), /mcp |
401 from the bearer handler |
UiAccess |
Session cookie only | The /app browser plane, read routes |
302 to Pulse for a page load, 401 from the in-handler checks on the identity routes |
UiAdministratorAccess |
Session cookie only, plus the pulse:administrator role |
/app admin mutations |
401 unauthenticated, 403 authenticated without the role |
HubAccess |
Cookie or bearer | /hubs/connector, /hubs/v1/branches/{branch}/domains/{domain}/subscribe, /hubs/logs |
401, never a redirect |
The default authentication scheme is the cookie and the default challenge scheme is OpenID Connect, which is why UiAccess sends a browser to Pulse and does not accept a bearer.
Part 3: Machine authentication (ApiAccess)#
The credential#
Authorization: Bearer <pulse-issued-jwt>
ApiAccess pins the JWT bearer scheme and requires an authenticated user. No scope and no role are needed on the machine plane. Tokens are validated against Pulse's OIDC metadata and JWKS at {scheme}://{Pulse:Host}:{Pulse:Port}/auth.
A working read:
curl -s \
-H "Authorization: Bearer $ATLAS_TOKEN" \
-H "Accept: application/json" \
"https://atlas.example.com:4552/api/v1/branches"
With a valid bearer this returns 200 application/json.
The audience requirement#
The token's aud claim must include Atlas's own Pulse:Auth:ClientId, which is Atlas by default. A valid Pulse token issued for another Timebase component is rejected with a 401.
So a client whose Pulse registration lists Historian as an audience needs Atlas added to that list before it can call Atlas. This is the same registration step Connector needs before its SignalR bearer clears the connector hub.
Validation parameters, all enforced:
| Parameter | Value | Consequence |
|---|---|---|
ValidateIssuer |
true |
The token must come from the configured Pulse IdP |
ValidateAudience |
true, ValidAudience = Pulse:Auth:ClientId |
See above |
ValidateLifetime |
true |
Expired tokens are rejected |
ClockSkew |
TimeSpan.Zero |
No tolerance, so client and server clocks must agree. A host drifting by a few seconds produces intermittent 401s |
ValidateIssuerSigningKey |
true |
Signature checked against Pulse's JWKS |
Diagnosing a 401#
Atlas logs every bearer rejection under its bearer-authentication logger, as two distinct events:
- A token was supplied and rejected. The log line carries the reason: bad signature, wrong audience, wrong issuer, expired, or malformed.
- A challenge was issued. This fires on every 401 on this scheme, including when no token was sent. The line records whether an
Authorizationheader arrived and whether it carried theBearerprefix. Only the scheme word and the token length are logged, never the token value.
So "header ABSENT" means your client did not attach the token, and a reason string means the token arrived and was rejected on one of the rules above. Start there before changing your Pulse registration.
What else ApiAccess covers#
/i3x/v1attaches the same policy, with one exception:GET /i3x/v1/infois anonymous, so that it can serve as a reachability probe./mcpattaches the same policy. An external Model Context Protocol client presents the same Pulse bearer, with the same audience.
An unauthenticated call may return a redirect#
An unauthenticated call to a machine-plane route can return 401 from the bearer handler or, on some deployments, a 302 to the Pulse sign-in page:
302 Found
Location: https://<pulse-host>:4542/auth/authorize?client_id=Atlas&redirect_uri=.../callback&response_type=code&scope=openid profile
A client that follows redirects then receives an HTML sign-in page. Write your client for both:
- Always send a bearer. Do not rely on an unauthenticated call producing a parseable JSON error.
- Disable redirect-following on API calls, so you see the status rather than a sign-in page.
- Check
Content-Typebefore parsing.text/htmlon the machine plane means a login redirect or the interface shell, never an API response.
Part 4: The browser plane (UiAccess and UiAdministratorAccess)#
/app takes the session cookie#
/app is the route set the Atlas web interface uses for itself. Reads sit behind UiAccess; most mutations are admin-gated.
UiAccess pins no authentication scheme, so it resolves against the default scheme - the cookie - and challenges through OIDC. A bearer credential therefore does not clear the UI plane.
If you are inspecting Atlas's own network traffic while designing an integration, you will see /app/... calls rather than /api/v1/.... Build against the /api/v1 twin of the same resource: those are the published routes, they appear in the OpenAPI documents, and they accept your service credential.
The identity and login routes#
The /app identity group is mapped anonymous and enforces authorization inside each handler, so that an XHR receives a 401 it can act on rather than an OIDC redirect it cannot follow.
| Route | Behaviour |
|---|---|
GET /app/me |
200 with the identity envelope when signed in, or when auth is disabled. 401 when auth is on and the caller is unauthenticated |
GET /app/_authcheck |
204 under a valid session, or when auth is disabled. 401 otherwise. A stable smoke-test contract, kept separate from /app/me so its payload can grow |
GET /app/login?returnUrl= |
Fires the Pulse OIDC challenge, then returns to returnUrl. Open-redirect guarded: absolute URLs and protocol-relative //host targets fall back to / |
GET /auth/logout |
Signs out of both the OIDC and cookie schemes |
The /app/me envelope is serialized camelCase:
{
"userId": "<sub claim>",
"displayName": "<name claim>",
"isAdmin": false,
"claims": [{ "type": "sub", "value": "..." }],
"version": "1.4.x"
}
userId is the sub claim, falling back to the name identifier; displayName is the name claim, falling back to the identity name and then to userId.
/auth/* serves /auth/access-denied and /auth/error, both anonymous HTML, plus the top-level /auth/logout.
401 versus 403, and the admin role#
Admin mutations on /app return:
- allow when auth is disabled, or the caller is authenticated and in the admin role
401when auth is on and the caller is unauthenticated - sign in403when the caller is authenticated but does not carry the role - request the role
The role is pulse:administrator, and the same string backs the UiAdministratorAccess policy. The built-in Bash, FileSystem and Script agent tools require it too.
Confirming the admin role reaches Atlas#
Admin status is derived from the role claim on the signed-in principal. After signing in, call GET /app/me in the browser and read isAdmin alongside the raw claims array, which is passed through as received precisely so you can see the claim types your Pulse deployment issues.
If isAdmin is false while claims shows the role, configure Pulse to issue the role in the claim type Atlas reads, then sign in again so a fresh session picks it up.
Part 5: Real-time surfaces (HubAccess)#
The three SignalR hubs accept either credential. HubAccess pins both the cookie scheme and the JWT bearer scheme:
| Hub | Route |
|---|---|
| Connector hub | /hubs/connector |
| Value hub | /hubs/v1/branches/{branch}/domains/{domain}/subscribe |
| Log hub | /hubs/logs |
Because the schemes are pinned, a failed handshake is answered with a plain 401 rather than a redirect, which is what a SignalR negotiate or WebSocket handshake needs.
A machine client attaches the bearer through SignalR's access-token provider rather than a header:
var connection = new HubConnectionBuilder()
.WithUrl("https://atlas.example.com:4552/hubs/v1/branches/main/domains/PlantModel/subscribe",
options => options.AccessTokenProvider = async () => await tokenProvider.GetTokenAsync())
.Build();
The same audience rule applies: the calling client's Pulse registration must list Atlas's Pulse:Auth:ClientId as an audience.
Part 6: Configuration#
The keys#
| Key | Default | Notes |
|---|---|---|
Pulse:Auth:Enabled |
true |
Authentication is on in a shipped deployment |
Pulse:Host |
localhost |
Startup reports an error if Host or Port is missing while auth is enabled |
Pulse:Port |
4542 |
|
Pulse:UseTls |
true |
Authority becomes http[s]://{Host}:{Port}/auth |
Pulse:Auth:ClientId |
Atlas |
Also the required bearer audience |
Pulse:Auth:ClientSecret |
"" |
Empty works for a Pulse client seeded with the grant |
OIDC scopes are openid and profile, and the callback path is /callback. Atlas issues its own session cookie for the browser plane.
Note on config precedence: environment variables and command-line arguments are loaded after
settings.config, so they take precedence over the file forAuth:*and everything else. In Docker there is no second settings file: the Docker overlay was composed intosettings.configon the first start, so changePulse:Hostthere, or setPulse__Hostin the Compose file.
What Pulse:Auth:Enabled=false changes#
Open mode is a development option. Four things change:
- Authentication is not registered, so no scheme is available.
- The gates are not attached. The machine plane is open because no policy is applied to it.
/mcpis open, serving its read and skill tool set to any caller that can reach the port. Keep an open-mode Atlas off an untrusted network.- The AI assistant is unavailable. The Gage-proxied agent transport requires an attributable, metered caller, so the readiness probe reports not-ready while auth is off.
GET /app/me also returns a local identity: userId: "local", displayName: "Local (authentication disabled)", isAdmin: true, and writes are attributed to Anonymous. See Part 7.
Part 7: Commit attribution#
Attribution comes from your credential. Atlas reads the principal's sub and name claims, which the cookie session and the bearer both carry:
| Caller | Git author |
|---|---|
| Authenticated user or machine client | {name claim} <{sub claim}@atlas.local> |
Authenticated with an id but no name |
Attributed to the id |
Unauthenticated, or Pulse:Auth:Enabled=false |
Anonymous <anonymous@atlas.local> |
| Agent tool write | The executing agent's name |
| Background or startup operation | Atlas System <atlas-api@atlas.local> |
The name and the id are sanitized to Git's identity rules: <, > and newlines are stripped from both, and whitespace and stray @ are additionally stripped from the email local part. The stable sub becomes the email local part, so attribution survives a display-name change.
To give an import pipeline its own identity in git log, register it as its own Pulse client and authenticate its requests.
Related articles#
- Atlas API Overview - the full
/api/v1route list, error format and pagination - Atlas API - Domains - branch-scoped domain routes
- Registering Timebase apps as OAuth clients in Pulse - registering a client and setting its audiences