Accessing data
In this tutorial you obtain an access token, send your first GraphQL query and then read runtime entities, follow associations and query time series: raw values, sums per day and month, and pre-aggregated rollups. All examples are read-only.
The examples use the energy-community model from tutorial 3, because it has master data and time series. The query patterns are the same for every Construction Kit.
Time: about 60 minutes.
The endpoint
Every tenant has one GraphQL endpoint on the asset repository:
https://assets.<your-domain>/tenants/<tenant>/GraphQL
Send queries as POST with a JSON body {"query": "...", "variables": {...}}
and the header Authorization: Bearer <access-token>. Use the path exactly as
shown, with /tenants/ in front of the tenant id; the spelling GraphQL is the
canonical one used throughout this documentation.
For interactive exploration the same service hosts a playground with schema
browser and auto-completion at https://assets.<your-domain>/tenants/<tenant>/graphql/playground.
Step 1: Get an access token
Quick start: reuse your octo-cli login
If you followed tutorial 1, octo-cli already holds a
token for your context. For experiments you can read it from the context file:
octo-cli --context <context-name> -c AuthStatus > /dev/null # refreshes the token if needed
export OCTO_TOKEN=$(jq -r '.Contexts["<context-name>"].Authentication.AccessToken' ~/.octo-cli/contexts.json)
export OCTO_GRAPHQL=https://assets.<your-domain>/tenants/<tenant>/GraphQL
The token is valid for about an hour. It carries your user's roles, so a script using it can do exactly what you can do in the Studio.
Your own application: Authorization Code with PKCE
A web or desktop application that acts on behalf of a user uses the OpenID
Connect Authorization Code flow with PKCE. Your administrator registers an
OAuth client for it in the tenant (see
Registering a new web application)
and grants it the scope octo_api (or octo_api.read_only for an application
that only reads).
The flow:
- Your app creates a random
code_verifierand its SHA-256 hash, thecode_challenge. - It sends the browser to the authorize endpoint of the identity service, with
acr_values=tenant:<tenant>so that the login page of the right tenant opens. - The user logs in, the browser returns to your
redirect_uriwith acode. - Your app exchanges
codepluscode_verifierfor tokens at the token endpoint.
The identity service publishes all endpoints in its discovery document at
https://connect.<your-domain>/.well-known/openid-configuration. A minimal
Python implementation for a local tool, using only the standard library:
import base64, hashlib, http.server, json, os, secrets, urllib.parse, urllib.request, webbrowser
ISSUER = "https://connect.<your-domain>"
CLIENT_ID = "<your-client-id>" # registered with redirect URI http://localhost:8765/callback
TENANT = "<tenant>"
REDIRECT_URI = "http://localhost:8765/callback"
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile email role octo_api offline_access",
"acr_values": f"tenant:{TENANT}",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
}
webbrowser.open(f"{ISSUER}/connect/authorize?{urllib.parse.urlencode(params)}")
class Callback(http.server.BaseHTTPRequestHandler):
def do_GET(self):
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
assert query["state"][0] == state, "state mismatch"
self.server.code = query["code"][0]
self.send_response(200); self.end_headers()
self.wfile.write(b"Login complete - you can close this window.")
server = http.server.HTTPServer(("localhost", 8765), Callback)
server.handle_request()
token_request = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": server.code,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier,
}).encode()
tokens = json.load(urllib.request.urlopen(f"{ISSUER}/connect/token", data=token_request))
access_token = tokens["access_token"] # keep it in memory, never log it
For browser applications use an established OIDC library (for example
angular-auth-oidc-client or oidc-client-ts) instead of hand-written code.
The background is described in
Authentication.
Services without a user: client credentials
A background service or a device uses the client credentials flow with its own client id and secret. This is covered in tutorial 5.
Step 2: Your first query
curl -s "$OCTO_GRAPHQL" \
-H "Authorization: Bearer $OCTO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ runtime { energyCommunityCustomer(first: 2) { totalCount items { rtId rtWellKnownName } } } }"}'
{
"data": {
"runtime": {
"energyCommunityCustomer": {
"totalCount": 45,
"items": [
{ "rtId": "6ac75edd1f682dc27a1b1d9f", "rtWellKnownName": "customer-018" },
{ "rtId": "6ac75ede1f682dc27a1b1e12", "rtWellKnownName": "customer-041" }
]
}
}
}
}
The root field runtime contains one typed field per CK type of the tenant.
Its name is the CK type id in camelCase without separators:
EnergyCommunity/Customer becomes energyCommunityCustomer,
Basic.Energy/EnergyMeasurement becomes basicEnergyEnergyMeasurement.
Step 3: Runtime entities, filters and paging
Select attributes
Typed fields expose the attributes of the type as GraphQL fields, records as nested objects:
query Customers {
runtime {
energyCommunityCustomer(first: 10) {
totalCount
items {
rtId
customerNumber
contact { firstName lastName companyName }
}
}
}
}
Filter
fieldFilter filters on attribute values. Several filters are combined with AND:
query ConsumptionAnchors {
runtime {
basicEnergyEnergyMeasurement(
first: 50
fieldFilter: [{ attributePath: "obisCode", operator: EQUALS, comparisonValue: "1-1:1.9.0 G.01" }]
) {
totalCount
items { rtId rtWellKnownName obisCode }
}
}
}
All operators, text search and sorting are described in Search and filter.
Page through large results
Connections are paged with first (page size) and after (cursor). Read
pageInfo.endCursor and pass it as after until hasNextPage is false:
query Page($after: String) {
runtime {
basicEnergyEnergyMeasurement(first: 500, after: $after) {
pageInfo { hasNextPage endCursor }
items { rtId obisCode }
}
}
}
Always set first. Without a page size a query returns everything it matches,
which is slow on large tenants.
Step 4: Follow associations
Associations appear as navigation fields named after the association role,
for example facilities on a customer or children on a facility. Because the
target of a role can have several types, a navigation field requires the list of
target types (ckTypeIds) and returns a union. Select the fields per type with
an inline fragment (... on <GraphQLTypeName>):
query CustomerTree {
runtime {
energyCommunityCustomer(first: 2) {
items {
customerNumber
facilities(ckTypeIds: ["Basic.Energy/OperatingFacility"]) {
items {
... on BasicEnergyOperatingFacility {
name
children(ckTypeIds: ["EnergyCommunity/Consumer", "EnergyCommunity/Producer"]) {
items {
... on EnergyCommunityConsumer { ckTypeId meteringPointNumber partitionFactor }
... on EnergyCommunityProducer { ckTypeId meteringPointNumber productionType }
}
}
}
}
}
}
}
}
}
{
"customerNumber": "20041",
"facilities": {
"items": [
{
"name": "PV plant 041",
"children": {
"items": [
{
"ckTypeId": "EnergyCommunity/Producer",
"meteringPointNumber": "AT0099990000090012000000000000009",
"productionType": "SOLAR"
}
]
}
}
]
}
}
The opposite direction works the same way: an energy measurement anchor reaches
its metering point through parent(ckTypeIds: [...]). For queries over any
association role and transitive relations see
Associations.
Step 5: Query stream data
Time series are read from an archive, addressed by its rtId. Find the
archives of your tenant in the Studio under Archives, or ask GraphQL which
archive family (the base archive and all its rollups) covers which time range:
query Coverage {
streamData {
coverageFor(rtId: "<archive-rtId>") {
archiveRtId
rtWellKnownName
status
isBase
bucketSizeMs
bucketAlignment
storedFunctions
availableFrom
availableTo
}
}
}
Ad-hoc queries go through streamData.transientStreamDataQuery, which offers
four query kinds: simple (rows), aggregation, groupingAggregation and
downsampling. The result has the column list in columns and the data in
rows, a paged connection of rows with cells.
Raw values (15-minute windows)
The energy-community raw archive is a time-range archive: each row covers a
window [window_start, window_end), here 15 minutes. rtIds restricts the query
to the anchors you are interested in, arg.from/arg.to to a time range (UTC):
query RawValues($archive: OctoObjectId!, $anchor: OctoObjectId!, $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
simple(
archiveRtId: $archive
rtIds: [$anchor]
columnPaths: ["window_start", "window_end", "amount.value", "amount.unit", "obisCode", "dataQuality"]
arg: { from: $from, to: $to, queryMode: DEFAULT }
sortOrder: [{ attributePath: "window_start", sortOrder: ASCENDING }]
) {
items {
columns { attributePath }
rows(first: 96) {
totalCount
pageInfo { hasNextPage endCursor }
items { rtId timestamp cells { items { attributePath value } } }
}
}
}
}
}
}
With from = 2026-10-05T22:00:00Z and to = 2026-10-06T22:00:00Z (one calendar
day in Vienna, summer time) the result has totalCount: 96, one row per quarter
hour:
{
"rtId": "6ac75edd1f682dc27a1b1dba",
"timestamp": "2026-10-05T22:15:00Z",
"cells": {
"items": [
{ "attributePath": "window_start", "value": "2026-10-05T22:00:00Z" },
{ "attributePath": "window_end", "value": "2026-10-05T22:15:00Z" },
{ "attributePath": "amount.value", "value": 0.065513 },
{ "attributePath": "amount.unit", "value": "KWh" },
{ "attributePath": "obisCode", "value": "1-1:1.9.0 G.01" },
{ "attributePath": "dataQuality", "value": "L1" }
]
}
}
Column paths are the CK attribute paths in camelCase (amount.value,
obisCode); time-range archives add the window columns window_start and
window_end. Rows are paged with rows(first:, after:) exactly like runtime
connections.
Sums: SUM per register for a day
groupingAggregation aggregates on the server. This query sums all 15-minute
values of one day per OBIS code, over all anchors of the tenant:
query DailyTotalsByObis($archive: OctoObjectId!, $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
groupingAggregation(
archiveRtId: $archive
groupByColumnPaths: ["obisCode"]
columnPaths: [{ attributePath: "amount.value", aggregationType: SUM }]
arg: { from: $from, to: $to, queryMode: DEFAULT }
) {
items {
columns { attributePath }
rows { items { cells { items { attributePath value } } } }
}
}
}
}
}
The result columns are obisCode and amountvalue_sum, for example:
| obisCode | amountvalue_sum (kWh) |
|---|---|
1-1:1.9.0 G.01 | 431.36 |
1-1:2.9.0 G.01 | 474.27 |
1-1:2.9.0 G.01T | 474.01 |
1-1:2.9.0 G.03 | 207.63 |
1-1:2.9.0 P.01T | 266.38 |
Tutorial 3 explains what these registers mean.
Rollups: SUM per day or month without touching raw data
Summing a year of 15-minute values means reading about 35,000 rows per anchor.
Rollup archives hold the sums already. In a rollup the aggregated columns are
named <column>_<function>, for example amountvalue_sum and dataquality_max.
Which logical attribute a rollup aggregates is returned by
streamData.rollupQueryMetadata(rtId:).
Daily values of one anchor from the daily rollup:
query DailyRollup($archive: OctoObjectId!, $anchor: OctoObjectId!, $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
simple(
archiveRtId: $archive
rtIds: [$anchor]
columnPaths: ["window_start", "window_end", "amountvalue_sum", "dataquality_max"]
arg: { from: $from, to: $to, queryMode: DEFAULT }
sortOrder: [{ attributePath: "window_start", sortOrder: ASCENDING }]
) {
items { rows(first: 31) { totalCount items { cells { items { attributePath value } } } } }
}
}
}
}
Calendar rollups are aligned to a reference time zone (for example
Europe/Vienna), so a "day" starts at local midnight: 22:00Z in summer,
23:00Z in winter. Pass from and to on these boundaries.
To get monthly totals for a group of anchors, query the monthly rollup with the list of anchor ids and group by the window start, as the Python example below does.
Step 6: A complete Python example
The script computes, per month, two key figures of an energy community that tutorial 3 explains: the share of consumption covered from within the community and the share of offered generation that was not distributed. It combines everything from this tutorial: a filtered runtime query with paging to find the anchors of each register, and a grouping aggregation over the monthly rollup.
"""Monthly coverage ratio and surplus ratio of an energy community.
export OCTO_GRAPHQL=https://assets.<your-domain>/tenants/<tenant>/GraphQL
export OCTO_TOKEN=... # see step 1
python monthly_kpis.py 2026-06 2026-09
"""
import os
import sys
from datetime import datetime
from zoneinfo import ZoneInfo
import requests
ENDPOINT = os.environ["OCTO_GRAPHQL"]
TOKEN = os.environ["OCTO_TOKEN"]
VIENNA = ZoneInfo("Europe/Vienna")
MONTHLY_ROLLUP = "<monthly-rollup-rtId>" # e.g. the archive "energy-measurements-monthly"
REGISTERS = {
"consumption": "1-1:1.9.0 G.01",
"self_coverage": "1-1:2.9.0 G.03",
"offered": "1-1:2.9.0 G.01T",
"surplus": "1-1:2.9.0 P.01T",
}
ANCHORS_QUERY = """
query Anchors($obis: SimpleScalar!, $after: String) {
runtime {
basicEnergyEnergyMeasurement(
first: 500, after: $after,
fieldFilter: [{ attributePath: "obisCode", operator: EQUALS, comparisonValue: $obis }]
) {
pageInfo { hasNextPage endCursor }
items { rtId }
}
}
}
"""
MONTHLY_QUERY = """
query Monthly($archive: OctoObjectId!, $rtIds: [OctoObjectId], $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
groupingAggregation(
archiveRtId: $archive
rtIds: $rtIds
groupByColumnPaths: ["window_start"]
columnPaths: [{ attributePath: "amountvalue_sum", aggregationType: SUM }]
arg: { from: $from, to: $to, queryMode: DEFAULT }
) {
items { rows { items { cells { items { attributePath value } } } } }
}
}
}
}
"""
def graphql(query: str, variables: dict) -> dict:
response = requests.post(
ENDPOINT,
json={"query": query, "variables": variables},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=60,
)
response.raise_for_status() # 401, 400 (invalid query), 5xx
body = response.json()
if body.get("errors"): # execution errors arrive with HTTP 200!
raise RuntimeError(body["errors"][0]["message"])
return body["data"]
def anchors_for(obis: str) -> list[str]:
ids, after = [], None
while True:
page = graphql(ANCHORS_QUERY, {"obis": obis, "after": after})
conn = page["runtime"]["basicEnergyEnergyMeasurement"]
ids += [item["rtId"] for item in conn["items"]]
if not conn["pageInfo"]["hasNextPage"]:
return ids
after = conn["pageInfo"]["endCursor"]
def month_start(year: int, month: int) -> str:
"""Local midnight in Europe/Vienna, expressed in UTC."""
return datetime(year, month, 1, tzinfo=VIENNA).astimezone(ZoneInfo("UTC")).isoformat()
def monthly_sums(rt_ids: list[str], first: str, last: str) -> dict[str, float]:
y1, m1 = map(int, first.split("-"))
y2, m2 = map(int, last.split("-"))
y2, m2 = (y2 + 1, 1) if m2 == 12 else (y2, m2 + 1) # exclusive upper bound
data = graphql(MONTHLY_QUERY, {
"archive": MONTHLY_ROLLUP,
"rtIds": rt_ids,
"from": month_start(y1, m1),
"to": month_start(y2, m2),
})
result = {}
rows = data["streamData"]["transientStreamDataQuery"]["groupingAggregation"]["items"][0]["rows"]["items"]
for row in rows:
cells = {c["attributePath"]: c["value"] for c in row["cells"]["items"]}
local = datetime.fromisoformat(cells["window_start"].replace("Z", "+00:00")).astimezone(VIENNA)
result[local.strftime("%Y-%m")] = cells["amountvalue_sum_sum"]
return result
def main() -> None:
first, last = sys.argv[1], sys.argv[2]
sums = {name: monthly_sums(anchors_for(obis), first, last) for name, obis in REGISTERS.items()}
print(f"{'month':8} {'consumption':>12} {'self-cov.':>10} {'coverage':>9} {'surplus %':>9}")
for month in sorted(sums["consumption"]):
consumption = sums["consumption"].get(month, 0.0)
covered = sums["self_coverage"].get(month, 0.0)
offered = sums["offered"].get(month, 0.0)
surplus = sums["surplus"].get(month, 0.0)
coverage = covered / consumption if consumption else 0.0
surplus_ratio = surplus / offered if offered else 0.0
print(f"{month:8} {consumption:12.1f} {covered:10.1f} {coverage:9.1%} {surplus_ratio:9.1%}")
if __name__ == "__main__":
main()
Sample output:
month consumption self-cov. coverage surplus %
2026-06 12940.8 7950.0 61.4% 61.7%
2026-07 13372.1 8093.9 60.5% 61.4%
2026-08 13372.1 7623.0 57.0% 60.2%
2026-09 12737.3 6720.7 52.8% 58.1%
Typical pitfalls
GraphQL errors come with HTTP 200. Only transport problems produce an HTTP
error status: 401 for a missing or expired token, 400 for a query that does
not match the schema. A query that is valid but fails during execution (an
unknown column path, a missing permission on a field, a server-side exception)
returns HTTP 200 with an errors array and null data for the failed part:
{
"errors": [{ "message": "Invalid column paths: timeRange.from", "extensions": { "code": "ASSET1004" } }],
"data": { "streamData": { "transientStreamDataQuery": { "simple": null } } }
}
Always check errors in the response body, as graphql() in the example does.
Omitting attributeNames returns every attribute. The generic
runtimeEntities query exposes attributes as name/value pairs through
attributes(attributeNames: [...]). If you leave the argument out you get
all attributes of the entity, including credentials stored on configuration
entities. Always pass an explicit, non-empty list. The names are matched in
camelCase only (["host"] works, ["Host"] returns nothing), and when you read a
record attribute, list the record's sub-attributes as well. Typed queries such as
energyCommunityCustomer { contact { ... } } do not have this problem because
you select each field explicitly. See
Secret attributes.
Use the exact endpoint path. It is /tenants/<tenant>/GraphQL on the asset
repository host, not on the Studio host and not without /tenants/.
Time zones. Archives store UTC. Calendar rollups are cut at local midnight
of their reference time zone. Convert local day or month boundaries to UTC
before you query, as month_start() does.
Partial windows. A time-range query returns every window that overlaps
[from, to). Put from and to on the window grid (full quarter hours for the
raw archive, local midnight for daily rollups), otherwise a window that is only
partly inside your range is counted with its full value.
Type names. The typed GraphQL field is camelCase
(basicEnergyOperatingFacility), the GraphQL type used in fragments is
PascalCase (BasicEnergyOperatingFacility), and filters and ckTypeIds use the
CK type id (Basic.Energy/OperatingFacility).
Summary
- Get a token from
octo-clifor experiments, through Authorization Code + PKCE for your own user-facing app, or through client credentials for a service. runtimehas a typed field per CK type, withfieldFilter, paging and navigation fields for associations.streamData.transientStreamDataQueryreads raw rows, aggregates and groups on the server; rollup archives deliver daily or monthly sums directly.- Check the
errorsarray even when the HTTP status is 200.
Next: Understanding energy data explains the model and registers you have just queried.