# Workfront UI Extension

> Use when building or editing the React/Spectrum front-end SPA of a Workfront App Builder extension. Reach for this whenever the user is: registering or changing extension points in `ExtensionRegistration` — a Main Menu button, a left-panel (`secondaryNav`) item for a specific Workfront object type (Project, Task, Issue, Portfolio, Program), or a custom-form widget with specific height and width; adding a new route in `App.js` to match an extension point URL; reading the Workfront shared context to get the current user, `objCode`, `objID`, or `hostname`; calling a Runtime action from the SPA via `actionWebInvoke`; or debugging a widget or route that renders blank after being registered. Never call Workfront or Adobe APIs directly from the SPA — all API calls belong in a Runtime action (see `workfront-actions`).

- Skill: `catcorner22/workfront-ui-extension` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add catcorner22/workfront-ui-extension`
- Raw SKILL.md: https://api.skillmd.com/api/skills/catcorner22/workfront-ui-extension/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: Apache-2.0
- Author: CatCorner22 (https://skillmd.com/u/catcorner22)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/catcorner22/workfront-ui-extension

---

# Workfront UI extension (front end)

Part of the `appbuilder-workfront` family. This is the **front end** — the app screens the user sees (the "SPA"). It registers **extension points** (the spots where the app appears in Workfront) and calls **Runtime actions** (the cloud back end) for all data.

> This skill is the **Workfront-specific** front end (extension points, WF shared context). For generic React/Spectrum patterns (pages, forms, data tables, dialogs, navigation) and ExC Shell / AEM UI surfaces, use **`appbuilder-ui-scaffolder`**. A ready-to-edit registration example is in `assets/ExtensionRegistration.example.js`.

## Rules

- One `register()` call wires everything up; individual views use `attach()`.
- Auth comes from `sharedContext` + `getWFInstanceUrl()` — Workfront supplies the signed-in user and instance; don't build a login.
- Talk to the back end with `actionWebInvoke` only — the browser must **never** call Workfront directly (`/attask/api/…`). Back-end code lives in `workfront-actions`.
- Every action replies with `{ data, error }` — always check `error` before using `data`.

## Extension points (in ExtensionRegistration)

`register()` takes **`id` at the top level** (a non-empty slug identifying the extension); the extension points go inside `methods`. Each item's `url` must map to a route in `App.js`, and every `id` must be unique.

```js
const guestConnection = await register({
  id: extensionId,               // top-level id, non-empty
  metadata,                      // from app-metadata.json (generated by a build hook)
  methods: {
    id: extensionId,             // ⚠️ REQUIRED here too — the menu item won't render without it (see Gotchas)
    mainMenu: {
      getItems() { return [{ id, url: '/index.html#/route', label, icon }] }
    },
    secondaryNav: {              // left panel, per object type
      PROJECT: { getItems() { return [{ id, label, icon, url: '/route' }] } },
      // register each separately: PROJECT, TASK, ISSUE, PORTFOLIO, PROGRAM
    },
    widgets: {                   // embed in a custom-form field
      getItems() {
        return [{
          id, url: '/index.html#/widgets1', label,
          dimensions: { height, width, maxHeight, maxWidth }   // all optional
        }]
      }
    },
  }
})
```

Widget `id`/`url`/`label` are required; `dimensions` is optional.

## Routing (App.js)

Add a `<Route>` per extension-point url:

```jsx
<Route exact path="custom-application" element={<CustomApplication />} />
```

## Shared context

`sharedContext` is **get-only** (`.get(key)` — not iterable). Confirmed shape from the Workfront host:

```js
const ctx  = conn?.sharedContext
const auth = ctx?.get('auth')      // { imsClientId, imsOrgID, imsToken }
const user = ctx?.get('user')      // { ID, email }
const host = ctx?.get('hostname')  // e.g. ai-dev-arm.devtest.workfront-dev.adobe.com (no protocol)
// also: protocol; plus objCode, objID, isLoginAs, isInBulkEditing on object-scoped points

