Skip to content

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:

export LEANIX_API_TOKEN='…'
leanix-pull pull --subdomain acme --type Application -o factsheets.json

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:

leanix-pull introspect --subdomain acme -o config/leanix-pull.yml
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: true where the type has the field.
  • subType: category where 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: whether Application has 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:

leanix2linkedarchi mapping-report models/leanix/factsheets.json

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]
leanix-pull pull --subdomain acme --config config/leanix-pull.yml -o factsheets.json

Two rules about where a field goes:

  • baseFields must exist on every type. GraphQL rejects a field the BaseFactSheet interface does not declare, so one over-eager entry here fails the pull for every type. level and category look 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):

leanix-pull print-query --type Application
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 errors array fails the run, even when it arrives with HTTP 200 and a partial data object — 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:

leanix-pull write-index --export factsheets.json --model-id leanix-acme

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:

leanix-pull pull-diagrams --subdomain acme \
  -o models/leanix/diagrams.json --write-index
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 state attribute 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 — see factSheetIds is 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
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.