Pull repair orders, jobs, operations (opcode + labor $), parts, and vehicle data from the official Tekion OpenAPI — no browser, no OTP. Also covers logging into the APC partner portal (apc.tekioncloud.com) to scrape API docs and download OpenAPI specs.
Install
npx skillscat add joecastelino/jay-skill-pack/tekion-openapi-repair-orders Install via the SkillsCat registry.
Tekion OpenAPI — Repair Orders, Parts, and APC Portal Scraping
Search payload shape — the two-hour trap (2026-08-21)
POST /openapi/v4.0.0/repair-orders:search takes filters (a LIST of{field, operator, values}) — NOT a filter dict and NOT a page object.
# ✅ WORKS — returns exactly 1 RO
{"filters":[{"field":"documentNumber","operator":"IN","values":["566709"]}],
"pageSize":50}
# ❌ SILENTLY WRONG — filter is IGNORED, returns the first 20 ROs in the store
{"filter":{"documentNumber":{"operator":"IN","values":["566709"]}},
"page":{"pageNumber":1,"pageSize":5}}The bad shape does not error. It 200s with 20 unrelated rows, and the first one
looks plausible enough to burn time on (I chased documentNumber 580460 thinking
566709 didn't exist). Tell: you asked for 1 RO and got exactly your pageSize back.
Valid search fields confirmed at SCT: documentNumber ✔, vin ✔, creationTime
(BTW, epoch-ms strings) ✔. roNumber returns 0 results — it is not a field,
even though the UI calls it the RO number. Use documentNumber.
⚠️ RO NUMBERS ARE NOT UNIQUE ACROSS THE 7 STORES (verified 2026-08-24)
Each store runs its own RO number sequence, so the SAME documentNumber can exist
at multiple dealers. RO 398422 returned a hit at BOTH ST/SCT (876 — a CLOSED
2023 internal PDI) AND TL (1092 — an open warranty transaxle job created that
morning). A documentNumber search is scoped by the dealer_id header, so a
single-store lookup will happily return the WRONG store's RO with no warning.
When given a bare RO# with no store named, sweep all 7 dealers before acting:
for k,d in cfg["dealers"].items(): # ar bc bt st sv tl vc
out=post("/repair-orders:search", d,
{"filters":[{"field":"documentNumber","operator":"IN","values":[RO]}],"pageSize":5})
for r in ((out.get("data") or {}).get("results") or []):
print(k, d, r["documentNumber"], r["status"], r["documentId"], r["creationTime"])Disambiguate on status + creationTime (a years-old CLOSED RO is rarely the one
being discussed); if two hits are both plausible, ask rather than guess. Full
triage workflow: skill tekion-ro-job-paytype-triage.
Pagination is meta.nextPageToken → echo back as paginationToken (there is nopageNumber). Reference implementation: ~/tekion-reports/sct_menu_sales_api.py::fetch_ros.
The search result row is a link envelope, not the RO: it carriesdocumentNumber, documentId, status, creationTime, modifiedTime, type,tagNumber — and {link,id} stubs for jobs/invoices/vehicle/customers. There is
no id, no mileageIn, no mileageOut at this level; id is null. FollowdocumentId / the sub-links to get mileage, jobs and dollars.
Critical correction
Our existing API key (Don's, ~/tekion-api/config.json) DOES have repair-order and parts scope. Past 403/404 results were caused by wrong paths. Tekion v4 uses colon-action endpoints, not REST-plural ones:
- ❌
GET /openapi/v4.0.0/repair-orders→ 404 - ❌
GET /openapi/v3.1.0/repair-orders→ 403 - ✅
POST /openapi/v4.0.0/repair-orders:search→ works - ✅
POST /openapi/v4.0.0/parts-inventory:search→ works
Client setup
import sys, json, urllib.request, urllib.error
sys.path.insert(0, "/home/itadmin/tekion-api")
from tekion_client import load_config, get_token
cfg = load_config(); tok = get_token(cfg)
BASE = cfg["base_url"] + "/openapi/v4.0.0"
# headers REQUIRED on every call:
h = {"Authorization": f"Bearer {tok}", "app_id": cfg["app_id"],
"dealer_id": cfg["dealers"]["st"], # st=SCT(876), bt=Blackstone, etc.
"Content-Type": "application/json"}Search repair orders
POST /repair-orders:search
{"filters":[{"field":"creationTime","operator":"GTE","values":["<epoch_ms_as_STRING>"]}],
"pageSize": 50}- Allowed fields:
opcode, make, status, vin, documentNumber, documentId, creationTime, invoicedTime, closedTime, modifiedTime, paytype - Allowed operators:
GT, GTE, LT, LTE, IN, NIN, BTW, BOOL— NOT "BETWEEN" (400 invalid.filter.operator). BTW takes exactly 2 values. - Epoch-ms values go in
valuesas strings. pageSizemax 50; paginate viapaginationTokenfrommeta.nextPageToken.- Response:
out["data"]["results"](list of ROs),out["meta"]["totalCount"]. - RO record:
documentId(internal id),documentNumber(RO#),status,tags(includes OPCODE + BASE_PAY_TYPE system tags — quick opcode presence check without fetching jobs), plus link stubs for jobs/vehicle/invoices/fees.
Nested RO resources (GET, same headers)
/repair-orders/{rid}/jobs→data.jobs[]— payType, status, concern text, job id
Reading job pay types (free, no browser) — for "job N should be W not I" tickets
data.jobs[] gives the whole per-job pay-type picture in ONE call. jobNumber is a
string ("1","2",…) and matches the numbered job list in the RO UI, so Joe's
"job 1" maps directly. payType ∈ CUSTOMER_PAY / WARRANTY / INTERNAL
(subPayType is usually null). Also free on each job: status, hold,concern.text, causes[], createdByUserId, modifiedByUserId, tags.
jobs=(get(f"/repair-orders/{rid}/jobs").get("data") or {}).get("jobs") or []
for jb in jobs:
print(jb["jobNumber"], jb["payType"], jb["status"],
(jb.get("concern") or {}).get("text","")[:60])Always run this BEFORE opening a browser — on 2026-08-24 it proved the reported
"job 1 is Internal" was already WARRANTY, so no edit was needed at all. There is no
OpenAPI WRITE path for payType (browser job-form radio only — seetekion-ro-void-job-remove-parts).
/repair-orders/{rid}/jobs/{jid}/operations→data.roOperations[]—opcode,opcodeDescription,labor.saleAmount(CENTS: 8999 = $89.99),labor.costAmount/repair-orders/{rid}/jobs/{jid}/operations/{oid}/parts→data.parts[]— partNumber, quantities[], costAmount, saleAmount/repair-orders/{rid}/ro-vehicle→ vin, year/make/model, mileageIn/Out/repair-orders/{rid}/ro-invoices→ works (data may be sparse pre-invoice).../technicians→ works- ❌
/repair-orders/{rid}/pricing→ 500 "unsupported.operation" — don't use
Gotcha: data payload shape varies per endpoint — sometimes data.jobs, data.roOperations, data.results, or a bare dict. Inspect keys, don't assume results.
Parts inventory search
POST /parts-inventory:search body: {"searchText": "87139"} (NOT textSearch — that 400s with "searchText is required"). Returns partNumber, description, brand, onHandQty. Paginated via meta.nextPageToken.
⚠️ Parts Inventory API exposes ONLY 4 fields — it CANNOT diagnose auto-order/stock-out root cause (verified 2026-06-23)
The entire Parts Inventory section of the Tekion OpenAPI has exactly ONE endpoint
(parts-inventory:search) and its response schema is just partNumber, description, brand, onHandQty (confirmed by dumping every property name in~/dealerdetail/specs/apis/parts-inventory__search-parts-inventory.json). It does
NOT expose any of the stocking-control settings that actually drive auto-ordering:
- min/max / best-stock, reorder point
- Source Type (Stock vs Special-Order / Non-Stock)
- phase-in / phase-out status, "do not order" flag
- replenishment / DRP run state
So the API can tell you WHAT (on-hand qty, e.g. all stocked out at 0) but never
WHY a part didn't auto-replenish. That config lives only in the Tekion Parts UI
(Parts → Inventory → part detail → Source/Stocking tab). For a parts-shortage / "why
didn't it auto-order" diagnosis: use the API to confirm onHandQty == 0, then drive the
:9223 persistent browser into the Parts module to read Source Type + Min/Max + phase
status per part. Don't waste calls probing for a richer parts endpoint — there isn't one.
Likely root causes (read these in the UI): (1) Source Type = Special-Order/Non-Stock →
never auto-orders by design (typical for CV axles, key fobs, low-frequency parts);
(2) Min/Max = 0 on a part added ad-hoc to an RO but never set up as a true stocked item
(common culprit for a fast-mover like a front brake pad kit 044650R010 sitting at 0);
(3) phase-out / do-not-order flag; (4) daily stock order (DRP) not being run/approved.
RO lookup by documentNumber — IN-filter pagination quirk
repair-orders:search with documentNumber IN [list] can return meta.totalCount
HIGHER than the rows it actually returns (e.g. totalCount=6 but only 4 results, nonextPageToken) when some RO records were very recently opened/closed and aren't fully
indexed yet. Re-running individually or paginating won't surface them until Tekion's
index catches up. Don't treat the missing rows as a bug — note them and re-pull later.
The on-the-record repair-orders:search result carries documentId, documentNumber,status, assignee.advisor.id, and OPCODE tags for free.
RO part lines — fulfillment + description gotchas
Walking /repair-orders/{rid}/jobs/{jid}/operations/{oid}/parts, each part haspartNumber, partName (NOT description — description is often None on the RO part
line; the human name lives in parts-inventory:search.description instead), status /fulfillmentStatus (values seen: DELIVERED, HOLD), quantities[] (type SALE),saleAmount/costAmount/unitSaleAmount/unitCostAmount (cents), core/coreReturn,eligibleForClaim. A HOLD fulfillment on a part = it was backordered / not yet pulled,
a useful signal in a shortage investigation.
Quota — THE THROTTLE IS REAL (hit 2026-06-12)
500,000 calls / 30 days, throttle 1,500 / 15 min (Basic Plan).
⚠️ OVERALL_QUOTA ≠ OVERALL_RATELIMIT (hit 2026-07-07)
429 "Limit exhausted for type : OVERALL_QUOTA" is the app-WIDE quota bucket, not
the 15-min throttle — it blocks EVERY OpenAPI consumer (scans, VI pull, DealerDetail
sync) and can persist for hours. When you see it: (1) check for a STUCK consumer
re-burning quota in retry loops (ps aux | grep vi-api-pull — the 2 AM VI job stuck
7+ hrs was the 2026-07-07 culprit; kill it + rm /tmp/tekion-vi.lock), (2) do NOT
blind-retry — park a watcher that probes a 1-row repair-orders:search every 20 min
and auto-resumes the blocked job on 200, (3) checkpoint enumeration scans BY MONTH so
⚠️ Watcher-probe trap (hit 2026-07-10): a probe that treats ANY non-200 as
"still blocked" can hide restoration behind its own bug. quota_ping_once.py
used "operation" instead of "operator" in its filter → HTTP 400"filters[0].operator must not be blank" → exit 1 → the watch job kept reporting
"still blocked" even though quota was RESTORED. Rules for quota watchers:
(a) the probe must PRINT the actual status+body and treat only 429 as blocked —
a 400/403/500 means the probe itself is broken and must be fixed, not silenced;
(b) filter key is operator, never operation (400, code 3003);
(c) when you find this bug in one script, grep siblings — "operation"\s*: also
lurked in backfill_tol_closed_20260707.sh, quota_probe_long.py,quota_probe_tol.py under ~/tekion-reports/.
The 2026-07-07 outage restored by 2026-07-10 ~10:15 AM PDT (~3 days); after
restoration the full 7-store VI pull ran clean in ~29 min with zero 429s, and the
nightly 1:30 AM DealerDetail sync:all backfills missed days automatically —
no manual backfill needed. Remember to REMOVE the temporary watch cron once
restoration is confirmed.
resume never redoes work, (4) for parts-CATEGORY unit counts (tires, batteries...),
bypass the OpenAPI entirely via the source-code path — see skilltekion-parts-sold-by-source-report (zero quota, browser internal APIs). A full-day backfill scan (200 ROs × ~4 nested calls with ThreadPoolExecutor(8)) plus exploratory probing in the same window exhausted the throttle → 429 "Limit exhausted for type : OVERALL_RATELIMIT". Resets after the 15-min window.
- Budget calls: prefilter ROs by
tagsOPCODE values BEFORE fan-out (skips ~95% of jobs/operations fetches). - For repeated comparisons, cache the day's scan to JSON and re-analyze locally instead of re-fetching.
- On 429, back off and retry after the window; don't hammer.
Parts pricing — VERIFIED (2026-06-12, RO 568555)
parts[].saleAmount = EXTENDED line total in CENTS (4968 = $49.68 for the line, qty already included). Do NOT multiply by quantity — that double-counts. Correct math:
line = part.get("saleAmount")
if line is None: # fallback only
qty = next((q["value"] for q in part.get("quantities", []) if q.get("type")=="SALE"), 0)
line = (part.get("unitSaleAmount") or 0) * (qty or 0)
parts_total += (line or 0) / 100unitSaleAmount is the per-unit price in cents.
⭐ Advisor names — UPGRADED: public OpenAPI /users/{id} NOW WORKS (2026-06-18)
THIS SUPERSEDES every browser/appointment-join method below. Read this first.
Joe upgraded the Tekion app's API scope on 2026-06-18. The PUBLIC OpenAPI Employee/User
endpoint that used to 403 now resolves names directly — no :9223 browser, no OTP,
no appointment join, no internal /api/ path. Production/serverless-safe.
import sys, json, urllib.request
sys.path.insert(0,"/home/itadmin/tekion-api")
from tekion_client import load_config, get_token
cfg=load_config(); tok=get_token(cfg)
BASE=cfg["base_url"]+"/openapi/v4.0.0"
def H(): return {"Authorization":f"Bearer {tok}","app_id":cfg["app_id"],
"dealer_id":cfg["dealers"]["st"],"Content-Type":"application/json"} # st=SCT/876
def resolve(uid):
req=urllib.request.Request(BASE+f"/users/{uid}", headers=H())
d=json.loads(urllib.request.urlopen(req,timeout=20).read())
data=d.get("data",d)
nd=data.get("userNameDetails",{})
disp=next((c["value"] for c in nd.get("completeNames",[]) if c.get("nameType")=="DISPLAY_NAME"),None)
name=disp or (nd.get("firstName","")+" "+nd.get("lastName","")).strip()
persona=data.get("userRoleDetails",{}).get("primaryRole",{}).get("persona")
return name, persona- Works for BOTH short numeric ids ("59","74","218") AND UUIDs. ~0.3s each.
- Returns
userNameDetails.completeNames[DISPLAY_NAME]+userRoleDetails.primaryRole.persona
(SERVICE_ADVISOR / TECHNICIAN / SERVICE_MANAGER / WARRANTY_CLERK / CASHIER). - Title-case the names (Tekion stores some ALL-CAPS).
⚠️ "Any Service Advisor" was a CACHE BUG, not a real placeholder (corrected 2026-06-18)
The old sct-advisor-cache.json had THREE UUIDs mislabeled "Any Service Advisor" →
they are actually real advisors: ee31e3e9…=Angel Gutierrez, bfb756e2…=Michael
Robert Costa, f3fabb57…=Juan Jose Perez. There is NO genuine "Unassigned" bucket —
every closed RO carries a real assignee.advisor.id that now resolves to a person.
If a fresh by-advisor report shows an "Unassigned" row, the cache is STALE — re-resolve
all unique advisor ids via /users/{id} and rebuild the cache before shipping. Joe will
(rightly) reject any "Unassigned" on a closed-RO advisor breakdown.
Rebuild-the-cache one-liner (do this when names look wrong)
advmap=json.load(open("data/sct-june-align-advisor-ids.json")) # docId->advisorId
cache={uid:(lambda n:(" ".join(w.capitalize() for w in n.split())))(resolve(uid)[0])
for uid in sorted(set(advmap.values()))}
json.dump(cache, open("data/sct-advisor-cache.json","w"), indent=1) # back up the old one firstVerified 2026-06-18: 17/17 SCT June advisor ids resolved, zero Unassigned, totals
unchanged (161 dedicated + 19 bundled = 180 across 176 ROs). Key fixes: 74=Brian Keat,
61=Jon Vu, 55=Jason Sulon, 73=Shaun Kamal, 218=Jose Moreno(tech), 69=Adam Esquivel.
When data lives in a tek_align (bundled) entry it's a DICT not a string
scan["tek_align"] items are {"opcode","desc"} dicts; align_op items are plain
strings. When building the page-2 detail labels, coerce: lbl = x if isinstance(x,str) else x.get("opcode"). Mixing them raises TypeError: dict + str in the renderer.
Advisor names — SOLVED via appointment join (2026-06-12 PM) [LEGACY — use /users/{id} above instead]
Working method: internal GET /api/service-module/u/ro/{documentId} → ro.appointmentId, then GET /api/scheduling/u/appointment/{apptId} → regex "serviceAdvisorName":"..." gives the full name ("EDGARDO OLIVER"). Run both via the persistent browser (:9223 /eval) with the full header bundle (tekion-api-token/dealerId/roleId/tek-siteId from localStorage). Implemented: resolve_advisors_via_browser() in /home/itadmin/tekion-reports/sct_menu_sales_api.py; cache data/sct-advisor-cache.json (numeric ids stable → cache makes browser calls rare). Walk-ins (no appointment) → "Any Service Advisor" → display "Unassigned".
Alternate working method (2026-06-12, confirmed live): the advisor user-id from assignee.advisor.id resolves directly via the INTERNAL app endpoint GET https://app.tekioncloud.com/api/userservice/u/apc/users/{id} (note the /api/ prefix — app-internal path run through the :9223 authenticated browser /eval, NOT the public OpenAPI). Response: data.userNameDetails.firstName + ' ' + lastName (title-case it). Verified live 2026-06-15: id "59" → "Edgardo Oliver". Cache id→name to data/advisor-name-cache.json / data/sct-advisor-cache.json.
PUBLIC vs INTERNAL — the critical distinction (clarified 2026-06-15):
- ❌ PUBLIC OpenAPI
GET /openapi/v4.0.0/users/{id}→ 403 "The app version 0.0.0-pilot-1.0.0 installed in the dealer does not support this API version." NOT a wrong path and NOT permanently forbidden — the Employee/User API is real and documented, but our app install is registered as a pilot version lacking the Employee/User scope. Same 403 hits/users,/users-by-permissions,/roles/{id}. v2/v3 → 404 (only v4 exists). RO/jobs/ops/parts/service-appointments/parts-inventory/vehicle-inventory all 200 on the SAME app → it's a per-endpoint-group SCOPE entitlement, not a blanket block. - ✅ INTERNAL
/api/userservice/u/apc/users/{id}via the authenticated browser WORKS today, but is NOT production/serverless-deployable (needs a logged-in browser + OTP-refreshed localStorage tokens on a real box). - Production fix: ask Tekion to enable Employee/User (+ GL) scope and promote the app off
0.0.0-pilot-1.0.0. Then public/users/{id}works server-to-server, no browser. Request template:~/dealer-detail/docs/tekion-api-scope-request.txt. - Pluggable-resolver pattern (recommended): one
resolveAdvisorName(id)interface, two strategies behind env flag (browserinterim /apiproduction). Resolve once, persist names to DB so the deployed app only READS names. Ref impl:~/dealer-detail/apps/web/lib/sources/tekion/advisors.ts.
BUSINESS GOTCHA — "Any Service Advisor" is a placeholder, not a person. When an RO is created without an advisor assigned, Tekion stamps the placeholder account on assignee.advisor. Fixing the employee's advisor setting, or having that employee log in/out, does NOT retroactively rewrite ROs already created on the placeholder — the advisor field must be edited on each individual RO record in Tekion, then a rerun picks it up. Display these as "Unassigned" until the RO-level edit is saved. (Confirmed repeatedly 2026-06-12: ROs 568503/568542/568606 stayed on the placeholder through multiple reruns despite employee-side fixes.)
Dead ends (don't retry): PUBLIC GET /openapi/v4.0.0/users/{uid} → NO LONGER A DEAD END — this WORKS as of the 2026-06-18 scope upgrade; see the ⭐ section above and USE IT for advisor names. Still 404/dead: /users:search, /employees*, /service-advisors; employee roster lacks usable id mapping; appointment serviceAdvisor.id is "TEK00".
- RO assignee is nested: read
assignee.advisor.id(NOTassignee.id). Ids are mixed UUIDs + short numerics ("59","285").
Gross vs Price — labor.saleAmount is PRICE not gross
labor.saleAmount (cents) = customer price. Gross = saleAmount − costAmount (same struct). Parts: line saleAmount − costAmount. Report Builder "Opcode Labor/Parts Gross" = sale − cost; skip the subtraction and totals come out high.
Known gaps (as of 2026-06-12)
- GL Data paths FOUND (2026-06-15):
GET /openapi/v4.0.0/general-ledger/balances/alland/general-ledger/balances/differential(delta). Currently return 400 "unexpected.error" — likely need required query params (accountingDate/period/pagination) AND/OR the same app-scope grant as Users (part of the pending Tekion scope request). Specs:~/dealerdetail/specs/apis/general-ledger-data__get-gl-balances-{all,delta}.json. Old/general-ledger/balances(no/all) was the wrong path → 404. - Report Builder data syncs ~4:50 AM — a "Today"-filtered RB report shows 0 rows all day until the next sync. OpenAPI is real-time. Any API-vs-RB comparison must use YESTERDAY's data after the overnight sync; intraday RB==0 is normal, not a scraper bug.
- On 429 the per-RO fan-out (jobs/operations/parts) silently drops ROs if the helper returns non-200 without retry — totals come out low with no error. Check for 429 explicitly and abort/retry rather than skipping.
APC partner portal scraping (docs + spec downloads)
URL: https://apc.tekioncloud.com/user/login
Login: jcastelino@americanmotorscorp.com + DMS password () + 6-digit email OTP.
Working scrapers (2026-06-15, re-verified end-to-end): ~/tekion-reports/apc_docs_scraper.py (REST API index) and ~/tekion-reports/apc_webhooks_scraper.py (webhook catalog). Copy/adapt these instead of rebuilding the login flow.
Pitfalls (all hit and solved):
- The DMS account email (
jcastelino@scvolkswagen.com) does NOT work here — APC is a separate account on the americanmotorscorp email. - Session does NOT persist —
storage_statesave/restore lands you back at /user/login. Do login + all scraping in ONE continuous Playwright session. - himalaya PATH: it lives at
/home/itadmin/.local/bin/himalaya. When invoking viasubprocesswith a customenv, setPATH="/home/itadmin/.local/bin:/usr/local/bin:/usr/bin:/bin"ANDHOME=/home/itadminor you getFileNotFoundError: himalaya. (Do NOT usesedto edit the script — use the patch tool.) - OTP email subject: "Your Login OTP is Here" (different from DMS "Tekion-Login OTP"). Read via
himalaya envelope list -a personal -f "[Gmail]/All Mail" -s 15; count baseline BEFORE triggering login, wait for count to increase, thenhimalaya message read <id>and regex\b\d{6}\b. - Browser launch: the playwright
chromium_headless_shellbinary is NOT installed →headless=Truefails with "Executable doesn't exist". Use the full headed chromium and run under xvfb:executable_path="/home/itadmin/.hermes/profiles/jay/home/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome"(note: under the PROFILE home~/.hermes/profiles/jay/home/.cache, NOT/home/itadmin/.cache)args=["--no-sandbox","--disable-dev-shm-usage"],headless=False- run the whole script with
HOME=/home/itadmin xvfb-run -a python3 ...
- Use
wait_until="domcontentloaded", NOT"networkidle"— the SPA keeps connections open so networkidle times out at 60s. Addtime.sleep(5-6)after goto for React to render. - Login is a 2-step React form:
- Email field is
input#WORK_EMAILwithtype=text(NOTtype=email, NOT anameattr).page.fill("#WORK_EMAIL", EMAIL). - Click Next:
page.get_by_role("button", name="Next", exact=True).click(). - Password field does not exist until after Next — use
page.wait_for_selector("input[type=password]", timeout=20000)before filling. Then fill + click Login (get_by_role("button", name="Login", exact=True)).
- Email field is
- OTP entry = six
input[type=tel]boxes.page.query_selector_all("input[type=tel]")→ click[0], thenpage.keyboard.type(code, delay=120)(auto-advances). Thenget_by_text("Verify", exact=True).first.click(). - After Verify,
page.urlmay still read /user/login but you ARE in. Justpage.goto()the docs URL in the same session.
Docs navigation:
- Index:
/app/docs/versions/4.0.0/apis— sidebar lists all sections (Repair Order has 40+ endpoints, GL Data, Parts Inventory, Service Appointments, etc.). Click section names withget_by_text(name, exact=True)to expand; grabdocument.body.innerText. - Direct endpoint pages:
/app/docs/versions/4.0.0/apis/<slug>— slugs are kebab-case but NOT always the obvious name (e.g.search-repair-order✅,get-parts-lookup✅ = parts-inventory:search, butget-repair-order/get-operationsslugs 404 → render empty 430-char shell). If a slug fails, expand the section on the index page instead. - "Download Specification" button on each endpoint page downloads a full OpenAPI 3.1 JSON (request/response schemas, filter enums, examples) — use
page.expect_download(). Saved specs live in~/tekion-reports/specs/.
APC Webhooks docs (SEPARATE tree from REST APIs — found 2026-06-15)
Webhooks are NOT under /apis. They live at a parallel top-level path:
- Index/first event:
https://apc.tekioncloud.com/app/docs/versions/4.0.0/webhooks(redirects to.../webhooks/deal.created) - Per-event slug:
.../webhooks/<event.type>(e.g.deal.created) - Left-nav sections are collapsed by default except the first —
page.get_by_text("Vehicle Inventory", exact=True).first.click()to expand. Nav links are router divs, not<a>tags, soa[href*='/webhooks/']finds nothing; readpage.inner_text("body")and parse the nav text instead. - Each event page has a "Download Specification" button (same mechanism as REST), plus an "Event Simulator" and "Schema" tab.
Payload envelope (all events): {"meta": {eventId, eventType, eventTime, version, subscriptionId, dealerId, oemId, programId}, "data": {<entity-id>, "link": "/<entity>/<id>"}} — data carries only an id + link; you fetch the full record via REST.
Catalog as of 2026-06-15 (v4.0.0):
- Deals (5): deal.created, deal delivery date promised, deal status updated, deal vehicle delivered, deal vehicle delivery reported
- Vehicle Inventory (17): vehicle created, vehicle deleted, general custom fields updated, GL balance updated, base invoice price updated, internet price updated, invoice price updated, marketable updated, mileage updated, MSRP updated, retail price updated, status updated, sub-status updated, stock ID updated, media updated, options updated, YMMT fields updated
- NO Repair Order / Service Appointment webhooks exist — RO + service data stays pull-only via
repair-orders:search. The VI webhooks map directly onto The Goods VI pipeline (price/status/mileage/media pushes could replace or augment the 2 AM full re-scrape).
Counting a specific opcode over a month (e.g. "how many alignments sold") — PROVEN 2026-06-18
Task pattern: count how many times an opcode (or family of opcodes) was SOLD in a
month at a store, INCLUDING instances bundled inside maintenance menus. Done for
SCT/May-2026 alignments: 246 total (231 dedicated ALIGN/OKAL + 15 bundled in
TEK menus). Generalizes to brakes, tires, etc.
The two-tier scan (this is what dodges the 429 rate-limit trap)
A full month of closed ROs at SCT = ~4,900. Fanning out jobs→operations on ALL of
them = thousands of calls → guaranteed 429 truncation. DON'T. Instead:
- Pass 1 — enumerate, NO fan-out.
POST /repair-orders:searchwithcreationTime BTW [ms0,ms1]+status IN [CLOSED,INVOICED], pageSize 50,
paginate ALL pages. For each RO capturedocumentId,documentNumber, and the
OPCODE tags ([t["value"] for t in ro["tags"] if t["field"]=="OPCODE"]).
Save this RO index to JSON so you never re-search. ~107 pages, ~55s, ZERO
fan-out calls. The OPCODE tags are FREE on the search result — use them to
prefilter. - Pass 2 — fan out ONLY on candidates. Candidates = ROs whose OPCODE tags
contain (a) the dedicated opcode(s) you're counting, OR (b) anyTEK*menu
opcode (because the target op may be bundled inside a menu). For SCT/May this
was 366 of 4,891 ROs — a 13x reduction. Only these get
jobs→operations fetches.
Dedicated vs bundled-in-menu — the key insight
- Dedicated: the opcode appears as its own operation (
opcode == "ALIGN").
Count these directly from the OPCODE tags in Pass 1 — no fan-out even needed for
the dedicated count (tag presence == op present). For SCT alignments:ALIGN,OKAL,ALIGN00BRA. - Bundled in a menu: a TEK* menu op (e.g.
TEK120000VNM= 120K service)
PERFORMS an alignment as part of the service, but the line opcode is the MENU,
notALIGN. The only way to detect it is to read the operation story.
STORY LIVES INoperation["corrections"][]— each is{"id","text"}. Join
allcorrections[].text(+opcodeDescription) and substring-match"align"
(lowercased). Joe explicitly wanted these counted ("scrape the story in the TEK
menus for the ones that have alignments in them"). Counting only theALIGN
opcode UNDERCOUNTS.
Implementation notes
- Run as a background terminal process (
nohup ... &, notify_on_complete),
NOT the execute_code tool — the fan-out over a few hundred ROs exceeds the
300s code-tool limit. Script:/home/itadmin/tekion-reports/align_scan.py
(adapt the opcode set + window). Checkpoint to JSON every 20 ROs so a
timeout/crash resumes instead of restarting. - Retry wrapper: on 429 sleep
25*(attempt+1)and retry (up to 5); on other
errors short backoff. Even so a handful of RO calls (~12 of 366) may fail —
impact is tiny (only affects the bundled count, not the dedicated total).
The checkpoint/resume mechanism ALREADY closes this gap — do NOT treat the
"N missed ROs" note as a standing TODO.align_scan.pyskipsif rid in done
(line ~40), so simply re-running the same script resumes and retries only the
failed ids. Verified 2026-06-18: SCT/May rerun printedresume: 354 / 354 → DONE 354, 0 missing. To confirm coverage, diff candidates vs the checkpoint:missing=[r for r in cand if r["id"] not in done]— if empty, you're at 100%,
no action needed. (The candidate set may also shrink between runs if the index
is rebuilt — e.g. 366→354 — which retires phantom "missing" ROs entirely.) - "Sold in a month" = closed by default →
status IN [CLOSED,INVOICED],
windowed oncreationTime(confirm window with Joe: full prior month vs MTD). - Report both: dedicated-opcode breakdown, bundled-menu breakdown (which TEK
menus), and combined unique-RO count + total instances. - :9223 browser is NOT needed for opcode counting (no advisor names required).
Building a comparison scorecard + emailing it (PROVEN 2026-06-18)
When Joe wants the opcode-count result as a polished report (PNG/PDF) emailed:
- Dump a summary JSON with both periods' figures (dedicated/bundled/total/
unique_ros/menu_mix per month) →data/sct-alignment-summary.json. - Render PNG+PDF by ADAPTING
render_scorecard.py, not from scratch. Copy it
to a purpose script (e.g.render_alignment_scorecard.py) and reuse the exact
visual language: white bg,logo_0.pngtop-left (base64-embedded), red#EB0A1E3px rule,.kpicards (.hero= red), dark#1a1a1atable header,
green totals. Render via headless Playwright:pg.goto(file://tmp.html)→pg.screenshot(full_page=True)for PNG →pg.pdf(width=1226px, height= scrollHeight+20)for PDF. Chromium IS the headless variant here (works withheadless=True), unlike the APC portal which needs xvfb+headed. - Verify the PNG with
vision_analyzebefore sending — confirm logo, title,
KPIs, no overlap. Caught nothing wrong but cheap insurance. - Hand off to STACEY (email-agent) to send — Jay does NOT send menu/report
emails himself. Stacey owns email formatting (hersct-menu-sales-report- emailskill = CID multipart/related via SMTP, AMG logo footer, "Sent from
Tekion Open API — live data", NO Joe signature). Reach her via the bridge:~/bin/ask-agent stacey "<self-contained instruction>".- The instruction MUST be fully self-contained (fresh session, no memory):
recipient (To/From), subject, absolute PNG+PDF paths (pre-generated so she
just attaches — this AVOIDS the ~180s bridge nesting timeout), and the exact
body copy (greeting + one bold-total summary line + footer). - Default recipient is Kevin Stapp, but for ad-hoc "email ME this" requests
set To: Joe<jcastelino@americanmotorscorp.com>explicitly — tell Stacey
to override, or she'll default to Kevin. - Tell her to send DIRECTLY via SMTP (no draft iteration) and report back the
Message-ID. Wrap the bridge call intimeout 170.
- The instruction MUST be fully self-contained (fresh session, no memory):
Breaking the opcode count down BY ADVISOR (PROVEN 2026-06-18)
Joe often follows up "how many alignments" with "now by advisor." Don't re-scan —
the advisor is already obtainable cheaply:
assignee.advisor.idis FREE in therepair-orders:searchresult — no
jobs/operations fan-out needed. Take the alignment-bearing RO ids from your scan
checkpoint, then batch them throughrepair-orders:searchwithdocumentId IN [<=50 ids], pageSize 50, and readro["assignee"]["advisor"]["id"]off each result. 176 ROs = 4 search calls,
~3s. Save id-per-RO to JSON.- Resolve ids→names from
data/sct-advisor-cache.jsonFIRST (16+ entries,
mostly covers it). Only unknown ids need the:9223browser. Map"Any Service Advisor"→ display "Unassigned". - Aggregate by name: dedicated (len align_op) + bundled (len tek_align) +
unique ROs per advisor; sort desc by total; add a TOTAL row that MUST reconcile
to the combined figures (161 dedicated + 19 bundled = 180 / 176 ROs for SCT
June MTD — Jon Vu led with 55, nearly 3× #2). - Render with a dedicated by-advisor script (
render_alignment_by_advisor.py) —
same visual language asrender_scorecard.pyplus a red horizontal bar per row
scaled to the top advisor. Then hand to Stacey (same email flow as below).
Advisor-name resolution GOTCHAS (hit 2026-06-18)
- The
:9223browser token goes STALE silently.t_tokenin localStorage (the
real key — NOTtekion-api-token, which may be absent) can be present but expired;
the internal/api/userservice/u/apc/users/{id}call then returns
500 "Token doesn't exist or is invalid" even withcredentials:'include'.
Fix = a fresh Tekion OTP re-login to refresh the session. Don't burn time retrying
header permutations — if it 500s "Token...invalid", the session is dead. data/sct-employees.json(1,200-rec roster) does NOT map to assignee ids. ItsuserId/empNo(UUIDs + 5-digit empNos like 8120) do NOT match the short numeric
assignee ids (59, 61, 74) — and the roster's userIds are even duplicated/garbage.
You CAN find a name by string-matching (e.g. "EDGARDO OLIVER" → empNo 8120) but
there's no id bridge, so it's useless for id→name resolution. Confirmed dead end.- Pragmatic call when one id is uncached and the token is stale: don't block the
whole report on a single name behind an OTP re-login. If the unknown advisor is a
tiny share (e.g. id 74 = 3 of 176 ROs, 1.7%), label it "Advisor #N (name pending)",
ship the report, and offer to patch+resend once Joe IDs them or the session
refreshes. 16/17 named = 98.3% coverage is a deliverable; perfect is the enemy here.
Per-day pace is the real story for MTD vs full-month
A June-MTD total looks lower than a full May only because the month is half over.
Always report alignments/day (total ÷ days elapsed) alongside raw totals —
that's the apples-to-apples comparison Joe cares about (June 10.0/day actually beat
May's 7.9/day despite 180 < 246 raw).
BY-ADVISOR breakdown + multi-page report (PROVEN 2026-06-18)
When Joe wants the opcode count broken down per service advisor (he asked for SCT
June alignments by advisor, then a "second page which is detailed"):
1. Advisor ID is FREE on the RO search — do NOT fan out. assignee.advisor.id
is already on each repair-orders:search result record. Re-query ONLY the
alignment-bearing ROs (the ~176 candidates that had an alignment) in batches of 50
via documentId IN [...], pageSize 50, and read (ro["assignee"] or {})["advisor"]["id"].
~4 calls total, no jobs/operations fan-out. Cache todata/sct-june-align-advisor-ids.json (docId→advisorId).
2. Resolve IDs to names from the cache first. data/sct-advisor-cache.json
(id→"First Last") covers ~16 advisors; title-case it. "Any Service Advisor" →
display "Unassigned" (placeholder, not a person). Ids are mixed short-numeric
("59","61","74") + UUIDs.
3. DEAD END — data/sct-employees.json roster does NOT map to assignee IDs.
The 1,200-rec roster has userId/empNo/name, but its userIds are duplicated/
garbage and the short numeric assignee IDs (59/61/74) are NOT present (verified:
"id 59 in roster: None"). Names ARE there by NAME match (Edgardo Oliver→empNo 8120)
but there's no id bridge. So the roster CANNOT resolve an unknown assignee id —
only the :9223 internal /api/userservice/u/apc/users/{id} call can.
4. The :9223 token goes stale — and that's OK for 1 missing name. When the
internal user lookup returns 500 "Token doesn't exist or is invalid", the browser
session has lapsed (token lives in localStorage key t_token, ~468 chars, NOTtekion-api-token; also currentActiveRoleId/currentActiveSiteId/currentActiveDealerId — but note the browser may be on a DIFFERENT store, e.g.
dealer 1251/SCVW not 876/SCT). Refreshing needs a full OTP re-login. Do NOT block
a whole report on one unresolvable advisor — if it's a tiny share (advisor 74 =
3 of 176 ROs = 1.7%), label it "Advisor #74 (name pending)", ship at 98% coverage,
and offer to resolve on the next session refresh or ask Joe who it is. Totals must
still reconcile (161 ded + 19 bundled = 180 across 176 ROs).
5. Two-page report = extend render_scorecard.py clone.render_alignment_by_advisor.py builds BOTH pages in one HTML with.page divs and .page.break {{ page-break-before:always }}:
- Page 1 = summary scorecard: KPI cards (Total / Unique ROs / #Advisors /
Daily Pace), ranked table (rank, advisor, dedicated, bundled, total-green, ROs,
red.barwidth = pct of leader). - Page 2 = RO-level detail: one
.advblockper advisor (red left-border
header w/ "{{n}} ROs · {{d}} dedicated · {{b}} bundled"), then.chips — one per
alignment line,RO {{no}} {{opcode}}, red chip = dedicated, blue chip =
bundled(menu). Build detail data intodata/sct-june-align-detail.json
(advisor→[{{ro, items:[{{type,label}}]}}]). - PNG screenshot = both pages stacked (full_page=True) for inline preview; PDF
pg.pdf(width="1226px", height="1700px")paginates (a dense detail page may
spill to a 3rd PDF page — that's fine, nothing is cut off). - Verify with
vision_analyzebefore emailing.
6. Bridge timeout discipline (exit 124) — VERIFY before resending. The Staceyask-agent call can exceed the ~180s nesting timeout (exit 124) even though she may
or may not have sent. Before resending, check it didn't already go out:himalaya envelope list -a personal -f "[Gmail]/All Mail" -s 12 | grep -i "<subject keyword>"
(quote [Gmail]/All Mail WITHOUT extra inner quotes; [Gmail]/Sent Mail works too).
If the subject is absent → safe to resend. To make Stacey's reply fit in the window,
tell her to "Reply with just: SENT or ERROR . Keep your reply
short." — a terse reply returns before the timeout; a verbose confirmation table
can blow it.
6b. Joe's stated preference (2026-06-18): STACEY owns email — route through her,
do NOT default to Jay direct-send. The SMTP fallback below is LAST RESORT only
(Stacey repeatedly failing) and you should FLAG it to Joe when you use it, not
silently take over. If Stacey times out REPEATEDLY (2+ exit-124 in a row), send it
YOURSELF via SMTP as a stopgap, then tell Joe Stacey was down and you covered. Her per-send work (read PNG+PDF off disk, base64 a multi-page PDF, build the
MIME tree) inside a nested ask-agent session is what blows the ~180s window — it's
not a transient hiccup, it'll keep failing for big attachments. Jay can replicate her
EXACT approved format directly (verified working 2026-06-18, msg-id
178181913915...@americanmotorscorp.com). The app password is in Stacey's himalaya
config (same Gmail account):
cfg='/home/itadmin/.hermes/profiles/email-agent/home/.config/himalaya/config.toml'
APP_PW = re.search(r'raw\s*=\s*"([^"]+)"', open(cfg).read()).group(1).replace(' ','')Then build multipart/mixed → related → (alternative: text+html) + CID png and attach
the PDF as MIMEBase('application','pdf') base64; smtplib.SMTP_SSL('smtp.gmail.com',465),
login as jcastelino@americanmotorscorp.com, send_message. Keep her rules: From+To
both Joe for "email me" requests, CID <img src="cid:...">, "Sent from Tekion Open API
— live data" footer, AMG logo via the Google-proxy URL, NO Joe signature. This bypasses
the bridge entirely and lands in ~3s. (Per Jay's mandate Stacey OWNS email formatting —
prefer the bridge; this SMTP fallback is only for when the bridge is unreliable, and it
reuses her exact template so the output is identical.)
6c. Inline image = PAGE 1 ONLY; PDF = full multi-page. Joe asked to keep the inline
email image to just the summary page (the stacked 2-page PNG made the inline preview
huge). Render TWO PNGs: screenshot a page-1-only HTML for the inline CID image, and keep
the PDF as the full report. In render_alignment_by_advisor.py build_html returns(full_html, page1_only_html); main() screenshots the page1 HTML → PNG (the inline/CID
file) and pdf()s the full HTML → multi-page PDF. Same filename for the PNG so the email
flow is unchanged.
7. Ad-hoc "email ME" recipient. For these one-off requests the recipient is
Joe (jcastelino@americanmotorscorp.com), NOT Kevin — state To: Joe explicitly
in the Stacey instruction or she defaults to Kevin Stapp per her skill.
PAGINATION TRAP — closedTime window scans (burned 2026-07-30)
On repair-orders:search with closedTime GTE/LTE filters: pageInfo.pageNumber is
IGNORED (every "page" returns the same first 20 rows; pageSize silently capped at 20),
and meta.nextPageToken drifts outside the filter window (following it walks into
other months — a 15-day SV window "grew" past 5,000 ROs). FIX = closedTime bisection:
recursively split [lo,hi] until meta.totalCount <= len(results), concat, dedupe.
Also: top-level id is null on search results — the RO document id for fan-out is thedocumentId field. Working example: /home/itadmin/tekion-reports/sv_flag_below_actual_h1jul.py.
Don't believe a "0 results" run without checking meta.totalCount vs rows returned.
Filtering ROs by DEPARTMENT (e.g. isolating Body Shop/Collision Center) — PROVEN 2026-08-18
assignee.department is a free LinkedResource on every repair-orders:search result
(assignee.department.id) — no fan-out needed. Resolve the id via GET /departments/{id}
(support__get-department.json spec) → {"data":{"id","name"}}.
Fleet department census (confirmed live across all 7 stores, 2026-08-18):
- BT (1249):
1249_department_3=Service,1249_department_5=Collision Center - BC (1251):
1251_department_d=Service,628efbdc...=Express Service,640635e2...=PDI,640635f5...=UCD - ST (876):
876_department_03=SERVICE,6421a787...=Used Car Department - SV (826):
826_department_64=SERVICE VW - TL (1092):
1092_department_3=Service - AR (6195), VC (1891): only a single generic
<dealer>_department_64id, name resolvesnull(single-department stores)
⚠️ BT is the ONLY AMG store with a Collision Center / Body Shop department in Tekion.
Don't assume other stores have a body-shop dept — verify per store before scoping a scan.
Department discovery gotcha: a single time-window snapshot can miss department ids
that only appear in ROs outside that window. Sample MULTIPLE anchor timestamps spread
across the full period you care about (e.g. 15 anchors across 92 days, creationTime LTE <anchor> pageSize 50 each) and union all assignee.department.id values seen before
concluding "these are the only depts" — a single-page sample at BC missed 2 of 4 real
departments (Express Service, PDI) on the first pass.
Filter recipe: pull the full RO set for the store/window via creationTime BTW
pagination (works fine with plain paginationToken chaining here — no bisection needed;
the bisection-required pagination bug documented above is specific to closedTime filters),
then results = [r for r in ros if (r.get("assignee") or {}).get("department",{}).get("id") == target_dept_id]. Exclude status == "VOIDED" for any dollar/count analysis.
Collision/Body Shop ROs — line items are LUMP SUM, not itemized (learned 2026-08-18)
Asked to find "calibration" mentions with a dollar figure across 3 months of BT body shop
ROs (Joe's alignment-machine ROI calc). Scanned all 168 active Collision Center ROs —
every job concern text, operation description/opcode, AND part name/description — for
"calibrat*". Result: only 2 hits, both buried in customer-concern free text, not a
broken-out billable line.
Why: Collision ROs are billed against an EXTERNAL insurance estimate (CCC ONE /
Mitchell "work file id" appears in the job concern text) using a small set of generic
lump-sum opcodes: BSRPE ("Collision Center Repair Per Estimate"), BS6 (Body Labor),BS7 (Paint Labor), BS8/BS10, BSRANDIGLASS, PDRWHEEL/PDRDENT. Whatever line
items exist on the ADJUSTER's estimate (ADAS/camera/radar recalibration after bumper,
windshield, or fender work is standard) get folded into ONE lump saleAmount/costAmount
on the RO — they are NEVER broken out as their own Tekion opcode or part line. Text-mining
Tekion RO data for a keyword like "calibration" will systematically UNDERCOUNT — the real
itemized figure lives only in the CCC ONE/Mitchell estimate PDF, which Tekion does not
ingest at the line-item level.
What IS available in Tekion as a fallback data point: the store's DEDICATED alignment
opcode (e.g. BT's 4ALIGN) — count + labor.saleAmount sum via the existing tags-first
scan method above — gives a real in-house alignment volume/dollar baseline even though it
won't capture insurance-billed calibration work.
Recommendation for any "scrub body shop ROs for line-item X and $" ask: if X is an
insurance-estimate line item (calibration, glass, PDR sub-items), tell the user up front
that Tekion structured search will undercount and that the ground truth is the CCC
ONE/Mitchell estimate export — offer to pull those directly (portal access) or hand-sample
a batch of "Per Estimate" RO estimate PDFs, rather than shipping a Tekion-only text-scan
result as if it were complete.
Verification
After any new endpoint, sanity-check against known data: today's RO count at SCT should roughly match the store's appointment volume; labor amounts are integer cents.