Skip to content
Draft
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
57 changes: 53 additions & 4 deletions examples/white-label-minimal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ chat with the builder, answer its questions, preview, and publish.
This example uses the [service-user tenancy model](https://base44-docs-white-label-rewrite.mintlify.site/white-label/tenancy-and-credentials#service-users).
Each builder gets a Base44 service user that owns their apps. Your backend provisions
that identity using a workspace API key, then uses the service user's access token
to create and manage apps. Both credentials stay on the server.
to create and manage apps. The API key stays on the server. For this socket integration, the current service-user
access token is sent to the signed-in browser after an app-ownership check.

## Run locally

Use Node.js 24. From the repository root:

```sh
npm install
npm ci
cp examples/white-label-minimal/.env.example examples/white-label-minimal/.env.local
```

Expand Down Expand Up @@ -67,12 +68,12 @@ on install and build; run `npm run db:generate` from the example after schema ed

Google login and Base44 connection are separate. `/api/base44/connection`
provisions a service principal using the workspace key. Builder requests use its
stored token; they do not provision identities or send credentials to the browser.
stored token; they do not provision identities during ordinary app operations.
The API handler validates requests and checks app ownership before app operations.

For the chat UI, copy `components/`, `lib/chat/builder-api.ts`, `lib/chat/conversation.ts`,
and `lib/chat/assistant-messages.ts`. The chat uses assistant-ui's external-store runtime
with Base44's polled conversation as its source of truth. `Question.tsx` handles
with an initial conversation snapshot followed by SDK builder socket updates. `Question.tsx` handles
approvals, choices, and secrets; retries preserve the original answer and request ID.
Preview URLs stay in page memory and remain stable during normal use. A timed-out
creation may still succeed, so the UI asks users to check before creating again.
Expand Down Expand Up @@ -132,3 +133,51 @@ build-status, runtime-auth, or heartbeat endpoints are used.
`loadPreview(appId)` calls your authenticated backend and returns `{ url }`. Reject with an error carrying `status: 401` or `403` to stop automatic recovery on authorization failures. Keep platform credentials on the backend. Changing the app or live mode starts a new preview session; changing the callback does not reload the iframe.

When copying the component, include the `preview-frame`, `preview-loading-frame`, `preview-fallback`, `preview-controls`, `widget-placeholder`, and `secondary` styles from `app/globals.css`, or provide equivalent styles and a sized parent container.
## Live builder updates

The example pins the preview of [SDK PR #286](https://github.com/base44/javascript-sdk/pull/286)
under the `@base44/sdk` alias. Update that exact version when adopting a released SDK.

```text
Builder.tsx → useBuilderSocket.ts → lib/chat/build-stream.ts
→ lib/chat/builder-connection.ts → @base44/sdk/platform/client
```

`builder-connection.ts` constructs the platform client and calls `client.builder.init`.
Its `refreshToken` callback calls the same-origin `POST /api/base44/socket-token`
endpoint on each reconnect. The endpoint requires a valid session, verifies the
request origin and app ownership, refreshes the existing server credential if
needed, and returns `{ serverUrl, token }` with `Cache-Control: no-store, private`.
The browser keeps the token in memory and supplies it only through Socket.IO
CONNECT `auth.token`. API keys and refresh tokens never go to the browser.

**Temporary credential decision:** this uses the existing service-user access token,
as requested, until the browser-specific token solution is available. This token
can authorize HTTP operations too; the read-only socket does not narrow its powers.
The app-ownership check protects token retrieval but does not make the token itself
app-scoped. Replace this exchange when the dedicated browser credential lands.
The backend must accept this credential at `/ws-whitelabel/socket.io/` and enable
the workspace's white-label socket flag. The pending verifier in the backend PR
still blocks deployed connections until integrated; there is no legacy-socket fallback.

The client subscribes before fetching initial history. The SDK buffers ordered
updates while that snapshot loads and resumes from applied cursors after transport
reconnects. App/message replacements and image resolutions are applied directly.
There are no periodic conversation or app reads. Invalidation events and completed
HTTP mutations trigger reconciliation; ready-state updates refresh preview metadata.
Queue/task events advance the cursor but have no separate UI in this minimal example.

Reviewed question and secret-form schemas arrive in the socket update that opens the
tool card. The browser renders those schemas directly and posts any answer through
the existing partner-backend mutation route. Tool cards use reviewed file paths,
activity summaries, entity counts, package names, plan fields, and generated-media
labels, state, and approved asset URLs; they never render source, diffs, commands,
execution output, secret values or raw results.
The partner backend remains responsible for applying the same filtering policy to
its existing HTTP history responses.

When retained history expires or an event cannot be applied, delivery stops and
**Reconnect live updates** starts a new session and snapshot. Snapshot recovery is
not an atomic history API; buffered events may briefly repeat newer snapshot state.
Switching apps/unmounting cancels reads and closes the builder session. Production
verification with the real token verifier remains a prerequisite for rollout.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createHandler } from "../../../../lib/server/api-handler";
import { getAppClient } from "../../../../lib/server/app-service";

export const runtime = "nodejs";
export const POST = createHandler(getAppClient, ["getBuilderConnection"]);
7 changes: 7 additions & 0 deletions examples/white-label-minimal/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,13 @@ iframe {
.tool-dot { width: 6px; height: 6px; border-radius: 50%; background: #929394; flex-shrink: 0; }
.tool-activity > div { padding: 8px 12px; border-top: 1px solid var(--border); background: white; }
.tool-activity pre { font-size: 11px; line-height: 1.5; }
.tool-widget { display: grid; gap: 6px; }
.tool-widget-heading { display: flex; align-items: center; gap: 6px; }
.tool-widget small { display: block; }
.tool-list { display: grid; gap: 4px; padding: 0; margin: 0; list-style: none; }
.tool-list li { overflow-wrap: anywhere; }
.tool-list code { font-size: 11px; }
.tool-media img { width: 100%; max-height: 220px; object-fit: cover; border: 1px solid var(--border); border-radius: 4px; }
.composer { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 8px; flex-shrink: 0; border: 1px solid var(--border); border-radius: var(--radius-inset); background: white; padding: 12px; margin: 0 14px 14px; box-shadow: var(--shadow-card); }
.composer textarea { flex: 1; width: 0; margin: 0; padding: 9px 10px; font-size: 14px; line-height: 20px; height: 40px; min-height: 40px; max-height: 112px; resize: none; border-radius: 4px; }
.composer .send-button { flex-shrink: 0; height: 38px; width: 38px; padding: 0; border-radius: 4px; }
Expand Down
26 changes: 13 additions & 13 deletions examples/white-label-minimal/components/Builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as api from "../lib/chat/builder-api";
import { Loader2, Eye, Upload, ExternalLink } from "lucide-react";
import { mergeOptimisticMessages, type OptimisticMessage } from "../lib/chat/optimistic-messages";
import BuilderChat from "./BuilderChat";
import { useBuildPolling } from "./useBuildPolling";
import { useBuilderSocket } from "./useBuilderSocket";

export default function Builder({
initialAppId,
Expand All @@ -27,7 +27,7 @@ export default function Builder({
const [published, setPublished] = useState<string | null>(null);
const [optimistic, setOptimistic] = useState<OptimisticMessage[]>([]);
const lock = useRef(false);
const { app, messages, error: pollingError, loading, refresh, resume } = useBuildPolling(appId);
const { app, messages, error: socketError, loading, refresh, resume } = useBuilderSocket(appId);
const displayedMessages = useMemo(
() => mergeOptimisticMessages(messages, optimistic), [messages, optimistic],
);
Expand All @@ -38,11 +38,11 @@ export default function Builder({
m.tool_calls?.some((t) => t.status === "waiting_for_user_input"),
);
const processing = app?.status?.state === "processing";
// A prompt/answer invalidates the previous ready state before polling catches up.
// A prompt/answer invalidates the previous ready state before live state catches up.
// Preview and deploy operations themselves should keep the card mounted.
const submittingBuild = busy === "Sending prompt…" || busy === "Answering question…";
const canDeliver = app?.id === appId && app?.status?.state === "ready" &&
!waiting && !pollingError && !submittingBuild && hasCompletedBuild(messages);
!waiting && !socketError && !submittingBuild && hasCompletedBuild(messages);

async function send(prompt: string) {
if (
Expand All @@ -51,7 +51,7 @@ export default function Builder({
!prompt.trim() ||
waiting ||
processing ||
pollingError ||
socketError ||
creationUncertain
)
return false;
Expand Down Expand Up @@ -132,9 +132,9 @@ export default function Builder({
the conversation and send a follow-up prompt.
</p>
)}
{(error || pollingError) && (
{(error || socketError) && (
<aside role="alert">
<p>{error || pollingError}</p>
<p>{error || socketError}</p>
{appId && (
<button
className="secondary"
Expand All @@ -144,7 +144,7 @@ export default function Builder({
resume();
}}
>
Resume polling
Reconnect live updates
</button>
)}
</aside>
Expand Down Expand Up @@ -190,8 +190,8 @@ export default function Builder({
busy={!!busy}
processing={processing}
waiting={waiting}
disabled={loading || !!busy || waiting || processing || !!pollingError || creationUncertain}
questionsDisabled={!!busy || !!pollingError}
disabled={loading || !!busy || waiting || processing || !!socketError || creationUncertain}
questionsDisabled={!!busy || !!socketError}
onSend={send}
onAnswer={answer}
>
Expand All @@ -215,7 +215,7 @@ export default function Builder({
<Eye size={14} /> Preview
</button>
<button
disabled={!!busy || waiting || !!pollingError || app?.status?.state !== "ready"}
disabled={!!busy || waiting || !!socketError || app?.status?.state !== "ready"}
aria-label="Deploy app"
onClick={() => void deploy()}
>
Expand All @@ -239,11 +239,11 @@ export default function Builder({

</section>
)}
{(busy || waiting || processing || pollingError) && (
{(busy || waiting || processing || socketError) && (
<div className="build-progress" role="status">
{(busy || processing) && <Loader2 size={12} className="spin" />}
{busy ||
(pollingError
(socketError
? "Connection paused"
: waiting
? "Waiting for your answer"
Expand Down
22 changes: 15 additions & 7 deletions examples/white-label-minimal/components/Question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,24 @@ import type { ToolCall, ToolInput } from "../lib/types";

type Choice = { question: string; options: string[]; multi: boolean };
type Field = { name: string; description: string };
type Package = { name: string; action: "install" | "uninstall" };
export function parseQuestion(
tool: ToolCall,
):
| { kind: "choice"; choices: Choice[] }
| { kind: "input"; fields: Field[] }
| { kind: "approval" }
| { kind: "approval"; packages: Package[] }
| { kind: "unknown" } {
try {
const args = JSON.parse(tool.arguments_string || "{}");
if (tool.waiting_on?.kind === "approval") return { kind: "approval" };
if (tool.waiting_on?.kind === "approval") {
const packages = tool.name === "install_npm_package" && Array.isArray(args.packages)
? args.packages.flatMap((pkg: Record<string, unknown>) => typeof pkg?.name === "string" && pkg.name
? [{ name: pkg.name, action: pkg.action === "uninstall" ? "uninstall" as const : "install" as const }]
: [])
: [];
return { kind: "approval", packages };
}
if (
tool.waiting_on?.kind === "choice" &&
Array.isArray(args.questions) &&
Expand Down Expand Up @@ -120,13 +128,13 @@ export default function Question({
<strong>{tool.name || "Agent action"}</strong>{" "}
<small>{submitted ? "Answer sent" : tool.status}</small>
{(question.kind === "unknown" || question.kind === "approval") && (
<details>
<details open={question.kind === "approval" && question.packages.length > 0}>
<summary>
{question.kind === "unknown"
? "Unsupported question / tool details"
: "Review proposed action"}
{question.kind === "unknown" ? "Unsupported question / tool details" : "Review proposed action"}
</summary>
<pre>{tool.arguments_string || "No arguments provided."}</pre>
{question.kind === "approval" && question.packages.length ? (
<ul className="tool-list">{question.packages.map(pkg => <li key={`${pkg.action}:${pkg.name}`}>{pkg.action === "uninstall" ? "Remove" : "Install"} <code>{pkg.name}</code></li>)}</ul>
) : <p>This action needs input that this example cannot render.</p>}
</details>
)}
{waiting && !submitted && (
Expand Down
Loading