Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions .github/workflows/manual-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ name: Manual Package Publish
on:
workflow_dispatch:
inputs:
package:
description: "Package to publish"
required: true
default: "sdk"
type: choice
options:
- sdk
- platform
version:
description: "Version to publish (e.g., 1.0.0, patch, minor, major)"
required: true
Expand Down Expand Up @@ -34,7 +42,8 @@ jobs:
publish:
runs-on: ubuntu-latest
env:
PACKAGE_DIR: packages/sdk
PACKAGE_DIR: packages/${{ github.event.inputs.package }}
TAG_PREFIX: ${{ github.event.inputs.package == 'sdk' && 'v' || 'platform-v' }}
permissions:
# contents: write for the version-bump commit, tag, and GitHub Release.
# id-token: write for npm trusted publishing (OIDC).
Expand Down Expand Up @@ -111,9 +120,10 @@ jobs:

- name: Publish to NPM
# Authenticates via npm trusted publishing (OIDC), so no NPM_TOKEN reaches
# the build. Needs a trusted publisher for `@base44/sdk` on npmjs.com
# registered against this repo and this workflow filename — the registry
# keys on the filename, so each publish workflow needs its own entry.
# the build. Needs a trusted publisher for `@base44/sdk` and for
# `@base44/platform` on npmjs.com registered against this repo and this
# workflow filename — the registry keys on the filename, so each publish
# workflow needs its own entry.
working-directory: ${{ env.PACKAGE_DIR }}
if: github.event.inputs.dry_run == 'false'
run: |
Expand All @@ -131,19 +141,19 @@ jobs:
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add ${{ env.PACKAGE_DIR }}/package.json package-lock.json
git commit -m "chore: bump version to ${{ env.NEW_VERSION }}"
git tag v${{ env.NEW_VERSION }}
git commit -m "chore(${{ github.event.inputs.package }}): bump version to ${{ env.NEW_VERSION }}"
git tag ${{ env.TAG_PREFIX }}${{ env.NEW_VERSION }}
git push origin HEAD:${{ github.ref }}
git push origin v${{ env.NEW_VERSION }}
git push origin ${{ env.TAG_PREFIX }}${{ env.NEW_VERSION }}

- name: Create Release
if: github.event.inputs.dry_run == 'false'
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ env.NEW_VERSION }}
release_name: Release v${{ env.NEW_VERSION }}
tag_name: ${{ env.TAG_PREFIX }}${{ env.NEW_VERSION }}
release_name: Release ${{ env.TAG_PREFIX }}${{ env.NEW_VERSION }}
body: |
## What's Changed

Expand All @@ -152,7 +162,7 @@ jobs:
### Installation

```bash
npm install @base44/sdk@${{ github.event.inputs.npm_tag }}
npm install @base44/${{ github.event.inputs.package }}@${{ github.event.inputs.npm_tag }}
```

### Version: ${{ env.NEW_VERSION }}
Expand All @@ -161,7 +171,7 @@ jobs:
prerelease: false

- name: Notify skills repo
if: github.event.inputs.dry_run == 'false' && github.event.inputs.notify_skills_repo == 'true'
if: github.event.inputs.dry_run == 'false' && github.event.inputs.notify_skills_repo == 'true' && github.event.inputs.package == 'sdk'
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with:
token: ${{ steps.generate-token.outputs.token }}
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ jobs:
- name: Run unit tests
run: npm run test:unit -w @base44/sdk

