Imported from teeks822828/exb-publish-agol (
SKILL.md). Install upstream withnpx skills add teeks822828/exb-publish-agol. Copyright stays with the author.
SKILL — Build & publish ArcGIS Experience Builder apps by script (ArcGIS Online)
Use when asked to create, modify, or debug an Experience Builder (EXB) app
on ArcGIS Online by script (trigger: /exb-publish-agol). Covers authoring
the EXB config JSON programmatically, publishing via the ArcGIS API for Python,
and the config traps that produce blank/erroring widgets.
Reference files (read on demand):
references/traps.md— every known trap in full detail (charts, tables, filters, layouts, custom-mobile freeze, hosting-widget layout slots). Read BEFORE writing or rewiring any widget config.references/recipes.md— proven widget config shapes (filter incl. date-range, list/cards, survey123, controller, multi-page + menu, message actions, live-app patch patterns). Read when scripting one of those.references/donors.md— donor index for AGOL. The cached configs indonors/ARE the donors (self-contained — no live donor apps needed), plus proven example generators (donors/*.py.example).
Environment (ArcGIS Online)
- Portal: your ArcGIS Online org (e.g.
https://<org>.maps.arcgis.com). EXB runtime on AGOL is currently 1.21.0 (July 2026). AGOL upgrades EXB ~3x/year with no warning — verify before every build: search recent publictype:"Web Experience"items and readversion:x.y.zfromtypeKeywords, or pull any app you just saved in the builder. - Python: ArcGIS Pro's conda env via
%LOCALAPPDATA%\Programs\ArcGIS\Pro\bin\Python\Scripts\propy.bat <script.py>(per-user install).arcgispackage v2.4.2. - Auth:
GIS("pro")uses the active ArcGIS Pro sign-in — make sure Pro is signed into the AGOL org, not an Enterprise portal (checkgis.url). No credentials in scripts. - Pro's active portal can CHANGE MID-SESSION — the user switching Pro's
connection (e.g. to view another org's app) silently redirects every
subsequent
GIS("pro"). July 2026: flipped to a different AGOL account between two script runs, nearly creating items in the wrong org. Both bundled scripts now enforce this viaassert_target_portal():- non-AGOL portals are refused on write (
SystemExit) — an Enterprise portal is a hard stop; use theexb-publishskill for those. Reads (exb_tools.py pull) are still allowed, so Enterprise donor mining works. EXB_EXPECT_ORG=<substr>/EXB_EXPECT_USER=<name>add org and username asserts — set these on shared machines and for any production patch.EXB_ALLOW_PORTAL=<host>is the deliberate override; it must name the exact host, so it can't be set once and forgotten. Never cache a GIS object across user interactions; the guard re-asserts per run. Any NEW script you write must call it before its first write.
- non-AGOL portals are refused on write (
- Filter propy noise: pipe through
grep -v "not recognized\|operable program"(harmless activate-gdal.bat warnings). - propy stdout is cp1252 — any Unicode in prints (emoji, ❀ in Wikimedia
artist names, ✻) crashes the script mid-run with UnicodeEncodeError. Run
with
PYTHONIOENCODING=utf-8always (July 2026: killed a photo-attach run halfway). - arcgis 2.4.2 API drift:
me.foldersyieldsFolderOBJECTS (f.name), not dicts —f["title"]throws.gis.content.add(..., folder=...)still works (deprecation warning only). Folder create:gis.content.folders.create(name). - Scripts may be pasted into the Pro Python window →
__file__doesn't exist there; guard withtry: __file__ except NameError: HERE = r"<fallback>".
Data prep for EXB-bound hosted layers (proven July 2026, Gardens build)
- FGDB→zip→
gis.content.add(File Geodatabase)→item.publish()carries attachments into the hosted service when the FC wasEnableAttachments-ed and populated viaAddAttachments(match table CSV of OBJECTID,filepath) BEFORE zipping — no per-feature REST uploads needed. - After
EnableAttachments, plainarcpy.da.UpdateCursoron that FC throws "cannot be updated outside an edit session" — wrap updates inwith arcpy.da.Editor(gdb):. - Esri-JSON query exports sometimes carry coordinates ONLY as X/Y attribute
fields with no
geometrykey per feature —arcpy.conversion.JSONToFeaturesthen creates rows with NULL geometry silently (attributes look perfect; nothing renders). After any JSON conversion, assertSHAPE@is non-null, and repair from the X/Y fields if not (July 2026: Gardens drinking fountains). HeaderspatialReferencecan also lie — those X/Y were WGS84 despite a wkid 7856 header. - Species/reference photos: Wikimedia Commons API
(
action=query&generator=search&gsrnamespace=6&prop=imageinfo&iiprop=url| extmetadata&iiurlwidth=900, addfiletype:bitmapto the search, skip titles matching map/distribution/herbarium/illustration) gives a real photo- Artist + LicenseShortName for attribution. Store credit in a field; surface it in the popup.
Ground rules
- Never create or modify Portal items, services, or settings without the user's OK. Deliver scripts for review, or get explicit approval per action. Never touch services the user doesn't own.
- Builder tabs must be closed before any push — a builder Save republishes its in-memory copy of the WHOLE config and clobbers patches.
- For risky changes to a shared/production app: clone it first, prove the patch on the clone, then run the identical patch on production.
- Pushing publishes any pending draft edits (draft base → both stores). Have the user Publish-and-save first, or diff the stores and report what's pending.
How an EXB app is stored
- Item type
Web Experience. The config JSON lives in two places: item data (item.get_data()/text) = published copy, and item resourceconfig/config.json= draft the builder edits. Update BOTH when patching (item.update(item_properties={"text": ...})+item.resources.update(...)). typeKeywordscarry state:status: Published,publishVersion:<v>,version:<v>. Viewer URLhttps://experience.arcgis.com/experience/<id>; builderhttps://experience.arcgis.com/builder/?id=<id>.exbVersionin config must match AGOL's current EXB version (see Environment; the scripts readEXB_VERSIONenv var, default 1.21.0).- Viewer caches config via a service worker — Ctrl+F5 after patching.
Authoring approach: donor cloning
Author the config by deep-copying widget shapes from an existing app on the
same portal ("donor"), then rewiring. Do NOT write widget JSON from scratch —
the config format is officially undocumented. Pick donors from
references/donors.md; if no AGOL donor exists for a widget type yet, build
one manually in the builder, pull it with exb_tools.py pull, and register it.
Data sources: create a FEATURE_SERVICE root DS with childDataSourceJsons
keyed by layer index (child id = <dsid>-<layerIndex>); charts/tables
reference the child via useDataSources: [{dataSourceId, mainDataSourceId, rootDataSourceId}]. No web-map child DS needed for charts/tables. A map
widget needs a WEB_MAP DS pointing at a real Web Map item.
The golden debugging method: build the same widget manually in the builder, pull the config, and diff it against the generated one. Every trap in traps.md was found that way. Never hand-tune from theory.
Trap summary (details + more in references/traps.md — read it)
- Charts: rewrite
orderByFieldsto the group field; keepseries[0]only, drop itsquery; seriesy= the outStatistic field name; temporal charts need the full binning contract + date axis format AND, on EXB >= 1.21,series[0].temporalBinning: {size, unit}— without it the chart renders empty axes silently (the legacy 1.17 keys are ignored); every chart needs an output DS with CLEANoriginDataSources(afieldskey there freezes the builder's mobile conversion) and trueoriginFieldson the stat field. - Tables:
tableFieldsneedvisible: true; purge donorsearchFields. - Filters:
DATE_OPERATOR_IS_BETWEENneedsDOUBLE_DATE_PICKER; expanding filter items CLIP at the widget bbox (overflow: hidden— NOT z-order; bring-to-front won't fix it): prefercollapseFilterExprs: false(always-open), one item per widget, ~12-14% page height; place filters LAST in layoutorder(z-order, for true poppers). - Hosting widgets (map/controller/list/accordion) MUST keep their layout slots; pre-author MEDIUM/SMALL layouts so the racy mobile conversion never runs; charts everywhere must match builder-canonical shape.
- Assert zero donor field names survive rewiring; validate all field names against the live layer before creating anything.
Toolbox (bundled: scripts/)
Run via propy.bat. PROPY = %LOCALAPPDATA%\Programs\ArcGIS\Pro\bin\Python\Scripts\propy.bat
scripts/build_or_patch_exb.py— the generalised generator/patcher: MODE new-app | add-pages | fill-page, PAGES spec (charts/tables/filters/ placements) in USER SETTINGS, auto-allocated non-colliding ids for patches, null-stat warnings, canonical shapes throughout. Patch modes never push — they emitpatched_config.json+newonly_config.json.EXB_VERSIONenv var overrides the default (1.21.0).scripts/validate_exb_config.py <itemid|config.json> [--widgets a,b | --min-id N]— runs the trap list as assertions (charts incl. 1.21 temporalBinning, tables, filters incl. cramped-collapsible sizing, message configs, hosting-widget slots, layout z-order). Scope per-widget checks with--min-id 400when validating a patched production app so pre-existing builder widgets don't flood the report. Run before every push. Exit 1 on failure.scripts/exb_tools.py pull <itemid> [--out-dir D]— downloads BOTH config stores and says whether they differ.scripts/exb_tools.py push <itemid> <config.json>— backs up both stores tobackups/(timestamped), then writes BOTH. Builder tabs closed first; Ctrl+F5 after. Rollback = push the backup file.scripts/exb_tools.py diff <file> --a widget_3 --b widget_9— value-level recursive widget diff; also across two files. The golden debugging tool.
Debugging a deployed app
- Pull live config; reproduce chart queries server-side via
gis._con._session.post(<layer>/query, ...)withf=jsonandf=pbf— if REST works, the bug is in the widget config, not the service. - Don't trust headless/automation screenshots of Esri apps — canvases often capture blank even for working apps. Verify via config diffs and REST; the user does the final visual check.
- Don't chase environment theories until a manually-built widget in the SAME app also fails. It was config every time.
Workflow checklist
- Pick the pattern: new app (generator), add pages, or fill an empty page — see "Patching a LIVE app" in recipes.md. Copy the USER SETTINGS style (item ids, chart specs, table fields, placements all at top; DRY_RUN env).
- Confirm the current AGOL EXB version; confirm target feature service item ids; validate fields; check filter values actually exist on the layer (and warn if a chart's stat field is all null — the chart will render empty).
- Deliver the script to the user to run, or get explicit OK to run/push.
- Validate the generated config (scoped for patches) — fix every FAIL.
- User checks visually; if a widget misbehaves: manual build → pull → diff → fix generator → push (builder tabs closed) → Ctrl+F5.
- Fold every new trap into
references/traps.md, every new shape intoreferences/recipes.md, every new donor intoreferences/donors.md, and add a matching validator check.