Custom Widgets

Custom Widgets extend Palantir Foundry Workshop with fully custom React UIs that integrate bidirectionally with Workshop variables, Object Sets, and events. Three distinct paths exist depending on your integration needs and existing codebase: Custom Widgets (native Workshop sandbox), Bidirectional iFrame Widgets (wrapping existing React apps), and OSDK React Apps (standalone Developer Console deployments). Choosing the right path early prevents costly rewrites — the packages, build tooling, and deployment flows are entirely separate.

Widget Types and When to Use Each

Foundry provides three distinct approaches to building custom UI. Custom Widgets use the @osdk/widget.client package and run directly in the Workshop page sandbox, giving them native access to Workshop variables and events with up to 50 parameters and 50 events. They support full Object Set sizes and are the recommended path for all new widget development. Bidirectional iFrame Widgets use @osdk/workshop-iframe-custom-widget to wrap an existing React application inside an <iframe> element. A postMessage-based bridge connects the iFrame to Workshop via the useWorkshopContext hook. The trade-off is a hard 10,000 object limit on Object Set parameters and no support for struct-typed variables. Use this path only when you need to integrate a pre-existing React app without a full rewrite. OSDK React Apps are standalone applications deployed through the Developer Console at their own URL. They use @osdk/client directly and have no Workshop variable or event constraints. They are appropriate for tools that do not need to live inside a Workshop dashboard — for example, administrative portals or data entry forms that navigate independently.
Widget Type Comparison
TypeFrameworkWorkshop IntegrationObject LimitKey Package
Custom WidgetReact (any version)Full — 50 params, 50 eventsNo hard limit@osdk/widget.client
Bidirectional iFrame WidgetReact (any version)Bridge — params and events via postMessage10,000 objects@osdk/workshop-iframe-custom-widget
OSDK React AppReact (any version)None — standalone URLNo hard limit@osdk/client
Basic Custom Widget Setup with defineWidget and Parameters
import { defineWidget, Parameters } from '@osdk/widget.client';

const parameters = {
  title: Parameters.string(),
  maxItems: Parameters.number(),
  showBorder: Parameters.boolean(),
  selectedObjects: Parameters.objectSet({ objectType: 'Employee' }),
} satisfies Parameters.Definition;

export default defineWidget({
  parameters,
  render: ({ parameters }) => {
    return {
      element: document.getElementById('root')!,
      component: MyWidgetApp,
      props: {
        title: parameters.title.value ?? 'Default Title',
        maxItems: parameters.maxItems.value ?? 25,
        showBorder: parameters.showBorder.value ?? false,
        selectedObjects: parameters.selectedObjects.value,
      },
    };
  },
});

⚠️ 10,000 object limit

Bidirectional iFrame Widgets silently truncate Object Set parameters at 10,000 objects. There is no error thrown — your component simply receives fewer objects than exist. If your widget needs to work with large Object Sets, use a Custom Widget instead. The Custom Widget sandbox has no such cap and streams objects through the Workshop variable binding layer.

📖 Related Module: Workshop Fundamentals

Parameters and Events

