diff --git a/seed/project-templates/simple-app-template/AGENTS.md b/seed/project-templates/simple-app-template/AGENTS.md
new file mode 100644
index 00000000..9e1f66bb
--- /dev/null
+++ b/seed/project-templates/simple-app-template/AGENTS.md
@@ -0,0 +1,65 @@
+# AGENTS.md — Simple App Template
+
+## Project Structure
+
+```
+src/
+├── index.ts # Bun server entry (routes, HMR)
+├── index.html # HTML entry point
+├── frontend.tsx # React root mount
+├── index.css # Tailwind + global styles
+├── App.tsx # Main React component
+└── components/ # UI components
+build.ts # Build script (bun run build → dist/)
+.officerdev/
+└── meta.json # Project metadata
+```
+
+## Tech Stack
+
+- **Runtime**: Bun
+- **Language**: TypeScript (strict)
+- **Framework**: React 19
+- **Styling**: Tailwind CSS v4
+- **UI**: shadcn/ui components (Radix UI primitives)
+- **Icons**: lucide-react
+
+## Build System
+
+- `bun dev` — starts dev server with HMR
+- `bun run build` — produces `dist/` via `build.ts`
+- Build uses `bun-plugin-tailwind` for CSS processing
+- Output: minified JS/CSS bundles + HTML in `dist/`
+
+## Publishing
+
+This project can be published as a standalone app in officer.dev.
+
+**Version**: Set in `package.json` → `version` field. Bump before republishing.
+
+**Icon**: When publishing, choose from these available lucide icon names:
+Activity, Airplay, Archive, BarChart3, Bell, Blocks, BookOpen, Box, BrainCircuit,
+Calendar, Camera, ChartPie, CircleDot, Cloud, Code, Compass, CreditCard, Database,
+FileText, Folder, Gamepad2, Globe, Heart, Home, Image, Inbox, Layers, Layout,
+LineChart, Link, List, Mail, Map, MessageCircle, Monitor, Music, Palette, PenTool,
+Play, Puzzle, Radio, Rocket, Search, Settings, ShoppingCart, Star, Sun, Table,
+Terminal, Timer, Users, Wand2, Zap
+
+## Iframe Constraints
+
+Published apps run inside an iframe in officer.dev workspace panels:
+
+- **Auth token injection**: The iframe URL includes a `?token=` param. A script is injected into the HTML `
` that automatically adds `Authorization: Bearer ` to all fetch/XHR requests.
+- **Path rewriting**: All absolute paths (`/api/...`, `/assets/...`) are rewritten to go through the serve proxy at `/api/app-serve/{slug}/`. Relative paths work as-is.
+- **No direct DOM access**: The app cannot access the parent frame.
+- **API access**: Use `fetch('/api/...')` — the injected script rewrites paths and adds auth headers automatically.
+
+## Coding Conventions
+
+- Functional components, arrow functions
+- No default exports — use named exports
+- Strict TypeScript, no `any`
+- Semicolons, single quotes (JS), double quotes (JSX)
+- 2-space indentation
+- Prefer composition over inheritance
+- Keep components small and focused
diff --git a/src/servers/api/_shared/html-rewrite.ts b/src/servers/api/_shared/html-rewrite.ts
new file mode 100644
index 00000000..9ad2a3ce
--- /dev/null
+++ b/src/servers/api/_shared/html-rewrite.ts
@@ -0,0 +1,46 @@
+// Injected into proxied HTML to intercept fetch/XHR/WebSocket so absolute paths
+// go through the proxy instead of hitting the host server directly.
+// Also injects JWT auth headers from localStorage for authenticated requests.
+export const proxyOverrideScript = (base: string) =>
+ ``;
+
+// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override
+export const rewriteHtml = (html: string, base: string) =>
+ html
+ .replace(/]*)>/i, `${proxyOverrideScript(base)}`)
+ .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`)
+ .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`)
+ .replace(/url\(\//g, `url(${base}/`);
+
+// JS/CSS: only rewrite /_bun/ asset paths (Bun dev server specific)
+export const rewriteAssetPaths = (text: string, base: string) =>
+ text.replace(/\/_bun\//g, `${base}/_bun/`);
diff --git a/src/servers/api/apps/index.ts b/src/servers/api/apps/index.ts
new file mode 100644
index 00000000..71e82238
--- /dev/null
+++ b/src/servers/api/apps/index.ts
@@ -0,0 +1,2 @@
+export { appsRouter, appServeRouter } from './router';
+export type { AppManifest } from './router';
diff --git a/src/servers/api/apps/router.ts b/src/servers/api/apps/router.ts
new file mode 100644
index 00000000..8bb6772e
--- /dev/null
+++ b/src/servers/api/apps/router.ts
@@ -0,0 +1,298 @@
+import type { Context } from 'hono';
+import { join } from 'node:path';
+import { existsSync } from 'node:fs';
+import { readdir, cp, rm, mkdir } from 'node:fs/promises';
+import { createRouter } from '@@/create-router';
+import type { HonoVariables } from '@@/create-router';
+import { getUserAppsDir, getUserProjectsDir } from '@@/data-path';
+import * as errors from '@@/custom-errors';
+import { verify } from '@@/jwt';
+import { isTokenBlacklisted } from 'officerdb';
+import { rewriteHtml, rewriteAssetPaths } from '../_shared/html-rewrite';
+
+export type AppManifest = {
+ slug: string;
+ name: string;
+ version: string;
+ icon: string;
+ description: string;
+ sourceProject: string;
+ commitHash: string;
+ publishedAt: string;
+ buildDir: string;
+};
+
+// ── Protected routes (inside protectedRouter) ──
+
+export const appsRouter = createRouter();
+
+appsRouter.get('/', async (ctx) => {
+ const user = ctx.get('user');
+ const appsDir = getUserAppsDir(user.email);
+
+ if (!existsSync(appsDir)) return ctx.json({ apps: [] });
+
+ const entries = await readdir(appsDir, { withFileTypes: true });
+ const apps: AppManifest[] = [];
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const manifestPath = join(appsDir, entry.name, 'manifest.json');
+ if (!existsSync(manifestPath)) continue;
+ try {
+ const manifest = JSON.parse(await Bun.file(manifestPath).text()) as AppManifest;
+ apps.push(manifest);
+ } catch {
+ // skip malformed manifests
+ }
+ }
+
+ return ctx.json({ apps });
+});
+
+appsRouter.post('/publish', async (ctx) => {
+ const user = ctx.get('user');
+ const body = ctx.get('body') as { projectSlug: string; name?: string; icon?: string; description?: string };
+ const { projectSlug, name, icon, description } = body;
+
+ if (!projectSlug) throw errors.BAD_REQUEST('Missing projectSlug');
+
+ const projectDir = join(getUserProjectsDir(user.email), projectSlug);
+ if (!existsSync(projectDir)) throw errors.BAD_REQUEST(`Project not found: ${projectSlug}`);
+
+ const pkgPath = join(projectDir, 'package.json');
+ if (!existsSync(pkgPath)) throw errors.BAD_REQUEST('No package.json found in project');
+
+ let pkgJson: { name?: string; version?: string; scripts?: Record };
+ try {
+ pkgJson = JSON.parse(await Bun.file(pkgPath).text());
+ } catch {
+ throw errors.BAD_REQUEST('Failed to read package.json');
+ }
+
+ if (!pkgJson.scripts?.build) throw errors.BAD_REQUEST('No "build" script in package.json');
+
+ const version = pkgJson.version ?? '0.1.0';
+ const appSlug = projectSlug;
+ const appName = name ?? pkgJson.name ?? projectSlug;
+
+ // Read .officerdev/meta.json for fallback description
+ let metaDescription = '';
+ const metaPath = join(projectDir, '.officerdev', 'meta.json');
+ if (existsSync(metaPath)) {
+ try {
+ const meta = JSON.parse(await Bun.file(metaPath).text());
+ metaDescription = meta.description ?? '';
+ } catch {
+ // ignore
+ }
+ }
+
+ // Get git commit hash
+ let commitHash = 'unknown';
+ try {
+ const proc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { cwd: projectDir, stdout: 'pipe', stderr: 'pipe' });
+ await proc.exited;
+ if (proc.exitCode === 0) {
+ commitHash = (await new Response(proc.stdout).text()).trim();
+ }
+ } catch {
+ // no git — that's fine
+ }
+
+ // Run build
+ const buildProc = Bun.spawn(['bun', 'run', 'build'], {
+ cwd: projectDir,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: { ...process.env, NODE_ENV: 'production' },
+ });
+
+ const buildTimeout = setTimeout(() => buildProc.kill(), 60_000);
+ const exitCode = await buildProc.exited;
+ clearTimeout(buildTimeout);
+
+ if (exitCode !== 0) {
+ const stderr = await new Response(buildProc.stderr).text();
+ throw errors.BAD_REQUEST(`Build failed (exit ${exitCode}):\n${stderr.slice(-500)}`);
+ }
+
+ // Verify dist output
+ const distDir = join(projectDir, 'dist');
+ if (!existsSync(distDir)) throw errors.BAD_REQUEST('Build did not produce a dist/ directory');
+
+ const distFiles = await readdir(distDir);
+ const hasHtml = distFiles.some((f) => f.endsWith('.html'));
+ if (!hasHtml) throw errors.BAD_REQUEST('Build output has no HTML files');
+
+ // Copy to versioned directory
+ const appsDir = getUserAppsDir(user.email);
+ const appDir = join(appsDir, appSlug);
+ const versionDir = join(appDir, 'v', version);
+
+ await mkdir(versionDir, { recursive: true });
+ await cp(distDir, versionDir, { recursive: true });
+
+ // Write manifest
+ const manifest: AppManifest = {
+ slug: appSlug,
+ name: appName,
+ version,
+ icon: icon ?? 'Box',
+ description: description ?? metaDescription,
+ sourceProject: projectSlug,
+ commitHash,
+ publishedAt: new Date().toISOString(),
+ buildDir: `v/${version}`,
+ };
+
+ await Bun.write(join(appDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
+
+ return ctx.json(manifest);
+});
+
+appsRouter.delete('/:slug', async (ctx) => {
+ const user = ctx.get('user');
+ const slug = ctx.req.param('slug');
+ if (!slug) throw errors.BAD_REQUEST('Missing slug');
+
+ const appDir = join(getUserAppsDir(user.email), slug);
+ if (!existsSync(appDir)) throw errors.NOT_FOUND('App not found');
+
+ await rm(appDir, { recursive: true, force: true });
+ return ctx.json({ ok: true });
+});
+
+// ── Static serving (outside protectedRouter) ──
+
+export const appServeRouter = createRouter();
+
+const MIME_TYPES: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.mjs': 'application/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.ico': 'image/x-icon',
+ '.webp': 'image/webp',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.eot': 'application/vnd.ms-fontobject',
+ '.map': 'application/json',
+};
+
+const getMimeType = (filePath: string): string => {
+ const ext = filePath.slice(filePath.lastIndexOf('.'));
+ return MIME_TYPES[ext] ?? 'application/octet-stream';
+};
+
+type ValidateResult = { email: string } | null;
+
+async function validateToken(req: Request): Promise {
+ const authHeader = req.headers.get('authorization');
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : new URL(req.url).searchParams.get('token');
+ if (!token) return null;
+ try {
+ const payload = await verify(token);
+ if (!payload?.email) return null;
+ if (payload.jti && (await isTokenBlacklisted(payload.jti))) return null;
+ return { email: payload.email };
+ } catch {
+ return null;
+ }
+}
+
+appServeRouter.all('/:slug/*', async (ctx) => {
+ const slug = ctx.req.param('slug');
+ if (!slug) return ctx.text('Not found', 404);
+
+ // Validate auth — HTML requests require token, assets check referer
+ const pathname = new URL(ctx.req.url).pathname;
+ const isHtml =
+ ctx.req.header('accept')?.includes('text/html') ||
+ pathname.endsWith('.html') ||
+ !pathname.slice(pathname.lastIndexOf('/') + 1).includes('.');
+
+ const validated = await validateToken(ctx.req.raw);
+
+ if (!validated) {
+ // For non-HTML assets, try referer-based resolution
+ if (!isHtml) {
+ const referer = ctx.req.header('referer');
+ if (referer) {
+ const match = referer.match(/\/api\/app-serve\/([^/?]+)/);
+ if (match) {
+ // Extract token from referer URL if present
+ try {
+ const refUrl = new URL(referer);
+ const refToken = refUrl.searchParams.get('token');
+ if (refToken) {
+ const payload = await verify(refToken);
+ if (payload?.email) {
+ if (!payload.jti || !(await isTokenBlacklisted(payload.jti))) {
+ return serveFile(ctx, payload.email, slug);
+ }
+ }
+ }
+ } catch {
+ // fall through
+ }
+ }
+ }
+ }
+ return ctx.text('Unauthorized', 401);
+ }
+
+ return serveFile(ctx, validated.email, slug);
+});
+
+async function serveFile(ctx: Context<{ Variables: HonoVariables }>, email: string, slug: string) {
+ const appDir = join(getUserAppsDir(email), slug);
+ const manifestPath = join(appDir, 'manifest.json');
+
+ if (!existsSync(manifestPath)) return ctx.text('App not found', 404);
+
+ let manifest: AppManifest;
+ try {
+ manifest = JSON.parse(await Bun.file(manifestPath).text());
+ } catch {
+ return ctx.text('Invalid manifest', 500);
+ }
+
+ const buildDir = join(appDir, manifest.buildDir);
+ const prefix = `/api/app-serve/${slug}`;
+
+ const url = new URL(ctx.req.url);
+ let filePath = url.pathname.replace(prefix, '') || '/';
+ if (filePath === '/') filePath = '/index.html';
+
+ const fullPath = join(buildDir, filePath);
+
+ // Security: prevent directory traversal
+ if (!fullPath.startsWith(buildDir)) return ctx.text('Forbidden', 403);
+
+ // If file doesn't exist, serve index.html for SPA routing
+ const targetPath = existsSync(fullPath) ? fullPath : join(buildDir, 'index.html');
+ if (!existsSync(targetPath)) return ctx.text('Not found', 404);
+
+ const file = Bun.file(targetPath);
+ const mimeType = getMimeType(targetPath);
+ const needsRewrite =
+ mimeType.includes('text/html') || mimeType.includes('javascript') || mimeType.includes('text/css');
+
+ if (needsRewrite) {
+ const content = await file.text();
+ const rewritten = mimeType.includes('text/html')
+ ? rewriteHtml(content, prefix)
+ : rewriteAssetPaths(content, prefix);
+ return new Response(rewritten, { headers: { 'content-type': mimeType } });
+ }
+
+ return new Response(file, { headers: { 'content-type': mimeType } });
+}
diff --git a/src/servers/api/dev-server/router.ts b/src/servers/api/dev-server/router.ts
index 8e565cfe..ad276fcd 100644
--- a/src/servers/api/dev-server/router.ts
+++ b/src/servers/api/dev-server/router.ts
@@ -8,6 +8,7 @@ import { CustomError } from '@@/custom-errors';
import * as errors from '@@/custom-errors';
import { verify } from '@@/jwt';
import { isTokenBlacklisted } from 'officerdb';
+import { rewriteHtml, rewriteAssetPaths } from '../_shared/html-rewrite';
export const devServerRouter = createRouter();
export const devServerProxyRouter = createRouter();
@@ -311,49 +312,3 @@ devServerProxyRouter.all('/:proxyId/*', async (ctx) => {
return ctx.text('No dev server running', 404);
});
-// Injected into proxied HTML to intercept fetch/XHR/WebSocket so absolute paths
-// go through the proxy instead of hitting the host server directly.
-// Also injects JWT auth headers from localStorage for authenticated requests.
-const proxyOverrideScript = (base: string) =>
- ``;
-
-// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override
-const rewriteHtml = (html: string, base: string) =>
- html
- .replace(/]*)>/i, `${proxyOverrideScript(base)}`)
- .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`)
- .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`)
- .replace(/url\(\//g, `url(${base}/`);
-
-// JS/CSS: only rewrite /_bun/ asset paths (Bun dev server specific)
-const rewriteAssetPaths = (text: string, base: string) =>
- text.replace(/\/_bun\//g, `${base}/_bun/`);
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index 6b527e39..97cfc6ff 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -92,3 +92,5 @@ export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode'
export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');
+
+export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps');
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 5d008e6b..69b7c86d 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -26,6 +26,7 @@ import { queueRouter } from './api/queue/queue';
import { emailRouter } from './api/email/email';
import { channelsRouter } from './channels/routes';
import { browserRouter } from './api/browser/router';
+import { appsRouter, appServeRouter } from './api/apps';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
@@ -51,6 +52,7 @@ honoServer.route('/api/auth', authRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter);
honoServer.route('/api/waitlist', waitlistRouter);
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
+honoServer.route('/api/app-serve', appServeRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
honoServer.get('/api/server-settings/onboarding-complete', async (ctx) => {
const { readServerSettings } = await import('officerdb');
@@ -83,6 +85,7 @@ protectedRouter.route('/queue', queueRouter);
protectedRouter.route('/email', emailRouter);
protectedRouter.route('/channels', channelsRouter);
protectedRouter.route('/browser', browserRouter);
+protectedRouter.route('/apps', appsRouter);
protectedRouter.route('/', piRestRouter);
honoServer.route('/api', protectedRouter);
diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
index 0e754a74..34ef31c1 100644
--- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
+++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useRef } from 'react';
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
@@ -9,10 +10,34 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as previewMetas } from '../apps/Preview';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { useAppRegistry } from './useAppRegistry';
+import { useUserApps } from 'state/useUserApps';
+import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
+import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
+import { resolveIcon } from '../utils/resolve-icon';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
export const AppRegistry = () => {
- useAppRegistry(apps);
+ const { registerApp } = useAppRegistry(apps);
+ const { apps: userApps, email } = useUserApps();
+ const registeredRef = useRef(new Set());
+
+ useEffect(() => {
+ if (!email || userApps.length === 0) return;
+
+ for (const app of userApps) {
+ const key = `${email}/${app.slug}`;
+ if (registeredRef.current.has(key)) continue;
+ registeredRef.current.add(key);
+
+ registerApp(key, {
+ name: app.name,
+ icon: resolveIcon(app.icon),
+ component: createUserAppPanel(app.slug),
+ header: createUserAppHeader(app.name, resolveIcon(app.icon)),
+ });
+ }
+ }, [userApps, email, registerApp]);
+
return null;
};
diff --git a/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx b/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx
index fc74548a..00873c72 100644
--- a/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx
+++ b/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
-import { FolderKanban, Plus, Pencil, Trash2, Search } from 'lucide-react';
+import { FolderKanban, Plus, Pencil, Trash2, Search, Rocket } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug';
import { useWorkspacesState } from 'state/useWorkspacesState';
import type { ProjectDefinition, ProjectType } from '../../components/Workspace';
+import { PublishDialog } from './PublishDialog';
import {
AlertDialog,
AlertDialogAction,
@@ -53,6 +54,7 @@ export const ProjectListApp = () => {
const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState(null);
+ const [publishing, setPublishing] = useState(null);
const isProjectsPage = location.pathname === '/projects';
const filtered = search
? projects.filter((p) => {
@@ -81,6 +83,11 @@ export const ProjectListApp = () => {
setDeleting(p);
};
+ const handlePublish = (ev: React.MouseEvent, p: ProjectDefinition) => {
+ ev.stopPropagation();
+ setPublishing(p);
+ };
+
const confirmDelete = () => {
if (!deleting) return;
// Optimistic: remove from cache immediately
@@ -166,6 +173,14 @@ export const ProjectListApp = () => {
{PROJECT_TYPE_LABELS[p.projectType]}
{isProjectsPage && (
<>
+