diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index d455e69277..6de67ed5c7 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -50,6 +50,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css - _never_ use cursor-help, or cursor-not-allowed (it looks terrible) - We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. +- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) ### RPC System diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts index a073d99c77..3a389cb8e4 100644 --- a/emain/emain-builder.ts +++ b/emain/emain-builder.ts @@ -78,9 +78,6 @@ export async function createBuilderWindow(appId: string): Promise { focusedBuilderWindow = typedBuilderWindow; console.log("builder window focused", builderId); diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 2f4263f261..83a32b8c28 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -127,6 +127,7 @@ function makeFileMenu(numWaveWindows: number, callbacks: AppMenuCallbacks): Elec if (isDev) { fileMenu.splice(1, 0, { label: "New WaveApp Builder Window", + accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", click: () => fireAndForget(() => createBuilderWindow("")), }); } diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 2872d1f242..f3a1f91c9c 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -49,10 +49,7 @@ const AIThinking = memo( )} {message && {message}} -
+
{displayText}
@@ -147,21 +144,22 @@ const isDisplayPart = (part: WaveUIMessagePart): boolean => { return ( part.type === "text" || part.type === "data-tooluse" || + part.type === "data-toolprogress" || (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") ); }; type MessagePart = | { type: "single"; part: WaveUIMessagePart } - | { type: "toolgroup"; parts: Array }; + | { type: "toolgroup"; parts: Array }; const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { const grouped: MessagePart[] = []; - let currentToolGroup: Array = []; + let currentToolGroup: Array = []; for (const part of parts) { - if (part.type === "data-tooluse") { - currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" }); + if (part.type === "data-tooluse" || part.type === "data-toolprogress") { + currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }); } else { if (currentToolGroup.length > 0) { grouped.push({ type: "toolgroup", parts: currentToolGroup }); @@ -225,7 +223,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { className={cn( "px-2 rounded-lg [&>*:first-child]:!mt-0", message.role === "user" - ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" + ? "py-2 bg-accent-800 text-white max-w-[calc(90%-10px)]" : "min-w-[min(100%,500px)]" )} > diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 09bc6b8758..3008404889 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -497,7 +497,7 @@ const AIPanelComponentInner = memo(() => { className="flex-1 overflow-y-auto p-2 relative" onContextMenu={(e) => handleWaveAIContextMenu(e, true)} > -
+
{model.inBuilder ? : } diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx index a6ef0538b5..a8f72f86bf 100644 --- a/frontend/app/aipanel/aipanelmessages.tsx +++ b/frontend/app/aipanel/aipanelmessages.tsx @@ -47,7 +47,7 @@ export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPane className="flex-1 overflow-y-auto p-2 space-y-4 relative" onContextMenu={onContextMenu} > -
+
{messages.map((message, index) => { diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 993e3132b5..bcd79e2ef1 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -13,6 +13,69 @@ import { WaveAIModel } from "./waveai-model"; // matches pkg/filebackup/filebackup.go const BackupRetentionDays = 5; +interface ToolDescLineProps { + text: string; +} + +const ToolDescLine = memo(({ text }: ToolDescLineProps) => { + let displayText = text; + if (displayText.startsWith("* ")) { + displayText = "• " + displayText.slice(2); + } + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + const regex = /(? lastIndex) { + parts.push(displayText.slice(lastIndex, match.index)); + } + + const sign = match[1]; + const number = match[2]; + const colorClass = sign === "+" ? "text-green-600" : "text-red-600"; + parts.push( + + {sign} + {number} + + ); + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < displayText.length) { + parts.push(displayText.slice(lastIndex)); + } + + return
{parts.length > 0 ? parts : displayText}
; +}); + +ToolDescLine.displayName = "ToolDescLine"; + +interface ToolDescProps { + text: string | string[]; + className?: string; +} + +const ToolDesc = memo(({ text, className }: ToolDescProps) => { + const lines = Array.isArray(text) ? text : text.split("\n"); + + if (lines.length === 0) return null; + + return ( +
+ {lines.map((line, idx) => ( + + ))} +
+ ); +}); + +ToolDesc.displayName = "ToolDesc"; + function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; } @@ -354,7 +417,7 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { )}
- {toolData.tooldesc &&
{toolData.tooldesc}
} + {toolData.tooldesc && } {(toolData.errormessage || effectiveApproval === "timeout") && (
{toolData.errormessage || "Not approved"}
)} @@ -370,16 +433,49 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { AIToolUse.displayName = "AIToolUse"; +interface AIToolProgressProps { + part: WaveUIMessagePart & { type: "data-toolprogress" }; +} + +const AIToolProgress = memo(({ part }: AIToolProgressProps) => { + const progressData = part.data; + + return ( +
+
+ +
{progressData.toolname}
+
+ {progressData.statuslines && progressData.statuslines.length > 0 && ( + + )} +
+ ); +}); + +AIToolProgress.displayName = "AIToolProgress"; + interface AIToolUseGroupProps { - parts: Array; + parts: Array; isStreaming: boolean; } type ToolGroupItem = | { type: "batch"; parts: Array } - | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } }; + | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } } + | { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } }; export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { + const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array< + WaveUIMessagePart & { type: "data-tooluse" } + >; + const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array< + WaveUIMessagePart & { type: "data-toolprogress" } + >; + + const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid)); + const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid)); + const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { const toolName = part.data?.toolname; return toolName === "read_text_file" || toolName === "read_dir"; @@ -392,7 +488,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) const readFileNeedsApproval: Array = []; const readFileOther: Array = []; - for (const part of parts) { + for (const part of tooluseParts) { if (isFileOp(part)) { if (needsApproval(part)) { readFileNeedsApproval.push(part); @@ -406,7 +502,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) let addedApprovalBatch = false; let addedOtherBatch = false; - for (const part of parts) { + for (const part of tooluseParts) { const isFileOpPart = isFileOp(part); const partNeedsApproval = needsApproval(part); @@ -425,19 +521,33 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) } } + filteredProgressParts.forEach((part) => { + groupedItems.push({ type: "progress", part }); + }); + return ( <> - {groupedItems.map((item, idx) => - item.type === "batch" ? ( -
- -
- ) : ( -
- -
- ) - )} + {groupedItems.map((item, idx) => { + if (item.type === "batch") { + return ( +
+ +
+ ); + } else if (item.type === "progress") { + return ( +
+ +
+ ); + } else { + return ( +
+ +
+ ); + } + })} ); }); diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts index 1b5d4122f9..a1192ec7ed 100644 --- a/frontend/app/aipanel/aitypes.ts +++ b/frontend/app/aipanel/aitypes.ts @@ -24,6 +24,12 @@ type WaveUIDataTypes = { writebackupfilename?: string; inputfilename?: string; }; + + toolprogress: { + toolcallid: string; + toolname: string; + statuslines: string[]; + }; }; export type WaveUIMessage = UIMessage; diff --git a/frontend/app/aipanel/thinkingmode.tsx b/frontend/app/aipanel/thinkingmode.tsx index 007dff1356..870634db7c 100644 --- a/frontend/app/aipanel/thinkingmode.tsx +++ b/frontend/app/aipanel/thinkingmode.tsx @@ -75,7 +75,7 @@ export const ThinkingLevelDropdown = memo(() => { {isOpen && ( <>
setIsOpen(false)} /> -
+
{(Object.keys(ThinkingModeData) as ThinkingMode[]).map((mode, index) => { const metadata = ThinkingModeData[mode]; const isFirst = index === 0; diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index 4733d33cff..88fc1c7daf 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -4,6 +4,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; +import { PublishAppModal } from "@/builder/builder-apppanel"; import { AboutModal } from "./about"; import { UserInputModal } from "./userinputmodal"; @@ -13,6 +14,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, + [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 3925ddd5e4..b1a561604b 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -382,6 +382,11 @@ class RpcApiType { return client.wshRpcCall("path", data, opts); } + // command "publishapp" [call] + PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { + return client.wshRpcCall("publishapp", data, opts); + } + // command "readappfile" [call] ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { return client.wshRpcCall("readappfile", data, opts); @@ -622,6 +627,11 @@ class RpcApiType { return client.wshRpcCall("writeappfile", data, opts); } + // command "writeappsecretbindings" [call] + WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writeappsecretbindings", data, opts); + } + // command "writetempfile" [call] WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { return client.wshRpcCall("writetempfile", data, opts); diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx index 05f715b7ba..c5c6e47def 100644 --- a/frontend/app/view/tsunami/tsunami.tsx +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -235,12 +235,13 @@ const TsunamiView = memo((props: ViewComponentProps) => { }, [domReady, model]); const appPath = blockData?.meta?.["tsunami:apppath"]; + const appId = blockData?.meta?.["tsunami:appid"]; const controller = blockData?.meta?.controller; // Check for configuration errors const errors = []; - if (!appPath) { - errors.push("App path must be set (tsunami:apppath)"); + if (!appPath && !appId) { + errors.push("App path or app ID must be set (tsunami:apppath or tsunami:appid)"); } if (controller !== "tsunami") { errors.push("Invalid controller (must be 'tsunami')"); @@ -283,7 +284,7 @@ const TsunamiView = memo((props: ViewComponentProps) => { return (

Tsunami

- {appPath &&
{appPath}
} + {(appPath || appId) &&
{appPath || appId}
} {isNotRunning && !isRestarting && (
); } diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index ba940ef277..5b361cc101 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -1,6 +1,10 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Modal } from "@/app/modals/modal"; +import { modalsModel } from "@/app/store/modalmodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; @@ -96,6 +100,56 @@ const ErrorStrip = memo(() => { ErrorStrip.displayName = "ErrorStrip"; +const PublishAppModal = memo(({ appName }: { appName: string }) => { + const builderAppId = useAtomValue(atoms.builderAppId); + + const handlePublish = async () => { + if (!builderAppId) { + console.error("No builder app ID found"); + modalsModel.popModal(); + return; + } + + try { + const result = await RpcApi.PublishAppCommand(TabRpcClient, { appid: builderAppId }); + console.log("App published successfully:", result.publishedappid); + modalsModel.popModal(); + } catch (error) { + console.error("Failed to publish app:", error); + } + }; + + const handleCancel = () => { + modalsModel.popModal(); + }; + + return ( + +
+

Publish App

+
+

+ This will publish your app to local/{appName} +

+

+ + This will overwrite any existing app with the same name. Are you sure? +

+
+
+
+ ); +}); + +PublishAppModal.displayName = "PublishAppModal"; + const BuilderAppPanel = memo(() => { const model = BuilderAppPanelModel.getInstance(); const focusElemRef = useRef(null); @@ -166,6 +220,12 @@ const BuilderAppPanel = memo(() => { model.restartBuilder(); }, [model]); + const handlePublishClick = useCallback(() => { + if (!builderAppId) return; + const appName = builderAppId.replace("draft/", ""); + modalsModel.pushModal("PublishAppModal", { appName }); + }, [builderAppId]); + return (
{ onClick={() => handleTabClick("env")} />
- {activeTab === "preview" && ( +
- )} +
{activeTab === "code" && ( +
 {
                             
                             
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index 276ec4ed14..9353f79edb 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -62,6 +62,15 @@ declare global {
         modtime: number;
     };
 
+    // wshrpc.AppManifest
+    type AppManifest = {
+        apptitle: string;
+        appshortdesc: string;
+        configschema: {[key: string]: any};
+        dataschema: {[key: string]: any};
+        secrets: {[key: string]: SecretMeta};
+    };
+
     // waveobj.Block
     type Block = WaveObj & {
         parentoref?: string;
@@ -124,6 +133,9 @@ declare global {
         exitcode?: number;
         errormsg?: string;
         version: number;
+        manifest?: AppManifest;
+        secretbindings?: {[key: string]: string};
+        secretbindingscomplete: boolean;
     };
 
     // waveobj.Client
@@ -325,6 +337,16 @@ declare global {
         message: string;
     };
 
+    // wshrpc.CommandPublishAppData
+    type CommandPublishAppData = {
+        appid: string;
+    };
+
+    // wshrpc.CommandPublishAppRtnData
+    type CommandPublishAppRtnData = {
+        publishedappid: string;
+    };
+
     // wshrpc.CommandReadAppFileData
     type CommandReadAppFileData = {
         appid: string;
@@ -482,6 +504,12 @@ declare global {
         data64: string;
     };
 
+    // wshrpc.CommandWriteAppSecretBindingsData
+    type CommandWriteAppSecretBindingsData = {
+        appid: string;
+        bindings: {[key: string]: string};
+    };
+
     // wshrpc.CommandWriteTempFileData
     type CommandWriteTempFileData = {
         filename: string;
@@ -826,6 +854,7 @@ declare global {
         "tsunami:*"?: boolean;
         "tsunami:sdkreplacepath"?: string;
         "tsunami:apppath"?: string;
+        "tsunami:appid"?: string;
         "tsunami:scaffoldpath"?: string;
         "tsunami:env"?: {[key: string]: string};
         "vdom:*"?: boolean;
@@ -950,6 +979,12 @@ declare global {
         winsize?: WinSize;
     };
 
+    // wshrpc.SecretMeta
+    type SecretMeta = {
+        desc: string;
+        optional: boolean;
+    };
+
     // webcmd.SetBlockTermSizeWSCommand
     type SetBlockTermSizeWSCommand = {
         wscommand: "setblocktermsize";
diff --git a/frontend/wave.ts b/frontend/wave.ts
index 9020012427..92e77614c9 100644
--- a/frontend/wave.ts
+++ b/frontend/wave.ts
@@ -39,7 +39,6 @@ import { createRoot } from "react-dom/client";
 const platform = getApi().getPlatform();
 document.title = `Wave Terminal`;
 let savedInitOpts: WaveInitOpts = null;
-let savedBuilderInitOpts: BuilderInitOpts = null;
 
 (window as any).WOS = WOS;
 (window as any).globalStore = globalStore;
@@ -219,11 +218,6 @@ async function initWave(initOpts: WaveInitOpts) {
 
 async function initBuilderWrap(initOpts: BuilderInitOpts) {
     try {
-        if (savedBuilderInitOpts) {
-            await reinitBuilder();
-            return;
-        }
-        savedBuilderInitOpts = initOpts;
         await initBuilder(initOpts);
     } catch (e) {
         getApi().sendLog("Error in initBuilder " + e.message + "\n" + e.stack);
@@ -235,28 +229,6 @@ async function initBuilderWrap(initOpts: BuilderInitOpts) {
     }
 }
 
-async function reinitBuilder() {
-    console.log("Reinit Builder");
-    getApi().sendLog("Reinit Builder");
-
-    // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active.
-    document.body.classList.add("nohover");
-    requestAnimationFrame(() =>
-        setTimeout(() => {
-            document.body.classList.remove("nohover");
-        }, 100)
-    );
-
-    await WOS.reloadWaveObject(WOS.makeORef("client", savedBuilderInitOpts.clientId));
-    document.title = `Tsunami Builder - ${savedBuilderInitOpts.appId}`;
-    getApi().setWindowInitStatus("wave-ready");
-    globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1);
-    globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus());
-    setTimeout(() => {
-        globalRefocus();
-    }, 50);
-}
-
 async function initBuilder(initOpts: BuilderInitOpts) {
     getApi().sendLog("Init Builder " + JSON.stringify(initOpts));
     console.log(
@@ -273,8 +245,6 @@ async function initBuilder(initOpts: BuilderInitOpts) {
         platform
     );
 
-    document.title = `Tsunami Builder - ${initOpts.appId}`;
-
     initGlobal({
         clientId: initOpts.clientId,
         windowId: initOpts.windowId,
@@ -288,7 +258,7 @@ async function initBuilder(initOpts: BuilderInitOpts) {
     (window as any).globalWS = globalWS;
     (window as any).TabRpcClient = TabRpcClient;
     await loadConnStatus();
-    
+
     let appIdToUse = initOpts.appId;
     try {
         const oref = WOS.makeORef("builder", initOpts.builderId);
@@ -299,7 +269,9 @@ async function initBuilder(initOpts: BuilderInitOpts) {
     } catch (e) {
         console.log("Could not load saved builder appId from rtinfo:", e);
     }
-    
+
+    document.title = appIdToUse ? `WaveApp Builder (${appIdToUse})` : "WaveApp Builder";
+
     globalStore.set(atoms.builderAppId, appIdToUse);
 
     const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId));
@@ -322,5 +294,4 @@ async function initBuilder(initOpts: BuilderInitOpts) {
     root.render(reactElem);
     await firstRenderPromise;
     console.log("Tsunami Builder First Render Done");
-    getApi().setWindowInitStatus("wave-ready");
 }
diff --git a/pkg/aiusechat/openai/openai-backend.go b/pkg/aiusechat/openai/openai-backend.go
index 34be1c8721..c05745ee6a 100644
--- a/pkg/aiusechat/openai/openai-backend.go
+++ b/pkg/aiusechat/openai/openai-backend.go
@@ -382,6 +382,7 @@ type openaiBlockState struct {
 	toolCallID   string // For function calls
 	toolName     string // For function calls
 	summaryCount int    // For reasoning: number of summary parts seen
+	partialJSON  []byte // For function calls: accumulated JSON arguments
 }
 
 type openaiStreamingState struct {
@@ -857,7 +858,25 @@ func handleOpenAIEvent(
 			_ = sse.AiMsgError(err.Error())
 			return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
 		}
-		// Noop as requested
+		if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse {
+			st.partialJSON = append(st.partialJSON, []byte(ev.Delta)...)
+
+			toolDef := state.chatOpts.GetToolDefinition(st.toolName)
+			if toolDef != nil && toolDef.ToolProgressDesc != nil {
+				parsedJSON, err := utilfn.ParsePartialJson(st.partialJSON)
+				if err == nil {
+					statusLines, err := toolDef.ToolProgressDesc(parsedJSON)
+					if err == nil {
+						progressData := &uctypes.UIMessageDataToolProgress{
+							ToolCallId:  st.toolCallID,
+							ToolName:    st.toolName,
+							StatusLines: statusLines,
+						}
+						_ = sse.AiMsgData("data-toolprogress", "progress-"+st.toolCallID, progressData)
+					}
+				}
+			}
+		}
 		return nil, nil
 
 	case "response.function_call_arguments.done":
@@ -876,8 +895,20 @@ func handleOpenAIEvent(
 			toolDef := state.chatOpts.GetToolDefinition(st.toolName)
 			toolUseData := createToolUseData(st.toolCallID, st.toolName, toolDef, ev.Arguments, state.chatOpts)
 			state.toolUseData[st.toolCallID] = toolUseData
-			if toolUseData.Approval == uctypes.ApprovalNeedsApproval && state.chatOpts.RegisterToolApproval != nil {
-				state.chatOpts.RegisterToolApproval(st.toolCallID)
+
+			if toolDef != nil && toolDef.ToolProgressDesc != nil {
+				var parsedJSON any
+				if err := json.Unmarshal([]byte(ev.Arguments), &parsedJSON); err == nil {
+					statusLines, err := toolDef.ToolProgressDesc(parsedJSON)
+					if err == nil {
+						progressData := &uctypes.UIMessageDataToolProgress{
+							ToolCallId:  st.toolCallID,
+							ToolName:    st.toolName,
+							StatusLines: statusLines,
+						}
+						_ = sse.AiMsgData("data-toolprogress", "progress-"+st.toolCallID, progressData)
+					}
+				}
 			}
 		}
 		return nil, nil
diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go
index 0fe117c6ec..1df1cd0221 100644
--- a/pkg/aiusechat/tools_builder.go
+++ b/pkg/aiusechat/tools_builder.go
@@ -7,6 +7,7 @@ import (
 	"context"
 	"fmt"
 	"log"
+	"strings"
 	"time"
 
 	"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
@@ -91,7 +92,26 @@ func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctype
 			"additionalProperties": false,
 		},
 		ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {
-			return fmt.Sprintf("writing app.go for %s", appId)
+			params, err := parseBuilderWriteAppFileInput(input)
+			if err != nil {
+				if output != nil {
+					return "wrote app.go"
+				}
+				return "writing app.go"
+			}
+			lineCount := len(strings.Split(params.Contents, "\n"))
+			if output != nil {
+				return fmt.Sprintf("wrote app.go (+%d lines)", lineCount)
+			}
+			return fmt.Sprintf("writing app.go (+%d lines)", lineCount)
+		},
+		ToolProgressDesc: func(input any) ([]string, error) {
+			params, err := parseBuilderWriteAppFileInput(input)
+			if err != nil {
+				return nil, err
+			}
+			lineCount := len(strings.Split(params.Contents, "\n"))
+			return []string{fmt.Sprintf("writing app.go (+%d lines)", lineCount)}, nil
 		},
 		ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {
 			params, err := parseBuilderWriteAppFileInput(input)
@@ -148,13 +168,35 @@ func parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error)
 	return result, nil
 }
 
+func formatEditDescriptions(edits []fileutil.EditSpec) []string {
+	numEdits := len(edits)
+	editStr := "edits"
+	if numEdits == 1 {
+		editStr = "edit"
+	}
+
+	result := make([]string, len(edits)+1)
+	result[0] = fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr)
+
+	for i, edit := range edits {
+		newLines := len(strings.Split(edit.NewStr, "\n"))
+		oldLines := len(strings.Split(edit.OldStr, "\n"))
+		desc := edit.Desc
+		if desc == "" {
+			desc = fmt.Sprintf("edit #%d", i+1)
+		}
+		result[i+1] = fmt.Sprintf("* %s (+%d -%d)", desc, newLines, oldLines)
+	}
+	return result
+}
+
 func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition {
 	return uctypes.ToolDefinition{
 		Name:        "builder_edit_app_file",
 		DisplayName: "Edit App File",
 		Description: "Edit the app.go file for this app using precise search and replace. " +
 			"Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " +
-			"All edits are applied atomically - if any single edit fails, the entire operation fails and no changes are made.",
+			"Edits are applied sequentially - if an edit fails, all previous edits are kept and subsequent edits are skipped.",
 		ToolLogName: "builder:edit_app",
 		Strict:      false,
 		InputSchema: map[string]any{
@@ -162,13 +204,13 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes
 			"properties": map[string]any{
 				"edits": map[string]any{
 					"type":        "array",
-					"description": "Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.",
+					"description": "Array of edit specifications. Edits are applied sequentially - if one fails, previous edits are kept but remaining edits are skipped.",
 					"items": map[string]any{
 						"type": "object",
 						"properties": map[string]any{
 							"old_str": map[string]any{
 								"type":        "string",
-								"description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, the entire edit operation will fail.",
+								"description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, this edit will fail.",
 							},
 							"new_str": map[string]any{
 								"type":        "string",
@@ -176,7 +218,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes
 							},
 							"desc": map[string]any{
 								"type":        "string",
-								"description": "Description of what this edit does",
+								"description": "Description of what this edit does (keep short, half a line of text max)",
 							},
 						},
 						"required": []string{"old_str", "new_str"},
@@ -191,12 +233,14 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes
 			if err != nil {
 				return fmt.Sprintf("error parsing input: %v", err)
 			}
-			numEdits := len(params.Edits)
-			editStr := "edits"
-			if numEdits == 1 {
-				editStr = "edit"
+			return strings.Join(formatEditDescriptions(params.Edits), "\n")
+		},
+		ToolProgressDesc: func(input any) ([]string, error) {
+			params, err := parseBuilderEditAppFileInput(input)
+			if err != nil {
+				return nil, err
 			}
-			return fmt.Sprintf("editing app.go for %s (%d %s)", appId, numEdits, editStr)
+			return formatEditDescriptions(params.Edits), nil
 		},
 		ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {
 			params, err := parseBuilderEditAppFileInput(input)
@@ -204,7 +248,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes
 				return nil, err
 			}
 
-			err = waveappstore.ReplaceInAppFile(appId, BuilderAppFileName, params.Edits)
+			editResults, err := waveappstore.ReplaceInAppFilePartial(appId, BuilderAppFileName, params.Edits)
 			if err != nil {
 				return nil, err
 			}
@@ -215,8 +259,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes
 			})
 
 			result := map[string]any{
-				"success": true,
-				"message": fmt.Sprintf("Successfully edited %s with %d changes", BuilderAppFileName, len(params.Edits)),
+				"edits": editResults,
 			}
 
 			if builderId != "" {
@@ -244,7 +287,7 @@ func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition {
 			"additionalProperties": false,
 		},
 		ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {
-			return fmt.Sprintf("listing files for %s", appId)
+			return "listing files"
 		},
 		ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {
 			result, err := waveappstore.ListAllAppFiles(appId)
diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go
index 8415fd56e6..9e7fe4a9d1 100644
--- a/pkg/aiusechat/uctypes/usechat-types.go
+++ b/pkg/aiusechat/uctypes/usechat-types.go
@@ -91,6 +91,7 @@ type ToolDefinition struct {
 	ToolCallDesc     func(any, any, *UIMessageDataToolUse) string  `json:"-"` // passed input, output (may be nil), *UIMessageDataToolUse (may be nil)
 	ToolApproval     func(any) string                              `json:"-"`
 	ToolVerifyInput  func(any, *UIMessageDataToolUse) error        `json:"-"` // *UIMessageDataToolUse will NOT be nil
+	ToolProgressDesc func(any) ([]string, error)                   `json:"-"`
 }
 
 func (td *ToolDefinition) Clean() *ToolDefinition {
@@ -161,6 +162,13 @@ func (d *UIMessageDataToolUse) IsApproved() bool {
 	return d.Approval == "" || d.Approval == ApprovalUserApproved || d.Approval == ApprovalAutoApproved
 }
 
+// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.toolprogress
+type UIMessageDataToolProgress struct {
+	ToolCallId  string   `json:"toolcallid"`
+	ToolName    string   `json:"toolname"`
+	StatusLines []string `json:"statuslines"`
+}
+
 type StopReasonKind string
 
 const (
diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go
index d6b866bf9a..a87ebdad1e 100644
--- a/pkg/aiusechat/usechat.go
+++ b/pkg/aiusechat/usechat.go
@@ -376,6 +376,9 @@ func processToolCalls(stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveC
 			log.Printf("AI data-tooluse %s\n", toolCall.ID)
 			_ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData)
 			updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData)
+			if toolCall.ToolUseData.Approval == uctypes.ApprovalNeedsApproval && chatOpts.RegisterToolApproval != nil {
+				chatOpts.RegisterToolApproval(toolCall.ID)
+			}
 		}
 	}
 
diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go
index ebef637f2c..29d0b500a4 100644
--- a/pkg/blockcontroller/tsunamicontroller.go
+++ b/pkg/blockcontroller/tsunamicontroller.go
@@ -20,13 +20,17 @@ import (
 
 	"github.com/wavetermdev/waveterm/pkg/tsunamiutil"
 	"github.com/wavetermdev/waveterm/pkg/utilds"
+	"github.com/wavetermdev/waveterm/pkg/waveappstore"
 	"github.com/wavetermdev/waveterm/pkg/wavebase"
 	"github.com/wavetermdev/waveterm/pkg/waveobj"
+	"github.com/wavetermdev/waveterm/pkg/wconfig"
 	"github.com/wavetermdev/waveterm/pkg/wps"
 	"github.com/wavetermdev/waveterm/pkg/wstore"
 	"github.com/wavetermdev/waveterm/tsunami/build"
 )
 
+const DefaultTsunamiSdkVersion = "v0.12.2"
+
 type TsunamiAppProc struct {
 	Cmd         *exec.Cmd
 	LineBuffer  *utilds.MultiReaderLineBuffer
@@ -122,40 +126,38 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap
 	c.runLock.Lock()
 	defer c.runLock.Unlock()
 
-	scaffoldPath := blockMeta.GetString(waveobj.MetaKey_TsunamiScaffoldPath, "")
+	settings := wconfig.GetWatcher().GetFullConfig().Settings
+	scaffoldPath := settings.TsunamiScaffoldPath
 	if scaffoldPath == "" {
-		return fmt.Errorf("tsunami:scaffoldpath is required")
-	}
-	scaffoldPath, err := wavebase.ExpandHomeDir(scaffoldPath)
-	if err != nil {
-		return fmt.Errorf("tsunami:scaffoldpath invalid: %w", err)
+		scaffoldPath = filepath.Join(wavebase.GetWaveAppPath(), "tsunamiscaffold")
 	}
-	if !filepath.IsAbs(scaffoldPath) {
-		return fmt.Errorf("tsunami:scaffoldpath must be absolute: %s", scaffoldPath)
-	}
-
-	sdkReplacePath := blockMeta.GetString(waveobj.MetaKey_TsunamiSdkReplacePath, "")
-	if sdkReplacePath == "" {
-		return fmt.Errorf("tsunami:sdkreplacepath is required")
-	}
-	sdkReplacePath, err = wavebase.ExpandHomeDir(sdkReplacePath)
-	if err != nil {
-		return fmt.Errorf("tsunami:sdkreplacepath invalid: %w", err)
-	}
-	if !filepath.IsAbs(sdkReplacePath) {
-		return fmt.Errorf("tsunami:sdkreplacepath must be absolute: %s", sdkReplacePath)
+	sdkReplacePath := settings.TsunamiSdkReplacePath
+	sdkVersion := settings.TsunamiSdkVersion
+	if sdkVersion == "" {
+		sdkVersion = DefaultTsunamiSdkVersion
 	}
+	goPath := settings.TsunamiGoPath
 
 	appPath := blockMeta.GetString(waveobj.MetaKey_TsunamiAppPath, "")
 	if appPath == "" {
-		return fmt.Errorf("tsunami:apppath is required")
-	}
-	appPath, err = wavebase.ExpandHomeDir(appPath)
-	if err != nil {
-		return fmt.Errorf("tsunami:apppath invalid: %w", err)
-	}
-	if !filepath.IsAbs(appPath) {
-		return fmt.Errorf("tsunami:apppath must be absolute: %s", appPath)
+		appId := blockMeta.GetString(waveobj.MetaKey_TsunamiAppId, "")
+		if appId == "" {
+			return fmt.Errorf("tsunami:apppath or tsunami:appid is required")
+		}
+		var err error
+		appPath, err = waveappstore.GetAppDir(appId)
+		if err != nil {
+			return fmt.Errorf("failed to get app directory from tsunami:appid: %w", err)
+		}
+	} else {
+		var err error
+		appPath, err = wavebase.ExpandHomeDir(appPath)
+		if err != nil {
+			return fmt.Errorf("tsunami:apppath invalid: %w", err)
+		}
+		if !filepath.IsAbs(appPath) {
+			return fmt.Errorf("tsunami:apppath must be absolute: %s", appPath)
+		}
 	}
 
 	appName := build.GetAppName(appPath)
@@ -185,7 +187,9 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap
 			OutputFile:     cachePath,
 			ScaffoldPath:   scaffoldPath,
 			SdkReplacePath: sdkReplacePath,
+			SdkVersion:     sdkVersion,
 			NodePath:       nodePath,
+			GoPath:         goPath,
 		}
 
 		err = build.TsunamiBuild(opts)
diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go
index 040167849d..2394a31317 100644
--- a/pkg/buildercontroller/buildercontroller.go
+++ b/pkg/buildercontroller/buildercontroller.go
@@ -23,6 +23,7 @@ import (
 	"github.com/wavetermdev/waveterm/pkg/waveobj"
 	"github.com/wavetermdev/waveterm/pkg/wconfig"
 	"github.com/wavetermdev/waveterm/pkg/wps"
+	"github.com/wavetermdev/waveterm/pkg/wshrpc"
 	"github.com/wavetermdev/waveterm/tsunami/build"
 )
 
@@ -95,30 +96,23 @@ func DeleteController(builderId string) {
 	if bc != nil {
 		bc.Stop()
 	}
-
-	cachesDir := wavebase.GetWaveCachesDir()
-	builderDir := filepath.Join(cachesDir, "builder", builderId)
-	if err := os.RemoveAll(builderDir); err != nil {
-		log.Printf("failed to remove builder cache directory for %s: %v", builderId, err)
-	}
 }
 
-func GetBuilderAppExecutablePath(builderId string, appName string) (string, error) {
-	cachesDir := wavebase.GetWaveCachesDir()
-	builderDir := filepath.Join(cachesDir, "builder", builderId)
+func GetBuilderAppExecutablePath(appPath string) (string, error) {
+	binDir := filepath.Join(appPath, "bin")
 
-	binaryName := appName
+	binaryName := "app"
 	if runtime.GOOS == "windows" {
-		binaryName = binaryName + ".exe"
+		binaryName = "app.exe"
 	}
-	cachePath := filepath.Join(builderDir, binaryName)
+	binPath := filepath.Join(binDir, binaryName)
 
-	err := wavebase.TryMkdirs(builderDir, 0755, "builder cache directory")
+	err := wavebase.TryMkdirs(binDir, 0755, "app bin directory")
 	if err != nil {
-		return "", fmt.Errorf("failed to create builder cache directory: %w", err)
+		return "", fmt.Errorf("failed to create app bin directory: %w", err)
 	}
 
-	return cachePath, nil
+	return binPath, nil
 }
 
 func Shutdown() {
@@ -132,12 +126,6 @@ func Shutdown() {
 	for _, bc := range controllers {
 		bc.Stop()
 	}
-
-	cachesDir := wavebase.GetWaveCachesDir()
-	builderCacheDir := filepath.Join(cachesDir, "builder")
-	if err := os.RemoveAll(builderCacheDir); err != nil {
-		log.Printf("failed to remove builder cache directory: %v", err)
-	}
 }
 
 func (bc *BuilderController) waitForBuildDone(ctx context.Context) error {
@@ -208,9 +196,7 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil
 		return
 	}
 
-	appName := build.GetAppName(appPath)
-
-	cachePath, err := GetBuilderAppExecutablePath(bc.builderId, appName)
+	cachePath, err := GetBuilderAppExecutablePath(appPath)
 	if err != nil {
 		bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err), resultCh)
 		return
@@ -248,6 +234,7 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil
 		NodePath:       nodePath,
 		GoPath:         goPath,
 		OutputCapture:  outputCapture,
+		MoveFileBack:   true,
 	})
 
 	for _, line := range outputCapture.GetLines() {
@@ -285,7 +272,7 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil
 		}
 	}
 
-	process, err := bc.runBuilderApp(ctx, cachePath, builderEnv)
+	process, err := bc.runBuilderApp(ctx, appId, cachePath, builderEnv)
 	if err != nil {
 		bc.handleBuildError(fmt.Errorf("failed to run app: %w", err), resultCh)
 		return
@@ -308,7 +295,29 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil
 	}()
 }
 
-func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) {
+func (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) {
+	manifest, err := waveappstore.ReadAppManifest(appId)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read app manifest: %w", err)
+	}
+
+	secretBindings, err := waveappstore.ReadAppSecretBindings(appId)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read secret bindings: %w", err)
+	}
+
+	secretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest, secretBindings)
+	if err != nil {
+		return nil, fmt.Errorf("failed to build secret environment: %w", err)
+	}
+
+	if builderEnv == nil {
+		builderEnv = make(map[string]string)
+	}
+	for k, v := range secretEnv {
+		builderEnv[k] = v
+	}
+
 	cmd := exec.Command(appBinPath)
 	cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1")
 
@@ -486,18 +495,50 @@ func (bc *BuilderController) stopProcess_nolock() {
 	bc.process = nil
 }
 
-func (bc *BuilderController) GetStatus() BuilderStatusData {
+func (bc *BuilderController) GetStatus() wshrpc.BuilderStatusData {
 	bc.statusLock.Lock()
 	defer bc.statusLock.Unlock()
 
 	bc.statusVersion++
-	return BuilderStatusData{
+	statusData := wshrpc.BuilderStatusData{
 		Status:   bc.status,
 		Port:     bc.port,
 		ExitCode: bc.exitCode,
 		ErrorMsg: bc.errorMsg,
 		Version:  bc.statusVersion,
 	}
+
+	if bc.appId != "" {
+		manifest, err := waveappstore.ReadAppManifest(bc.appId)
+		if err == nil && manifest != nil {
+			wshrpcManifest := &wshrpc.AppManifest{
+				AppTitle:     manifest.AppTitle,
+				AppShortDesc: manifest.AppShortDesc,
+				ConfigSchema: manifest.ConfigSchema,
+				DataSchema:   manifest.DataSchema,
+				Secrets:      make(map[string]wshrpc.SecretMeta),
+			}
+			for k, v := range manifest.Secrets {
+				wshrpcManifest.Secrets[k] = wshrpc.SecretMeta{
+					Desc:     v.Desc,
+					Optional: v.Optional,
+				}
+			}
+			statusData.Manifest = wshrpcManifest
+		}
+
+		secretBindings, err := waveappstore.ReadAppSecretBindings(bc.appId)
+		if err == nil {
+			statusData.SecretBindings = secretBindings
+		}
+
+		if manifest != nil && secretBindings != nil {
+			_, err := waveappstore.BuildAppSecretEnv(bc.appId, manifest, secretBindings)
+			statusData.SecretBindingsComplete = (err == nil)
+		}
+	}
+
+	return statusData
 }
 
 func (bc *BuilderController) GetOutput() []string {
@@ -538,13 +579,6 @@ func (bc *BuilderController) publishOutputLine(line string, reset bool) {
 	})
 }
 
-type BuilderStatusData struct {
-	Status   string `json:"status"`
-	Port     int    `json:"port,omitempty"`
-	ExitCode int    `json:"exitcode,omitempty"`
-	ErrorMsg string `json:"errormsg,omitempty"`
-	Version  int    `json:"version"`
-}
 
 func exitCodeFromWaitErr(waitErr error) int {
 	if waitErr == nil {
diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go
index f8f12ca4c8..85f48077a1 100644
--- a/pkg/util/fileutil/fileutil.go
+++ b/pkg/util/fileutil/fileutil.go
@@ -262,29 +262,86 @@ type EditSpec struct {
 	Desc   string `json:"desc,omitempty"`
 }
 
+type EditResult struct {
+	Applied bool   `json:"applied"`
+	Desc    string `json:"desc"`
+	Error   string `json:"error,omitempty"`
+}
+
+// applyEdit applies a single edit to the content and returns the modified content and result.
+func applyEdit(content []byte, edit EditSpec, index int) ([]byte, EditResult) {
+	result := EditResult{
+		Desc: edit.Desc,
+	}
+	if result.Desc == "" {
+		result.Desc = fmt.Sprintf("Edit %d", index+1)
+	}
+
+	if edit.OldStr == "" {
+		result.Applied = false
+		result.Error = "old_str cannot be empty"
+		return content, result
+	}
+
+	oldBytes := []byte(edit.OldStr)
+	count := bytes.Count(content, oldBytes)
+	if count == 0 {
+		result.Applied = false
+		result.Error = "old_str not found in file"
+		return content, result
+	}
+	if count > 1 {
+		result.Applied = false
+		result.Error = fmt.Sprintf("old_str appears %d times, must appear exactly once", count)
+		return content, result
+	}
+
+	modifiedContent := bytes.Replace(content, oldBytes, []byte(edit.NewStr), 1)
+	result.Applied = true
+	return modifiedContent, result
+}
+
 // ApplyEdits applies a series of edits to the given content and returns the modified content.
-// Each edit's OldStr must appear exactly once in the content or an error is returned.
+// This is atomic - all edits succeed or all fail.
 func ApplyEdits(originalContent []byte, edits []EditSpec) ([]byte, error) {
 	modifiedContents := originalContent
 
 	for i, edit := range edits {
-		if edit.OldStr == "" {
-			return nil, fmt.Errorf("edit %d (%s): old_str cannot be empty", i, edit.Desc)
+		var result EditResult
+		modifiedContents, result = applyEdit(modifiedContents, edit, i)
+		if !result.Applied {
+			return nil, fmt.Errorf("edit %d (%s): %s", i, result.Desc, result.Error)
 		}
+	}
 
-		oldBytes := []byte(edit.OldStr)
-		count := bytes.Count(modifiedContents, oldBytes)
-		if count == 0 {
-			return nil, fmt.Errorf("edit %d (%s): old_str not found in file", i, edit.Desc)
-		}
-		if count > 1 {
-			return nil, fmt.Errorf("edit %d (%s): old_str appears %d times, must appear exactly once", i, edit.Desc, count)
+	return modifiedContents, nil
+}
+
+// ApplyEditsPartial applies edits incrementally, continuing until the first failure.
+// Returns the modified content (potentially partially applied) and results for each edit.
+func ApplyEditsPartial(originalContent []byte, edits []EditSpec) ([]byte, []EditResult) {
+	modifiedContents := originalContent
+	results := make([]EditResult, len(edits))
+	failed := false
+
+	for i, edit := range edits {
+		if failed {
+			results[i].Desc = edit.Desc
+			if results[i].Desc == "" {
+				results[i].Desc = fmt.Sprintf("Edit %d", i+1)
+			}
+			results[i].Applied = false
+			results[i].Error = "previous edit failed"
+			continue
 		}
 
-		modifiedContents = bytes.Replace(modifiedContents, oldBytes, []byte(edit.NewStr), 1)
+		modifiedContents, results[i] = applyEdit(modifiedContents, edit, i)
+		if !results[i].Applied {
+			failed = true
+		}
 	}
 
-	return modifiedContents, nil
+	return modifiedContents, results
 }
 
 func ReplaceInFile(filePath string, edits []EditSpec) error {
@@ -317,3 +374,33 @@ func ReplaceInFile(filePath string, edits []EditSpec) error {
 
 	return nil
 }
+
+// ReplaceInFilePartial applies edits incrementally up to the first failure.
+// Returns the results for each edit and writes the partially modified content.
+func ReplaceInFilePartial(filePath string, edits []EditSpec) ([]EditResult, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to stat file: %w", err)
+	}
+
+	if !fileInfo.Mode().IsRegular() {
+		return nil, fmt.Errorf("not a regular file: %s", filePath)
+	}
+
+	if fileInfo.Size() > MaxEditFileSize {
+		return nil, fmt.Errorf("file too large for editing: %d bytes (max: %d)", fileInfo.Size(), MaxEditFileSize)
+	}
+
+	contents, err := os.ReadFile(filePath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	modifiedContents, results := ApplyEditsPartial(contents, edits)
+
+	if err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil {
+		return nil, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	return results, nil
+}
diff --git a/pkg/util/utilfn/partial.go b/pkg/util/utilfn/partial.go
new file mode 100644
index 0000000000..eb91da41f6
--- /dev/null
+++ b/pkg/util/utilfn/partial.go
@@ -0,0 +1,175 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package utilfn
+
+import (
+	"encoding/json"
+)
+
+type stackItem int
+
+const (
+	stackInvalid stackItem = iota
+	stackLBrace
+	stackLBrack
+	stackBeforeKey
+	stackKey
+	stackKeyColon
+	stackQuote
+)
+
+type jsonStack []stackItem
+
+func (s *jsonStack) push(item stackItem) {
+	*s = append(*s, item)
+}
+
+func (s *jsonStack) pop() stackItem {
+	if len(*s) == 0 {
+		return stackInvalid
+	}
+	item := (*s)[len(*s)-1]
+	*s = (*s)[:len(*s)-1]
+	return item
+}
+
+func (s jsonStack) peek() stackItem {
+	if len(s) == 0 {
+		return stackInvalid
+	}
+	return s[len(s)-1]
+}
+func (s jsonStack) isTop(items ...stackItem) bool {
+	top := s.peek()
+	for _, item := range items {
+		if top == item {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *jsonStack) replaceTop(item stackItem) {
+	if len(*s) > 0 {
+		(*s)[len(*s)-1] = item
+	}
+}
+
+func repairJson(data []byte) []byte {
+	if len(data) == 0 {
+		return data
+	}
+
+	var stack jsonStack
+	inString := false
+	escaped := false
+	lastComma := false
+
+	for i := 0; i < len(data); i++ {
+		b := data[i]
+
+		if escaped {
+			escaped = false
+			continue
+		}
+
+		if inString {
+			if b == '\\' {
+				escaped = true
+				continue
+			}
+			if b == '"' {
+				inString = false
+			}
+			continue
+		}
+
+		if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
+			continue
+		}
+		valueStart := b == '{' || b == '[' || b == 'n' || b == 't' || b == 'f' || b == '"' || (b >= '0' && b <= '9') || b == '-'
+		if valueStart && lastComma {
+			lastComma = false
+		}
+		if valueStart && stack.isTop(stackKeyColon) {
+			stack.pop()
+		}
+		if valueStart && stack.isTop(stackBeforeKey) {
+			stack.replaceTop(stackKey)
+		}
+		switch b {
+		case '{':
+			stack.push(stackLBrace)
+			stack.push(stackBeforeKey)
+		case '[':
+			stack.push(stackLBrack)
+		case '}':
+			if stack.isTop(stackBeforeKey) {
+				stack.pop()
+			}
+			if stack.isTop(stackLBrace) {
+				stack.pop()
+			}
+		case ']':
+			if stack.isTop(stackLBrack) {
+				stack.pop()
+			}
+		case '"':
+			inString = true
+		case ':':
+			if stack.isTop(stackKey) {
+				stack.replaceTop(stackKeyColon)
+			}
+		case ',':
+			lastComma = true
+			if stack.isTop(stackLBrace) {
+				stack.push(stackBeforeKey)
+			}
+		default:
+		}
+	}
+
+	if len(stack) == 0 && !inString {
+		return data
+	}
+
+	result := append([]byte{}, data...)
+	if escaped && len(result) > 0 {
+		result = result[:len(result)-1]
+	}
+	if inString {
+		result = append(result, '"')
+	}
+	if lastComma {
+		for i := len(result) - 1; i >= 0; i-- {
+			if result[i] == ',' {
+				result = result[:i]
+				break
+			}
+		}
+	}
+	for i := len(stack) - 1; i >= 0; i-- {
+		switch stack[i] {
+		case stackKeyColon:
+			result = append(result, []byte("null")...)
+		case stackKey:
+			result = append(result, []byte(": null")...)
+		case stackLBrace:
+			result = append(result, '}')
+		case stackLBrack:
+			result = append(result, ']')
+		}
+	}
+	return result
+}
+
+func ParsePartialJson(data []byte) (any, error) {
+	fixedData := repairJson(data)
+	var output any
+	err := json.Unmarshal(fixedData, &output)
+	if err != nil {
+		return nil, err
+	}
+	return output, nil
+}
diff --git a/pkg/util/utilfn/partial_test.go b/pkg/util/utilfn/partial_test.go
new file mode 100644
index 0000000000..a676c4de36
--- /dev/null
+++ b/pkg/util/utilfn/partial_test.go
@@ -0,0 +1,135 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package utilfn
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestRepairJson(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "open bracket",
+			input:    "[",
+			expected: "[]",
+		},
+		{
+			name:     "empty array",
+			input:    "[]",
+			expected: "[]",
+		},
+		{
+			name:     "unclosed string in array",
+			input:    `["a`,
+			expected: `["a"]`,
+		},
+		{
+			name:     "unclosed array with string",
+			input:    `["a"`,
+			expected: `["a"]`,
+		},
+		{
+			name:     "unclosed array with number",
+			input:    `[5`,
+			expected: `[5]`,
+		},
+		{
+			name:     "array with trailing comma",
+			input:    `["a",`,
+			expected: `["a"]`,
+		},
+		{
+			name:     "array with unclosed second string",
+			input:    `["a","`,
+			expected: `["a",""]`,
+		},
+		{
+			name:     "unclosed array with string and number",
+			input:    `["a",5`,
+			expected: `["a",5]`,
+		},
+		{
+			name:     "open brace",
+			input:    "{",
+			expected: "{}",
+		},
+		{
+			name:     "empty object",
+			input:    "{}",
+			expected: "{}",
+		},
+		{
+			name:     "unclosed key",
+			input:    `{"a`,
+			expected: `{"a": null}`,
+		},
+		{
+			name:     "key without colon",
+			input:    `{"a"`,
+			expected: `{"a": null}`,
+		},
+		{
+			name:     "key with colon no value",
+			input:    `{"a": `,
+			expected: `{"a": null}`,
+		},
+		{
+			name:     "unclosed object with number value",
+			input:    `{"a": 5`,
+			expected: `{"a": 5}`,
+		},
+		{
+			name:     "unclosed object with true",
+			input:    `{"a": true`,
+			expected: `{"a": true}`,
+		},
+		// {
+		// 	name:     "unclosed object with partial value",
+		// 	input:    `{"a": fa`,
+		// 	expected: `{"a": fa}`,
+		// },
+		{
+			name:     "object with trailing comma",
+			input:    `{"a": true,`,
+			expected: `{"a": true}`,
+		},
+		{
+			name:     "object with unclosed second key",
+			input:    `{"a": true, "`,
+			expected: `{"a": true, "": null}`,
+		},
+		{
+			name:     "complete object",
+			input:    `{"a": true, "b": false}`,
+			expected: `{"a": true, "b": false}`,
+		},
+		{
+			name:     "nested incomplete",
+			input:    `[1, {"a": true, "b`,
+			expected: `[1, {"a": true, "b": null}]`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := repairJson([]byte(tt.input))
+			resultStr := string(result)
+
+			if resultStr != tt.expected {
+				t.Errorf("repairJson() of %s = %s, expected %s", tt.input, resultStr, tt.expected)
+			}
+
+			var parsed any
+			err := json.Unmarshal(result, &parsed)
+			if err != nil {
+				t.Errorf("repaired JSON is not valid: %v\nInput: %q\nOutput: %q", err, tt.input, resultStr)
+			}
+		})
+	}
+}
diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go
index 4f63d9e9a7..c9af0a6777 100644
--- a/pkg/waveappstore/waveappstore.go
+++ b/pkg/waveappstore/waveappstore.go
@@ -4,15 +4,18 @@
 package waveappstore
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
 	"regexp"
 	"strings"
 
+	"github.com/wavetermdev/waveterm/pkg/secretstore"
 	"github.com/wavetermdev/waveterm/pkg/util/fileutil"
 	"github.com/wavetermdev/waveterm/pkg/wavebase"
 	"github.com/wavetermdev/waveterm/pkg/wshrpc"
+	"github.com/wavetermdev/waveterm/tsunami/engine"
 )
 
 const (
@@ -21,6 +24,9 @@ const (
 
 	MaxNamespaceLen = 30
 	MaxAppNameLen   = 50
+
+	ManifestFileName       = "manifest.json"
+	SecretBindingsFileName = "secret-bindings.json"
 )
 
 var (
@@ -350,6 +356,24 @@ func ReplaceInAppFile(appId string, fileName string, edits []fileutil.EditSpec)
 	return fileutil.ReplaceInFile(filePath, edits)
 }
 
+func ReplaceInAppFilePartial(appId string, fileName string, edits []fileutil.EditSpec) ([]fileutil.EditResult, error) {
+	if err := ValidateAppId(appId); err != nil {
+		return nil, fmt.Errorf("invalid appId: %w", err)
+	}
+
+	appDir, err := GetAppDir(appId)
+	if err != nil {
+		return nil, err
+	}
+
+	filePath, err := validateAndResolveFilePath(appDir, fileName)
+	if err != nil {
+		return nil, err
+	}
+
+	return fileutil.ReplaceInFilePartial(filePath, edits)
+}
+
 func RenameAppFile(appId string, fromFileName string, toFileName string) error {
 	if err := ValidateAppId(appId); err != nil {
 		return fmt.Errorf("invalid appId: %w", err)
@@ -644,3 +668,125 @@ func RenameLocalApp(appName string, newAppName string) error {
 
 	return nil
 }
+
+func ReadAppManifest(appId string) (*engine.AppManifest, error) {
+	if err := ValidateAppId(appId); err != nil {
+		return nil, fmt.Errorf("invalid appId: %w", err)
+	}
+
+	appDir, err := GetAppDir(appId)
+	if err != nil {
+		return nil, err
+	}
+
+	manifestPath := filepath.Join(appDir, ManifestFileName)
+	data, err := os.ReadFile(manifestPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read %s: %w", ManifestFileName, err)
+	}
+
+	var manifest engine.AppManifest
+	if err := json.Unmarshal(data, &manifest); err != nil {
+		return nil, fmt.Errorf("failed to parse %s: %w", ManifestFileName, err)
+	}
+
+	return &manifest, nil
+}
+
+func ReadAppSecretBindings(appId string) (map[string]string, error) {
+	if err := ValidateAppId(appId); err != nil {
+		return nil, fmt.Errorf("invalid appId: %w", err)
+	}
+
+	appDir, err := GetAppDir(appId)
+	if err != nil {
+		return nil, err
+	}
+
+	bindingsPath := filepath.Join(appDir, SecretBindingsFileName)
+	data, err := os.ReadFile(bindingsPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return make(map[string]string), nil
+		}
+		return nil, fmt.Errorf("failed to read %s: %w", SecretBindingsFileName, err)
+	}
+
+	var bindings map[string]string
+	if err := json.Unmarshal(data, &bindings); err != nil {
+		return nil, fmt.Errorf("failed to parse %s: %w", SecretBindingsFileName, err)
+	}
+
+	if bindings == nil {
+		bindings = make(map[string]string)
+	}
+
+	return bindings, nil
+}
+
+func WriteAppSecretBindings(appId string, bindings map[string]string) error {
+	if err := ValidateAppId(appId); err != nil {
+		return fmt.Errorf("invalid appId: %w", err)
+	}
+
+	appDir, err := GetAppDir(appId)
+	if err != nil {
+		return err
+	}
+
+	if bindings == nil {
+		bindings = make(map[string]string)
+	}
+
+	data, err := json.MarshalIndent(bindings, "", "  ")
+	if err != nil {
+		return fmt.Errorf("failed to marshal bindings: %w", err)
+	}
+
+	bindingsPath := filepath.Join(appDir, SecretBindingsFileName)
+	if err := os.WriteFile(bindingsPath, data, 0644); err != nil {
+		return fmt.Errorf("failed to write %s: %w", SecretBindingsFileName, err)
+	}
+
+	return nil
+}
+
+func BuildAppSecretEnv(appId string, manifest *engine.AppManifest, bindings map[string]string) (map[string]string, error) {
+	if manifest == nil {
+		return make(map[string]string), nil
+	}
+
+	if bindings == nil {
+		bindings = make(map[string]string)
+	}
+
+	secretEnv := make(map[string]string)
+
+	for secretName, secretMeta := range manifest.Secrets {
+		boundSecretName, hasBinding := bindings[secretName]
+
+		if !secretMeta.Optional && !hasBinding {
+			return nil, fmt.Errorf("required secret %q is not bound", secretName)
+		}
+
+		if !hasBinding {
+			continue
+		}
+
+		secretValue, exists, err := secretstore.GetSecret(boundSecretName)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get secret %q: %w", boundSecretName, err)
+		}
+
+		if !exists {
+			if !secretMeta.Optional {
+				return nil, fmt.Errorf("required secret %q is bound to %q which does not exist in secret store", secretName, boundSecretName)
+			}
+			continue
+		}
+
+		secretEnv[secretName] = secretValue
+	}
+
+	return secretEnv, nil
+}
diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go
index 64fcc7643b..27b93a95e0 100644
--- a/pkg/waveobj/metaconsts.go
+++ b/pkg/waveobj/metaconsts.go
@@ -127,6 +127,7 @@ const (
 	MetaKey_TsunamiClear                     = "tsunami:*"
 	MetaKey_TsunamiSdkReplacePath            = "tsunami:sdkreplacepath"
 	MetaKey_TsunamiAppPath                   = "tsunami:apppath"
+	MetaKey_TsunamiAppId                     = "tsunami:appid"
 	MetaKey_TsunamiScaffoldPath              = "tsunami:scaffoldpath"
 	MetaKey_TsunamiEnv                       = "tsunami:env"
 
diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go
index 99a168c126..cf45dd61da 100644
--- a/pkg/waveobj/wtypemeta.go
+++ b/pkg/waveobj/wtypemeta.go
@@ -131,6 +131,7 @@ type MetaTSType struct {
 	TsunamiClear          bool              `json:"tsunami:*,omitempty"`
 	TsunamiSdkReplacePath string            `json:"tsunami:sdkreplacepath,omitempty"`
 	TsunamiAppPath        string            `json:"tsunami:apppath,omitempty"`
+	TsunamiAppId          string            `json:"tsunami:appid,omitempty"`
 	TsunamiScaffoldPath   string            `json:"tsunami:scaffoldpath,omitempty"`
 	TsunamiEnv            map[string]string `json:"tsunami:env,omitempty"`
 
diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go
index c92a33055c..79daaa5ba8 100644
--- a/pkg/wshrpc/wshclient/wshclient.go
+++ b/pkg/wshrpc/wshclient/wshclient.go
@@ -464,6 +464,12 @@ func PathCommand(w *wshutil.WshRpc, data wshrpc.PathCommandData, opts *wshrpc.Rp
 	return resp, err
 }
 
+// command "publishapp", wshserver.PublishAppCommand
+func PublishAppCommand(w *wshutil.WshRpc, data wshrpc.CommandPublishAppData, opts *wshrpc.RpcOpts) (*wshrpc.CommandPublishAppRtnData, error) {
+	resp, err := sendRpcRequestCallHelper[*wshrpc.CommandPublishAppRtnData](w, "publishapp", data, opts)
+	return resp, err
+}
+
 // command "readappfile", wshserver.ReadAppFileCommand
 func ReadAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandReadAppFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandReadAppFileRtnData, error) {
 	resp, err := sendRpcRequestCallHelper[*wshrpc.CommandReadAppFileRtnData](w, "readappfile", data, opts)
@@ -743,6 +749,12 @@ func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData,
 	return err
 }
 
+// command "writeappsecretbindings", wshserver.WriteAppSecretBindingsCommand
+func WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppSecretBindingsData, opts *wshrpc.RpcOpts) error {
+	_, err := sendRpcRequestCallHelper[any](w, "writeappsecretbindings", data, opts)
+	return err
+}
+
 // command "writetempfile", wshserver.WriteTempFileCommand
 func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) {
 	resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts)
diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go
index e7d2e46653..bb2a3708f6 100644
--- a/pkg/wshrpc/wshrpctypes.go
+++ b/pkg/wshrpc/wshrpctypes.go
@@ -157,18 +157,20 @@ const (
 	Command_TermGetScrollbackLines = "termgetscrollbacklines"
 
 	// builder
-	Command_ListAllEditableApps   = "listalleditableapps"
-	Command_ListAllAppFiles       = "listallappfiles"
-	Command_ReadAppFile           = "readappfile"
-	Command_WriteAppFile          = "writeappfile"
-	Command_DeleteAppFile         = "deleteappfile"
-	Command_RenameAppFile         = "renameappfile"
-	Command_DeleteBuilder         = "deletebuilder"
-	Command_StartBuilder          = "startbuilder"
-	Command_RestartBuilderAndWait = "restartbuilderandwait"
-	Command_GetBuilderStatus      = "getbuilderstatus"
-	Command_GetBuilderOutput      = "getbuilderoutput"
-	Command_CheckGoVersion        = "checkgoversion"
+	Command_ListAllEditableApps    = "listalleditableapps"
+	Command_ListAllAppFiles        = "listallappfiles"
+	Command_ReadAppFile            = "readappfile"
+	Command_WriteAppFile           = "writeappfile"
+	Command_DeleteAppFile          = "deleteappfile"
+	Command_RenameAppFile          = "renameappfile"
+	Command_WriteAppSecretBindings = "writeappsecretbindings"
+	Command_DeleteBuilder          = "deletebuilder"
+	Command_StartBuilder           = "startbuilder"
+	Command_RestartBuilderAndWait  = "restartbuilderandwait"
+	Command_GetBuilderStatus       = "getbuilderstatus"
+	Command_GetBuilderOutput       = "getbuilderoutput"
+	Command_CheckGoVersion         = "checkgoversion"
+	Command_PublishApp             = "publishapp"
 
 	// electron
 	Command_ElectronEncrypt = "electronencrypt"
@@ -333,12 +335,14 @@ type WshRpcInterface interface {
 	WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error
 	DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error
 	RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error
+	WriteAppSecretBindingsCommand(ctx context.Context, data CommandWriteAppSecretBindingsData) error
 	DeleteBuilderCommand(ctx context.Context, builderId string) error
 	StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error
 	RestartBuilderAndWaitCommand(ctx context.Context, data CommandRestartBuilderAndWaitData) (*RestartBuilderAndWaitResult, error)
 	GetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error)
 	GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error)
 	CheckGoVersionCommand(ctx context.Context) (*CommandCheckGoVersionRtnData, error)
+	PublishAppCommand(ctx context.Context, data CommandPublishAppData) (*CommandPublishAppRtnData, error)
 
 	// proc
 	VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate]
@@ -1015,6 +1019,11 @@ type CommandRenameAppFileData struct {
 	ToFileName   string `json:"tofilename"`
 }
 
+type CommandWriteAppSecretBindingsData struct {
+	AppId    string            `json:"appid"`
+	Bindings map[string]string `json:"bindings"`
+}
+
 type CommandStartBuilderData struct {
 	BuilderId string `json:"builderid"`
 }
@@ -1029,12 +1038,28 @@ type RestartBuilderAndWaitResult struct {
 	BuildOutput  string `json:"buildoutput"`
 }
 
+type SecretMeta struct {
+	Desc     string `json:"desc"`
+	Optional bool   `json:"optional"`
+}
+
+type AppManifest struct {
+	AppTitle     string                `json:"apptitle"`
+	AppShortDesc string                `json:"appshortdesc"`
+	ConfigSchema map[string]any        `json:"configschema"`
+	DataSchema   map[string]any        `json:"dataschema"`
+	Secrets      map[string]SecretMeta `json:"secrets"`
+}
+
 type BuilderStatusData struct {
-	Status   string `json:"status"`
-	Port     int    `json:"port,omitempty"`
-	ExitCode int    `json:"exitcode,omitempty"`
-	ErrorMsg string `json:"errormsg,omitempty"`
-	Version  int    `json:"version"`
+	Status                 string            `json:"status"`
+	Port                   int               `json:"port,omitempty"`
+	ExitCode               int               `json:"exitcode,omitempty"`
+	ErrorMsg               string            `json:"errormsg,omitempty"`
+	Version                int               `json:"version"`
+	Manifest               *AppManifest      `json:"manifest,omitempty"`
+	SecretBindings         map[string]string `json:"secretbindings,omitempty"`
+	SecretBindingsComplete bool              `json:"secretbindingscomplete"`
 }
 
 type CommandCheckGoVersionRtnData struct {
@@ -1044,6 +1069,14 @@ type CommandCheckGoVersionRtnData struct {
 	ErrorString string `json:"errorstring,omitempty"`
 }
 
+type CommandPublishAppData struct {
+	AppId string `json:"appid"`
+}
+
+type CommandPublishAppRtnData struct {
+	PublishedAppId string `json:"publishedappid"`
+}
+
 type CommandElectronEncryptData struct {
 	PlainText string `json:"plaintext"`
 }
diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go
index d493948e7e..41019fe2e1 100644
--- a/pkg/wshrpc/wshserver/wshserver.go
+++ b/pkg/wshrpc/wshserver/wshserver.go
@@ -1027,6 +1027,13 @@ func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.Comma
 	return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName)
 }
 
+func (ws *WshServer) WriteAppSecretBindingsCommand(ctx context.Context, data wshrpc.CommandWriteAppSecretBindingsData) error {
+	if data.AppId == "" {
+		return fmt.Errorf("must provide an appId to WriteAppSecretBindingsCommand")
+	}
+	return waveappstore.WriteAppSecretBindings(data.AppId, data.Bindings)
+}
+
 func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error {
 	if builderId == "" {
 		return fmt.Errorf("must provide a builderId to DeleteBuilderCommand")
@@ -1079,20 +1086,13 @@ func (ws *WshServer) RestartBuilderAndWaitCommand(ctx context.Context, data wshr
 	}, nil
 }
 
-
 func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) {
 	if builderId == "" {
 		return nil, fmt.Errorf("must provide a builderId to GetBuilderStatusCommand")
 	}
 	bc := buildercontroller.GetOrCreateController(builderId)
 	status := bc.GetStatus()
-	return &wshrpc.BuilderStatusData{
-		Status:   status.Status,
-		Port:     status.Port,
-		ExitCode: status.ExitCode,
-		ErrorMsg: status.ErrorMsg,
-		Version:  status.Version,
-	}, nil
+	return &status, nil
 }
 
 func (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) {
@@ -1118,6 +1118,16 @@ func (ws *WshServer) CheckGoVersionCommand(ctx context.Context) (*wshrpc.Command
 	}, nil
 }
 
+func (ws *WshServer) PublishAppCommand(ctx context.Context, data wshrpc.CommandPublishAppData) (*wshrpc.CommandPublishAppRtnData, error) {
+	publishedAppId, err := waveappstore.PublishDraft(data.AppId)
+	if err != nil {
+		return nil, fmt.Errorf("error publishing app: %w", err)
+	}
+	return &wshrpc.CommandPublishAppRtnData{
+		PublishedAppId: publishedAppId,
+	}, nil
+}
+
 func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error {
 	err := telemetry.RecordTEvent(ctx, &data)
 	if err != nil {
diff --git a/tsunami/build/build.go b/tsunami/build/build.go
index 402f922028..7d01b75c29 100644
--- a/tsunami/build/build.go
+++ b/tsunami/build/build.go
@@ -662,7 +662,13 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) {
 	}
 
 	// Build the Go application
-	if err := runGoBuild(tempDir, opts); err != nil {
+	outputPath, err := runGoBuild(tempDir, opts)
+	if err != nil {
+		return buildEnv, err
+	}
+
+	// Generate manifest
+	if err := generateManifest(tempDir, outputPath, opts); err != nil {
 		return buildEnv, err
 	}
 
@@ -688,7 +694,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture)
 		return fmt.Errorf("failed to copy go.mod back: %w", err)
 	}
 	if verbose {
-		oc.Printf("Moved go.mod back to %s", goModDest)
+		oc.Printf("[debug] Moved go.mod back to %s", goModDest)
 	}
 
 	// Move go.sum back to original directory (only if it exists)
@@ -699,7 +705,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture)
 			return fmt.Errorf("failed to copy go.sum back: %w", err)
 		}
 		if verbose {
-			oc.Printf("Moved go.sum back to %s", goSumDest)
+			oc.Printf("[debug] Moved go.sum back to %s", goSumDest)
 		}
 	}
 
@@ -709,7 +715,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture)
 		return fmt.Errorf("failed to create static directory: %w", err)
 	}
 	if verbose {
-		oc.Printf("Ensured static directory exists at %s", staticDir)
+		oc.Printf("[debug] Ensured static directory exists at %s", staticDir)
 	}
 
 	// Move tw.css back to original directory
@@ -719,37 +725,52 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture)
 		return fmt.Errorf("failed to copy tw.css back: %w", err)
 	}
 	if verbose {
-		oc.Printf("Moved tw.css back to %s", twCssDest)
+		oc.Printf("[debug] Moved tw.css back to %s", twCssDest)
+	}
+
+	// Move manifest.json back to original directory (only if it exists)
+	manifestSrc := filepath.Join(tempDir, "manifest.json")
+	if _, err := os.Stat(manifestSrc); err == nil {
+		manifestDest := filepath.Join(originalDir, "manifest.json")
+		if err := copyFile(manifestSrc, manifestDest); err != nil {
+			return fmt.Errorf("failed to copy manifest.json back: %w", err)
+		}
+		if verbose {
+			oc.Printf("[debug] Moved manifest.json back to %s", manifestDest)
+		}
 	}
 
 	return nil
 }
 
-func runGoBuild(tempDir string, opts BuildOpts) error {
+func runGoBuild(tempDir string, opts BuildOpts) (string, error) {
 	oc := opts.OutputCapture
 	var outputPath string
+	var absOutputPath string
 	if opts.OutputFile != "" {
 		// Convert to absolute path resolved against current working directory
 		var err error
-		outputPath, err = filepath.Abs(opts.OutputFile)
+		absOutputPath, err = filepath.Abs(opts.OutputFile)
 		if err != nil {
-			return fmt.Errorf("failed to resolve output path: %w", err)
+			return "", fmt.Errorf("failed to resolve output path: %w", err)
 		}
+		outputPath = absOutputPath
 	} else {
 		binDir := filepath.Join(tempDir, "bin")
 		if err := os.MkdirAll(binDir, 0755); err != nil {
-			return fmt.Errorf("failed to create bin directory: %w", err)
+			return "", fmt.Errorf("failed to create bin directory: %w", err)
 		}
 		outputPath = "bin/app"
+		absOutputPath = filepath.Join(tempDir, "bin", "app")
 	}
 
 	goFiles, err := listGoFilesInDir(tempDir)
 	if err != nil {
-		return fmt.Errorf("failed to list go files: %w", err)
+		return "", fmt.Errorf("failed to list go files: %w", err)
 	}
 
 	if len(goFiles) == 0 {
-		return fmt.Errorf("no .go files found in %s", tempDir)
+		return "", fmt.Errorf("no .go files found in %s", tempDir)
 	}
 
 	// Build command with explicit go files
@@ -770,18 +791,44 @@ func runGoBuild(tempDir string, opts BuildOpts) error {
 	}
 
 	if err := buildCmd.Run(); err != nil {
-		return fmt.Errorf("compilation failed (see output for errors)")
+		return "", fmt.Errorf("compilation failed (see output for errors)")
 	}
 	if oc != nil {
 		oc.Flush()
 	}
 
 	if opts.Verbose {
-		if opts.OutputFile != "" {
-			oc.Printf("Application built successfully at %s", outputPath)
-		} else {
-			oc.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app"))
-		}
+		oc.Printf("Application built successfully")
+		oc.Printf("[debug] Output path: %s", absOutputPath)
+	}
+
+	return absOutputPath, nil
+}
+
+func generateManifest(tempDir, exePath string, opts BuildOpts) error {
+	oc := opts.OutputCapture
+
+	manifestCmd := exec.Command(exePath, "--manifest")
+	manifestCmd.Dir = tempDir
+
+	if opts.Verbose {
+		oc.Printf("[debug] Running: %s --manifest", exePath)
+		oc.Printf("Generating manifest...")
+	}
+
+	manifestOutput, err := manifestCmd.Output()
+	if err != nil {
+		return fmt.Errorf("manifest generation failed: %w", err)
+	}
+
+	manifestPath := filepath.Join(tempDir, "manifest.json")
+	if err := os.WriteFile(manifestPath, manifestOutput, 0644); err != nil {
+		return fmt.Errorf("failed to write manifest.json: %w", err)
+	}
+
+	if opts.Verbose {
+		oc.Printf("Manifest generated successfully")
+		oc.Printf("[debug] Manifest path: %s", manifestPath)
 	}
 
 	return nil