@fepro/workhub-app-sdk
v0.4.1
Published
Client SDK for apps embedded in WorkHub. Use it inside an iframe app to call back into the host (identity, storage, notifications, events, API proxy).
Maintainers
Readme
@fepro/workhub-app-sdk
Client SDK for apps embedded inside the WorkHub portal.
Building with an AI agent? See AGENTS.md (or fetch https://workhubplatform.io/sdk/agents.md) for a dense, machine-friendly reference with the full SDK surface, three worked examples, the gotcha catalogue, and a copy-paste starter template.
Install
The platform serves the compiled SDK at /sdk.js on the portal origin —
uploaded apps can import it without any build step or external CDN. This is
the recommended path for hand-written and AI-generated bundles:
<script type="module">
import { workhub } from '/sdk.js';
</script>For TypeScript projects with a build step:
npm install @fepro/workhub-app-sdk
# or
pnpm add @fepro/workhub-app-sdk
# or
yarn add @fepro/workhub-app-sdkDo not use cross-origin CDNs (esm.sh, jsdelivr, unpkg, etc.). Uploaded apps run inside a fully sandboxed iframe whose CSP is
default-src 'self', which blocks external script loads./sdk.jsis the same-origin platform SDK and is the only supported way to load it without bundling.
The bundle format
A WorkHub developer-app is a .zip archive with this layout:
my-app.zip
├── workhub.json ← manifest (required, at root)
├── index.html ← entry HTML (or whatever `entry` in the manifest names)
└── assets/
├── app.js
└── app.cssworkhub.json
{
"key": "my-tools",
"name": "My Tools",
"version": "1.0.0",
"description": "Internal tools dashboard",
"entry": "index.html",
"icon": "assets/icon.svg",
"scopes": [
"identity:read",
"storage:read",
"storage:write",
"notifications:send",
"events:listen"
]
}| field | purpose |
| --- | --- |
| key | stable identifier; new uploads with the same key upgrade in place |
| version | informational; surfaced in the upload list |
| entry | path to the entry HTML (default index.html) |
| scopes | capabilities the app needs; operator approves at upload |
Using the SDK
import { workhub } from '@fepro/workhub-app-sdk';
// On boot
const me = await workhub.identity.current();
console.log(`Hi ${me.user?.displayName}, you're in ${me.tenant?.name}`);
// Storage — KV scoped to (tenant, installation)
await workhub.storage.set('lastView', { page: 7 });
const last = await workhub.storage.get<{ page: number }>('lastView');
// Toast
await workhub.notifications.toast('Saved!', { variant: 'success' });
// Listen for theme changes — payload now carries full token set
workhub.events.on('theme:change', (tokens) => {
document.documentElement.dataset.theme = tokens.mode;
for (const [k, v] of Object.entries(tokens.cssVars)) {
document.documentElement.style.setProperty(`--${k}`, v);
}
});
// Locale propagation — react to the user's language preference
const locale = await workhub.locale.current(); // { tag, language, timezone }
workhub.events.on('locale:change', (next) => i18n.use(next.tag));
// App-domain events — emitted by your backend via ctx.events.emit(...)
workhub.events.on('app:customer.created', (e) => refresh());
// Escape hatch — call any /v1 endpoint
const dbs = await workhub.api.call('GET /v1/databases');
// Call your own workspace backend — JSON in / JSON out, typed errors
try {
const leads = await workhub.backend.call<Lead[]>('/leads');
const payment = await workhub.backend.call('/payments', {
method: 'POST',
body: { amount: 1200, currency: 'usd' },
idempotencyKey: crypto.randomUUID(), // safe retries
});
} catch (err) {
if (err instanceof WorkhubError && err.code === 'rate_limited') {
// backoff…
}
}Typed app events
The default WorkhubAppEventPayload interface is empty — augment it from
your app's types module so each events.on('app:...') call gets type
inference for the payload:
declare module '@fepro/workhub-app-sdk' {
interface WorkhubAppEventPayload {
'app:customer.created': { id: string; displayName: string };
'app:invoice.overdue': { invoiceId: string; daysLate: number };
}
}
workhub.events.on('app:customer.created', (e) => {
e.displayName; // ← inferred as string
});Scopes
| scope | grants |
| --- | --- |
| identity:read | workhub.identity.current() |
| storage:read | workhub.storage.get/list |
| storage:write | workhub.storage.set/delete |
| notifications:send | workhub.notifications.toast |
| events:listen | workhub.events.on |
| api:proxy | workhub.api.call(...) — gated on the user's existing permissions |
Content Security Policy & sandbox
Your app runs in a sandboxed iframe with a strict CSP. Knowing the rules up front saves a debugging session — most "why won't my font/script load?" issues are the CSP doing its job.
Sandbox (set by the host on the <iframe>): production grants only
allow-scripts allow-forms. So your app can run JS and submit forms, but
cannot do top-level navigation, open popups, or break out of the frame.
(Local dev additionally grants allow-same-origin so cookies flow to the
dev bundle server — production keeps the iframe on a separate origin and
omits it.)
CSP applied to your served bundle:
| directive | value | what it means for you |
| --- | --- | --- |
| frame-ancestors | the portal origin | only the WorkHub portal can embed your app |
| default-src | 'self' 'unsafe-inline' 'unsafe-eval' data: blob: | scripts/styles must come from your bundle (inline is allowed); no external origins |
| img-src | 'self' data: blob: https: | images may load from any https origin |
| connect-src | 'self' | fetch/XHR/WebSocket only to your own origin |
What this means in practice:
- Bundle everything. External CDNs (Google Fonts, jsdelivr, unpkg, an
analytics snippet) are blocked —
default-srchas no external origins. Self-host your fonts and vendor your scripts/styles into the bundle. Images are the one exception: anyhttpsURL works. workhub.backend.fetch()/workhub.backend.call()work — they hit/v1/apps/runtime/<id>/api…on your own origin, whichconnect-src 'self'permits. No extra config.- You cannot call third-party APIs from the browser (Stripe, a geocoder,
a webhook target) —
connect-src 'self'blocks them. Call them from your workspace backend instead and expose a route your frontend hits viaworkhub.backend.call(...). (Outbound egress from the backend is a separate, server-side concern.) - Inline
<script>/<style>andevalare allowed in the default policy (most SPA bundlers need them), bounded by the sandbox. A stricter no-unsafe-inline/no-unsafe-evalpolicy is applied to apps that opt in (bundles that emit hashed/nonced inline code).
CSP violations are reported to the platform (report-uri) and surfaced to
operators, so a misbehaving app shows up in the apps-anomaly view.
Upload
Upload via the portal at /apps/development → Upload bundle, or via the API:
curl -X POST https://your-host/v1/apps/dev/upload \
-H "Authorization: Bearer $TOKEN" \
-F [email protected]After upload, the app appears in the sidebar under Apps and at /apps/<slug>.