Parameters are the inputs a Custom Widget receives from Workshop. They are declared in the widget definition using the Parameters namespace from @osdk/widget.client and include primitive types (boolean, number, string, date, timestamp), Object Set bindings, and array variants of all primitive types. Workshop binds values to parameters through the widget configuration panel; the bound values are available at runtime via parameters.<id>.value. Events are the outputs a Custom Widget sends back to Workshop. They are declared similarly using the Events namespace and can carry typed payloads. In the React binding layer (@osdk/widget.client-react), events are emitted by calling the emit function returned from useEmitEvent. Workshop can subscribe to these events to update variables or trigger Actions. A widget is limited to 50 parameters and 50 events. These counts include all parameter types combined. For most widgets this is generous, but complex dashboards that duplicate many filter parameters can approach the limit. If you hit the ceiling, consolidate related parameters into a JSON string parameter and parse it in the widget.
Parameter Types
TypeTypeScript TypeExample Value
booleanboolean | undefinedtrue
numbernumber | undefined42
stringstring | undefined"hello"
datestring | undefined (ISO date)"2024-03-15"
timestampstring | undefined (ISO 8601)"2024-03-15T10:30:00Z"
objectSetObjectSet<T> | undefinedObjectSet<Employee>
array (string)string[] | undefined["a", "b"]
array (number)number[] | undefined[1, 2, 3]
array (boolean)boolean[] | undefined[true, false]
Defining Parameters and Emitting Events with @osdk/widget.client-react
import React from 'react';
import { useParameterValue, useEmitEvent } from '@osdk/widget.client-react';
import { defineWidget, Parameters, Events } from '@osdk/widget.client';

const parameters = {
  employeeSet: Parameters.objectSet({ objectType: 'Employee' }),
  filterActive: Parameters.boolean(),
} satisfies Parameters.Definition;

const events = {
  employeeSelected: Events.objectClicked({ objectType: 'Employee' }),
  filterChanged: Events.string(),
} satisfies Events.Definition;

export default defineWidget({ parameters, events });

export function EmployeeListWidget() {
  const employeeSet = useParameterValue('employeeSet');
  const filterActive = useParameterValue('filterActive');
  const emitEmployeeSelected = useEmitEvent('employeeSelected');
  const emitFilterChanged = useEmitEvent('filterChanged');

  const handleEmployeeClick = (employee: Employee) => {
    emitEmployeeSelected({ object: employee });
  };

  const handleFilterToggle = (value: string) => {
    emitFilterChanged(value);
  };

  return (
    <div>
      {/* render employee list */}
    </div>
  );
}

⚠️ Parameter ID mismatch

Parameter IDs must be camelCase and must match exactly between the widget definition object and any reference in code. A typo in the ID causes Workshop to silently ignore the binding — no error is thrown, the parameter value is simply always `undefined`. Always define parameter IDs as string literals in one place and reference them via the object key, not a separate string constant that can drift.

📖 Related Module: Advanced Workshop Patterns

State Management in Custom Widgets

Custom Widgets run in a sandboxed iframe context with a strict Content Security Policy that blocks writes to localStorage, sessionStorage, and IndexedDB. Any call to these APIs will throw a security error at runtime. This means you cannot persist state the way a typical web app would — all state that needs to survive navigation, tab switches, or page refreshes must be stored externally. The correct persistence target depends on the state type. User selections and filter values that should survive across sessions belong in Workshop string or boolean variables bound as parameters. The widget writes back to these variables by emitting typed events that Workshop subscribers update. For heavier computed results, consider caching in an Ontology object via a Function-backed Action rather than re-computing on every render. React's useState and useRef are perfectly fine for ephemeral UI state (open/closed dropdowns, animation phase, in-progress form values before submission). Understand clearly which state is ephemeral and which must persist — the boundary is whether the user would be surprised to lose it on a tab switch.
State Persistence Patterns
State TypeStore InExample
User selectionsWorkshop string/boolean variableSelected tab ID, active filter name
Filters / search termsWorkshop string variable (emit event on change)Search input value, date range string
Current view / navigationWorkshop string variableActive panel ID ("detail" | "list")
Form draftsWorkshop string variable (JSON-serialized)Partially filled form as JSON string
Heavy computation resultsOntology object via ActionPre-aggregated report stored as object property
Using Workshop Variables for State via useWorkshopContext
import React, { useCallback } from 'react';
import { useParameterValue, useEmitEvent } from '@osdk/widget.client-react';
import { defineWidget, Parameters, Events } from '@osdk/widget.client';

const parameters = {
  activeTab: Parameters.string(),
  searchQuery: Parameters.string(),
} satisfies Parameters.Definition;

