Pulling a LeanIX Workspace¶
leanix-pull reads fact sheets from a LeanIX workspace over the GraphQL API and writes a
canonical export. It converts nothing — that is
leanix2linkedarchi's job.
graph LR
W[LeanIX workspace] -->|GraphQL| P[leanix-pull pull]
P -->|factsheets.json| G[(git)]
G --> C[leanix2linkedarchi convert]
C -->|model.trig| R[(graph)]
H[LeanIX webhook] -->|payload| I[leanix-pull ingest-webhook]
I -->|pending.jsonl| P
Credentials¶
Authentication needs an API token belonging to a technical user, created by a workspace
administrator under Administration → Technical Users. The token is not itself a bearer token: it is
exchanged for a short-lived access token at the OAuth2 endpoint, which leanix-pull does for you.
Pass it in the environment, not on the command line — a token on the command line is recorded in shell history and in CI job logs:
In GitLab CI, store it as a masked, protected variable. --api-token exists for a local one-off and
is documented as the worse option.
Introspect first¶
A LeanIX metamodel is configured per tenant, so the field names in the built-in configuration are the
documented ones rather than yours — and a field that does not exist is a GraphQL validation error that
fails the whole query for its type. introspect removes the guesswork by reading the workspace's own schema:
3 fact sheet type(s) declared by this workspace:
Application (4 scalar field(s), lifecycle)
relApplicationToBusinessCapability
relApplicationToITComponent
relApplicationToOwningTeam
relToChild
relToParent
BusinessCapability (2 scalar field(s))
relBusinessCapabilityToApplication
ProductFamily (2 scalar field(s))
relProductFamilyToApplication
3 type(s), 7 relation field(s). Field and relation names are per workspace, so these are the
authoritative ones — not the built-in defaults.
Wrote config/leanix-pull.yml — review the field selections, then pull with --config.
That output is doing two useful things at once: relApplicationToOwningTeam is a relation the documented
metamodel does not have, and ProductFamily is a fact sheet type it does not have either. Both would have
been invisible until a pull failed or a graph came out untyped.
Why GraphQL introspection rather than a LeanIX data-model endpoint: it is a standard part of the protocol rather than a path that could move, it is served by the same URL with the same credentials the pull already uses, and it describes exactly what the API will accept — which is the thing that actually breaks. A separate data-model description could disagree with the schema; the schema cannot disagree with itself.
A fact sheet type is an object type implementing the BaseFactSheet interface, which is how the API models
it. A schema naming that interface differently yields no types and says so, rather than writing an empty
configuration that would read as a workspace with no fact sheets.
What the generated configuration contains¶
- Every relation field, because relations become relationships and a missing one is a missing edge — and they are the names most likely to differ from the documented set.
- No scalar field, with one exception. A type can declare dozens, and requesting all of them would pull
data nobody chose into a committed file. The available names go in a comment beside each type, so choosing
one is a matter of deleting a
#. Fields already requested at base level are left out of that menu. lifecycle: truewhere the type has the field.subType: categorywhere the type has the field — the exception above, and the reason it is one: that scalar says what kind of fact sheet this is, so leaving it out flattens every subtype in the workspace into its parent rather than merely leaving an attribute behind. Being read from the real schema is what makes this reliable: whetherApplicationhas the field at all is a per-workspace fact. It is left out of the# available:menu too, so nobody asks for it twice.
An existing configuration is never overwritten, for the same reason the index is not: it is edited after it is generated.
# Work offline, or keep a record of what the workspace looked like
leanix-pull introspect --subdomain acme --save-schema schema.json
leanix-pull introspect --subdomain acme --schema schema.json -o config/leanix-pull.yml
Then check what the converter will do with it¶
introspect says what the workspace has; mapping-report says
which of it reaches a published LeanIX class:
The two together close the loop: discovery, then coverage.
The type inventory also tells you which meta model the workspace runs, which the converter needs as an
input. UserGroup, Project, Process or TechPlatform in the list means Meta Model v3, so the
conversion wants
--meta-model v3 — otherwise those fact sheets fall back to
lmm:FactSheet and the model claims a v4 conformance it does not have. mapping-report --meta-model v3
reports against the same choice, so the two stay in step. The pull itself is version-agnostic: it asks for
the types it was given.
Start with a subset¶
--type takes the fact sheet types the workspace's own data model declares, so a first pull can be as
narrow as you like:
leanix-pull pull --subdomain acme \
--type BusinessCapability,Application,Interface \
-o models/leanix/factsheets.json \
--write-index
Scope by type rather than by count, and keep the scope closed over its own relations where you can. A
pull of applications and capabilities has both ends of relApplicationToBusinessCapability; a pull of
applications alone has an application's link to a capability pointing at a fact sheet nothing describes.
That is a supported state — the converter warns, mints the IRI the capability would have, and joins it
up when a later pull widens the scope — but it is worth knowing you have chosen it.
The built-in configuration covers the four out-of-the-box types an architecture graph normally starts from:
| Type | Fields | Relations |
|---|---|---|
BusinessCapability |
level |
relToChild, relToParent, relBusinessCapabilityToApplication |
Application |
functionalSuitability, technicalSuitability, lifecycle |
relToChild, relToParent, relApplicationToBusinessCapability, relApplicationToITComponent, relApplicationToInterface |
Interface |
interfaceType, frequency |
relInterfaceToProviderApplication, relInterfaceToConsumerApplication, relInterfaceToITComponent, relInterfaceToDataObject |
ITComponent |
subtype (category), lifecycle |
relToChild, relToParent, relITComponentToApplication, relITComponentToProvider |
A type nobody configured is still pullable — it gets the base fields alone — so trying a custom type needs no configuration file.
The relation lists are deliberately short. A field name this workspace does not have is a GraphQL validation
error that fails the whole query for that type, so the defaults stay with the relations the documented
metamodel is clearest about; add the rest once print-query has shown they exist. The converter maps a wider
set of names than the pull requests, so adding one costs nothing on the conversion side.
Configuring the pull¶
A LeanIX metamodel is configured per workspace: types can be renamed, fields are defined per tenant, and a relation is a field whose name is derived from the type pair. Nothing above is a schema; it is a starting point. A workspace that has been reshaped needs its own configuration:
# config/leanix-pull.yml
pageSize: 500
baseFields: [id, name, displayName, type, description, status, createdAt, updatedAt]
tags: true
subscriptions: true
completion: true
types:
- name: Application
fields: [functionalSuitability, technicalSuitability, customCostCentre]
lifecycle: true
relations: [relApplicationToBusinessCapability, relApplicationToITComponent]
- name: ProductFamily # a type this tenant invented
fields: [strategicImportance]
relations: [relProductFamilyToApplication]
Two rules about where a field goes:
baseFieldsmust exist on every type. GraphQL rejects a field theBaseFactSheetinterface does not declare, so one over-eager entry here fails the pull for every type.levelandcategorylook universal and are not — they belong in the type that has them.- A type's own fields and relations go in its entry, where they end up inside an
... on Type { … }fragment.
An empty types: [] pulls nothing rather than silently defaulting to four: a file that lists none is
asking for none.
Subtypes¶
subType: names the field a type's subtype rides on, conventionally category:
types:
- name: ITComponent
subType: category # software / hardware / service
- name: Application
subType: category # businessApplication / deployment / microservice
This needs asking for explicitly, and it is easy to miss why. A subtype is not a fact sheet type. LeanIX
models a Business Application as an Application fact sheet whose category is businessApplication, so
type always comes back as the parent and --type BusinessApplication matches nothing. Without subType:
there is nothing in the export to convert, and nothing warns — no field was requested, so no request failed.
It is per type rather than a base field for the reason above: category is not on BaseFactSheet. Only
ITComponent has it in the built-in defaults, because that is the one type whose subtypes are in the standard
metamodel; Application's three are optional upstream and off by default, so a workspace that has not added
them may have no category on Application at all. introspect reads the real schema and
writes subType: wherever the field is there, which avoids the guess entirely.
subType: true is accepted as shorthand for subType: category, and the field is de-duplicated against
fields: — listing category in both is harmless. The converter resolves the value to a class rather than a
string; see Subtypes.
Checking a query before running it¶
The only authority on whether a field exists is the workspace. print-query prints exactly what would
be sent, ready to paste into GraphiQL (Administration → Developers → Tools):
query pullFactSheets($first: Int!, $after: String) {
allFactSheets(
first: $first
after: $after
filter: { facetFilters: [
{ facetKey: "FactSheetTypes", operator: OR, keys: ["Application"] }
] }
) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
…
... on Application {
functionalSuitability
lifecycle { asString phases { phase startDate } }
relApplicationToITComponent {
edges { node { id activeFrom activeUntil description factSheet { id type } } }
}
}
}
}
}
}
A field the workspace does not have comes back as Cannot query field "x" on type "Y", and that message
is passed through verbatim — it says precisely what to fix.
One query per type, not one for all of them. A single query would need every type's fragment in it, so one bad field name would fail the pull for every type at once; per type, the same mistake costs one type and names it.
Paging, and what a pull refuses to do¶
Paging follows pageInfo.endCursor, never an offset. That is what the API offers and it is also what
keeps a long pull correct: a fact sheet created while page seven is being fetched shifts every offset
after it, so an offset walk would silently skip a record.
--page-size defaults to 500 and is capped at 1000, following LeanIX's own recommendation; a single
allFactSheets query is limited to 15,000 edges.
Two failure behaviours are deliberate:
- A GraphQL
errorsarray fails the run, even when it arrives with HTTP 200 and a partialdataobject — which is exactly what a wrong field name looks like. Writing the partial answer would produce an export missing fact sheets for a reason no reader could see, and the next conversion would delete triples as if the data were gone. - 429 and 5xx are retried with exponential backoff, and each retry is announced on stderr so a slow pull does not look like a hung one. 401 and 403 are not retried: a rejected token does not become accepted by asking again.
The export, and the index beside it¶
--write-index writes the fact sheet index the converter reads, beside the export, if it is not
already there:
# models/leanix/factsheet-index.yaml
model:
id: leanix-acme
title: "LeanIX — acme"
description: "Fact sheet types in scope: BusinessCapability, Application, Interface"
views:
- id: factsheets
file: factsheets.json
status: publish
An existing index is never overwritten. It holds the model id, the lifecycle state and any extension data someone decided on — none of which can be regenerated from an export, and all of which a regenerating pull would quietly discard.
For an export you already have, write-index does the same job on its own:
The default model id is leanix-<workspace>. It becomes an IRI path segment, so it is validated here
rather than later — an invalid id fails at the point it is written, with the index as the thing to fix.
Commit the export¶
The export is meant to go in git. It is written with a fixed key order and null values omitted, so an
unchanged workspace produces an unchanged file: the diff is the change in the inventory, not noise from
a reserialisation. That makes "what changed in the architecture this week" a git log question, and it
makes a conversion reproducible.
Diagrams, which are views¶
pull reads the inventory. It does not read diagrams, and that is a distinction with consequences
rather than a gap: a LeanIX diagram is a view — somebody chose what to put on a canvas — while a fact
sheet export answers "what is in the workspace". Converting an inventory into an arch:View would mint a
diagram nobody drew.
So diagrams have their own command, their own file, and their own half of the API:
Wrote 3 diagram(s) to models/leanix/diagrams.json (2 with fact sheet references, 6 reference(s) in total)
Wrote models/leanix/diagram-index.yaml — fill in 'viewpoint:' for each diagram you want typed.
Then convert the two together, so each view's nodes point at the elements it shows:
leanix2linkedarchi convert models/leanix/factsheets.json \
--diagrams-export models/leanix/diagrams.json \
--diagram-index models/leanix/diagram-index.yaml \
--base-iri https://example.org/la/ --format TRIG -o out.trig
Diagrams are bookmarks, and bookmarks are REST¶
Upstream models a diagram as a bookmark — one resource covering saved searches, reports, diagrams and
dashboards, discriminated by bookmarkType — and only the REST API exposes them. So this is a second
protocol against the same host with the same bearer token, not another query. --bookmark-type is
overridable because the same endpoint and envelope serve the other kinds; only VISUALIZER is a view.
Two things worth knowing before the first run:
- Bookmark access is permissioned separately from fact sheet access. A technical user that pulls the inventory happily can still be refused diagrams, and a 403 here says so by name rather than leaving you to guess at the token.
- What is inside a diagram's layout is not documented. SAP says the
stateattribute holds "the diagram layout and filters" and that its content varies by bookmark type, and publishes no schema for the diagram case. So the fact sheets on a canvas are discovered rather than parsed — seefactSheetIdsis a search result — and the pull reports per diagram how that went instead of quietly writing an empty one:
[WARN] diagram 'Whiteboard sketch' (d1000000-…-000000000003) yielded no fact sheet references. Either it
draws none, or its layout keeps them somewhere this tool does not look. Its state's top-level keys are:
shapes. Re-run with --retain-state to keep the blob for inspection.
--fail-on-empty turns "no diagram yielded anything" into exit 1, for a pipeline that should notice the
layout format having moved rather than publishing a set of empty views. It is deliberately not the default:
a workspace whose diagrams really are empty is not a broken pipeline.
The diagram index is where a viewpoint is declared¶
--write-index scaffolds one entry per diagram, keyed by LeanIX diagram id:
# models/leanix/diagram-index.yaml
diagrams:
- id: d1000000-0000-4000-8000-000000000001
# name: Payments landscape
# 3 fact sheet reference(s)
viewpoint: InterfaceMap
status: publish
viewpoint: is the reason the file exists, and it is blank on purpose. It cannot be inferred: the
published lmmvp:ApplicationPortfolio and lmmvp:CapabilityMap declare identical arch:includesConcept
sets, so no examination of a diagram's contents distinguishes them, and a Free Draw canvas says nothing
about what was drawn on it. Left blank the view carries no viewpoint, which is honest; filled in, "show me
every capability map" becomes one query. See
the converter page for how it reaches the graph.
This is a second index rather than a field on the fact sheet one because the ordinary index is keyed by file — one entry is one file is one model with at most one view — and a single diagram export holds every diagram in the workspace. As with the fact sheet index, an existing file is never overwritten.
--retain-state, and why it is off¶
The layout blob is dropped by default. It is a layout, so it changes whenever somebody nudges a box, and a committed export full of coordinates stops being reviewable as a diff. Switch it on to investigate a diagram whose contents were not recognised, or to keep the raw material for a release that can read layouts.
Webhooks: a trigger, not a patch¶
A scheduled pull re-reads the whole scope whether anything changed or not. LeanIX webhooks can say when
something changed, and leanix-pull uses them as a trigger:
# The receiving end of a webhook delivery
leanix-pull ingest-webhook --queue .leanix-pending.jsonl < payload.json
# The scheduled job: quiet while the workspace is unchanged
leanix-pull pull --subdomain acme --if-pending .leanix-pending.jsonl -o factsheets.json
With an empty queue the pull does nothing and says so, without even asking for credentials. After a successful write the queue is cleared — after, not before, so a failed pull does not lose the record of why it was due.
Set the webhook up in Administration → Webhooks with type PUSH, a target URL, and the events
FACT_SHEET_CREATED, FACT_SHEET_UPDATED, FACT_SHEET_DELETED.
The queue is JSON Lines — one object per delivery — so appending is a single write and concurrent deliveries need no lock. A malformed line is skipped rather than failing the read: the queue is written by an endpoint receiving third-party payloads, and letting one bad delivery block every later pull would turn it into an outage.
Why not apply the events directly?
A payload says a fact sheet changed, not what it now looks like. Relation changes arrive on one side of the edge only — a parent/child change reports the parent — and a deletion has to remove triples a merge-only pipeline never revisits. So the pull that follows a trigger is a full pull of the selected scope, which is always internally consistent. The queue saves the run, not the correctness.
In CI¶
# .gitlab-ci.yml in the source repository
pull-leanix:
stage: fetch
image: registry.example.org/linked-archi/converters:1.3.0
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- leanix-pull pull --subdomain "$LEANIX_SUBDOMAIN"
--type BusinessCapability,Application,Interface
-o models/leanix/factsheets.json
--write-index
artifacts:
paths: [models/leanix/]
LEANIX_API_TOKEN and LEANIX_SUBDOMAIN come from masked CI variables. Whether the job commits the
export back or hands it to the convert job as an artifact is a choice about how much history you want:
committing gives a reviewable trail of the inventory, an artifact gives a shorter pipeline.
Options¶
pull¶
| Option | Default | Description |
|---|---|---|
--subdomain |
required | acme, acme.leanix.net or https://acme.leanix.net/ |
--api-token |
$LEANIX_API_TOKEN |
Technical user's API token. Prefer the environment |
-o, --output |
required | Export file to write |
--type |
the built-in four | Fact sheet type, repeatable or comma-separated |
--config |
built-in | Pull configuration YAML |
--page-size |
500 |
Fact sheets per request, maximum 1000 |
--write-index |
false |
Also write the fact sheet index, if absent |
--index-file |
beside the export | Where --write-index writes |
--model-id |
leanix-<workspace> |
Model id for the generated index |
--if-pending |
none | Pull only when this webhook queue records a change, then clear it |
--token-url |
derived | Override the OAuth2 endpoint, for a dedicated MTM instance |
--graphql-url |
derived | Override the GraphQL endpoint |
--workspace-url |
derived | Human-facing workspace base URL, recorded in the export |
pull-diagrams¶
| Option | Default | Description |
|---|---|---|
--subdomain |
required | Workspace subdomain or host |
--api-token |
$LEANIX_API_TOKEN |
Technical user's API token |
-o, --output |
required | Diagram export file to write |
--bookmark-type |
VISUALIZER |
Which bookmark type to fetch. VISUALIZER is what LeanIX calls a diagram |
--page-size |
100 |
Bookmarks per request |
--retain-state |
false |
Keep each diagram's raw layout blob in the export |
--write-index |
false |
Also write the diagram index, if absent — where viewpoints are declared |
--index-file |
beside the export | Where --write-index writes |
--fail-on-empty |
false |
Exit 1 when no diagram yielded a single fact sheet reference |
--bookmarks-url |
derived | Override the Pathfinder bookmarks endpoint |
introspect¶
| Option | Default | Description |
|---|---|---|
--subdomain |
required | Workspace subdomain or host |
--api-token |
$LEANIX_API_TOKEN |
Technical user's API token |
-o, --output |
none | Write a pull configuration for the types found. Never overwrites |
--type |
all | Restrict the written configuration; the report always covers everything |
--schema |
none | Read a saved introspection response instead of calling the API |
--save-schema |
none | Save the raw response, for offline reuse |
print-query¶
| Option | Default | Description |
|---|---|---|
--type |
the built-in four | Fact sheet type, repeatable or comma-separated |
--config |
built-in | Pull configuration YAML |
write-index¶
| Option | Default | Description |
|---|---|---|
-e, --export |
required | The export the index should point at |
--model-id |
required | Model id |
-o, --output |
beside the export | Index file to write |
--view-id |
factsheets |
View id inside the model |
--title |
from the export | Model title |
ingest-webhook¶
| Option | Default | Description |
|---|---|---|
--queue |
required | Queue file to append to |
--payload |
stdin | Webhook payload JSON file |
Sources¶
API behaviour above was taken from the SAP LeanIX developer documentation, read 2026-08-15: authentication, technical users, retrieving fact sheets, pagination, filtering, webhooks. Content was rephrased for compliance with licensing restrictions.