Skills
TC Help Server Authoring
Skill
tc-help-server-authoring. Author, publish, package, and distribute custom manuals on a local Siemens Support Center Documentation Server (the "Teamcenter Help Server") so they serve, search, and deep-link exactly like an out-of-the-box collection. Covers the real content and Elasticsearch index formats (reverse-engineered from a live install AND from taking apart two real Siemens installer .exe files, no public admin guide exists), the reindex command found in the installer's own log, real bugs hit building this and root-caused with an A/B test (a PowerShell ConvertTo-Json read that silently mangled the schema; an "[ids] failed to parse field [values]" importer error that looked like a nondeterministic vendor bug but was actually caused by launching help-server-win.exe with the wrong process working directory -- 4/4 failures with the wrong directory, 9/9 clean with it pinned via Start-Process -WorkingDirectory, confirmed causal by reproducing the failure on demand; and a Copy-Item -Recurse destination-exists gotcha that doubled a folder segment), and a PowerShell toolkit -- author, publish, package, install-elsewhere, verify -- that automates and has proven the complete pipeline end to end. Use for "add a custom manual to the help server", "install documentation like Teamcenter's own", "package documentation for another server", or any Documentation Portal / help-server-win.exe investigation.
Everything here was verified against a real local install
(D:\Siemens\Help Server\, SiemensPLMDocumentationServer service, ports 51000/51001) on
2026-08-17. There is no public Siemens admin guide for adding custom content to this product; this
was reverse-engineered by reading the install's own files and log, and proven by actually
registering a working section (SKU XCEL00000001, "Documentation Builder") with two live handles.
Prefer the scripts. scripts/XcelDocBuilder.psm1 plus New-XcelDocSection.ps1 /
Add-XcelDocModule.ps1 / Publish-XcelDocs.ps1 encode everything below and were used to build and
verify the real section this skill documents. Read the rest of this file for the reasoning, or if
you need to do something the scripts don't cover.
The layout
<Root>\collections\documentation\external\<SKU>\<locale>\<collection>\<handle>\ content
<Root>\collections\es\external\<SKU>\<locale>\ search index
<Root>\help-server-win.exe server binary + CLI
A handle is one manual (e.g. AWAdmin, MBSE, or a custom one). A SKU is a product
release install; every SKU has exactly one productId, the number used in shell URLs
(282219420 = "Teamcenter", shared across every Teamcenter SKU). Pick a custom SKU/productId that
cannot collide with a real Siemens value — this work uses the convention XCEL0000000N /
90000000N.
Content format (per handle)
- HTML topic pages, any folder nesting, no required template — plain self-contained HTML works.
toc.json— nested nav tree:{href, title, id, children: [...]}. Leaf nodes have anhrefto an HTML file (relative to the handle root); section headers havechildrenand nohref.topics.json— the same tree flattened to one array, with a syntheticHeaderTopicentry per section header, plus asearchtextfield per topic (plain-text body extract) that powers the in-collection search box. Read client-side; editing it does not require a reindex.copyright.html— present on every shipped handle; not proven required, kept for consistency.
Search-index format (per SKU, under es\external\<SKU>\<locale>\)
package.json— one record:{id, baseVersion, releaseVersion, productTitle, productId, sku, lang, internal, packageId, packageVersion, createDate, largeFile}.product.json— feeds the Elasticsearchproductsindex (see below); a minimal{versions, meta, created, groups}shape works, does not need Siemens' full shared taxonomy.content\NNNN.json— a JSON array of Elasticsearch bulk-index docs. Every handle needs exactly one"collection":"dc"record in here. This is where the first real bug lived — see below.
The three Elasticsearch indices, and why all three matter
| Index | Fed by | What breaks if missing |
|---|---|---|
packages |
package.json |
— (nothing observed yet; but see the correction below, it is NOT reliably written) |
content |
content\NNNN.json "dc" records |
the handle's shell page (/en-US/doc/<productId>/<sku>.<handle>) and the in-collection search box |
products |
product.json |
the product's documents-listing page (/en-US/documents/<productId>/<sku>) — sometimes 404s when the handle itself is perfectly reachable, see the correction below |
A handle can look completely done (static file serves fine, shell page resolves) and still 404 on
the listing page because products alone is empty. Verify all three, not just one.
⚠ Corrected 2026-08-19, twice. The packages row used to read "this one always succeeded in
testing". It does not. On XCEL00000004 all three records were absent after an import that
reported success, packages included, checked by its real id (<SKU>-<locale>, read out of
package.json, not guessed). So there is no index that can be assumed written, and none of the
three is a proxy for the others.
And the products row is not a reliable sentinel in the other direction. On
XCEL00000004 and XCEL00000006 the listing page answered 200 with no products document at
all, while the shell page 404'd. So a green listing page does not prove products was written,
and the shell page was the only endpoint that told the truth. Prove the index by fetching each
record by its own id (/content/_doc/<sku>.<handle>, /products/_doc/<productId>) and reading
found. See Bug 6.
The reindex command
Found verbatim in the installer's own transcript, <Root>\Debug.info — the installer runs this
same command after laying down content, many times in sequence with no ill effect:
"<Root>\help-server-win.exe" import -e http://localhost:51001 -c "<Root>\collections"
Static content (HTML/toc.json/topics.json) is served directly from disk and needs no reindex to go
live. Only the search-index layer (es\external\...) needs import — or the fallback below — to
become visible in search/shell/listing pages.
Bug 1: a PowerShell object-to-JSON round-trip lied about the schema
The first draft of a content-index record was modeled on a real example read via
Get-Content | ConvertFrom-Json | ConvertTo-Json -Depth 3. That command silently truncates
anything nested deeper than the depth limit and prints its .ToString() instead — the real
files field (an array of objects: {type, uri, key, ext}) rendered as the literal string
"@{type=application/json; uri=...}", and that mangled text got copied into the hand-authored
record as-is (valid JSON syntax, wrong shape). Re-reading the same file as raw text
(Get-Content -Raw, no object conversion) showed the truth: files is an array of real objects,
and categories/enrichedCategories values are arrays of strings, never bare strings.
Never trust a lossy object-serializer round-trip as ground truth for an unfamiliar JSON schema.
Read the raw bytes, or use New-XcelDcRecord in the provided module, which was checked against
the raw file and builds a real object graph (so PowerShell's own ConvertTo-Json -Depth 12 on it
serializes correctly with no truncation).
Bug 2: [ids] failed to parse field [values] — traced to the wrong process working directory (see Bug 4)
help-server-win.exe import would sometimes fail partway through:
error: Error Importing: <SKU>-<locale>, <releaseVersion>
error: Error: [parsing_exception] [1:37] [ids] failed to parse field [values]
Diagnosis: query Elasticsearch directly (http://localhost:51001/_cat/indices?v) — the packages
doc gets created, but content/products docs for the affected SKU do not. The exact same record,
PUT directly to Elasticsearch, is accepted immediately:
PUT http://localhost:51001/content/_doc/<record.id> body = the one record object
PUT http://localhost:51001/products/_doc/<productId> body = product.json's content
PUT http://localhost:51001/packages/_doc/<package.id> body = package.json's content (usually unnecessary — this one succeeds via the normal importer)
Index name is the real Elasticsearch index (content, products, packages) — not the "index"
field value inside a content record ("content-external", which is the app's own internal routing
label, not an ES index name). Type is always _doc, id is the record's own id field.
Root cause: almost certainly the same broken process working directory documented as Bug 4.
Two earlier versions of this doc got the diagnosis wrong in opposite directions — first "recurs on
every full reindex" (written after 3 failures in a row, before anything else had changed), then
"not deterministic, root cause unknown" (written after a 4th attempt succeeded right after the Bug 4
fix landed, which read as coincidence rather than cause). A deliberate A/B test resolved it: 4 of 4
attempts using the old bare-& invocation from an arbitrary directory failed with this exact error
(including one reproduced on demand, after the fix, by deliberately reverting to the old invocation
style); 9 of 9 attempts using Start-Process -WorkingDirectory $HelpServerRoot succeeded. 13 data
points, zero exceptions to the split. The mechanism is still not confirmed (no source access) — the
leading hypothesis is that the importer reads some local state relative to its own working directory
to decide how to build its create-vs-update ids query, and gets it wrong when that directory isn't
its own install root — but the correlation is strong enough that this is no longer treated as an
unpredictable vendor bug: always invoke help-server-win.exe with its working directory pinned to
$HelpServerRoot, and this error should not occur. Publish-XcelDocs does this via Start-Process
and additionally still detects and auto-recovers from the error signature if it ever appears anyway.
Bug 3: Copy-Item -Recurse doubles a folder when the destination already exists
Hit while building Export-XcelDocSection.ps1. Pre-creating the destination SKU folder
(New-Item -ItemType Directory ...\<SKU>) and then running
Copy-Item -Path <source>\<SKU> -Destination ...\<SKU> -Recurse does not merge the source folder's
contents into the destination — PowerShell copies the source folder itself into it, producing
...\<SKU>\<SKU>\.... Caught by running Import-XcelDocPackage.ps1 -WhatIf (which extracts into a
scratch copy and reports the resulting file list) before ever pointing the script at a real install.
Fix: create only the parent directory and let Copy-Item create the SKU leaf itself.
This is the general reason -WhatIf exists on the packaging script and not the others: authoring
scripts write content this workspace wrote, trivially easy to inspect and re-run; a packaging/copy
step moves a whole tree in one shot and is a different failure class, worth a dry run before trusting
against a real target.
Bug 4: Publish-XcelDocs was writing logs into whatever folder it happened to be called from
help-server-win.exe writes its own logs\ folder relative to its **caller's process working
directory**, not its own install directory. Running Publish-XcelDocs.ps1 from inside this skill's
own scripts\ folder left a stray logs\ folder there — found only because it showed up in git status before a commit, not because anything looked broken.
Two fixes were tried and both failed, which is worth recording exactly because the failure was silent — the script kept working, it just kept leaving the same litter behind:
Push-Location $HelpServerRootbefore the call. Does not work:Push-Location/Set-Locationonly move PowerShell's own$PWD, which a native child process launched via&does not inherit.[System.IO.Directory]::SetCurrentDirectory($HelpServerRoot)before the call. Also does not work, even though this sets the real, process-wideEnvironment.CurrentDirectorythat native children are supposed to inherit — PowerShell appears to re-sync that value back to$PWDat each pipeline invocation, undoing the manual set before& $exeever actually launches the process.
**What actually works, verified from three different starting directories with zero stray logs\
folders left behind afterward:** Start-Process -FilePath $exe -WorkingDirectory $HelpServerRoot -RedirectStandardOutput ... -RedirectStandardError ... -Wait -NoNewWindow. -WorkingDirectory sets
the Win32 CreateProcess working directory directly and is not subject to either of the above.
✅ Confirmed causal, not coincidental, for Bug 2. A controlled A/B test deliberately reverted to
the broken bare-& invocation from an arbitrary directory after this fix was in place: it reproduced
[ids] failed to parse field [values] on demand, immediately. Combined with 9 consecutive clean runs
using Start-Process -WorkingDirectory, the full record is 4 failures / 4 attempts with the wrong
working directory and 9 successes / 9 attempts with the right one. See Bug 2 above for the corrected
writeup — treat this fix as the actual resolution, not a workaround for a separate unexplained bug.
Bug 5: the verifier never verified anything, and reported a pass over a real 404
⚠ Found 2026-08-19, while registering the second real section (XCEL00000002, Tier Health).
Every "All endpoints live" this skill has ever printed should be treated as unproven. The
reference build in the section below is genuinely live, but that was confirmed by hand afterwards,
not by this function.
Two independent defects stacked, and each one alone was enough:
Invoke-WebRequestcould never reach this server at all. Windows PowerShell 5.1 parsesSet-Cookieeagerly, and the Documentation Server issues one withDomain='.localhost', which .NET rejects:The 'Domain'='.localhost' part of the cookie is invalid.The call threw before the response was ever inspected. Every check, every time.⚠ Scope: this is Windows PowerShell 5.1 only, not
Invoke-WebRequestas such. Measured 2026-08-19 on PowerShell 7.6.5 Core, where the same cmdlet against the same server reads normally:200for a live handle and a404for a fake one on the identical call shape, which an always-throwing call cannot produce. TheSet-CookiewithDomain=.localhostis genuinely there (dumped from the response header) and PowerShell 7's HTTP stack simply does not reject it. Sopwshreadings are real readings. The module still usesHttpWebRequestwith noCookieContainer, which is right, because it must work under either edition. The distinction matters when judging someone's evidence: "verified in raw PowerShell" is only worthless if it was 5.1.The catch block then threw too.
if ($_.Exception.Response)underSet-StrictMode -Version LatestraisesPropertyNotFoundStrictwhen the exception has noResponseproperty. That killed the wholeforeach, so the caller received an EMPTY collection.
And the caller's verdict was if ($checks.Pass -contains $false). Nothing is $false in an empty
collection, so it fell to the else branch and printed "All endpoints live." all([]) is
$true. The same shape appears in Add-XcelDcRecordToIndex, which also crashed under StrictMode
while Add-XcelDocModule.ps1 still printed New handle '<x>' is live.
Observed consequence, not hypothetical: three of four handles for XCEL00000002 had no content
record in Elasticsearch, the shell pages 404'd, and all three scripts reported success. It was
caught only because the endpoints were checked independently in Python afterwards.
Fixed:
Test-XcelDocEndpointsuses[System.Net.HttpWebRequest]with noCookieContainer, so the cookie header is never parsed. It walks the exception chain for the status code, because calling a .NET method from PowerShell wraps the realWebExceptionin aMethodInvocationException.- Its outcome is three-valued:
Pass, plusRandistinguishing "answered 404" from "nobody could ask", plus the error text. - New
Assert-XcelDocLiveis the single verdict, and it refuses to pass a result set of the wrong size.New-XcelDocSection.ps1andAdd-XcelDocModule.ps1both route through it andexit 1on failure.
Verified against known-bad input, which is the only reason to believe any of it:
| Case | Statuses | Verdict |
|---|---|---|
| live handle | ran:200 ran:200 ran:200 |
pass |
| handle that does not exist | ran:404 ran:404 ran:200 |
refused |
| server port unreachable | COULD-NOT-RUN x3 |
refused |
| empty result set | none | refused, "the verifier itself did not complete" |
⇒ The lesson is the one this workspace already had and this skill did not apply to itself: a checker is not a checker until it has been run against known-bad input. A broken check reports exactly like a healthy one, so the reassurance survives the defect it exists to catch. Run it against a deliberately broken copy AND a known-good one, and keep both controls.
Extended 2026-08-19, folding in Bug 6
The first version of this fix still had the weakness Bug 6 documents: it checked three HTTP endpoints and never asked Elasticsearch anything, so it counted the product documents listing as evidence. On the cameo-switcher session's SKUs that page answered 200 with nothing indexed at all, which would have turned a total indexing failure into a two-of-three "partial success".
Test-XcelDocEndpoints now runs five checks, and only four of them can decide anything:
| Check | Decisive |
|---|---|
| static content page | yes |
| handle shell page | yes |
content/_doc/<sku>.<handle>, reading found |
yes |
products/_doc/<productId>, reading found |
yes |
| product documents listing | no, reported for information only |
The two Elasticsearch checks fetch by id and read found, never a search. Both obvious
alternatives lie: _count?q=<SKU> matches an analysed field and returned 9471 on a SKU with zero
documents and 10207 on one with four, and a match_phrase on sku returns 0 for everything
because these records carry no sku field at all. A 200 from Elasticsearch is also not the
answer on its own; found is.
Verified by reproducing Bug 6 deliberately: the content record for one live handle was deleted from the index, leaving static content 200 and the listing 200, and the checker returned False on the two decisive reds. The record was then restored and re-confirmed. All four real handles pass, a fake SKU is refused.
Bug 6: import printed Done. and indexed nothing, and the listing page still answered 200
⚠ Found 2026-08-19 on XCEL00000004 (Cameo Connector Configuration) and again on XCEL00000006
(Teamcenter Subscriptions), and independently reproduced the same day by another session on
XCEL00000005 (Teamcenter Single Sign-On). EXERCISED, three SKUs, two sessions.
⚠ The third case rules out the wrapper scripts. That session reached it through
Add-XcelDocModule -SkipPublish followed by a separate Publish-XcelDocs call, bypassing the
wrapper scripts' own auto-recovery entirely, and got the identical clean-success-with-nothing-indexed
result. So this is the importer, not Publish-XcelDocs swallowing something.
help-server-win.exe import ran clean. No Error Importing: line, no [ids] failed to parse field [values], no non-zero exit, the working directory correctly pinned per Bug 4, and its last
word was Done. For the brand new SKU it had created no content document and no products
document at all:
GET http://localhost:51001/content/_doc/XCEL00000004.cameo_switcher 404
GET http://localhost:51001/products/_doc/900000004 404
So SawIdsParsingBug=False FailedSkus=[] is not evidence that anything was indexed. The importer's
own success output does not distinguish "imported" from "silently skipped".
What makes this worse than Bug 2 is which check it defeats. Of the three endpoints:
| Endpoint | Answered | Should have |
|---|---|---|
| static content page | 200 | 200, correctly, it is served from disk and needs no index |
| product documents listing | 200 | 404, per this skill's own table, since products was empty |
| handle shell page | 404 | the only honest red |
The listing page answering 200 with no products document contradicts the index table near the top
of this file. Do not rely on it as the products sentinel: on this install it renders regardless.
The handle shell page was the single check that told the truth, and a two-out-of-three pass reads
as a partial success rather than as a total indexing failure, which is exactly the wrong impression.
⚠ Counting index documents does not settle it either. GET /content/_count?q=<SKU> returned
9471 for a SKU with zero documents: that field is analysed, so the query matches loosely and the
number is meaningless. Fetch the record by its own id (/content/_doc/<sku>.<handle> and
/products/_doc/<productId>) and read found, or ask a question the instrument cannot answer
sideways.
Fix, and it is cheap: re-run with -ForceFallbackForSkus "<SKU>", which PUTs the records into
Elasticsearch directly. Both SKUs went to three real 200s immediately afterwards, confirmed by
fetching each endpoint and each _doc by id.
⇒ Given Bug 2 and this one, treat the importer as unreliable for a brand-new SKU by default.
Cause unknown and not the working directory this time. Either pass -ForceFallbackForSkus on the
first publish of any new section, or verify by _doc id before believing Done.
While you are here: the SKU number is claimed by hand, and sessions collide on it
New-XcelDocSection.ps1 refuses a SKU whose content folder exists, which is the only thing
preventing two sections from landing on one number. It fired for real: XCEL00000005 was free when
checked and taken by another session before the create, seconds later. Both sessions had checked
the folder first, and both still nearly collided, so this is a routine race rather than
carelessness.
⇒ There is no registry, so list collections\documentation\external in the same breath as the
create, not earlier in the session. A number that was free when you planned the work can be gone
by the time you mint it, and the gap is seconds wide when several sessions are publishing at once.
Expect the throw rather than treating it as a surprise, and just take the next free number. Claimed
so far: XCEL00000001 Documentation Builder,
00000002 Tier Health, 00000003 SSO Setup, 00000004 Cameo Connector Configuration,
00000005 Teamcenter Single Sign-On, 00000006 Teamcenter Subscriptions.
SUPERSEDED 2026-08-19: "six SKUs sharing one productId" did not produce what it was for
The section below this note is kept for the record — a wrong approach that got proven wrong, with the evidence, is more useful to the next person than a gap where it used to be. Do not follow it. See the corrected recipe immediately after.
Chris's actual ask was one entry on the server's home page, with the six apps' manuals appearing as
topics under it. Four SKUs (XCEL00000001, 00000003, 00000004, 00000006) migrated onto a
shared productId/productTitle to try to achieve that. It could not have worked, and did not:
Chris's own screenshot of the home page showed four separate rows, each labeled "Xcelerator
Workspace Modules" / P XCELERATOR WORKSPACE TOOLS — visually similar, structurally still four
independent entries. Checking how real Teamcenter's own multi-SKU product renders on the exact same
home page confirms this is not a configuration mistake but how the page fundamentally works: its
282219420 "Teamcenter" productId spans four real SKUs, and two of them (Teamcenter 2606 and
Teamcenter 2606 Developer References) render as two separate rows sharing the identical bold
label and P TEAMCENTER badge — never one collapsed entry. This page is one row per package
(packages/_doc/<sku>-en-US), always, regardless of productId or productTitle overlap. No
combination of productId/productTitle/baseVersion values changes that. Getting one row with
several manuals under it needs the manuals to be handles under one shared SKU, which is a
different thing from four SKUs quietly agreeing on a shared productId — it is what
XCEL00000001 looked like internally the whole time (doc_builder + automation-toolkit, two
handles, one package, one row).
The "six SKUs, one productId" work is now reverted: XCEL00000003 (SSO Setup), XCEL00000004
(Cameo Connector Configuration) and XCEL00000006 (Teamcenter Subscriptions)'s standalone
packages/content documents were deleted and their content re-registered as three new handles
under XCEL00000001 instead. Their source files were kept as a rollback path, not deleted — but
moved out from under collections\ entirely (see Bug 10 below) rather than left in place, after a
first pass that left them in place turned out to be a live fuse: help-server-win.exe import
rescans everything under collections\ on every publish, and would have silently resurrected both
retired SKUs' index entries the next time anyone ran it. See "One shared SKU, one handle per module"
below for the current, correct recipe and where things
stand.
One shared SKU, one handle per module — the corrected recipe, 2026-08-19
Target: one SKU (XCEL00000001), one shared productId (900000001) / productTitle
("Xcelerator Workspace Tools"), one package-level releaseVersion representing the umbrella
("Xcelerator Workspace Modules 1.0", not any single app's version), and one handle per app
under the existing xcelerator_docs collection. Each handle keeps the app's own real version in
its own content record's releaseVersions/productVersions — that per-handle versioning was never
the problem; the per-SKU versioning was.
This is exactly what Add-XcelDocModule.ps1 already does. Nothing new needed there — point it
at -Sku "XCEL00000001" -ProductId "900000001" with your own -Handle/-HandleTitle, same as
adding any other module manual. New-XcelDocSection.ps1 is no longer the right tool for a new
Xcelerator app's docs — it mints a new SKU, which is exactly the pattern being reverted. Use it
only for a genuinely separate product that should not share this productId at all.
Live now, migrated by hand this pass (copy the handle's content folder into
documentation\external\XCEL00000001\en-US\xcelerator_docs\<handle>\, rebuild its content record
via New-XcelDcRecord — do not reuse the old SKU's record verbatim, two of the three had drifted
categories/enrichedCategories.productGroups still keyed by their old, pre-migration productId
even after their earlier productId migration, an inconsistency this rebuild fixes as a side effect):
| Handle | Was | Now |
|---|---|---|
doc_builder, automation-toolkit |
XCEL00000001 (native) |
unchanged |
ssosetup |
XCEL00000003 |
XCEL00000001 |
cameo_switcher |
XCEL00000004 |
XCEL00000001 |
tc_subscriptions |
XCEL00000006 |
XCEL00000001 |
Migrated 2026-08-19: Tier Health (XCEL00000002 → handle tier_health) and Teamcenter Single
Sign-On (XCEL00000005 → handle tc_sso), by their own owning sessions, following this same
recipe: add as a handle under XCEL00000001 via Add-XcelDocModule.ps1, then retire the old SKU
the two-step way (Bug 10) — delete its packages/content ES documents, and move its files out
from under collections\ to _retired-skus\{documentation,es}\external\<SKU>\ rather than leaving
them in place. All seven handles (doc_builder, automation-toolkit, ssosetup, cameo_switcher,
tc_subscriptions, tc_sso, tier_health) are live under XCEL00000001 as of that date — see
Bug 11 below for what nearly slipped through during this round of migrations.
⚠ Corrected 2026-08-20: the second half of that claim was false for both SKUs. Their ES
documents really were deleted (confirmed found:false on both packages/_doc at the time), but
their files were never actually moved out of collections\ — both sat there, live, for a full day,
undetected because nothing re-scans collections\ until either our own import call or a real
Siemens installer runs one. See Bug 12: this wasn't cosmetic. XCEL00000002's package.json also
carries a packageVersion the importer itself rejects ("Unsupported Package Version: 0.6.0, Supported: ^1.x"), a second, independent defect. Both are now actually moved to
_retired-skus\{documentation,es}\external\<SKU>\, verified by a real reset+import cycle that no
longer mentions either SKU.
Verified end to end: all five handles pass every check in Test-XcelDocEndpoints; the home page
shows exactly one "Xcelerator Workspace Modules" row; that row's own listing page shows
Showing 1-5 of 5 with all five manuals as separate topic rows; the three old bare SKU URLs
(/en-US/doc/900000001/XCEL0000000{3,4,6}.<handle>) now 404; the three new consolidated URLs
(/en-US/doc/900000001/XCEL00000001.<handle>) all resolve 200.
⚠ Merge-XcelProductJson merges components and additively unions versions[], but does not
prune stale entries on its own — after three components were retired, the shared document still
carried their old release strings and component rows until they were removed by hand (a direct PUT
of a hand-built, cleaned document, not through the merge helper, since removal is a different
operation from the additive merge the helper is designed for). If this consolidation pattern
recurs, that cleanup step needs doing explicitly; nothing currently automates it.
Bug 7: the ES fallback has never repaired a SKU with more than one handle
⚠ Found 2026-08-19. This one silently limits the fix everyone has been relying on, including the change that made the fallback run by default.
Publish-XcelDocsFallback read its content chunk like this:
$records = @(Get-Content $_.FullName -Raw | ConvertFrom-Json)
foreach ($rec in $records) { ... "/content/_doc/$($rec.id)" ... }
In PowerShell 5.1 ConvertFrom-Json emits a JSON array as one object down the pipeline, so
@() wraps the whole array as a single element. Measured on a 4-record chunk:
| Form | Count | [0].id |
|---|---|---|
| `@(Get-Content ... | ConvertFrom-Json)` | 1 |
| `$o = Get-Content ... | ConvertFrom-Json; @($o)` | 4 |
So $rec was the array, $rec.id was four ids, and the PUT went to
/content/_doc/A.one A.two A.three A.four. Elasticsearch answers 400 Bad Request with an empty
body, which is why nothing in the output named a cause.
Why it looked fine: every SKU it had been exercised on had exactly one handle, and a single-element array survives the bug intact. It fails from the second handle onward, and it fails for ALL of them, not just the extras.
Fixed by assigning before wrapping. Verified against known-bad input: all four content records
for XCEL00000002 were deleted, the fallback was run with no manual PUTs, and all four came back
found=true with their shell pages at 200.
⇒ The general shape, again: a bug that only appears past N=1 reads as a working feature for as long as every test case is N=1. Two SKUs proved the fallback "worked" and both had one handle.
Second site, found 2026-08-19 while adding the seventh handle
Add-XcelDcRecordToIndex had the identical shape and I did not sweep for it when fixing the first:
$existing = @(Get-Content $chunkPath -Raw | ConvertFrom-Json)
if ($existing.id -contains $Record.id) { ... }
With one existing record the chunk parses to a bare object and this works. From the second record
onward $existing holds a single nested array, .id is read off an object[], and StrictMode
throws The property 'id' cannot be found on this object. The record is never appended, and
Add-XcelDocModule.ps1 carried on and reported the handle live.
That is the precise explanation for a symptom seen earlier the same day and misattributed at the time: registering four handles on a fresh SKU, the first two landed and the last two did not. First append: no chunk. Second: one record, parses bare, fine. Third and fourth: two or more records, throw.
⇒ When you fix one of these, grep for the shape, not the line. One grep -rn "@(Get-Content.*ConvertFrom-Json)" across the toolkit would have found both sites at once, and
this one cost an extra debugging round because it was not run.
Bug 8: Expand-XcelSectionPackage's package check silently rejects a valid zip, depending on which .NET runtime built it
⚠ Found and diagnosed 2026-08-19 by the SSO Setup session (XCEL00000003), while trying to
export/import their own real section — reported here, not touched by them, because the scripts
belong to this skill; fixed and re-verified in this pass, credit for the diagnosis is theirs.
Expand-XcelSectionPackage tested a zip's root entries with $_.FullName -match '^(documentation|es)/'
— a forward slash only. A perfectly valid package built by Export-XcelDocSection.ps1 was rejected:
"...does not look like an Xcel doc section package (no documentation/ or es/ root)."
Root cause: [System.IO.Compression.ZipFile]::CreateFromDirectory's entry-name separator is not
stable across .NET runtimes, from the identical call, on the identical machine. Confirmed directly
by building a real package both ways and reading the raw .FullName values (not through a library
that normalizes them — Python's zipfile does exactly that, and reading a package through it here
would have shown clean documentation/... paths and sent the investigation looking for a different
bug entirely, the same trap Bug 1's ConvertTo-Json posed from the other direction: an object layer
that helpfully cleans up the thing being inspected):
| Runtime | Entry separator | Old regex match |
|---|---|---|
| Windows PowerShell 5.1.26100.8894 (.NET Framework) | \ (backslash) |
0 of 17 |
| pwsh 7.6.5 Core (.NET 10.0.11) | / (forward slash) |
20 of 20 |
Same PS5.1-vs-pwsh7 divergence shape as Bug 5, different mechanism. This one matters more than a
cosmetic inconsistency because of what this specific function is for: Export-XcelDocSection.ps1
exists precisely so a package built on one machine installs on another, and there is no reason to
expect both ends run the same PowerShell edition. A check that only accepts one runtime's output
defeats the feature in exactly the cases it exists to serve.
Fixed: '^(documentation|es)[\\/]' — tolerant of either separator. Verified against known-bad
input in both directions, not just reasoned about: a package built by genuine Windows PowerShell 5.1
(ZipFile.CreateFromDirectory run directly in powershell.exe, not simulated) now extracts
correctly through Import-XcelDocPackage.ps1 -WhatIf (17 files, correct paths, no doubled SKU
segment), and a genuinely malformed zip (real content, wrong root structure) is still correctly
rejected with the same error. Only the guard was broken; the packaging mechanism itself was never at
fault.
⚠ This also corrects the round-trip claim in the Reference Build section below. That claim was
true as stated but narrower in scope than written: the export/re-import test was run entirely from a
pwsh 7 session, where CreateFromDirectory happens to produce forward slashes, so the broken
regex accidentally matched and the bug never manifested. The round trip was genuinely proven for
pwsh 7 and was never proven — and would have failed — for Windows PowerShell 5.1, which is what
powershell.exe actually launches by default on this OS. It is proven for both now, per the table
above. A proof recorded in a skill is the thing the next session relies on instead of re-testing;
say exactly what was covered, because "proven end to end" silently meant "end to end, on one of the
two runtimes people actually run this on."
Official packaging, for comparison (and why this skill doesn't imitate it)
Two real docs-<product>-<version>-en-US.exe installers were taken apart (Simcenter Flotherm 2604
and Polarion 2606, both Siemens-signed). Both are a native PE launcher (~39.1 MB, consistent with
InstallAnywhere/Flexera — strings LAX, IA_P, .jar) with a ZIP appended to the end of the file.
The embedded PE OriginalFilename (e.g. 202510077-en-US-20260422100347.exe) matches, character for
character, the <SKU>-<locale>-<timestamp>.xml.gz release-manifest filename already in the local
install's own _msidata\ReleaseInfo\IXW\collections\ — proving these are the actual installer
artifacts for collections already present locally, not generic samples.
The payload unzips to exactly /documentation/... + /es/... — the same two trees this skill
already authors. A real content record from it validates this skill's schema field-for-field
(files as an array of objects, baseVersions keyed by productId with array values), with one
variant: a PDF-only collection uses "subTypes":{"dc":["dc.system"]} / "status":"APPROVED" where
an HTML+toc.json collection uses dc.product / PUBLISHED.
Deliberately not replicated: the signed InstallAnywhere wrapper. Building one needs Siemens' own
build tooling and signing certificate, and buys nothing on a locally-managed install — the
.zip + import path already produces the identical collections\ result and passes the same
verification. Export-XcelDocSection.ps1 packages the same two folders as a plain .zip;
Import-XcelDocPackage.ps1 is the install step. Full writeup, including the "false EOCD" detour
(a valid-but-irrelevant 1-entry archive found before the real 5,807/9,218-entry one — a signature
match that parses correctly is not proof it answers the question), lives in the "Official Packaging"
topic of the live reference build below.
Bug 9: the product.json refresh gap wasn't just in the manual migration, it was in every publish path
⚠ Found 2026-08-19 while migrating XCEL00000001's own releaseVersion. The read-merge-write
recipe (Bug 7 / Merge-XcelProductJson, new this pass) fixed the manual migration case, but
Add-XcelDocModule.ps1 and Publish-XcelDocs.ps1 both still wrote their local product.json
straight to disk (or not at all) and then called the fallback, which PUTs that local file to the
shared document unconditionally. Any handle-add or republish on an already-shared productId could
still silently evict every other module, exactly the failure mode this whole exercise exists to
prevent, just triggered from a different entry point than the one that got fixed first.
Fixed in all three publish paths (New-XcelDocSection.ps1, Add-XcelDocModule.ps1,
Publish-XcelDocs.ps1): each now calls Merge-XcelProductJson — read the live
products/_doc/<productId> document, update-in-place only the caller's own component (matched by
id: $Sku, not the shared productId — a second thing New-XcelProductJson got wrong until this
pass, defaulting a solo product's first component's id to $ProductId instead of its SKU, which
is exactly the inconsistency Bug 7's manual fix corrected by hand for XCEL00000001 specifically —
now it can't recur for any future section), leave every other component and version string
untouched — immediately before publishing, not earlier in the script. versions[] is treated as
purely additive (an old release string is never removed on a bump) to match the real Siemens
product.json pattern of keeping historical release strings rather than pruning them.
Verified against the real shared document (4 components live: Cameo Connector Configuration, SSO
Setup, Teamcenter Subscriptions, Documentation Builder): republishing XCEL00000001 with a bumped
releaseVersion left the other three components byte-for-byte untouched, updated only its own
updated timestamp, and all three neighbors' shell pages still resolved 200 afterward.
RETRACTED: "documents listing showing one row instead of two" was never real — the checking instrument was
⚠ The bug this section originally described did not exist. Diagnosed 2026-08-19 by the Cameo
Switcher session, and confirmed here. The original report, and the investigation below it, both
checked the listing by fetching the served HTML directly (curl/grep) and looking for the handle
titles in it. The listing rows are rendered client-side from a POST /api/universal-search/search
call — they are never present in the raw HTML response, for any handle, on any SKU, real
Teamcenter included. So a grep-the-HTML check reads as "N-1 of N handles missing" whenever it's run
against a multi-handle SKU, independent of whether anything is actually wrong.
The negative control that caught it: grepping real Teamcenter's own served HTML for "Active
Workspace Customization" and "1st Stop" — both plainly present as rows when that same page is loaded
in a browser — found neither. A control failing on known-good input means the instrument doesn't
measure the thing, exactly the standing house rule about testing a checker against known-bad (and
here, known-good) input before trusting what it reports. The correct check, used throughout this
skill's own scripts (Test-XcelDocEndpoints, and this file's own real-Teamcenter comparisons), is
either the live search API response or a browser-rendered read — get_page_text against the loaded
page, not the page source. There was nothing wrong with the index, the listing, or any content
record. Both handles rendered correctly the entire time.
Kept below for reference, since it's true independent of the phantom bug it was gathered to
investigate: the listing does not gate on carrying a downloadable asset. Querying the real
Teamcenter SKU PL20251212545240207's 215 collection:dc records directly, 3 of 215 carry
formats:["html"] alone (no pdf, no other asset), and the very first hit in that SKU's own live
search response, handle home, carries formats:["custom","json"] — neither html nor pdf. This
was gathered as evidence toward a bug that turned out not to exist, but the fact itself is real and
may be useful again: asset presence is not a factor in whether a handle renders.
Two claims from an earlier pass through this file did not survive being tested against a second real SKU, and are corrected here rather than left standing:
- "The listing keys on productId, not the SKU in the URL" — wrong. Querying
/en-US/documents/282219420/PL20241125556497283(Teamcenter's 2506 SKU, same shared productId282219420as the 2606 one) returns 202 documents,releaseVersions:["Teamcenter 2506"],releasesku:"PL20241125556497283"throughout — a completely different result set from the 215Teamcenter 2606documents the other SKU under the same productId returns, not "the same ~188 KB whichever SKU addresses it" as previously stated. The search request is scoped byreleaseskuregardless of which productId the URL resolves through. Directly consistent with what this install's own shared-productId section shows:XCEL00000001's listing returnstotal:2(its own two handles) even though three other modules (Cameo, SSO Setup, Subscriptions) share its productId and are not in that response. A shared productId does not aggregate a combined listing across SKUs on this install; each SKU still gets its own listing, scoped by its ownreleasesku. What productId sharing actually buys is untested beyond this — possibly just a valid shell-route namespace and theproducts/_doctaxonomy entry, not a merged catalog page. - "The
urlfield's missing productId segment is a broken link in every record" — not a defect. Both the malformed-looking bare form (/en-US/doc/<sku>.<handle>, no productId) this module writes and the identical bare form a real Teamcenter record stores in its ownurlfield (verified:PL20251212545240207.AWAdmin's liveurlis/en-US/doc/PL20251212545240207.AWAdmin?audience=..., no productId either) both resolve with a302to the canonical productId-qualified form and land on200. Tested both, side by side, following redirects. This matches Siemens' own stored convention exactly; there is nothing here forNew-XcelDcRecordto fix. A check that flags a302as broken without following it would produce this false alarm; a browser, and every normal HTTP client, does not.
⇒ Corroborates the standing house rule at one more level: a correction to a correction is still just a claim until it's tested against a second real data point, not one. Everything above was checked directly against this real install (two different real Teamcenter SKUs, the live search API, the raw content-index query, curl with and without following redirects) rather than reasoned from the shape of the first result. Both corrections stand even after the phantom-bug retraction above — they were reached by fetching the live search API response and following real redirects, not by grepping served HTML, so they don't share the flawed instrument that produced the phantom.
Bug 10: a retired SKU's files left under collections\ can resurrect it on the next full import
⚠ Found 2026-08-19 by the Cameo Switcher session, immediately after the SSO Setup/Cameo/
Subscriptions consolidation. Retiring a SKU (see "One shared SKU, one handle per module" above)
deleted its packages/content Elasticsearch documents, which is what made its row disappear from
the home page. It did not touch the SKU's files under collections\documentation\external\<SKU>\
and collections\es\external\<SKU>\ — and help-server-win.exe import (step one of every
Publish-XcelDocs run, by anyone, for any SKU) scans and re-registers everything under
collections\, including a package.json/content\0001.json nobody has touched in weeks. A
retired SKU's files sitting there are a live fuse: the next full import — triggered by any session's
routine republish, not necessarily anything to do with the retired SKU — silently resurrects its
packages/content documents and the duplicate home-page row reappears, "long after everyone has
forgotten why" (the session's own words, and accurate).
Fixed: moved the three retired SKUs' complete trees out from under collections\ entirely, to
D:\Siemens\Help Server\_retired-skus\{documentation,es}\external\<SKU>\ — a sibling location the
importer never scans, so nothing there can be rediscovered. This keeps every file as a byte-for-byte
rollback path (nothing deleted) while fully defusing the resurrection risk, which a same-directory
rename or a "do not touch" comment would not have. Verified by running the real import command
afterward: none of the three retired SKUs appear in its "Importing packages" output, their
packages/_doc documents still read found:false, and XCEL00000001 still carries exactly 5
content records.
⇒ Retiring a SKU under this model is two steps, not one: delete the ES documents (what makes it
stop rendering) and move its files out of collections\ (what stops it from coming back). Doing
only the first looks complete — the visible symptom is gone — right up until someone's unrelated
publish silently undoes it.
Bug 11: "my handle published cleanly" no longer proves "the section is live"
⚠ Found 2026-08-19 by the SSO Setup session, publishing ssosetup right after the Tier Health and
TC SSO migrations landed. On a shared SKU, reindexing is per SKU, not per handle — any one
module's Publish-XcelDocs.ps1 run re-imports the whole SKU's chunk file, all seven handles at
once. That means one module's publish can drop a different module out of the index, and from
inside that publish the failure is invisible: every check it runs is scoped to its own id, and
those all pass.
Observed, not hypothetical. tc_sso had been added to the chunk file (content\0001.json,
now 7 records) and its record read PUBLISHED with a valid dtManifest, its folder was complete,
its topics.json served — but a later publish reindexed the SKU without also re-registering it in
Elasticsearch, and both content/_doc/XCEL00000001.tc_sso and its shell page 404'd. Nobody's own
publish noticed, because nobody's publish checks a sibling's id. The SSO Setup session's own
republish (which reindexes the whole SKU) happened to fix it as a side effect; it could just as
easily have broken a different sibling instead.
Fix, contributed by that session: scripts/check_neighbours.py.
Reads a SKU's chunk file, looks up every record id in it (not just "mine") against the live
content index, and exits 0 only if all of them resolve — 1 if any is missing, 2 if Elasticsearch
could not be reached at all (never reported as a clean estate, per the house rule that a check which
cannot run must never look like a pass). Carries its own --self-test against a known-bad id and a
known-good control, which was re-run against this install (not just trusted) before adoption:
python scripts\check_neighbours.py --self-test --es http://localhost:51001
python scripts\check_neighbours.py "D:\Siemens\Help Server\collections\es\external\XCEL00000001\en-US\content\0001.json" --es http://localhost:51001
Both passed here on 2026-08-19, and a direct check of all seven handles' shell pages plus the shared
products/_doc/900000001 document confirmed 7/7 live with no stray components.
⇒ Run check_neighbours.py against the shared SKU's chunk file after any publish that touches
XCEL00000001, not just after your own handle's. It is the only check in this toolkit that looks
at anyone else's id. Test-XcelDocEndpoints (Bug 5) is still correct for verifying your own handle
end to end; it was never meant to catch a sibling regression and does not.
Bug 12: a real Siemens documentation installer dies on our own SKU, and rolls back its own install
⚠ Found 2026-08-20. Chris ran a genuine Siemens documentation installer (adding "Active
Integration Gateway 2606" to this server) and it reported the index could not be rebuilt. The new
documentation did not land — collections\ held no trace of it afterward, and re-running the
installer produced the identical failure.
Every Siemens documentation installer's post-copy step runs the exact reset then import
sequence documented at the top of this file, against the entire collections\ tree — there is
no way to scope it to just the packages the installer is adding. Debug.info (the installer's own
transcript) showed why this one failed:
Importing packages: "XCEL00000001, en-US, 1.0.0, Xcelerator Workspace Modules 1.0"
error: Error Importing: XCEL00000001-en-US, Xcelerator Workspace Modules 1.0
error: Error: [parsing_exception] [1:37] [ids] failed to parse field [values], with { line=1 & col=37 }
Bug 2's exact error text, but a different trigger: this is the vendor's own installer invoking
help-server-win.exe import, not one of our own scripts, and six real Siemens packages had already
imported cleanly in the same run before it reached ours and died. That rules out Bug 2's
process-working-directory explanation — cwd was constant for the whole run, and it didn't stop the
first six packages. The installer appears to treat an import failure as fatal to the whole
transaction and rolls back its own just-copied files. New Siemens documentation cannot currently
be installed on this server while our SKU is present and broken. This is worse than Bug 6/Bug 2:
those left our own section degraded; this blocks Siemens' own content.
Checked for a real fix, not just a workaround. Diffed a live XCEL00000001 content record
against a real Siemens one (PL20251212545240207.AWAdmin) field-by-field — identical keys, identical
types, nothing malformed on our side. So there is no schema defect in our records to correct; the
failure lives inside the closed help-server-win.exe binary's own create-vs-update logic, most
likely specific to a SKU that has ever been fallback-published (direct PUT to Elasticsearch)
rather than created purely by import. Not confirmed without decompiling the vendor binary, and not
worth chasing further given the fix below is cheap and general. There is no "do it the right way so
it stops being a bug" available to us here — only the operational workaround.
One data point complicates even that theory, recorded rather than smoothed over: reproducing the
failure with XCEL00000001 deliberately suspended (see below) still hit the identical [ids] error,
but on XCEL00000005 — one of the two dangling SKUs described below, not XCEL00000001 at all. And
once XCEL00000002 and XCEL00000005 were properly retired (moved out of collections\, not just
de-indexed), a plain help-server-win.exe import of XCEL00000001 alone succeeded with no error
at all** — the fallback wasn't even needed that time. **So this may not be a property of any one SKU
in isolation; it may be a knock-on effect of XCEL00000002's separate Unsupported Package Version
error (below) leaving the importer's internal state broken for whatever package it processes next.
Marked as a hypothesis with one supporting data point, not a conclusion — the fix below doesn't
depend on which explanation is right.
A second, independent defect surfaced in the same test: XCEL00000002's package.json carries
"packageVersion": "0.6.0", which the importer rejects outright (Unsupported Package Version: "0.6.0", Supported: "^1.x") — logged as an error but non-fatal, unlike the [ids] crash. Every SKU
this skill has minted uses 1.0.0 (see New-XcelProductJson); 0.6.0 on Tier Health's package
must have come from a hand-edit that used the app's own version instead of the SKU's. Worth checking
before it's someone's real problem: packageVersion in package.json must satisfy ^1.x,
independent of whatever version string the app itself is on.
And two loose ends from the "One shared SKU" migration turned out not to be cosmetic. Per the
correction added above, XCEL00000002 and XCEL00000005 were de-indexed but never actually moved
out of collections\ — sitting there live for a full day. That's exactly Bug 10's resurrection risk,
and this session's test proved it's not theoretical: both independently broke a real import (the
version error on 2, the [ids] crash on 5) before either one had anything to do with
XCEL00000001. Both are now moved to _retired-skus\{documentation,es}\external\<SKU>\, verified by
a real reset+import that no longer mentions either.
Fix: Suspend-XcelDocSection.ps1 / Resume-XcelDocSection.ps1, using the same move-out-of-scan
mechanism Bug 10 uses for permanent retirement, applied reversibly:
# Before running ANY Siemens documentation installer:
.\scripts\Suspend-XcelDocSection.ps1 -Sku "XCEL00000001"
# ... run the Siemens installer ...
# Immediately after:
.\scripts\Resume-XcelDocSection.ps1 -Sku "XCEL00000001" -ProductId "900000001" -Handle "doc_builder"
Suspend moves both source trees (documentation\external\<SKU> and es\external\<SKU>) to
_suspended-during-install\, refusing if the SKU isn't currently present or is already suspended.
Resume moves them back and unconditionally republishes through
Publish-XcelDocs.ps1 -ForceFallbackForSkus, never trusting a plain import to have picked the SKU
back up correctly — because the installer's reset step wipes every index regardless of which files
were present, so there is nothing to "check" first, only something to restore.
Verified end to end, not just reasoned about: suspended XCEL00000001, ran a real reset then
import — all seven then-present Siemens packages imported clean, confirming the suspend actually
prevents the installer-fatal crash. Separately hit the version and [ids] errors on the two
still-dangling orphans in that same run, retired both properly, ran reset+import again — fully
clean, and it picked up "Active Integration Gateway 2606," the package whose install this bug had
been blocking, confirming the fix's actual purpose, not just its absence-of-error. Resumed
XCEL00000001, verified all seven handles found:true in Elasticsearch and 200 on their shell
pages.
⇒ Any custom section on this server is a standing risk to every future Siemens documentation
install, not just to its own visibility. Suspend before installing anything from Siemens; resume
after. And per Bug 10, a "retired" SKU is not actually retired until its files are physically out of
collections\ — checking Elasticsearch alone (as the migration writeup above did) is exactly the
kind of narrower-than-it-looks verification the workspace's standing rules warn about.
Using the scripts
Import-Module .\scripts\XcelDocBuilder.psm1
# New section (once per SKU/productId):
.\scripts\New-XcelDocSection.ps1 -Sku "XCEL00000002" -ProductId "900000002" `
-ProductTitle "Tier Health" -Collection "xcelerator_docs" `
-Handle "overview" -HandleTitle "Tier Health"
# Add a module to a section that already exists:
.\scripts\Add-XcelDocModule.ps1 -Sku "XCEL00000002" -ProductId "900000002" `
-Handle "install-guide" -HandleTitle "Install Guide"
# Republish after hand-editing content, or force the fallback:
.\scripts\Publish-XcelDocs.ps1 -Sku "XCEL00000002" -ProductId "900000002" -Handle "overview"
.\scripts\Publish-XcelDocs.ps1 -ForceFallbackForSkus "XCEL00000002"
# Before/after any real Siemens documentation installer (Bug 12):
.\scripts\Suspend-XcelDocSection.ps1 -Sku "XCEL00000001"
.\scripts\Resume-XcelDocSection.ps1 -Sku "XCEL00000001" -ProductId "900000001" -Handle "doc_builder"
# Package a section and install it on a different server:
.\scripts\Export-XcelDocSection.ps1 -Sku "XCEL00000002" -OutputZipPath "C:\temp\tier-health-docs.zip"
.\scripts\Import-XcelDocPackage.ps1 -ZipPath "C:\temp\tier-health-docs.zip" `
-TargetHelpServerRoot "\\other-machine\D$\Siemens\Help Server" -WhatIf # dry run first
.\scripts\Import-XcelDocPackage.ps1 -ZipPath "C:\temp\tier-health-docs.zip" `
-TargetHelpServerRoot "\\other-machine\D$\Siemens\Help Server" `
-Sku "XCEL00000002" -ProductId "900000002" -Handle "overview"
All three scripts finish by calling Test-XcelDocEndpoints, which checks the static content page,
the handle shell page, and the product documents-listing page — report a handle as live only when
all three return 200.
What this does NOT do
- Does not touch
TC_Help_Documentation_Link(the Teamcenter site preference driving the real product's Help menu) — that is one global value shared with all of Teamcenter's own documentation. Pointing a specific module's UI at its own shell URL (an About/Help button) is the safer integration point; changing the global preference needs a deliberate human decision. - Does not index individual topic pages for full-text site search — only the one
dcrecord per handle. The in-collection search box (readstopics.jsonclient-side) works regardless; the server's global "search everything" box does not yet reach individual custom topic pages.
Reference build
The recipe above was proven on a real section, still live on the local install:
http://localhost:51000/en-US/documents/900000001/XCEL00000001 (SKU XCEL00000001, "Documentation
Builder" — now one of several modules sharing productId 900000001 / "Xcelerator Workspace Tools",
see the shared-productId note above; the URL itself did not change since this SKU minted that
productId originally), two handles: doc_builder documents this whole process (including the
"Official Packaging" topic above)
and automation-toolkit documents the scripts themselves, was created by
Add-XcelDocModule.ps1, and was exported and re-imported onto this same server as a round-trip
proof of Export-XcelDocSection.ps1 / Import-XcelDocPackage.ps1). That first round trip ran
entirely under pwsh 7 and, per Bug 8, could not have exercised the Windows PowerShell 5.1 failure
mode — it proved pwsh 7 only, not "end to end" as originally written. Re-verified 2026-08-19 with a
package built by genuine Windows PowerShell 5.1 (powershell.exe directly, not simulated): rejected
by the broken check, accepted after the Bug 8 fix, 17/17 files landing correctly, no doubled SKU
segment. Both runtimes are covered now.
Generated from skills/tc-help-server-authoring/SKILL.md in the tc-automation-skills library, which is the canonical copy and also serves as the agent skill set for Teamcenter work.