const events = {
  tabChanged: Events.string(),
  searchQueryChanged: Events.string(),
} satisfies Events.Definition;

export default defineWidget({ parameters, events });

export function TabbedWidget() {
  const activeTab = useParameterValue('activeTab') ?? 'overview';
  const searchQuery = useParameterValue('searchQuery') ?? '';

  const emitTabChanged = useEmitEvent('tabChanged');
  const emitSearchQueryChanged = useEmitEvent('searchQueryChanged');

  const handleTabChange = useCallback(
    (tabId: string) => {
      emitTabChanged(tabId);
    },
    [emitTabChanged],
  );

  const handleSearchChange = useCallback(
    (query: string) => {
      emitSearchQueryChanged(query);
    },
    [emitSearchQueryChanged],
  );

  return (
    <div>
      <TabBar active={activeTab} onChange={handleTabChange} />
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
    </div>
  );
}

⚠️ State lost on tab switch

When a user navigates away from a Workshop tab and returns, the widget iframe is torn down and re-mounted. All React state (useState, useRef, context) is wiped. Any value that was not persisted to a Workshop variable will reset to its initial value. Users experience this as the widget "forgetting" what they were doing. Design for this from the start — treat in-memory React state as purely ephemeral and route anything meaningful through Workshop variable events.

📖 Related Module: Advanced Workshop Patterns

Bidirectional iFrame Widget Integration

The Bidirectional iFrame Widget pattern is designed for teams with an existing React application that needs to be surfaced inside a Foundry Workshop dashboard without a full rewrite. The @osdk/workshop-iframe-custom-widget package injects a useWorkshopContext hook into your React app that provides access to Workshop parameters and event emitters via a postMessage bridge between the iFrame and the parent Workshop page. Installation requires adding @osdk/workshop-iframe-custom-widget to your app's dependencies and wrapping your top-level component (or a subtree) with the WorkshopContextProvider. The useWorkshopContext hook then returns the current parameter values and emit functions. Critical limitations to understand before choosing this path: Object Set parameters are capped at 10,000 objects with no workaround. Struct-typed Workshop variables cannot be passed across the iFrame boundary — only primitives and Object Sets are supported. Because the widget runs at a separate URL, OSDK client initialization requires that the Foundry hostname be derived from window.location.origin rather than a hardcoded string, since the deployment URL varies across Foundry stacks.
iFrame Widget Limitations
LimitationImpactWorkaround
10,000 object cap on Object SetsWidget silently receives truncated Object SetMigrate to Custom Widget if larger sets are needed
No struct variable supportStruct-typed Workshop variables cannot be passed inSerialize struct fields as separate string parameters or JSON string
Separate URL deploymentCORS and auth must be configured for the Foundry stack domainUse window.location.origin for OSDK client base URL
postMessage bridge latencyParameter updates have small async delay vs native WorkshopDebounce rapid parameter changes in Workshop bindings
No Workshop Action integrationCannot directly invoke Actions from iFrame contextRoute Action calls through OSDK client using user token from context
React Component Using useWorkshopContext from @osdk/workshop-iframe-custom-widget
import React from 'react';
import {
  WorkshopContextProvider,
  useWorkshopContext,
} from '@osdk/workshop-iframe-custom-widget';
import { createClient } from '@osdk/client';

const client = createClient({
  url: window.location.origin,
  clientId: 'my-widget-client-id',
  ontologyRid: 'ri.ontology.main.ontology.abc123',
});

function EmployeePanel() {
  const context = useWorkshopContext();

  const employees = context.fieldValues.employeeSet;
  const statusFilter = context.fieldValues.statusFilter ?? 'all';

  const handleEmployeeSelect = (employeeRid: string) => {
    context.executeEvent('employeeSelected', employeeRid);
  };

  if (!employees) {
    return <div>No employees bound.</div>;
  }

  return (
    <div>
      <p>Loaded {employees.objects.length} employees</p>
      {employees.objects.map((emp) => (
        <button key={emp.$primaryKey} onClick={() => handleEmployeeSelect(emp.$primaryKey)}>
          {emp.fullName}
        </button>
      ))}
    </div>
  );
}

