diff --git a/.oxlintrc.json b/.oxlintrc.json
index ad09650..169e13c 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -4,20 +4,7 @@
"./node_modules/ultracite/config/oxlint/core/.oxlintrc.json",
"./node_modules/ultracite/config/oxlint/react/.oxlintrc.json"
],
- "overrides": [
- {
- "files": [
- "src/index.ts",
- "src/schemas/index.ts",
- "src/schemas/base/index.ts",
- "src/schemas/responses/index.ts",
- "src/schemas/requests/index.ts",
- "src/errors/index.ts",
- "src/app-bridge/index.ts"
- ],
- "rules": {
- "oxc/no-barrel-file": "off"
- }
- }
- ]
+ "rules": {
+ "oxc/no-barrel-file": "off"
+ }
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 44a169c..aaac452 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,16 @@ Pre-commit hook (lefthook) auto-runs `bun x ultracite fix` on staged files.
After any significant code change, always run `bun run lint` and `bun run fix` to ensure lint and formatting pass before committing.
+## Documentation Rules
+
+After any significant code change, update the following:
+
+1. **`docs/progress.md`** — mark completed features, update status
+2. **`README.md`** — keep usage examples and API docs current
+3. **Feature-specific docs** — if README.md grows too large, create docs under `docs/` (e.g. `docs/app-bridge.md`) and link from the main README
+
+Always keep docs in sync with the code. Do not defer documentation to a later step.
+
## Architecture
`assembly-kit` is a TypeScript-first SDK for the Assembly platform. It is an **ESM-only single package** with 4 entry points, targeting Node.js 18+, Node.js 24+, and Bun.
@@ -33,7 +43,7 @@ After any significant code change, always run `bun run lint` and `bun run fix` t
| `assembly-kit` | `createClient()`, error classes, token utilities, `paginate()` |
| `assembly-kit/schemas` | All Zod schemas and inferred types (no client dependency) |
| `assembly-kit/app-bridge` | Framework-agnostic `sendToParent()` postMessage utilities |
-| `assembly-kit/react` | React hooks wrapping app-bridge (`usePrimaryCta`, `useSecondaryCta`, `useActionsMenu`) |
+| `assembly-kit/bridge-ui` | React hooks wrapping app-bridge (`usePrimaryCta`, `useSecondaryCta`, `useActionsMenu`) |
### Source Layer Dependency Order
@@ -46,7 +56,7 @@ src/pagination/ ← paginate() AsyncIterable cursor helper
src/client/ ← createClient() factory + AssemblyClient class
src/resources/ ← workspace, clients, companies, internalUsers, notifications, customFields, tasks, token
src/app-bridge/ ← parallel track, no dependency on layers above
-src/react/ ← depends on app-bridge only
+src/bridge-ui/ ← depends on app-bridge only
```
### Zod Version
diff --git a/README.md b/README.md
index 5e5b91d..2ff9b0c 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,242 @@ try {
| `AssemblyResponseParseError` | 500 | API response failed Zod schema validation (`.zodError`) |
| `AssemblyConnectionError` | 503 | Network error reaching the API |
+### Schemas
+
+All Zod schemas live under `assembly-kit/schemas`. Each resource has a **base** schema, a **response** schema (wrapping the base for paginated API responses), and optionally a **request** schema for create/update payloads.
+
+```typescript
+// Base schemas — the core shape of each resource
+import {
+ ClientSchema,
+ CompanySchema,
+ TaskSchema,
+ TaskStatusSchema,
+ WorkspaceSchema,
+ InternalUserSchema,
+ InvoiceSchema,
+ CustomFieldSchema,
+ TokenPayloadSchema,
+ HexColorSchema,
+} from "assembly-kit/schemas";
+
+// TypeScript types inferred from schemas
+import type {
+ Client,
+ Company,
+ Task,
+ TaskStatus,
+ Workspace,
+ InternalUser,
+} from "assembly-kit/schemas";
+```
+
+#### Response schemas
+
+Response schemas wrap the base schemas into the paginated shape returned by the Assembly API:
+
+```typescript
+import {
+ ClientsResponseSchema,
+ CompaniesResponseSchema,
+ TasksResponseSchema,
+} from "assembly-kit/schemas";
+
+import type {
+ ClientsResponse,
+ CompaniesResponse,
+ TasksResponse,
+} from "assembly-kit/schemas";
+```
+
+#### Request schemas
+
+Request schemas define the shape of create/update payloads:
+
+```typescript
+import {
+ ClientCreateRequestSchema,
+ ClientUpdateRequestSchema,
+ CompanyCreateRequestSchema,
+ TaskCreateRequestSchema,
+} from "assembly-kit/schemas";
+
+import type {
+ ClientCreateRequest,
+ ClientUpdateRequest,
+} from "assembly-kit/schemas";
+```
+
+#### Validating data
+
+```typescript
+import { ClientSchema } from "assembly-kit/schemas";
+
+const result = ClientSchema.safeParse(unknownData);
+
+if (result.success) {
+ console.log(result.data.name);
+} else {
+ console.error(result.error);
+}
+```
+
+#### Sub-path imports
+
+You can also import from specific schema groups to reduce bundle size:
+
+```typescript
+import { ClientSchema } from "assembly-kit/schemas/base";
+import { ClientsResponseSchema } from "assembly-kit/schemas/responses";
+import { ClientCreateRequestSchema } from "assembly-kit/schemas/requests";
+```
+
+### App Bridge
+
+The app-bridge entry point provides framework-agnostic utilities for communicating with the Assembly dashboard from an embedded iframe app. Works in any JavaScript environment — no React dependency required.
+
+#### `sendToParent`
+
+Sends a typed postMessage payload to the Assembly dashboard parent frame:
+
+```typescript
+import { sendToParent, Icons } from "assembly-kit/app-bridge";
+import type { PrimaryCtaPayload } from "assembly-kit/app-bridge";
+
+// Register a primary CTA button in the dashboard header
+const payload: PrimaryCtaPayload = {
+ type: "header.primaryCta",
+ label: "Create Invoice",
+ icon: Icons.Plus,
+ onClick: "header.primaryCta.onClick",
+};
+
+sendToParent(payload);
+```
+
+When called without a `portalUrl`, it fans out the message to all known Assembly dashboard domains. Pass a specific origin to restrict:
+
+```typescript
+sendToParent(payload, "https://dashboard.assembly.com");
+```
+
+`sendToParent` is SSR-safe — it's a no-op when `window` is undefined.
+
+#### Payload types
+
+```typescript
+import type {
+ PrimaryCtaPayload, // { type: "header.primaryCta", label?, icon?, onClick? }
+ SecondaryCtaPayload, // { type: "header.secondaryCta", label?, icon?, onClick? }
+ ActionsMenuPayload, // { type: "header.actionsMenu", items: ActionItem[] }
+ AppBridgePayload, // Discriminated union of all three
+ ActionItem, // { label, onClick, icon?, color? }
+ CtaConfig, // { label?, icon?, onClick?(), color? }
+ BridgeOpts, // { portalUrl?, show? }
+} from "assembly-kit/app-bridge";
+```
+
+#### Clearing a slot
+
+Send a payload with only the `type` field to remove a button, or an empty items array for the actions menu:
+
+```typescript
+sendToParent({ type: "header.primaryCta" });
+sendToParent({ type: "header.actionsMenu", items: [] });
+```
+
+### Bridge UI (React Hooks)
+
+React hooks that wrap `sendToParent` into a declarative API. They handle setup, cleanup, and `beforeunload` automatically.
+
+Requires `react >= 18` as a peer dependency.
+
+#### `usePrimaryCta`
+
+Registers a primary CTA button in the dashboard header:
+
+```tsx
+import { usePrimaryCta } from "assembly-kit/bridge-ui";
+import { Icons } from "assembly-kit/app-bridge";
+
+function MyApp() {
+ usePrimaryCta({
+ label: "Create Invoice",
+ icon: Icons.Plus,
+ onClick: () => {
+ console.log("Primary CTA clicked");
+ },
+ });
+
+ return
My App
;
+}
+```
+
+#### `useSecondaryCta`
+
+Registers a secondary CTA button. Same API as `usePrimaryCta`:
+
+```tsx
+import { useSecondaryCta } from "assembly-kit/bridge-ui";
+import { Icons } from "assembly-kit/app-bridge";
+
+function MyApp() {
+ useSecondaryCta({
+ label: "Export",
+ icon: Icons.Download,
+ onClick: () => {
+ console.log("Secondary CTA clicked");
+ },
+ });
+
+ return My App
;
+}
+```
+
+#### `useActionsMenu`
+
+Registers a dropdown actions menu in the dashboard header:
+
+```tsx
+import { useActionsMenu } from "assembly-kit/bridge-ui";
+import { Icons } from "assembly-kit/app-bridge";
+
+function MyApp() {
+ useActionsMenu([
+ { label: "Archive", onClick: "actions.archive", icon: Icons.Archive },
+ {
+ label: "Delete",
+ onClick: "actions.delete",
+ icon: Icons.Trash,
+ color: "red",
+ },
+ ]);
+
+ return My App
;
+}
+```
+
+#### Visibility toggle
+
+All hooks accept an optional second argument to control visibility:
+
+```tsx
+usePrimaryCta({ label: "Save", onClick: () => save() }, { show: hasChanges });
+```
+
+When `show` is `false`, the slot is cleared in the dashboard header. Defaults to `true`.
+
+#### Portal URL
+
+If your app is embedded in a custom portal, pass the portal origin to restrict postMessage targeting:
+
+```tsx
+usePrimaryCta(
+ { label: "Save", onClick: () => save() },
+ { portalUrl: "https://my-portal.example.com" }
+);
+```
+
## Development
```bash
diff --git a/bun.lock b/bun.lock
index a265569..4161886 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
},
"devDependencies": {
"@types/bun": "^1.3.9",
+ "@types/react": "^19.2.14",
"bumpp": "^10.4.1",
"bunup": "^0.16.31",
"lefthook": "^2.1.1",
@@ -20,9 +21,11 @@
"ultracite": "7.2.4",
},
"peerDependencies": {
+ "react": "",
"typescript": ">=4.5.0",
},
"optionalPeers": [
+ "react",
"typescript",
],
},
@@ -260,6 +263,8 @@
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"args-tokenizer": ["args-tokenizer@0.3.0", "", {}, "sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q=="],
@@ -296,6 +301,8 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
diff --git a/bunup.config.ts b/bunup.config.ts
index fe17263..05a3a33 100644
--- a/bunup.config.ts
+++ b/bunup.config.ts
@@ -10,6 +10,7 @@ const bunupConfig = defineConfig({
"src/schemas/responses/index.ts",
"src/schemas/requests/index.ts",
"src/errors/index.ts",
+ "src/bridge-ui/index.ts",
],
format: "esm",
minify: true,
diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md
index a3d22a0..eb20966 100644
--- a/docs/planning/implementation-plan.md
+++ b/docs/planning/implementation-plan.md
@@ -39,7 +39,7 @@ This is a feature-by-feature implementation plan. Each feature is a self-contain
5. Create `.ultracite.json` or equivalent formatter config.
6. Create top-level `src/`, `tests/`, and `tests/fixtures/` directories.
-> **Runtime compatibility rule (applies to all features):** Always import crypto as `node:crypto` (explicit protocol prefix). Bun and Node.js 18/24 both honour the `node:` prefix. Never use bare `'crypto'`. Do not use any Node.js APIs that Bun does not implement — this SDK only needs `node:crypto` (AES-128-CBC + HMAC-SHA256), which Bun supports natively. 7. Create `src/index.ts`, `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/react/index.ts` as empty stubs. 8. Generate token test fixtures: using the `@assembly-js/node-sdk` crypto functions, generate 3-4 encrypted token strings (client user, internal user, with tokenId, with baseUrl) for a fixed test apiKey. Save to `tests/fixtures/tokens.ts` as constants. These are used by all token-related tests without mocking crypto. 8. Verify: `bun build src/index.ts --outdir dist` succeeds. 9. Verify: `bun test` runs (zero tests, zero failures).
+> **Runtime compatibility rule (applies to all features):** Always import crypto as `node:crypto` (explicit protocol prefix). Bun and Node.js 18/24 both honour the `node:` prefix. Never use bare `'crypto'`. Do not use any Node.js APIs that Bun does not implement — this SDK only needs `node:crypto` (AES-128-CBC + HMAC-SHA256), which Bun supports natively. 7. Create `src/index.ts`, `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/bridge-ui/index.ts` as empty stubs. 8. Generate token test fixtures: using the `@assembly-js/node-sdk` crypto functions, generate 3-4 encrypted token strings (client user, internal user, with tokenId, with baseUrl) for a fixed test apiKey. Save to `tests/fixtures/tokens.ts` as constants. These are used by all token-related tests without mocking crypto. 8. Verify: `bun build src/index.ts --outdir dist` succeeds. 9. Verify: `bun test` runs (zero tests, zero failures).
**Definition of Done:**
@@ -707,7 +707,7 @@ React hooks wrapping the app-bridge core. Shipped as a separate entry point so R
### Files to create
```
-src/react/
+src/bridge-ui/
├── index.ts
├── use-primary-cta.ts
├── use-secondary-cta.ts
@@ -718,7 +718,7 @@ src/react/
**8.1 — `usePrimaryCta`**
-`src/react/use-primary-cta.ts`:
+`src/bridge-ui/use-primary-cta.ts`:
- Accept `(cta: CtaConfig, opts?: BridgeOpts)`.
- `useEffect` (deps: `cta`, `opts.portalUrl`, `opts.show`):
@@ -741,7 +741,7 @@ Similar: sends `header.actionsMenu` with items array. No click listener needed (
**8.4 — Barrel export**
-`src/react/index.ts` exports all hooks.
+`src/bridge-ui/index.ts` exports all hooks.
### Tests
@@ -777,9 +777,9 @@ Final build config, export map, and type declarations.
"import": "./dist/app-bridge/index.js",
"types": "./dist/app-bridge/index.d.ts"
},
- "./react": {
- "import": "./dist/react/index.js",
- "types": "./dist/react/index.d.ts"
+ "./bridge-ui": {
+ "import": "./dist/bridge-ui/index.js",
+ "types": "./dist/bridge-ui/index.d.ts"
}
},
"sideEffects": false
@@ -882,6 +882,6 @@ The overall project is ready for `1.0.0` publish when:
| `@assembly-js/node-sdk` token decode is async / hits Assembly API | Feature 3 must verify this. If it requires a network call, `parseToken()` becomes async and all callers update. |
| `@assembly-js/node-sdk` not suitable as a dependency (license / size) | Reimplement token decode using Assembly's documented JWT format. |
| `p-throttle` sliding window behavior differs from Assembly's exact rate limit model | Run load tests against staging; fall back to `bottleneck` with a larger `minTime` only if needed. |
-| React hooks export causes issues with RSC (React Server Components) | Add `"use client"` directive at the top of `src/react/index.ts`. |
+| React hooks export causes issues with RSC (React Server Components) | Add `"use client"` directive at the top of `src/bridge-ui/index.ts`. |
| Zod version conflicts in consuming apps | Pin `zod` as a peer dependency; document compatibility. |
| Assembly API response shapes change without notice | `validateResponses` flag gives teams an escape hatch; `AssemblyResponseParseError` is easy to catch. |
diff --git a/docs/planning/prd.md b/docs/planning/prd.md
index aabba3a..0be4b67 100644
--- a/docs/planning/prd.md
+++ b/docs/planning/prd.md
@@ -57,12 +57,12 @@ assembly-kit
### 3.2 Entry Points (Export Map)
-| Export path | Purpose |
-| ------------------------- | --------------------------------------------------- |
-| `assembly-kit` | Core client, token utilities, error classes |
-| `assembly-kit/schemas` | All Zod schemas and inferred TypeScript types |
-| `assembly-kit/app-bridge` | Framework-agnostic postMessage utilities |
-| `assembly-kit/react` | React-specific app-bridge hooks (peer dep: `react`) |
+| Export path | Purpose |
+| ------------------------- | --------------------------------------------- |
+| `assembly-kit` | Core client, token utilities, error classes |
+| `assembly-kit/schemas` | All Zod schemas and inferred TypeScript types |
+| `assembly-kit/app-bridge` | Framework-agnostic postMessage utilities |
+| `assembly-kit/bridge-ui` | React app-bridge hooks (peer dep: `react`) |
---
@@ -371,7 +371,7 @@ React-specific wrappers over the app-bridge core, replacing the scattered `usePr
**Requirements**
-Entry point: `assembly-kit/react`
+Entry point: `assembly-kit/bridge-ui`
Peer dependency: `react >= 18`
- `usePrimaryCta(cta: CtaConfig, opts?: BridgeOpts): void`
@@ -425,7 +425,10 @@ Peer dependency: `react >= 18`
"import": "./dist/app-bridge.js",
"types": "./dist/app-bridge.d.ts"
},
- "./react": { "import": "./dist/react.js", "types": "./dist/react.d.ts" }
+ "./bridge-ui": {
+ "import": "./dist/bridge-ui.js",
+ "types": "./dist/bridge-ui.d.ts"
+ }
}
}
```
@@ -623,6 +626,6 @@ React component mounts
| 1 | Does `@assembly-js/node-sdk` expose token decoding without making a network call? | **Resolved** — fully local, synchronous AES-128-CBC + HMAC-SHA256 using Node.js `crypto`. No network call. See `node-sdk-internals.md`. |
| 2 | Should token decoding be reimplemented or delegated to `@assembly-js/node-sdk`? | **Resolved** — reimplement. The algorithm is simple (4 lines of Node.js `crypto`), the package uses yarn/CJS internals, and we should avoid the singleton `OpenAPI` object entirely. Full details in `node-sdk-internals.md`. |
| 3 | What is the exact format of the `Retry-After` header from Assembly's 429 responses? | Needs verification against API docs |
-| 4 | Should `assembly-kit/react` be a separate npm package (`assembly-kit-react`) to avoid shipping React as a dep for pure server users? | Decision needed |
+| 4 | Should `assembly-kit/bridge-ui` be a separate npm package to avoid shipping React as a dep for pure server users? | Decision needed |
| 5 | Tasks endpoint (`/tasks/public`) uses a custom token encoding — should `encodePayload` from crypto utils be part of assembly-kit or remain app-specific? | Decision needed |
| 6 | For custom apps with no token provided, only local/staging envs are supported by the existing SDK. Does Assembly's production API accept a raw `apiKey` without a compound key at all? | Needs verification |
diff --git a/docs/progress.md b/docs/progress.md
index 21a5418..58e3050 100644
--- a/docs/progress.md
+++ b/docs/progress.md
@@ -11,10 +11,10 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- ✅ Repo & tooling bootstrap (bunup, tsconfig, lefthook, oxlint, ultracite)
- ✅ `src/index.ts` — exports all error classes
- ✅ `src` path alias (`@/*`) configured in tsconfig.json
-- ✅ `bunup.config.ts` — 4 entry points (index, schemas, app-bridge, react)
+- ✅ `bunup.config.ts` — 4 entry points (index, schemas, app-bridge, bridge-ui)
- ✅ Runtime deps: `zod` installed
- ⬜ Runtime deps: `ky`, `p-throttle` (needed for Feature 4)
-- ✅ Entry point stubs: `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/react/index.ts`
+- ✅ Entry point stubs: `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/bridge-ui/index.ts`
- ⬜ Test fixtures: `test/fixtures/tokens.ts` (encrypted token constants for token tests)
---
@@ -206,12 +206,12 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
> Dependency: Feature 7 · Peer dep: `react >= 18`
-- ⬜ `src/react/use-primary-cta.ts`
-- ⬜ `src/react/use-secondary-cta.ts`
-- ⬜ `src/react/use-actions-menu.ts`
-- ⬜ `src/react/index.ts` — barrel export with `"use client"` directive
-- ⬜ TypeScript compile-time check that hooks accept correct types
-- ⬜ `bun run type-check` passes
+- ✅ `src/bridge-ui/use-primary-cta.ts`
+- ✅ `src/bridge-ui/use-secondary-cta.ts`
+- ✅ `src/bridge-ui/use-actions-menu.ts`
+- ✅ `src/bridge-ui/index.ts` — barrel export with `"use client"` directive
+- ✅ TypeScript compile-time check that hooks accept correct types
+- ✅ `bun run type-check` passes
---
diff --git a/package.json b/package.json
index 2953ea9..4f5b090 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,12 @@
"default": "./dist/schemas/requests/index.js"
}
},
+ "./bridge-ui": {
+ "import": {
+ "types": "./dist/bridge-ui/index.d.ts",
+ "default": "./dist/bridge-ui/index.js"
+ }
+ },
"./package.json": "./package.json"
},
"scripts": {
@@ -74,6 +80,7 @@
},
"devDependencies": {
"@types/bun": "^1.3.9",
+ "@types/react": "^19.2.14",
"bumpp": "^10.4.1",
"bunup": "^0.16.31",
"lefthook": "^2.1.1",
@@ -84,9 +91,13 @@
"ultracite": "7.2.4"
},
"peerDependencies": {
+ "react": ">=18",
"typescript": ">=4.5.0"
},
"peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
"typescript": {
"optional": true
}
diff --git a/src/app-bridge/ensure-https.ts b/src/app-bridge/ensure-https.ts
new file mode 100644
index 0000000..4769914
--- /dev/null
+++ b/src/app-bridge/ensure-https.ts
@@ -0,0 +1,7 @@
+/** Ensures a URL uses HTTPS, upgrading HTTP if necessary. */
+export const ensureHttps = (url: string): string => {
+ if (url.startsWith("https://")) {
+ return url;
+ }
+ return `https://${url.replace(/^http:\/\//, "")}`;
+};
diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts
index 78645d7..2a94f22 100644
--- a/src/app-bridge/index.ts
+++ b/src/app-bridge/index.ts
@@ -1,4 +1,6 @@
export { DASHBOARD_DOMAINS } from "src/app-bridge/constants";
+export { ensureHttps } from "src/app-bridge/ensure-https";
+export { isAllowedOrigin } from "src/app-bridge/is-allowed-origin";
export { sendToParent } from "src/app-bridge/send";
export { Icons } from "src/app-bridge/types";
export type {
diff --git a/src/app-bridge/is-allowed-origin.ts b/src/app-bridge/is-allowed-origin.ts
new file mode 100644
index 0000000..b8791e4
--- /dev/null
+++ b/src/app-bridge/is-allowed-origin.ts
@@ -0,0 +1,23 @@
+import { DASHBOARD_DOMAINS } from "src/app-bridge/constants";
+import { ensureHttps } from "src/app-bridge/ensure-https";
+
+/**
+ * Checks whether a postMessage event origin is from an allowed Assembly domain.
+ *
+ * When `portalUrl` is provided, only that exact origin (after HTTPS upgrade) is
+ * allowed. Otherwise, the origin must match one of the known `DASHBOARD_DOMAINS`.
+ *
+ * @param {string} origin - The origin from the MessageEvent.
+ * @param {string} [portalUrl] - Optional portal URL to restrict allowed origins.
+ * @returns {boolean} Whether the origin is trusted.
+ */
+export const isAllowedOrigin = (
+ origin: string,
+ portalUrl?: string
+): boolean => {
+ if (portalUrl) {
+ return origin === ensureHttps(portalUrl);
+ }
+
+ return (DASHBOARD_DOMAINS as readonly string[]).includes(origin);
+};
diff --git a/src/app-bridge/send.ts b/src/app-bridge/send.ts
index dca26d4..dcd6013 100644
--- a/src/app-bridge/send.ts
+++ b/src/app-bridge/send.ts
@@ -1,14 +1,7 @@
import { DASHBOARD_DOMAINS } from "src/app-bridge/constants";
+import { ensureHttps } from "src/app-bridge/ensure-https";
import type { AppBridgePayload } from "src/app-bridge/types";
-/** Ensures a URL uses HTTPS, upgrading HTTP if necessary. */
-const ensureHttps = (url: string): string => {
- if (url.startsWith("https://")) {
- return url;
- }
- return `https://${url.replace(/^http:\/\//, "")}`;
-};
-
/**
* Sends a postMessage payload to the Assembly dashboard parent frame.
*
diff --git a/src/bridge-ui/index.ts b/src/bridge-ui/index.ts
new file mode 100644
index 0000000..e9d7bc1
--- /dev/null
+++ b/src/bridge-ui/index.ts
@@ -0,0 +1,5 @@
+"use client";
+
+export { useActionsMenu } from "./use-actions-menu";
+export { usePrimaryCta } from "./use-primary-cta";
+export { useSecondaryCta } from "./use-secondary-cta";
diff --git a/src/bridge-ui/use-actions-menu.ts b/src/bridge-ui/use-actions-menu.ts
new file mode 100644
index 0000000..04d1b52
--- /dev/null
+++ b/src/bridge-ui/use-actions-menu.ts
@@ -0,0 +1,47 @@
+import { useEffect } from "react";
+import { sendToParent } from "src/app-bridge/send";
+import type {
+ ActionItem,
+ ActionsMenuPayload,
+ BridgeOpts,
+} from "src/app-bridge/types";
+
+/**
+ * Registers an actions menu in the Assembly dashboard header.
+ *
+ * Sends a `header.actionsMenu` postMessage to the parent frame with the
+ * provided menu items. Each item's `onClick` is a string event type identifier
+ * that the dashboard uses internally to handle clicks — the hook does not
+ * listen for inbound click events. When the component unmounts or the page
+ * unloads, the menu is automatically cleared.
+ *
+ * @param {ActionItem[]} items - Array of menu items with labels, icons, and click event types.
+ * @param {BridgeOpts} [opts] - Optional portal URL and visibility toggle.
+ */
+export const useActionsMenu = (
+ items: ActionItem[],
+ opts?: BridgeOpts
+): void => {
+ const { portalUrl, show = true } = opts ?? {};
+
+ useEffect(() => {
+ const payload: ActionsMenuPayload = {
+ items: show ? items : [],
+ type: "header.actionsMenu",
+ };
+
+ sendToParent(payload, portalUrl);
+ }, [items, portalUrl, show]);
+
+ useEffect(() => {
+ const handleUnload = (): void => {
+ sendToParent({ items: [], type: "header.actionsMenu" }, portalUrl);
+ };
+
+ addEventListener("beforeunload", handleUnload);
+
+ return () => {
+ removeEventListener("beforeunload", handleUnload);
+ };
+ }, [portalUrl]);
+};
diff --git a/src/bridge-ui/use-primary-cta.ts b/src/bridge-ui/use-primary-cta.ts
new file mode 100644
index 0000000..1c917bb
--- /dev/null
+++ b/src/bridge-ui/use-primary-cta.ts
@@ -0,0 +1,62 @@
+import { useEffect } from "react";
+import { isAllowedOrigin } from "src/app-bridge/is-allowed-origin";
+import { sendToParent } from "src/app-bridge/send";
+import type {
+ BridgeOpts,
+ CtaConfig,
+ PrimaryCtaPayload,
+} from "src/app-bridge/types";
+
+/**
+ * Registers a primary CTA button in the Assembly dashboard header.
+ *
+ * Sends a `header.primaryCta` postMessage to the parent frame and listens
+ * for click events. When the component unmounts or the page unloads, the
+ * slot is automatically cleared.
+ *
+ * @param {CtaConfig} cta - Label, icon, and click handler for the primary CTA.
+ * @param {BridgeOpts} [opts] - Optional portal URL and visibility toggle.
+ */
+export const usePrimaryCta = (cta: CtaConfig, opts?: BridgeOpts): void => {
+ const { portalUrl, show = true } = opts ?? {};
+
+ useEffect(() => {
+ const payload: PrimaryCtaPayload = {
+ icon: show ? cta.icon : undefined,
+ label: show ? cta.label : undefined,
+ onClick: show ? "header.primaryCta.onClick" : undefined,
+ type: "header.primaryCta",
+ };
+
+ sendToParent(payload, portalUrl);
+
+ const handleMessage = (event: MessageEvent): void => {
+ if (
+ isAllowedOrigin(event.origin, portalUrl) &&
+ event.data.type === "header.primaryCta.onClick" &&
+ typeof event.data.id === "string" &&
+ cta.onClick
+ ) {
+ cta.onClick();
+ }
+ };
+
+ addEventListener("message", handleMessage);
+
+ return () => {
+ removeEventListener("message", handleMessage);
+ };
+ }, [cta, portalUrl, show]);
+
+ useEffect(() => {
+ const handleUnload = (): void => {
+ sendToParent({ type: "header.primaryCta" }, portalUrl);
+ };
+
+ addEventListener("beforeunload", handleUnload);
+
+ return () => {
+ removeEventListener("beforeunload", handleUnload);
+ };
+ }, [portalUrl]);
+};
diff --git a/src/bridge-ui/use-secondary-cta.ts b/src/bridge-ui/use-secondary-cta.ts
new file mode 100644
index 0000000..634e1f1
--- /dev/null
+++ b/src/bridge-ui/use-secondary-cta.ts
@@ -0,0 +1,62 @@
+import { useEffect } from "react";
+import { isAllowedOrigin } from "src/app-bridge/is-allowed-origin";
+import { sendToParent } from "src/app-bridge/send";
+import type {
+ BridgeOpts,
+ CtaConfig,
+ SecondaryCtaPayload,
+} from "src/app-bridge/types";
+
+/**
+ * Registers a secondary CTA button in the Assembly dashboard header.
+ *
+ * Sends a `header.secondaryCta` postMessage to the parent frame and listens
+ * for click events. When the component unmounts or the page unloads, the
+ * slot is automatically cleared.
+ *
+ * @param {CtaConfig} cta - Label, icon, and click handler for the secondary CTA.
+ * @param {BridgeOpts} [opts] - Optional portal URL and visibility toggle.
+ */
+export const useSecondaryCta = (cta: CtaConfig, opts?: BridgeOpts): void => {
+ const { portalUrl, show = true } = opts ?? {};
+
+ useEffect(() => {
+ const payload: SecondaryCtaPayload = {
+ icon: show ? cta.icon : undefined,
+ label: show ? cta.label : undefined,
+ onClick: show ? "header.secondaryCta.onClick" : undefined,
+ type: "header.secondaryCta",
+ };
+
+ sendToParent(payload, portalUrl);
+
+ const handleMessage = (event: MessageEvent): void => {
+ if (
+ isAllowedOrigin(event.origin, portalUrl) &&
+ event.data.type === "header.secondaryCta.onClick" &&
+ typeof event.data.id === "string" &&
+ cta.onClick
+ ) {
+ cta.onClick();
+ }
+ };
+
+ addEventListener("message", handleMessage);
+
+ return () => {
+ removeEventListener("message", handleMessage);
+ };
+ }, [cta, portalUrl, show]);
+
+ useEffect(() => {
+ const handleUnload = (): void => {
+ sendToParent({ type: "header.secondaryCta" }, portalUrl);
+ };
+
+ addEventListener("beforeunload", handleUnload);
+
+ return () => {
+ removeEventListener("beforeunload", handleUnload);
+ };
+ }, [portalUrl]);
+};