- name: Test the Platform SDK
run: npm test -w @base44/platform
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
| Package | Directory | What it's for |
| :- | :- | :- |
| [`@base44/sdk`](https://www.npmjs.com/package/@base44/sdk) | [`packages/sdk`](packages/sdk) | Apps built on Base44: entities, functions, auth and integrations |
| [`@base44/platform`](https://www.npmjs.com/package/@base44/platform) | [`packages/platform`](packages/platform) | White-label platforms: watch the builder of your apps from your own frontend |

Each package is versioned and released on its own through the **Manual Package Publish** workflow.
Each package is versioned and released on its own (tags `v…` for the SDK, `platform-v…` for the Platform SDK) through the **Manual Package Publish** workflow.

## Development

Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions packages/platform/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 base44

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
171 changes: 171 additions & 0 deletions packages/platform/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# @base44/platform

```sh
npm install @base44/platform
```

This package implements the read-only white-label builder socket contract: a
partner's browser watches the builder chat of up to eight apps over the platform's
shared socket. It is independent of the app SDK (`@base44/sdk`).
The workspace must have white-label sockets enabled; this SDK does not open sessions.

```ts
import { Base44PlatformClient } from "@base44/platform";

const client = new Base44PlatformClient({
serverUrl: "https://base44.app",
async getSessionToken() {
const response = await fetch("/api/builder-socket-session", { method: "POST" });
if (!response.ok) throw new Error("Session request failed");
return (await response.json()).session_token;
},
});
const builder = client.builder.init({
onError(error) { showConnectionError(error.code); },
});
const subscription = builder.subscribe(appId, {
onSnapshot(snapshot) { replaceAppState(snapshot); },
async onEvent(event) { await applyEvent(event); }, // awaited before the next event
onError(error) { showSubscriptionError(error.code); },
});
await builder.connect();
// On view teardown:
subscription.unsubscribe();
builder.close();
```

## Sessions

`/api/builder-socket-session` is your backend's route, not an SDK endpoint. It opens a
socket session with its workspace key (`apps:watch` scope) through
`POST /api/service/socket-sessions` with `{ app_ids }`, and returns only the
`session_token` to the browser. Never pass the workspace key or any other server
credential to this client. Subscribe only to apps on the session's allowlist; your
backend adds or removes apps with `PUT`/`DELETE /api/service/socket-sessions/{session_id}/rooms/{app_id}`.

A session lasts one hour and is never extended. The token goes exclusively in
CONNECT `auth.session_token`, never a URL, and the SDK keeps it in memory only. It
is reused across automatic reconnects. `getSessionToken` is called when a builder
session first connects, and again only when the server rejects the cached token or
expires the session; the SDK then reconnects once with the new token. Retrieval has
a twenty-second timeout and reports `session_unavailable` on failure.

One live socket per session: a newer connection with the same token replaces the
older one, which stops with `session_replaced`. Open one session per page.

## Lifecycle

`new Base44PlatformClient({ serverUrl, getSessionToken })` creates a lightweight module
container: no Socket.IO instance, timers, token request or network activity. Its
`builder` module follows the server SDK's module-factory pattern.

`client.builder.init({ onError })` synchronously creates an independent `BuilderSession`
without connecting. Repeated calls create separate sessions, each owning its own
socket, listeners, subscriptions and cleanup. Close the returned session when its
view is disposed; the root client and other sessions remain usable.

`builder.connect()` resolves on CONNECT, before any snapshot. The socket uses the
default namespace on `/ws/socket.io/`, WebSocket only, with a dedicated manager.
Transport reconnection uses five attempts, starting at one second and capped at ten
seconds with jitter; each attempt has a twenty-second timeout. A temporary server
refusal is retried the same way. After exhaustion, a refusal or `session_replaced`,
fix the cause and call `builder.connect()`.

`builder.subscribe(appId, options)` returns a handle with `appId`, `active` and
idempotent `unsubscribe()`. App IDs are 24 lowercase hexadecimal characters. One
subscription per app and at most eight subscriptions are allowed per builder session.
Subscriptions can be created before connecting. `builder.close()` is terminal for that
session; call `client.builder.init(...)` again to start another.

## Snapshots and events

There is no replay cursor. Every join, including each rejoin after a reconnect, is
answered with a `Snapshot`: the app's current `status` and its last 50 public
messages. After a main-conversation rewrite (undo, restore, sync), the SDK delivers
`conversation.changed` and rejoins the room, so a fresh snapshot follows. Treat a
snapshot as the app's current state. The app's live events follow it, including
those that landed while it was read. It holds only the last 50 messages, so merge
by `id` if you keep longer history.

Delivery is serialized **per app**, snapshots included; a slow app does not block
others. Callbacks must settle; they cannot be forcibly cancelled. The client bounds
pending delivery to 1,000 items per app. Error observers are synchronous notifications;
their exceptions are isolated so they cannot interrupt another app's delivery.

## Public shapes

- `PlatformClientOptions` (`client.types.ts`): `serverUrl`, `getSessionToken`.
- `BuilderModule` (`modules/builder.types.ts`): lazy `init(options)` factory.
- `BuilderInitOptions`: connection-level `onError` observer.
- `BuilderSession`: `connect()`, `subscribe(appId, options)`, `close()`.
- `SubscriptionOptions`: required `onSnapshot`, `onEvent` and `onError`.
- `PlatformSubscription`: read-only `appId`, `active`, and `unsubscribe()`.
- Event/message models live in `modules/builder.events.types.ts`; error codes in `errors.types.ts`.

Every exported shape and field has JSDoc. Run `npm run docs` for
validated reference pages in `docs`. `PlatformEvent` is a
**discriminated union**: switch on `event.type` to narrow `event.data`. Each delivery
contains `type`, `appId` and `data`. On the wire every event is `{room, data}`; the SDK
routes by `room` and validates the envelope, while payload schemas and private-field
filtering are the server's. Omitted keys mean unchanged and explicit null clears.
Events carrying `branch_id` belong to that branch; null or absent is main. Unknown
event names are not forwarded.

| Event | Data contract |
| --- | --- |
| `message.updated` | `MessageUpdated`: the whole `message`, optional `conversation_id`. Replace the message with the same `id`. |
| `message.removed` | `MessageRemoved`: `message_id`, optional `conversation_id`. Sent when a message is deleted or hidden; an unknown ID is a no-op. |
| `app.status_changed` | `AppStatusChanged`: `status` (`AppStatus`), null clears it. |
| `preview.reload_requested` | Reload the app preview. |
| `preview.navigation_requested` | `PreviewNavigationRequested`: `path`, and `force` to navigate even when the user moved away. |
| `queue.updated` | `QueueUpdated`: `items`, `is_paused`, optional `processed_item_id`. Replaces the entire queue. |
| `task.progressed` | `TaskProgressed`: optional `tool_call_id`, `message_id`, `event_type`, numeric `progress`. |
| `image.resolved` | `ImageResolved`: `placeholder_url`, `status` (`pending`, `completed`, `failed`), nullable `image_url`. Replace the placeholder wherever it appears. |
| `conversation.changed` | The conversation was rewritten. On main, a fresh snapshot follows. |
| `files.changed`, `branch.deleted`, `repository.changed`, `pull_request.changed` | Re-read signals with no content. |

`ChatMessage` has optional `id`, `role` (`user`/`assistant`), text/null `content`,
`tool_calls`, timestamp-only `metadata.created_date`, `checkpoint_id`, and scalar
`additional_message_params` (`client_creation_id`, `plan_mode`, `plan_approved`,
`skip_ai_response`, `system_message_type`). `ToolCall` has optional `id`, `name`,
`status`, `requires_user_input`, `auto_approved`, `mutation_applied` and
`waiting_on.kind` (`approval`/`choice`/`input`). Each builder tool and guard declares
which of its parts are public; a tool with no declaration shows only these fields.

| Field | Public contract |
| --- | --- |
| `arguments` | One of `ToolQuestionArguments`, `ToolSecretArguments`, `ToolPackageArguments`, `ToolPlanArguments`, `ToolPrdArguments` (`generate_prd`, the plan in plan mode) or `ToolMediaArguments`, narrowed by the tool's `name`. Absent for other tools and partial streaming arguments. |
| `display` | File activity has `file_paths` and optional `content_empty`; execution activity has `summary` and `writes_entities`; entity activity has `entity_name` and `record_count`. |
| `user_input` | `ToolQuestionInput` only, for clarifying-question answers (`selected_label` for single-select, `selected_labels` for multi-select). Secret-form values are never exposed. |
| `results` | Once the call is not waiting on a guard: `ToolMediaResult` for image generation, or a URL string for game images and backgrounds (the placeholder `image.resolved` replaces) and videos (the finished video). |
| `approval` | While a call waits on a safety guard: `ToolGuardApproval` with `guard`, and `reason` and `details` where the guard declares them (shell-command approvals carry only `guard`). |

In plan mode the message `content` beside a `generate_prd` call can be empty; render the
plan from its arguments. Approving a plan, answering a question and approving a
guarded call are HTTP actions of your backend. `Snapshot` contains `room`, `status`
(`AppStatus`: optional `state` of `ready`/`processing`/`error`, nullable
`last_updated_date`, and nullable `turn_id`, the user message that started the turn),
`messages`, and `queue` (`SnapshotQueue`: the main branch's `items` and `is_paused`;
absent from older servers). Dates remain wire strings. Structural filtering
does not promise redaction of generated prose or user content.

`PlatformSocketError` extends `Error` with `code` and optional `appId`. Original
exceptions, payloads and credentials are never attached.

| Error code | Action |
| --- | --- |
| `connection_denied` | The session was rejected even with a fresh token: the workspace, key or session is not usable. |
| `session_unavailable` | `getSessionToken` failed or timed out; no provider details are forwarded. |
| `session_replaced` | Another connection with the same session took over. Stop, or open a new session. |
| `connection_failed` | Transport failure or exhausted retries; call `connect()` after fixing connectivity. |
| `access_denied` | The join was refused: the app is not on the session's allowlist, or joins were rate limited. |
| `access_revoked` | The app left the session's allowlist; do not resubscribe. |
| `snapshot_unavailable` | Joined, but the snapshot failed. Live events still flow; the subscription stays active. |
| `subscription_limit` | Release a subscription before adding another. |
| `delivery_overflow` | 1,000 deliveries queued for the app; subscribe again for a fresh snapshot. |
| `protocol_error` | Invalid routing or envelope. Stop and investigate. |
| `handler_failed` | An application callback failed; fix it and subscribe again. |
| `client_closed` | The builder session was explicitly closed; initialize another session. |

No SDK dependency, package version or lockfile changes are required. Socket.IO
remains the existing SDK dependency. Chat commands and HTTP APIs are outside this entry point.
36 changes: 36 additions & 0 deletions packages/platform/examples/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Base44PlatformClient, type PlatformEvent } from "@base44/platform";

const client = new Base44PlatformClient({
serverUrl: "https://base44.app",
// Your backend opens the session with its workspace key and returns only the session token.
async getSessionToken() {
const response = await fetch("/api/builder-socket-session", { method: "POST" });
if (!response.ok) throw new Error("Unable to open a socket session");
const { session_token } = await response.json();
return session_token;
},
});
const builder = client.builder.init({
onError(error) { console.error(error.code); },
});

const subscription = builder.subscribe("0123456789abcdef01234567", {
onSnapshot(snapshot) {
// Replace this app's state; merge messages by id to keep history older than the last 50.
console.log(snapshot.status?.state, snapshot.messages.length);
},
async onEvent(event: PlatformEvent) {
// Replace messages by id; omitted keys mean unchanged, explicit null clears.
if (event.type === "message.updated") console.log(event.data.message.content);
if (event.type === "message.removed") console.log("removed", event.data.message_id);
},
onError(error) {
// access_revoked: the app left the session's allowlist; do not resubscribe.
console.error(error.code);
},
});

await builder.connect();
// Later, during teardown:
subscription.unsubscribe();
builder.close();
Loading
Loading