export function App() {
  return (
    <WorkshopContextProvider>
      <EmployeePanel />
    </WorkshopContextProvider>
  );
}

⚠️ OSDK client auth failure

Hardcoding the Foundry hostname in the OSDK client initialization (e.g., `url: "https://my-company.palantirfoundry.com"`) will cause auth failures when the widget is deployed to a different Foundry stack, a staging environment, or behind a custom domain. Always derive the base URL from `window.location.origin` — because the iFrame is served from the Foundry stack itself, `window.location.origin` is always the correct Foundry base URL regardless of environment.

📖 Related Module: Workshop Fundamentals

Deployment

Custom Widgets are packaged as a zip archive containing the built widget bundle and a manifest file. Three deployment methods are available depending on team maturity and frequency of releases. Manual zip upload is the quickest path for one-off deployments or initial setup: run your build command, zip the output directory, and upload via the Foundry Widget Registry UI. This is adequate for infrequent updates but does not scale. The CLI method uses npx @osdk/cli@latest widgetset deploy with a foundry.config.json at the repo root that specifies the widget RID, stack URL, and authentication token. This can be run locally by any developer with appropriate permissions and is suitable for teams without CI infrastructure. CI/CD automation wraps the CLI invocation in a pipeline (GitHub Actions, GitLab CI, etc.) triggered on merge to the main branch. A service token stored as a CI secret is passed to the CLI. This is the recommended approach for production widgets — it ensures every merge to main produces a deployed artifact without manual intervention. The Foundry CSP is the single most common deployment failure: all JavaScript, CSS, fonts, and images must be bundled into the zip. No requests to fonts.googleapis.com, cdn.jsdelivr.net, or any third-party host will succeed. Webpack/Vite configs must be set to bundle all assets locally.
Deployment Methods
MethodWhen to UseCommand/Steps
Manual zip uploadInitial setup, infrequent one-off deploys1. Build widget. 2. Zip output dir. 3. Upload via Widget Registry UI.
CLI (@osdk/cli)Developer-initiated deploys, teams without CInpx @osdk/cli@latest widgetset deploy --foundry-url <url> --token <token>
CI/CD automationProduction widgets, frequent releases, team environmentsPipeline runs CLI on merge to main using stored service token secret
CI/CD Script Using @osdk/cli for Automated Deployment
name: Deploy Custom Widget

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build widget
        run: npm run build

      - name: Deploy to Foundry
        env:
          FOUNDRY_TOKEN: ${{ secrets.FOUNDRY_SERVICE_TOKEN }}
          FOUNDRY_URL: ${{ secrets.FOUNDRY_STACK_URL }}
        run: |
          npx @osdk/cli@latest widgetset deploy \
            --foundry-url "$FOUNDRY_URL" \
            --token "$FOUNDRY_TOKEN" \
            --widget-set-rid ri.widgetregistry.main.widget-set.abc123

⚠️ CSP blocking external resources

A widget that works perfectly in local development will show a blank white screen after deployment to Foundry if it loads any resource from an external origin. Foundry's Content Security Policy blocks all external script, style, font, and fetch requests. Common culprits: Google Fonts imported via a `<link>` tag or `@import` in CSS, icon libraries loaded from a CDN, analytics scripts, and API calls to third-party services. Fix: configure your bundler to inline all fonts (base64 or self-hosted), import icon SVGs directly, and proxy any required external API calls through a Foundry Function.

📖 Related Module: Workshop Fundamentals

Decision Trees

Which custom UI approach should you use?

Does your app need to integrate with Workshop (variables, events)?

Knowledge Check

Test your understanding with 3 questions. You need 2/3 to pass.