diff --git a/examples/white-label-minimal/app/tiny.css b/examples/white-label-minimal/app/tiny.css
index 8db50de..27ab0b7 100644
--- a/examples/white-label-minimal/app/tiny.css
+++ b/examples/white-label-minimal/app/tiny.css
@@ -52,8 +52,34 @@
.chat-user-text {
@apply ml-auto max-w-[85%] rounded-lg bg-secondary px-3 py-2;
}
+.chat-pending {
+ @apply opacity-60;
+}
.chat-assistant-text {
- @apply whitespace-pre-wrap;
+ @apply flex flex-col gap-2;
+}
+.chat-assistant-text ul {
+ @apply list-disc pl-5;
+}
+.chat-assistant-text ol {
+ @apply list-decimal pl-5;
+}
+.chat-assistant-text h1,
+.chat-assistant-text h2,
+.chat-assistant-text h3 {
+ @apply font-semibold;
+}
+.chat-assistant-text code {
+ @apply rounded bg-secondary px-1 font-mono text-xs;
+}
+.chat-assistant-text pre {
+ @apply overflow-x-auto rounded bg-secondary p-2;
+}
+.chat-assistant-text pre code {
+ @apply bg-transparent p-0;
+}
+.chat-assistant-text a {
+ @apply underline;
}
.chat-tool-line {
@apply text-xs text-muted-foreground;
@@ -80,6 +106,9 @@
.question-options {
@apply flex flex-wrap gap-2;
}
+.chat-answer {
+ @apply ml-4 text-xs text-muted-foreground;
+}
.question-actions {
@apply flex gap-2;
}
diff --git a/examples/white-label-minimal/components/Chat.tsx b/examples/white-label-minimal/components/Chat.tsx
index b852d17..ca8aa46 100644
--- a/examples/white-label-minimal/components/Chat.tsx
+++ b/examples/white-label-minimal/components/Chat.tsx
@@ -1,28 +1,36 @@
"use client";
import { useEffect, useState } from "react";
-import type { ToolCall } from "@base44/platform";
+import { useChat, type UseChatResult, type ChatBackend, type ChatItem, type ChatStep } from "@base44/platform/react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
+import Markdown from "react-markdown";
import { StickToBottom } from "use-stick-to-bottom";
-import type { App } from "../server/base44";
+import { createApp, openLiveSession, sendMessage, submitToolCallInput, type App } from "../server/base44";
import ToolQuestion from "./ToolQuestion";
-import { useLiveChat } from "./useLiveChat";
+
+// The four Base44 calls, run on the server. The hook calls them, so the browser never holds a key.
+const backend: ChatBackend = { createApp, openLiveSession, sendMessage, submitToolCallInput };
export default function Chat({ app, onAppCreated, onBuildingChange }: {
app: App | null;
onAppCreated: (app: App) => void;
onBuildingChange: (building: boolean) => void;
}) {
- const { messages, status, building, canSend, send } = useLiveChat({ app, onAppCreated });
+ const chat = useChat({ appId: app?.id ?? null, backend, onAppCreated });
const [prompt, setPrompt] = useState("");
+ const building = chat.status === "building";
+ // Send only when the AI can take a prompt now. After a failed build, a prompt can ask for the fix.
+ const canSend = chat.status === "ready" || chat.status === "error";
useEffect(() => {
onBuildingChange(building);
}, [building, onBuildingChange]);
+ // The first prompt creates the app; every later one goes to it.
function submit() {
- if (!prompt.trim() || !canSend) return;
- send(prompt);
+ if (!canSend || !prompt.trim()) return;
+ if (app) chat.send(prompt);
+ else chat.create(prompt);
setPrompt("");
}
@@ -30,21 +38,31 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: {
- {messages.map((m) => (
-
- {m.content && (
-
{m.content}
+ {chat.items.map((item) => (
+
+ {item.text && item.role === "user" &&
{item.text}
}
+ {/* The AI answers in Markdown. */}
+ {item.text && item.role === "assistant" && (
+
)}
- {m.tool_calls?.map((tool) =>
- tool.status === "waiting_for_user_input" && app ? (
-
+ {item.steps.map((step) =>
+ step.question ? (
+
) : (
-
{toolLine(tool)}
+
+
{stepIcon(step)} {step.label}
+ {/* What the user answered stays after the question closes. */}
+ {step.answered?.map((answer, i) => (
+
{answer.question}: {[...answer.labels, answer.text].filter(Boolean).join(", ")}
+ ))}
+
),
)}
))}
- {status &&
{status}
}
+
@@ -60,14 +78,39 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: {
className="chat-input"
rows={1}
/>
-
+
);
}
-// One tool step as a short line, e.g. "✓ write_file src/pages/Home.jsx".
-function toolLine(tool: ToolCall) {
- const icon = tool.status === "running" ? "…" : tool.status === "error" ? "✗" : "✓";
- return `${icon} ${tool.name} ${tool.display?.file_paths?.join(", ") ?? ""}`;
+// A prompt Base44 hasn't confirmed yet shows faded.
+function userTextClass(item: ChatItem) {
+ if (item.pending) return "chat-user-text chat-pending";
+ return "chat-user-text";
+}
+
+function stepIcon(step: ChatStep) {
+ if (step.status === "running") return "…";
+ if (step.status === "waiting") return "?";
+ if (step.status === "error") return "✗";
+ if (step.status === "stopped") return "–";
+ return "✓";
+}
+
+// Tiny's wording for what the hook reports.
+function StatusLine({ chat, hasApp }: { chat: UseChatResult; hasApp: boolean }) {
+ let text = "";
+ if (chat.error) text = chat.error.message;
+ else if (chat.status === "submitted") text = hasApp ? "Sending…" : "Creating app…";
+ else if (chat.status === "waiting") text = "Waiting for your answer";
+ else if (chat.status === "building") text = "Building…";
+ else if (hasApp && chat.connection === "connecting") text = "Loading the conversation…";
+ if (!text) return null;
+ return (
+
+ {text}
+ {chat.error && }
+
+ );
}
diff --git a/examples/white-label-minimal/components/ToolQuestion.tsx b/examples/white-label-minimal/components/ToolQuestion.tsx
index d215f0d..b1f962b 100644
--- a/examples/white-label-minimal/components/ToolQuestion.tsx
+++ b/examples/white-label-minimal/components/ToolQuestion.tsx
@@ -1,60 +1,100 @@
"use client";
import { useState } from "react";
-import type { ToolCall, ToolQuestionArguments, ToolSecretArguments } from "@base44/platform";
+import type { ApprovalQuestion, ChatQuestion, ChoiceQuestion, SecretsQuestion } from "@base44/platform/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { submitToolCallInput } from "../server/base44";
-// A tool step the builder waits on: a choice, values to enter (such as API keys), or an
-// approval. Declining is always an answer.
-export default function ToolQuestion({ appId, messageId, tool }: { appId: string; messageId: string; tool: ToolCall }) {
- const [sent, setSent] = useState(false);
- const [picked, setPicked] = useState>({});
- const [secrets, setSecrets] = useState>({});
- const kind = tool.waiting_on?.kind;
- const questions = kind === "choice" ? (tool.arguments as ToolQuestionArguments)?.questions ?? [] : [];
- // Tiny passes these values to Base44 and keeps none.
- const fields = kind === "input" ? (tool.arguments as ToolSecretArguments)?.secrets_schema ?? [] : [];
+// A question the AI waits on: a choice (options, or free text), secrets to enter (such as API
+// keys), or an approval. Its actions are already bound to its tool call, and declining is always
+// an answer.
+export default function ToolQuestion({ question }: { question: ChatQuestion }) {
+ if (question.kind === "choice") return ;
+ if (question.kind === "secrets") return ;
+ return ;
+}
- function answer(approve: boolean, extraUserInput?: object) {
- setSent(true);
- submitToolCallInput(appId, { toolCallId: tool.id!, messageId, approve, extraUserInput }).catch(() => setSent(false));
- }
+function Choice({ question }: { question: ChoiceQuestion }) {
+ const [picked, setPicked] = useState(question.questions.map(() => []));
+ const [texts, setTexts] = useState([]);
- function pick(index: number, label: string, multi?: boolean) {
- const current = picked[index] ?? [];
- const next = !multi ? [label] : current.includes(label) ? current.filter((l) => l !== label) : [...current, label];
- setPicked({ ...picked, [index]: next });
+ function pick(index: number, label: string, multi: boolean) {
+ const current = picked[index];
+ let next = [label];
+ if (multi && current.includes(label)) next = current.filter((l) => l !== label);
+ else if (multi) next = [...current, label];
+ setPicked(picked.map((labels, i) => (i === index ? next : labels)));
}
- function submit() {
- if (kind === "choice") answer(true, { answers: questions.map((_, i) => ({ question_index: i, selected_labels: picked[i] ?? [] })) });
- else if (kind === "input") answer(true, { secrets });
- else answer(true);
+ function write(index: number, text: string) {
+ const next = [...texts];
+ next[index] = text;
+ setTexts(next);
}
return (
- {kind === "approval" &&
Allow {tool.name}? {tool.approval?.reason ?? tool.approval?.details?.summary}
}
- {questions.map((q, i) => (
+ {question.questions.map((q, i) => (
-
{q.question}
-
- {(q.options ?? []).map((o) => (typeof o === "string" ? o : o.label ?? "")).map((label) => (
-
- ))}
-
+
{q.text}
+ {q.type === "text_input" ? (
+
write(i, e.target.value)} />
+ ) : (
+
+ {q.options.map((label) => (
+
+ ))}
+
+ )}
))}
- {fields.map(({ secretName = "" }) => (
-
setSecrets({ ...secrets, [secretName]: e.target.value })} />
+
question.answer(picked, texts)} onDecline={question.decline} />
+
+ );
+}
+
+function Secrets({ question }: { question: SecretsQuestion }) {
+ // Tiny passes these values to Base44 and keeps none.
+ const [values, setValues] = useState>({});
+
+ return (
+
+ {question.fields.map(({ name }) => (
+
setValues({ ...values, [name]: e.target.value })} />
))}
-
-
-
-
+
question.submit(values)} onDecline={question.decline} />
+
+ );
+}
+
+function Approval({ question }: { question: ApprovalQuestion }) {
+ return (
+
+
Allow {question.action}? {question.reason}
+
+
+ );
+}
+
+function Actions({ submitLabel, onSubmit, onDecline }: {
+ submitLabel: string;
+ onSubmit: () => Promise;
+ onDecline: () => Promise;
+}) {
+ const [sent, setSent] = useState(false);
+
+ // A failed answer opens the question again, so the buttons come back.
+ async function send(action: () => Promise) {
+ setSent(true);
+ await action();
+ setSent(false);
+ }
+
+ return (
+
+
+
);
}
diff --git a/examples/white-label-minimal/components/Workspace.tsx b/examples/white-label-minimal/components/Workspace.tsx
index 734ab99..62f772f 100644
--- a/examples/white-label-minimal/components/Workspace.tsx
+++ b/examples/white-label-minimal/components/Workspace.tsx
@@ -32,7 +32,7 @@ export default function Workspace() {
);
}
diff --git a/examples/white-label-minimal/components/useLiveChat.ts b/examples/white-label-minimal/components/useLiveChat.ts
deleted file mode 100644
index 1cd7022..0000000
--- a/examples/white-label-minimal/components/useLiveChat.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-"use client";
-import { useEffect, useState } from "react";
-import { Base44PlatformClient, type ChatMessage } from "@base44/platform";
-import { createApp, openLiveSession, sendMessage, type App } from "../server/base44";
-
-// The chat's state: the live connection to Base44, message ordering, create-or-send,
-// and the status line.
-export function useLiveChat({ app, onAppCreated }: {
- app: App | null;
- onAppCreated: (app: App) => void;
-}) {
- const appId = app?.id ?? null;
- const [messages, setMessages] = useState([]);
- const [building, setBuilding] = useState(false);
- const [loading, setLoading] = useState(!!appId);
- const [creating, setCreating] = useState(false);
- const [error, setError] = useState("");
-
- // Live updates: a snapshot on every connect, then events.
- useEffect(() => {
- if (!appId) return;
- let stopped = false;
- let socket: { close(): void } | undefined;
- const fail = (e: { code?: string; message?: string }) => setError(e.code || e.message || "Live updates failed");
-
- openLiveSession(appId)
- .then(({ serverUrl, sessionToken }) => {
- if (stopped) return;
- const builder = new Base44PlatformClient({ serverUrl, getSessionToken: () => sessionToken }).builder.init({ onError: fail });
- socket = builder;
- builder.subscribe(appId, {
- onSnapshot: (snapshot) => {
- setMessages(snapshot.messages);
- setLoading(false);
- setBuilding(snapshot.status?.state === "processing");
- },
- onEvent: (event) => {
- if (event.type === "message.updated") {
- const message = event.data.message;
- // Replace the message with the same id in place, or add it at the end.
- setMessages((all) => (all.some((m) => m.id === message.id) ? all.map((m) => (m.id === message.id ? message : m)) : [...all, message]));
- }
- if (event.type === "app.status_changed") setBuilding(event.data.status?.state === "processing");
- },
- onError: fail,
- });
- return builder.connect();
- })
- .catch(fail);
-
- return () => {
- stopped = true;
- socket?.close();
- };
- }, [appId]);
-
- // Events can arrive out of order, so order messages by when they were created.
- const sorted = [...messages].sort((a, b) => (a.metadata?.created_date ?? "").localeCompare(b.metadata?.created_date ?? ""));
- // Lock the message box while a question is open or a build runs.
- const waiting = sorted.some((m) => m.tool_calls?.some((tool) => tool.status === "waiting_for_user_input"));
-
- let status = error;
- if (!status && creating) status = "Creating app…";
- else if (!status && loading) status = "Loading the conversation…";
- else if (!status && waiting) status = "Waiting for your answer";
- else if (!status && building) status = "Building…";
-
- // The first prompt creates the app; every later one goes to it.
- async function send(prompt: string) {
- if (appId) {
- setBuilding(true);
- sendMessage(appId, prompt).catch((e) => {
- setBuilding(false);
- setError(e.message);
- });
- return;
- }
- setCreating(true);
- try {
- onAppCreated(await createApp(prompt));
- } catch (e) {
- setError((e as Error).message);
- }
- setCreating(false);
- }
-
- return { messages: sorted, status, building, canSend: !creating && !waiting && !building, send };
-}
diff --git a/examples/white-label-minimal/netlify.toml b/examples/white-label-minimal/netlify.toml
index bb91561..084491b 100644
--- a/examples/white-label-minimal/netlify.toml
+++ b/examples/white-label-minimal/netlify.toml
@@ -7,7 +7,7 @@
[build.environment]
NODE_VERSION = "24"
- # This example pins @base44/platform 0.1.0, outside the root's 0.3.0 range, so
- # npm installs it under the example's own node_modules, which the secrets
- # scanner would otherwise read. Installed packages are never served.
+ # When this example's @base44/platform range leaves the root's, npm installs it
+ # under the example's own node_modules, which the secrets scanner would
+ # otherwise read. Installed packages are never served.
SECRETS_SCAN_OMIT_PATHS = "examples/white-label-minimal/node_modules/**"
diff --git a/examples/white-label-minimal/package.json b/examples/white-label-minimal/package.json
index d3226cd..372ad2a 100644
--- a/examples/white-label-minimal/package.json
+++ b/examples/white-label-minimal/package.json
@@ -9,7 +9,7 @@
"typecheck": "next typegen && tsc --noEmit"
},
"dependencies": {
- "@base44/platform": "^0.1.0",
+ "@base44/platform": "^0.5.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"lucide-react": "^0.475.0",
@@ -17,6 +17,7 @@
"radix-ui": "^1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
+ "react-markdown": "^9.0.1",
"shadcn": "^4.21.0",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6"
diff --git a/examples/white-label-minimal/server/base44.ts b/examples/white-label-minimal/server/base44.ts
index d32af4b..12c8261 100644
--- a/examples/white-label-minimal/server/base44.ts
+++ b/examples/white-label-minimal/server/base44.ts
@@ -1,4 +1,5 @@
"use server";
+import type { SocketSession } from "@base44/platform/react";
export type App = { id: string; name: string };
export type ToolCallAnswer = { toolCallId: string; messageId: string; approve: boolean; extraUserInput?: object };
@@ -29,17 +30,18 @@ export async function listApps(): Promise {
.map(({ id, name }) => ({ id, name }));
}
-export async function openLiveSession(appId: string) {
- const session = await call("/api/v1/socket-sessions", { body: { app_ids: [appId] } });
- return { serverUrl: new URL(session.socket_url).origin, sessionToken: session.session_token as string };
+// The body holds only a session token, never the access token, so it goes to the browser unchanged.
+export async function openLiveSession(appId: string): Promise {
+ return call("/api/v1/socket-sessions", { body: { app_ids: [appId] } });
}
export async function sendMessage(appId: string, content: string) {
- await call(`/api/apps/${appId}/chat/message`, { body: { content } });
+ const sent = request(`/api/apps/${appId}/chat/message`, { body: { content } });
+ await waitForRefusal("/chat/message", sent);
}
export async function submitToolCallInput(appId: string, answer: ToolCallAnswer) {
- await call(`/api/apps/${appId}/chat/submit-tool-call-input`, {
+ const sent = request(`/api/apps/${appId}/chat/submit-tool-call-input`, {
body: {
tool_call_id: answer.toolCallId,
message_id: answer.messageId,
@@ -48,6 +50,7 @@ export async function submitToolCallInput(appId: string, answer: ToolCallAnswer)
},
headers: { ...accessToken, "X-Request-ID": `submit-${answer.toolCallId}` },
});
+ await waitForRefusal("/chat/submit-tool-call-input", sent);
}
// A new app has no latest preview until its first build gives it a slug: Base44 answers 400
@@ -64,6 +67,24 @@ export async function getPreviewUrl(appId: string, target: "latest_preview" | "l
return data.embed_url as string;
}
+// Base44 holds a chat request open for the whole turn, and Next.js runs one server action at a time,
+// so waiting for it would hold every later call. Wait only long enough to catch a refusal.
+async function waitForRefusal(name: string, sent: Promise) {
+ const timeout = new Promise((resolve) => setTimeout(resolve, 3000, null));
+ const response = await Promise.race([sent, timeout]);
+ if (response === null) {
+ // The caller has moved on, so a later failure can only be logged.
+ sent.then(
+ (late) => {
+ if (!late.ok) console.error(`Base44 ${name} failed: ${late.status}`);
+ },
+ (error) => console.error(`Base44 ${name} failed:`, error),
+ );
+ return;
+ }
+ if (!response.ok) throw new Error(`Base44 ${name} failed: ${response.status}`);
+}
+
function request(path: string, { body, headers = accessToken }: Options = {}) {
return fetch(`${host}${path}`, {
method: body ? "POST" : "GET",
diff --git a/package-lock.json b/package-lock.json
index 59375e0..f90754b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"examples/white-label-minimal"
],
"dependencies": {
- "@base44/platform": "^0.3.0",
+ "@base44/platform": "^0.5.0",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.3",
"@radix-ui/react-checkbox": "^1.1.4",
@@ -51,41 +51,11 @@
"typescript": "^5"
}
},
- "examples/tiny-sunny": {
- "name": "@base44/tiny-sunny",
- "version": "0.1.0",
- "extraneous": true,
- "dependencies": {
- "@assistant-ui/react": "^0.15.21",
- "@assistant-ui/react-markdown": "^0.14.16",
- "@base44/platform": "^0.1.0",
- "@chatscope/chat-ui-kit-react": "^2.1.1",
- "@chatscope/chat-ui-kit-styles": "^1.4.0",
- "class-variance-authority": "^0.7.1",
- "cn": "^0.4.0",
- "lucide-react": "^0.475.0",
- "next": "16.3.0",
- "radix-ui": "^1.6.7",
- "react": "19.2.8",
- "react-dom": "19.2.8",
- "shadcn": "^4.21.0",
- "tw-animate-css": "^1.4.0",
- "tw-shimmer": "^0.4.13"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4.3.3",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "tailwindcss": "^4.3.3",
- "typescript": "^5"
- }
- },
"examples/white-label-minimal": {
"name": "@base44/white-label-minimal",
"version": "0.1.0",
"dependencies": {
- "@base44/platform": "^0.1.0",
+ "@base44/platform": "^0.5.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"lucide-react": "^0.475.0",
@@ -93,6 +63,7 @@
"radix-ui": "^1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
+ "react-markdown": "^9.0.1",
"shadcn": "^4.21.0",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6"
@@ -109,15 +80,6 @@
"node": "^22 || ^24 || >=26"
}
},
- "examples/white-label-minimal/node_modules/@base44/platform": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.1.0.tgz",
- "integrity": "sha512-6InHb3mM2RPLqU42atFprwkkjR1Osu0CFQpAmloD1sAv7EfDg0h4rGMuUtSB5EZJLJiz4f6DaQ44dP/lR7mjtQ==",
- "license": "MIT",
- "dependencies": {
- "socket.io-client": "^4.8.3"
- }
- },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -575,9 +537,9 @@
}
},
"node_modules/@base44/platform": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.3.0.tgz",
- "integrity": "sha512-BlBWUIEdYLH0po4LFMzKPo1mIOKUv8bo9z3LmyABSEv/2AXGP/fIN4KxZl5DBYGlgnVf8h3aQeY36XT4eHnWVQ==",
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.5.0.tgz",
+ "integrity": "sha512-Mn6t1YjQFpHPtXLbl1h+c6aJQVg6n1CQQkWpQ0Ygzx7dQ+XNKZYzMjjhy0CYIDmfugRiV0xJ9foF/vVRgmBZGQ==",
"license": "MIT",
"dependencies": {
"socket.io-client": "^4.8.3"
diff --git a/package.json b/package.json
index e099722..25ceb4c 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"minimal:typecheck": "npm run typecheck --workspace @base44/white-label-minimal"
},
"dependencies": {
- "@base44/platform": "^0.3.0",
+ "@base44/platform": "^0.5.0",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.3",
"@radix-ui/react-checkbox": "^1.1.4",