const imsToken = auth?.imsToken
const imsOrgId = auth?.imsOrgID     // ⚠️ key is `imsOrgID` (capital ID) — NOT imsOrgId / imsOrg
```

Everything an action needs is right here — pass `imsToken`, `imsOrgId` (`auth.imsOrgID`), and `host` into `actionWebInvoke`. **Don't call Workfront for the org**: it's in `auth`, and a cross-origin `currentUser` fetch from the SPA is CORS-blocked anyway. Only set the `x-gw-ims-org-id` header when you have a value — Fetch turns `undefined` into the string `"undefined"` (→ `401 Org Id undefined`). Widgets receive the same context.

## Workers / errors

Do heavy CSV/XLSX (spreadsheet) work in **Web Workers** — background threads, so the screen doesn't freeze — but never call the WF API inside a worker. Show a **toast** (small popup notice) on failure; never expose tokens.

## Gotchas (from building a real Main Menu extension)

- **Main Menu item not rendering? It's the `id`, and the fix is trivial.** The WF template scaffolds `register({ metadata, methods: { id: extensionId, mainMenu } })` with `extensionId = ''` in `Constants.js`. Two things are required for the item to appear: (1) give `extensionId` a **non-empty** value in `Constants.js`, and (2) **keep `id: extensionId` under `methods`** — that placement is exactly what the menu needs; if you remove it, the item still registers (Workfront even calls `getItems`) but **silently never renders**. Simplest working form: set `extensionId`, and keep `id: extensionId` inside `methods` (having it at the top level of the config too is fine). **Don't be thrown off by reading `@adobe/uix-guest`:** `register(config)` does `new GuestServer(config.id)` then `guest.register(config.methods, …)` and just *forwards* `methods` to the host — so from the guest source a bare `methods.id` looks like an ignored no-op. It isn't: **Workfront's host side consumes `methods.id`**, and that behavior isn't visible in the guest package. Verified in a live Main Menu extension — the item does not render without it. *(This is easy to misdiagnose as an environment problem, or as dead code — it's neither.)*
- **`aio app init -y` (or skipping the "Add a custom button to Main Menu Item" prompt) generates no menu item at all** — no `mainMenu` block, no view component, no `App.js` route, no `icons.js`, empty `extensionId`. Either answer that prompt during init, or hand-add: `icons.js` (exporting `icon1`/`icon2`), the `mainMenu` block, a `<Route>`, and the view component.
- **Calling actions:** the template's `web-src/src/utils.js` exports `actionWebInvoke(url, headers, params, options = { method: 'POST' })`. Action URLs are injected into `web-src/src/config.json` at build time under **both** `"<action>"` and `"<package>/<action>"` — `import actions from '../config.json'` and look up by name; never hardcode a Runtime URL.
- **`register` vs `attach`:** the background registration frame uses `register()` (a GuestServer, declares the extension points); a displayed view uses `attach()` (a GuestUI). Both expose `sharedContext`.
- **The deployed app's URL is the Experience Cloud shell link — not the bare CDN.** Clicking the Main Menu button navigates to `…/workfront/custom-applications/<extensionId>/<menuRoute>` (org- and instance-scoped); that **is** the app's shareable URL, and after `aio app deploy` you should hand it to the user (build recipe in `workfront-local-testing`). First segment = the registration `id`; second = the menu item's `#/route`. Two traps: reusing the app id as the second segment (`…/<extensionId>/<extensionId>`) loads the background registration frame, not the view; and the raw CDN `…/index.html#/route` renders with no host → no `sharedContext`. If the item registers but won't render live, suspect the Workfront environment, not the code (see `workfront-local-testing`).
- **React Spectrum `TableView`'s "select all" header checkbox gives you the string `'all'`, not a `Set`.** `onSelectionChange` for `selectionMode="multiple"` normally hands you a `Set` of row keys, and code like `selectedKeys.size > 0` works fine when the user checks rows one at a time. But clicking the header's own "select all" checkbox sets `selectedKeys` to the **literal string `'all'`** instead — `'all'.size` is `undefined`, so a naive `selectedKeys.size > 0` check silently evaluates to `false` and a bulk-action button stays disabled with no visible error. Always branch on the string case: `const count = selectedKeys === 'all' ? totalItems : selectedKeys.size`, and use the same branch anywhere you turn the selection into an array of ids (`selectedKeys === 'all' ? items.map(i => i.id) : Array.from(selectedKeys)`). This is easy to miss in testing if you only ever click individual row checkboxes.

