Skills
TC Classification Bulkload
Skill
tc-classification-bulkload. Bulk-load any domain into Teamcenter Classification. The four-stage reconcile / resolve / generate-plmxml / idempotent-rerun pipeline proven end to end, why the plmxml plus CLI route beats direct SOA writes, the search-index caveat that makes REST-write success reports unreliable, and the real documented SOA op for creating hierarchy nodes (not just placements). Use for classifying items at scale (notes, functions, logical elements, signals, parts) or building the class tree itself.
Domain-independent pipeline, proven end to end on an 845-plus-48 placement load, and written to carry directly to the next domain by swapping the source spreadsheet and the class-tree branch.
The four stages
1. Reconcile
Parse the system of record (a spreadsheet, an extract) into a flat work-order of
required (item, class-breadcrumb) placements, then diff it against what is
actually live in Teamcenter today.
Do not trust that a prior one-time load did this correctly. Re-derive the true requirement from source data. On the proven run this surfaced that more than 60% of required placements were missing, and that the original loader supported only one classification per item and silently dropped the rest.
2. Resolve live
Given a class breadcrumb (an arrow-separated path) and a leaf name, resolve it to Teamcenter's real class ID:
Internal-IcsAw-2019-12-Classification/findClassificationInfo3
searchAttribute: CLASS_NAME
Disambiguate candidates by walking classParents to match the full expected
ancestor chain. This endpoint is undocumented (it is what the Active Workspace
Classification UI itself calls) but read-only and therefore low risk. It is not in
the SDK's WSDL kit; see tc-capture-awc-calls.
2b. Resolving items you JUST created in this same pipeline
If an earlier stage of this same pipeline created the items being classified
(see tc-object-authoring's createAttachAndSubmitObjects), do not assume
you can find them again by searching for the source data's original ID.
Found live 2026-08-05, reported by a real user whose classification stage
resolved zero items despite the create stage having succeeded: most
Requirement-family types have a site naming rule that faults on a
manually-supplied ID ("no matching Naming Rule pattern could be found", code
74007), so the create step has to let Teamcenter auto-assign the real ID
instead. The auto-assigned ID (e.g. REQ-000351) then has zero relationship
to the source spreadsheet's original designation. A name/ID search for the
original designation will never find it — not a staleness problem that
resolves itself, a permanent mismatch. (A user who manually corrected the ID
mismatch and still got nothing is proof of a second, separate cause
stacked on top — see 2c below. It first reads as "must be staleness," but
confirm rather than assume: it turned out to be something else entirely.)
Fix: track the mapping at creation time, don't search for it later. Have
the create stage log {req_id, item_uid, revision_uid} per row as it creates
(straight from the create call's own response). Have the resolve stage try
the search first (for genuinely pre-existing items), and fall back to that
log — keyed by the ORIGINAL untransformed identifier, not any domain-specific
ID transform a reconcile step may have applied for a different, older
identifier scheme — when search comes up empty.
2c. Search is scoped by Teamcenter's Project, not by account
Root-caused live 2026-08-11, superseding an earlier, incomplete diagnosis below this line in an older version of this skill. The earlier note said a DBA-privileged login "cannot find items via search at all" and reserved DBA strictly for the hierarchy-node create call. That was a real, reproducible observation but not the actual mechanism — DBA privilege itself does not block search.
The real variable is Teamcenter's session-level Project context: a
setting distinct from user/group/role, visible in Active Workspace's own
user-profile menu (top right — "Project: Core-2015-10-Session/setUserSessionStateAndUpdateDefaults
(pairs: [{"name": "currentProject", "value": "<TC_Project uid>"}] — it takes
a UID, not the display name; passing a name faults cleanly with "The given
string is invalid.", code 515029). executeSavedQuery (and therefore every
"find item" step built on it) is scoped by this Project independently of
which account is logged in.
Live A/B/A proof, same account throughout, same query, same real item: found under Project=F-35 (nFound 1) → not found under Project="No Project" (nFound 0) → found again after switching back (nFound 1). Separately, the account originally suspected of being "DBA and therefore write-only" turned out to simply have a different default Project set (an unrelated program) — pointing that same DBA session's Project at F-35 made its search behave identically to the ordinary account's, no privilege change involved.
Practical consequence: check/set Project before trusting any empty search
result from ANY stage, not just the one using a DBA account — a stale or
wrong Project on an otherwise-ordinary account produces the identical silent
"nothing resolves" symptom. No SOA op to enumerate/resolve project name->uid
was found working in this session (Core-2012-09-ProjectLevelSecurity's
getUserProjects2 and siblings register but reject every body shape tried
with a bare "invalid syntax" — no WSDL/XSD for the op was available locally to
read the real shape from). Known project UIDs for a given site have to be
captured once, live, from Active Workspace's own dropdown-switch network call
(see tc-capture-awc-calls) and cached in a name->uid map; a raw UID always
works directly, bypassing the map.
3. Generate the bulk-load artifact. Do not hand-write it.
The officially supported mechanism is server-side CLI:
plmxml_import # class hierarchy AND ICO/placement objects
clsutility -import -hierarchy # hierarchy only
ics_connect -file=<item-list>.txt # placements only
smlutility -create_indexing_views # rebuild the classification search index
Prefer this over scripting direct SOA writes, for two reasons: it is the path Siemens' own delivery teams use on real programs, and it correctly rebuilds the search index (see the caveat below).
The plmxml schema:
xmlns:plmxml_cl="http://www.plmxml.org/Schemas/PLMXMLClassificationSchema"
transferContext="ICSExportICOs"
One <plmxml_cl:ICO icoId="<item_id>/-"> per (item, class) placement, carrying a
<plmxml_cl:ClassId> for the leaf then repeated <plmxml_cl:Parent> elements
walking the ancestor chain out to the classification root. Custom classes use
numeric IDs; fixed built-in roots use symbolic IDs (SAM, ICM). Confirm the
fixed-root suffix for whatever branch a new domain lives under: it may differ
from the one you saw last time.
Multi-classifying one item is multiple <ICO> elements sharing the same
icoId. That is exactly what the original single-classification loader got
wrong.
3b. Creating hierarchy NODES (structure), not just placements
The three stages above build placements (ICOs) into an existing tree. Building
the tree itself — the class/hierarchy nodes — is a different operation, and unlike
the undocumented Internal-IcsAw placement endpoints, it has a real, documented SOA
op:
ClassificationCore-2013-05-Classification/createOrUpdateHierarchyNodes
Found in the Help Server WSDL kit (ClassificationCore1305Classification.xsd /
...Service.wsdl, see tc-soa-docs-navigation) by grepping every .xsd for
createOrUpdateHierarchyNodes — not discoverable by guessing service/domain names
(a first blind guess of ClassificationCore-2013-05-ClassificationCore/... used the
wrong domain segment and faulted with a generic syntax error indistinguishable from
"this operation doesn't exist"; the real domain is Classification, matching the
XSD's own .../ClassificationCore/2013-05/Classification namespace).
Request shape (hierarchyNodeDetails, one entry per node), fully live-confirmed
2026-08-05 under a DBA account — created a brand-new AbstractClass node plus a
brand-new StorageClass child under it, neither existing beforehand, in one run, 0
errors, each independently verified via a direct getProperties read on the uid
Teamcenter returned:
{
"nodeToUpdate": NULLTAG, // NULLTAG = create mode
"parent": {"uid": "<parent node uid>", "type": "unknownType"},
"masterNode": NULLTAG, // only for Reference-type nodes
"icon": NULLTAG, "images": [], "attachments": [],
"nodeId": "<leaf name>", "nodeName": "<leaf name>", "nodeDesc": "<leaf name>",
"nodeType": "HierarchyNode_Master", // Group/Master/Reference; Group untested
"storageClassTypeName": "", // leave empty even for Storage Class -- see below
"isLeafNode": false, "levelSortIndex": -1, // yes, false/-1 for BOTH types -- see below
"isAbstract": true, // true = Abstract Class, false = Storage Class
"isAssembly": false, "allowMultipleClassification": true, "instanceCount": 0
}
An existing node's own parent reference (needed to attach the first new level) is
its NODE_UID property, readable from findClassificationInfo3's
clsClassDescriptors[...].properties — same read call as stage 2 above, no new
lookup needed.
Three fields whose XSD prose is misleading, corrected by reading back a real
existing node's actual getHierarchyNodeDetails:
storageClassTypeNamelooks like it should be a fixed type-name string (a first guess,"Cls0ClassBase", faulted with"class Cls0ClassBase cannot be found", code 132008) but a real Storage Class node's own value is just its own numeric class id — self-referential, doesn't exist until after creation. Leave it empty at create time; it gets populated automatically.isLeafNode: a real, populated, genuinely-terminal Storage Class node reads backfalse. Not something creation intent sets — leavefalse.levelSortIndex: a real node reads back-1, not a sequential per-sibling index. Leave-1rather than trying to compute sibling ordering.
Response shape: the new node's uid is in the top-level nodeObjects field
([{"uid", "className", "type"}], one per input entry, in order) — NOT under
ServiceData.created, the first (wrong) guess.
Same search-index caveat as REST placement writes (see below): a node created
this way is invisible to findClassificationInfo3 immediately after — confirmed
live, a query for the just-created node by name returned zero matches while a
direct getProperties on its uid fully confirmed it exists. Track new nodes from
the create call's own nodeObjects response within a run; don't re-query
findClassificationInfo3 to "confirm" a just-created node, and don't trust a
same-instance re-run's "already exists" check across sessions until
smlutility -create_indexing_views has run.
Privilege: needs a DBA-privileged account/group (same "Active Admin" boundary as the Classification Manager UI) — a non-privileged session reaches the operation fine but faults with a real privilege error (code 51057), not a generic one.
RETRACTED 2026-08-11 — the generic delete op DOES work, contrary to the earlier
note below. Core-2006-03-DataManagement/deleteObjects with {"uid": "<node uid>", "type": "Cls0MasterNode"} (the object's REAL type — passing "unknownType"
is a plausible reason an earlier flat single-node test may have silently failed)
successfully removed 5 real leftover test nodes, live-verified: a batch delete of a
parent+child pair in one call partially failed with a real, honest error —
"The Partition object cannot be deleted because it contains at least one child Partition. Please delete all child Partitions first." (code 280010) — for the
parents, while the child in the same call succeeded. Re-running the delete call for
just the parents, after their children were confirmed gone via a direct
getProperties re-read, then succeeded cleanly with no errors. Delete
bottom-up: children before parents, and re-verify by dereferencing the uid
directly afterward (type comes back empty on a genuinely deleted object) — do not
trust a same-batch "no error" as proof for objects with dependents.
An older attempt on a single, childless test node reported "no error, but the
object was still fully readable afterward" — plausibly caused by passing
"unknownType" instead of the object's real type rather than a genuine incapability
of the operation; not independently re-confirmed which it was. Either way: prefer
deleteObjects with the object's real type over assuming the Classification
Manager UI is required. Search (findClassificationInfo3) staying stale for a
deleted node proves nothing either way — dereference the uid directly to confirm.
This makes hand-rolling a class-hierarchy plmxml (a schema variant never actually seen) unnecessary — this documented SOA op is the confirmed path.
4. Idempotent re-run
Cache class and item resolution, diff against current live state, emit only the delta. This must be safe to re-run as the source data changes without tracking what was already done externally.
Actually do the second run. Re-running against already-populated state is the
only thing that exercises the already-populated code path, and it is where latent
bugs live: one generator's existence-check loop body was never reached on a first
run and threw a NameError only on the second.
The caveat that makes REST writes untrustworthy here
Writes via saveClassificationObjects2 (undocumented AW-internal REST) report
success but are invisible to findClassificationInfo3 reads taken immediately
after, and stayed invisible after several minutes and a second write attempt.
Strong evidence the read is backed by a search index that only gets refreshed by
smlutility -create_indexing_views, which cannot be triggered without TC-server
shell access.
Practical consequence: do not treat the REST path's own "already classified" check as proof of persistence. The plmxml plus CLI path does not have this problem, which is the main reason to prefer it for anything beyond a quick manual fix.
Reference implementation
A standalone, no-Claude-dependency Python toolchain sits at
Siemens/f35-notes-classification/lockheed_package/: reconcile.py,
build_icos_plmxml.py, apply.py (placements), plus create_requirements.py
(creates the source items themselves via tc-object-authoring's
createAttachAndSubmitObjects) and build_hierarchy_plmxml.py (creates missing
hierarchy nodes via createOrUpdateHierarchyNodes, above). All share
tc_client.py, which as of 2026-08-11 also exposes set_current_project() and
a TC_PROJECT env var on every script's connect() — see 2c above for why.
Environment-variable TC connection config, pure stdlib plus
openpyxl. Fork it for the next domain and adapt only the domain-specific bits:
spreadsheet sheet names and columns, root class IDs, and the item-ID transform.
Related skills
tc-capture-awc-calls, tc-soa-docs-navigation (how the hierarchy-node op was
found — grep every .xsd, don't guess service/domain names), tc-object-authoring,
tc-verify-and-cleanup, tc-parameters-units (UMS is also built on Classification).
Generated from skills/tc-classification-bulkload/SKILL.md in the tc-automation-skills library, which is the canonical copy and also serves as the agent skill set for Teamcenter work.