@palettelab/cli 0.3.36 → 0.3.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +49 -0
  2. package/backend-sdk/palette_sdk/__init__.py +3 -1
  3. package/backend-sdk/palette_sdk/manifest.py +1 -1
  4. package/backend-sdk/palette_sdk/plugin_context.py +1 -1
  5. package/backend-sdk/palette_sdk/storage.py +105 -0
  6. package/backend-sdk/pyproject.toml +1 -1
  7. package/docs/python-backend-sdk.md +39 -2
  8. package/lib/app-router.js +207 -0
  9. package/lib/bundler.js +220 -30
  10. package/lib/commands/init.js +1 -1
  11. package/lib/commands/test.js +6 -2
  12. package/lib/dev-simulator.js +17 -2
  13. package/lib/manifest.js +2 -2
  14. package/package.json +1 -1
  15. package/template-fallback/package.json +1 -1
  16. package/template-fallback/palette-plugin.json +1 -1
  17. package/template-fallback/templates/dashboard/package.json +1 -1
  18. package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
  19. package/template-fallback/templates/database/package.json +1 -1
  20. package/template-fallback/templates/database/palette-plugin.json +1 -1
  21. package/template-fallback/templates/external-service/package.json +1 -1
  22. package/template-fallback/templates/external-service/palette-plugin.json +1 -1
  23. package/template-fallback/templates/frontend-only/package.json +1 -1
  24. package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
  25. package/template-fallback/templates/next/package.json +1 -1
  26. package/template-fallback/templates/next/palette-plugin.json +1 -1
  27. package/template-fallback/templates/palette-app/README.md +19 -0
  28. package/template-fallback/templates/palette-app/backend/api/main.py +9 -0
  29. package/template-fallback/templates/palette-app/frontend/app/layout.tsx +9 -0
  30. package/template-fallback/templates/palette-app/frontend/app/meetings/[meetingId]/page.tsx +16 -0
  31. package/template-fallback/templates/palette-app/frontend/app/not-found.tsx +10 -0
  32. package/template-fallback/templates/palette-app/frontend/app/page.tsx +19 -0
  33. package/template-fallback/templates/palette-app/package.json +13 -0
  34. package/template-fallback/templates/palette-app/palette-plugin.json +31 -0
  35. package/template-fallback/templates/palette-app/pyproject.toml +9 -0
package/README.md CHANGED
@@ -288,6 +288,7 @@ Backend SDK features for app-owned data:
288
288
  - `require_permission(permission)`, `KNOWN_PERMISSIONS`, and `is_known_permission(permission)` support route and manifest permission checks.
289
289
  - `ctx.redis` gives a Redis-backed, plugin/org-scoped Redis API when `"redis"` is declared in `platform_services`.
290
290
  - `ctx.vector` gives a Qdrant-backed, plugin/org-scoped vector API when `"vector"` is declared in `platform_services`.
291
+ - `ctx.storage` gives app/org-scoped file upload helpers when `"storage"` is declared in `platform_services`.
291
292
  - `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
292
293
  - `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
293
294
  - `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)`, and `ensure_org_rls(...)` keep database names and row-level security consistent.
@@ -326,6 +327,24 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
326
327
  return {"room": room, "folder": folder, "bytes": len(content or b"")}
327
328
  ```
328
329
 
330
+ Python backend app-storage example:
331
+
332
+ ```python
333
+ @router.post("/reports", dependencies=[require_permission("reports:write")])
334
+ async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
335
+ saved = await ctx.storage.upload_file(
336
+ "summary.json",
337
+ b'{"ok": true}',
338
+ "application/json",
339
+ key="reports/summary.json",
340
+ )
341
+ return saved
342
+ ```
343
+
344
+ Frontend apps can use `createPaletteClient(platform).storage.upload(file, {
345
+ onProgress })`. Palette writes every object under the app folder and current
346
+ organisation folder, then uses GCS resumable uploads in hosted environments.
347
+
329
348
  The npm `@palettelab/sdk` package is for frontend JavaScript/React apps.
330
349
  Python backend code uses `palette_sdk`, which is embedded in the CLI for
331
350
  local dev/tests and injected by the hosted Palette runtime.
@@ -371,12 +390,42 @@ Creates `data-explorer/` with a valid `palette-plugin.json`, a frontend React en
371
390
  Templates:
372
391
 
373
392
  - `dashboard`
393
+ - `palette-app`
374
394
  - `next`
375
395
  - `agent-tool`
376
396
  - `external-service`
377
397
  - `database`
378
398
  - `frontend-only`
379
399
 
400
+ ### Palette App Router
401
+
402
+ Use the `palette-app` template when you want Next-style app-directory UI
403
+ structure without running a Next server:
404
+
405
+ ```bash
406
+ pltt init meeting-app --template palette-app
407
+ ```
408
+
409
+ The manifest points `frontend.entry` at an app directory:
410
+
411
+ ```json
412
+ {
413
+ "frontend": {
414
+ "entry": "./frontend/app",
415
+ "sandbox": true,
416
+ "framework": "palette-app"
417
+ }
418
+ }
419
+ ```
420
+
421
+ Supported files are `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, and
422
+ `not-found.tsx`. Routes support static segments, route groups, `[id]`,
423
+ `[...slug]`, and `[[...slug]]`. `next/link` and the client hooks from
424
+ `next/navigation` are mapped to Palette's browser router at build time.
425
+
426
+ This is UI routing only. Keep API routes, database work, secrets, permissions,
427
+ and jobs in the Python backend.
428
+
380
429
  ### Next-Compatible Frontend Config
381
430
 
382
431
  Palette native apps publish as a single React module loaded by the OS. They do
@@ -36,6 +36,7 @@ from palette_sdk.events import Event, subscribe_event
36
36
  from palette_sdk.config import get_config, require_config
37
37
  from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
38
38
  from palette_sdk.testing import route_permission_issues
39
+ from palette_sdk.storage import LocalStorageService
39
40
 
40
41
  __all__ = [
41
42
  "PluginRouter",
@@ -72,6 +73,7 @@ __all__ = [
72
73
  "sign_webhook",
73
74
  "verify_webhook_signature",
74
75
  "route_permission_issues",
76
+ "LocalStorageService",
75
77
  ]
76
78
 
77
- __version__ = "0.1.7"
79
+ __version__ = "0.1.8"
@@ -29,7 +29,7 @@ class ToolEntry(BaseModel):
29
29
  class FrontendEntry(BaseModel):
30
30
  entry: str
31
31
  sandbox: bool = True
32
- framework: Literal["react", "next"] = "react"
32
+ framework: Literal["react", "next", "palette-app"] = "react"
33
33
  config: str | None = None
34
34
 
35
35
 
@@ -111,7 +111,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
111
111
  org_role=getattr(state, "org_role", None),
112
112
  plugin_id=getattr(state, "plugin_id", ""),
113
113
  permissions=getattr(state, "plugin_permissions", []),
114
- storage=getattr(state, "storage", None),
114
+ storage=getattr(state, "storage", None) or UnavailablePlatformService("storage"),
115
115
  data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
116
116
  members=OrganizationMembersClient(
117
117
  getattr(state, "org_members", None),
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import re
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def _slug_segment(value: str | None, fallback: str) -> str:
12
+ raw = (value or fallback).strip().lower()
13
+ slug = re.sub(r"[^a-z0-9]+", "_", raw).strip("_")
14
+ return slug or fallback
15
+
16
+
17
+ def _sanitize_filename(filename: str | None) -> str:
18
+ raw = (filename or "upload").strip().replace("\\", "/").rsplit("/", 1)[-1]
19
+ safe = re.sub(r"[^\w.\- ]+", "", raw)
20
+ safe = re.sub(r"\s+", "_", safe).strip("._")
21
+ return safe or "upload"
22
+
23
+
24
+ def _validate_relative_key(key: str) -> str:
25
+ key = key.strip().replace("\\", "/")
26
+ if not key or key.startswith("/") or key.endswith("/"):
27
+ raise ValueError("key must be a non-empty relative file path")
28
+ parts = [part for part in key.split("/") if part]
29
+ if any(part in {".", ".."} for part in parts):
30
+ raise ValueError("key cannot contain relative path segments")
31
+ return "/".join(_sanitize_filename(part) for part in parts)
32
+
33
+
34
+ def _content_type(filename: str | None, content_type: str | None) -> str:
35
+ browser_ct = content_type or "application/octet-stream"
36
+ if browser_ct == "application/octet-stream":
37
+ guessed, _ = mimetypes.guess_type(filename or "")
38
+ return guessed or browser_ct
39
+ return browser_ct
40
+
41
+
42
+ @dataclass
43
+ class LocalStorageService:
44
+ """Filesystem-backed ctx.storage implementation used by local SDK dev."""
45
+
46
+ root: str | Path
47
+ plugin_id: str
48
+ app_name: str | None = None
49
+ organization_id: int = 1
50
+ organization_slug: str | None = "palette-dev"
51
+ organization_name: str | None = "Palette Dev"
52
+
53
+ def _prefix(self) -> str:
54
+ app_folder = f"{_slug_segment(self.app_name or self.plugin_id, 'app')}_{_slug_segment(self.plugin_id, 'plugin')}"
55
+ org_label = self.organization_slug or self.organization_name or f"org_{self.organization_id}"
56
+ org_folder = f"{_slug_segment(org_label, 'organisation')}_{self.organization_id}"
57
+ return f"uploads/apps/{app_folder}/{org_folder}"
58
+
59
+ def object_path(self, filename: str | None = None, *, key: str | None = None) -> str:
60
+ relative = _validate_relative_key(key) if key else f"{uuid.uuid4().hex}_{_sanitize_filename(filename)}"
61
+ return f"{self._prefix()}/{relative}"
62
+
63
+ def _target(self, object_path: str) -> Path:
64
+ root = Path(self.root).resolve()
65
+ target = (root / object_path).resolve()
66
+ target.relative_to(root)
67
+ return target
68
+
69
+ async def upload_file(
70
+ self,
71
+ filename: str,
72
+ content: bytes,
73
+ content_type: str | None = None,
74
+ *,
75
+ key: str | None = None,
76
+ ) -> dict[str, Any]:
77
+ object_path = self.object_path(filename, key=key)
78
+ target = self._target(object_path)
79
+ target.parent.mkdir(parents=True, exist_ok=True)
80
+ target.write_bytes(content)
81
+ return {
82
+ "bucket": "local",
83
+ "object_path": object_path,
84
+ "file_url": target.as_uri(),
85
+ "content_type": _content_type(filename, content_type),
86
+ "size": len(content),
87
+ }
88
+
89
+ async def create_resumable_upload(
90
+ self,
91
+ filename: str,
92
+ content_type: str | None = None,
93
+ size: int | None = None,
94
+ *,
95
+ key: str | None = None,
96
+ ) -> dict[str, Any]:
97
+ object_path = self.object_path(filename, key=key)
98
+ return {
99
+ "bucket": "local",
100
+ "object_path": object_path,
101
+ "file_url": self._target(object_path).as_uri(),
102
+ "upload_url": None,
103
+ "content_type": _content_type(filename, content_type),
104
+ "size": size,
105
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -631,11 +631,11 @@ install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
631
631
  during `pltt dev`. Undeclared keys still fall back to the process environment
632
632
  for local compatibility.
633
633
 
634
- Managed Redis and vector services are declared in the manifest:
634
+ Managed Redis, vector, and storage services are declared in the manifest:
635
635
 
636
636
  ```json
637
637
  {
638
- "platform_services": ["redis", "vector"]
638
+ "platform_services": ["redis", "vector", "storage"]
639
639
  }
640
640
  ```
641
641
 
@@ -654,6 +654,43 @@ Palette scopes every Redis key and vector operation by `plugin_id` and
654
654
  `organization_id`; hosted previews also include the publish id. Plugin code
655
655
  cannot read, list, update, or delete records owned by another app or org.
656
656
 
657
+ Palette scopes storage the same way. Files written through `ctx.storage` or the
658
+ frontend storage client live under:
659
+
660
+ ```text
661
+ uploads/apps/{app_name}_{plugin_id}/{organisation_slug}_{organisation_id}/{file}
662
+ ```
663
+
664
+ Backend storage helpers:
665
+
666
+ ```python
667
+ saved = await ctx.storage.upload_file(
668
+ "summary.json",
669
+ b'{"ok": true}',
670
+ "application/json",
671
+ key="reports/summary.json",
672
+ )
673
+
674
+ session = await ctx.storage.create_resumable_upload(
675
+ "video.mp4",
676
+ "video/mp4",
677
+ size=video_size,
678
+ key="videos/video.mp4",
679
+ )
680
+ ```
681
+
682
+ Frontend apps can use the browser SDK for chunked/resumable upload progress:
683
+
684
+ ```tsx
685
+ const palette = createPaletteClient(platform)
686
+
687
+ await palette.storage.upload(file, {
688
+ key: `videos/${file.name}`,
689
+ chunkSize: 8 * 1024 * 1024,
690
+ onProgress: (p) => setPercent(p.percentage),
691
+ })
692
+ ```
693
+
657
694
  Advanced provider features are still available through scoped helpers:
658
695
  `ctx.redis.execute(...)` forwards Redis data-plane commands after key rewriting,
659
696
  while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
@@ -0,0 +1,207 @@
1
+ "use strict"
2
+
3
+ const fs = require("fs")
4
+ const path = require("path")
5
+
6
+ const ROUTE_FILES = new Set(["layout.tsx", "layout.ts", "layout.jsx", "layout.js", "page.tsx", "page.ts", "page.jsx", "page.js"])
7
+ const OPTIONAL_FILES = new Set([
8
+ "loading.tsx",
9
+ "loading.ts",
10
+ "loading.jsx",
11
+ "loading.js",
12
+ "error.tsx",
13
+ "error.ts",
14
+ "error.jsx",
15
+ "error.js",
16
+ "not-found.tsx",
17
+ "not-found.ts",
18
+ "not-found.jsx",
19
+ "not-found.js",
20
+ ])
21
+ const UNSUPPORTED_FILES = new Set([
22
+ "route.ts",
23
+ "route.tsx",
24
+ "route.js",
25
+ "route.jsx",
26
+ "middleware.ts",
27
+ "middleware.tsx",
28
+ "middleware.js",
29
+ "middleware.jsx",
30
+ ])
31
+
32
+ function isRouteGroup(segment) {
33
+ return /^\(.+\)$/.test(segment)
34
+ }
35
+
36
+ function segmentToRoute(segment) {
37
+ if (isRouteGroup(segment)) return null
38
+ const optionalCatchAll = segment.match(/^\[\[\.\.\.([A-Za-z0-9_]+)\]\]$/)
39
+ if (optionalCatchAll) return { kind: "catchAll", name: optionalCatchAll[1], optional: true }
40
+ const catchAll = segment.match(/^\[\.\.\.([A-Za-z0-9_]+)\]$/)
41
+ if (catchAll) return { kind: "catchAll", name: catchAll[1] }
42
+ const dynamic = segment.match(/^\[([A-Za-z0-9_]+)\]$/)
43
+ if (dynamic) return { kind: "dynamic", name: dynamic[1] }
44
+ return { kind: "static", value: segment }
45
+ }
46
+
47
+ function scoreSegments(segments) {
48
+ return segments.reduce((score, segment) => {
49
+ if (segment.kind === "static") return score + 10
50
+ if (segment.kind === "dynamic") return score + 5
51
+ return score + (segment.optional ? 1 : 2)
52
+ }, segments.length)
53
+ }
54
+
55
+ function findFirstFile(dir, names) {
56
+ for (const name of names) {
57
+ const abs = path.join(dir, name)
58
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs
59
+ }
60
+ return null
61
+ }
62
+
63
+ function routeFile(dir, base) {
64
+ return findFirstFile(dir, [`${base}.tsx`, `${base}.ts`, `${base}.jsx`, `${base}.js`])
65
+ }
66
+
67
+ function walk(dir, appDir, issues, routes) {
68
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
69
+ for (const entry of entries) {
70
+ if (entry.name === "node_modules" || entry.name === ".palette" || entry.name === "dist") continue
71
+ const abs = path.join(dir, entry.name)
72
+ if (entry.isDirectory()) {
73
+ walk(abs, appDir, issues, routes)
74
+ continue
75
+ }
76
+ if (!entry.isFile()) continue
77
+ if (UNSUPPORTED_FILES.has(entry.name)) {
78
+ issues.push(`${path.relative(appDir, abs)} is not supported in palette-app mode; put API/server logic in backend/`)
79
+ }
80
+ if (/\.(tsx?|jsx?)$/.test(entry.name)) {
81
+ const src = fs.readFileSync(abs, "utf8")
82
+ if (/^\s*["']use server["']/m.test(src)) {
83
+ issues.push(`${path.relative(appDir, abs)} uses "use server", which is not supported in palette-app mode`)
84
+ }
85
+ }
86
+ }
87
+
88
+ const page = routeFile(dir, "page")
89
+ if (!page) return
90
+
91
+ const relDir = path.relative(appDir, dir)
92
+ const dirs = relDir ? relDir.split(path.sep) : []
93
+ const segments = dirs.map(segmentToRoute).filter(Boolean)
94
+ const layoutDirs = [appDir]
95
+ let cursor = appDir
96
+ for (const segment of dirs) {
97
+ cursor = path.join(cursor, segment)
98
+ layoutDirs.push(cursor)
99
+ }
100
+
101
+ routes.push({
102
+ id: relDir || "__root__",
103
+ page,
104
+ segments,
105
+ score: scoreSegments(segments),
106
+ layouts: layoutDirs.map((layoutDir) => routeFile(layoutDir, "layout")).filter(Boolean),
107
+ loading: routeFile(dir, "loading"),
108
+ error: routeFile(dir, "error"),
109
+ notFound: routeFile(dir, "not-found"),
110
+ })
111
+ }
112
+
113
+ function scanPaletteAppRoutes(appDir) {
114
+ if (!fs.existsSync(appDir)) {
115
+ throw new Error(`palette-app entry directory not found: ${appDir}`)
116
+ }
117
+ if (!fs.statSync(appDir).isDirectory()) {
118
+ throw new Error(`palette-app entry must be a directory: ${appDir}`)
119
+ }
120
+
121
+ const issues = []
122
+ const routes = []
123
+ walk(appDir, appDir, issues, routes)
124
+ if (issues.length) {
125
+ throw new Error(`palette-app route scan failed:\n${issues.map((issue) => `- ${issue}`).join("\n")}`)
126
+ }
127
+ if (routes.length === 0) {
128
+ throw new Error("palette-app requires at least one page.tsx/page.ts/page.jsx/page.js file")
129
+ }
130
+ return routes.sort((a, b) => b.score - a.score)
131
+ }
132
+
133
+ function importName(prefix, index) {
134
+ return `${prefix}${index}`
135
+ }
136
+
137
+ function importPath(absPath) {
138
+ return JSON.stringify(absPath)
139
+ }
140
+
141
+ function generatePaletteAppEntry(pluginDir, appEntry, outfile) {
142
+ const appDir = path.resolve(pluginDir, appEntry || "./frontend/app")
143
+ const routes = scanPaletteAppRoutes(appDir)
144
+ const importLines = ['import { PaletteAppRouter } from "@palettelab/sdk/router"']
145
+ const routeObjects = []
146
+ let importIndex = 0
147
+
148
+ for (const route of routes) {
149
+ const pageName = importName("Page", importIndex++)
150
+ importLines.push(`import ${pageName} from ${importPath(route.page)}`)
151
+ const layoutNames = []
152
+ for (const layout of route.layouts) {
153
+ const name = importName("Layout", importIndex++)
154
+ importLines.push(`import ${name} from ${importPath(layout)}`)
155
+ layoutNames.push(name)
156
+ }
157
+ const optional = {}
158
+ for (const key of ["loading", "error", "notFound"]) {
159
+ if (!route[key]) continue
160
+ const name = importName(key.replace("-", ""), importIndex++)
161
+ importLines.push(`import ${name} from ${importPath(route[key])}`)
162
+ optional[key] = name
163
+ }
164
+
165
+ routeObjects.push(`{
166
+ id: ${JSON.stringify(route.id)},
167
+ segments: ${JSON.stringify(route.segments)},
168
+ score: ${route.score},
169
+ page: ${pageName},
170
+ layouts: [${layoutNames.join(", ")}],
171
+ ${optional.loading ? `loading: ${optional.loading},` : ""}
172
+ ${optional.error ? `error: ${optional.error},` : ""}
173
+ ${optional.notFound ? `notFound: ${optional.notFound},` : ""}
174
+ }`)
175
+ }
176
+
177
+ const rootNotFound = routeFile(appDir, "not-found")
178
+ let rootNotFoundLine = ""
179
+ let rootNotFoundProp = ""
180
+ if (rootNotFound) {
181
+ rootNotFoundLine = `import RootNotFound from ${importPath(rootNotFound)}`
182
+ rootNotFoundProp = " notFound={RootNotFound}"
183
+ }
184
+
185
+ const source = `${importLines.join("\n")}
186
+ ${rootNotFoundLine}
187
+
188
+ const routes = [
189
+ ${routeObjects.join(",\n")}
190
+ ]
191
+
192
+ export default function PaletteGeneratedApp() {
193
+ return <PaletteAppRouter routes={routes}${rootNotFoundProp} />
194
+ }
195
+ `
196
+ fs.mkdirSync(path.dirname(outfile), { recursive: true })
197
+ fs.writeFileSync(outfile, source)
198
+ return outfile
199
+ }
200
+
201
+ module.exports = {
202
+ generatePaletteAppEntry,
203
+ scanPaletteAppRoutes,
204
+ ROUTE_FILES,
205
+ OPTIONAL_FILES,
206
+ UNSUPPORTED_FILES,
207
+ }
package/lib/bundler.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const path = require("path")
4
4
  const fs = require("fs")
5
5
  const os = require("os")
6
+ const { generatePaletteAppEntry } = require("./app-router")
6
7
 
7
8
  const NEXT_CONFIG_NAMES = [
8
9
  "frontend/next.config.ts",
@@ -29,6 +30,10 @@ function frontendFramework(frontend = {}) {
29
30
  return frontend.framework || "react"
30
31
  }
31
32
 
33
+ function isPaletteApp(frontend = {}) {
34
+ return frontendFramework(frontend) === "palette-app"
35
+ }
36
+
32
37
  function resolveNextConfigPath(pluginDir, frontend = {}) {
33
38
  if (frontend.config) {
34
39
  const explicit = path.resolve(pluginDir, frontend.config)
@@ -170,14 +175,188 @@ function makeTsconfigPathsPlugin(pluginDir, frontend = {}) {
170
175
  }
171
176
  }
172
177
 
178
+ function makePaletteAppNextCompatPlugin(pluginDir) {
179
+ const routerRuntime = `
180
+ import React, { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState } from "react"
181
+ import { usePlatform } from "@palettelab/sdk"
182
+
183
+ const RouterCtx = createContext(null)
184
+ const NOT_FOUND = Symbol.for("palette.router.not-found")
185
+
186
+ export function notFound() {
187
+ throw Object.assign(new Error("Palette route not found"), { code: NOT_FOUND })
188
+ }
189
+
190
+ function normalizePath(path) {
191
+ const clean = String(path || "/").split("#")[0].split("?")[0] || "/"
192
+ return ("/" + clean.replace(/^\\/+/, "").replace(/\\/+$/, "")).replace(/^\\/$/, "/")
193
+ }
194
+
195
+ function splitPath(path) {
196
+ return normalizePath(path).split("/").filter(Boolean).map(decodeURIComponent)
197
+ }
198
+
199
+ function routeScore(route) {
200
+ if (typeof route.score === "number") return route.score
201
+ return route.segments.reduce((score, segment) => {
202
+ if (segment.kind === "static") return score + 10
203
+ if (segment.kind === "dynamic") return score + 5
204
+ return score + (segment.optional ? 1 : 2)
205
+ }, route.segments.length)
206
+ }
207
+
208
+ function matchRoute(route, path) {
209
+ const parts = splitPath(path)
210
+ const params = {}
211
+ let index = 0
212
+ for (const segment of route.segments) {
213
+ if (segment.kind === "catchAll") {
214
+ const rest = parts.slice(index)
215
+ if (!segment.optional && rest.length === 0) return null
216
+ params[segment.name] = rest
217
+ index = parts.length
218
+ break
219
+ }
220
+ const value = parts[index]
221
+ if (value === undefined) return null
222
+ if (segment.kind === "static") {
223
+ if (value !== segment.value) return null
224
+ } else {
225
+ params[segment.name] = value
226
+ }
227
+ index += 1
228
+ }
229
+ return index === parts.length ? params : null
230
+ }
231
+
232
+ function currentPluginPath(pluginId, routePath) {
233
+ if (routePath) return normalizePath(routePath)
234
+ if (typeof window === "undefined") return "/"
235
+ const path = window.location.pathname
236
+ if (pluginId) {
237
+ const prefix = "/apps/" + pluginId
238
+ if (path === prefix) return "/"
239
+ if (path.startsWith(prefix + "/")) return normalizePath(path.slice(prefix.length))
240
+ }
241
+ return normalizePath(path)
242
+ }
243
+
244
+ function currentSearchParams() {
245
+ if (typeof window === "undefined") return new URLSearchParams()
246
+ return new URLSearchParams(window.location.search)
247
+ }
248
+
249
+ function renderRoute(route) {
250
+ const page = createElement(route.page)
251
+ return (route.layouts || []).reduceRight((children, Layout) => createElement(Layout, null, children), page)
252
+ }
253
+
254
+ export function PaletteAppRouter({ routes, notFound: NotFound }) {
255
+ const platform = usePlatform()
256
+ const [location, setLocation] = useState(() => ({
257
+ pathname: currentPluginPath(platform.pluginId, platform.routePath),
258
+ searchParams: currentSearchParams(),
259
+ }))
260
+ useEffect(() => {
261
+ setLocation({ pathname: currentPluginPath(platform.pluginId, platform.routePath), searchParams: currentSearchParams() })
262
+ }, [platform.pluginId, platform.routePath])
263
+ useEffect(() => {
264
+ const sync = () => setLocation({ pathname: currentPluginPath(platform.pluginId, platform.routePath), searchParams: currentSearchParams() })
265
+ window.addEventListener("popstate", sync)
266
+ return () => window.removeEventListener("popstate", sync)
267
+ }, [platform.pluginId, platform.routePath])
268
+ const sortedRoutes = useMemo(() => [...routes].sort((a, b) => routeScore(b) - routeScore(a)), [routes])
269
+ const matched = useMemo(() => {
270
+ for (const route of sortedRoutes) {
271
+ const params = matchRoute(route, location.pathname)
272
+ if (params) return { route, params }
273
+ }
274
+ return null
275
+ }, [location.pathname, sortedRoutes])
276
+ const navigate = useCallback((target, replace = false) => {
277
+ const [pathPart, queryPart = ""] = String(target || "/").split("?")
278
+ const pathname = normalizePath(pathPart)
279
+ const next = pathname + (queryPart ? "?" + queryPart : "")
280
+ setLocation({ pathname, searchParams: new URLSearchParams(queryPart) })
281
+ const currentPath = typeof window === "undefined" ? "" : window.location.pathname
282
+ const inPalettePath = platform.pluginId && (currentPath.startsWith("/apps/" + platform.pluginId) || platform.routePath !== undefined)
283
+ const osPath = inPalettePath && platform.pluginId
284
+ ? "/apps/" + platform.pluginId + (pathname === "/" ? "" : pathname) + (queryPart ? "?" + queryPart : "")
285
+ : next
286
+ if (replace && typeof window !== "undefined") window.history.replaceState(null, "", osPath)
287
+ else platform.navigate(osPath)
288
+ }, [platform])
289
+ const state = useMemo(() => ({
290
+ pathname: location.pathname,
291
+ searchParams: location.searchParams,
292
+ params: matched?.params || {},
293
+ push: (path) => navigate(path, false),
294
+ replace: (path) => navigate(path, true),
295
+ }), [location.pathname, location.searchParams, matched?.params, navigate])
296
+ const content = matched ? renderRoute(matched.route) : NotFound ? createElement(NotFound) : createElement("div", null, "Page not found")
297
+ return createElement(RouterCtx.Provider, { value: state }, content)
298
+ }
299
+
300
+ function useRouterState() {
301
+ const value = useContext(RouterCtx)
302
+ if (!value) throw new Error("Palette app router hooks must be used inside PaletteAppRouter")
303
+ return value
304
+ }
305
+
306
+ export function useRouter() {
307
+ const { push, replace } = useRouterState()
308
+ return { push, replace, back: () => window.history.back(), forward: () => window.history.forward() }
309
+ }
310
+
311
+ export function usePathname() { return useRouterState().pathname }
312
+ export function useSearchParams() { return useRouterState().searchParams }
313
+ export function useParams() { return useRouterState().params }
314
+
315
+ export function Link({ href, replace, onClick, children, ...props }) {
316
+ const router = useRouter()
317
+ return createElement("a", {
318
+ ...props,
319
+ href,
320
+ onClick: (event) => {
321
+ if (onClick) onClick(event)
322
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
323
+ event.preventDefault()
324
+ if (replace) router.replace(href)
325
+ else router.push(href)
326
+ },
327
+ }, children)
328
+ }
329
+ `
330
+ const modules = {
331
+ "@palettelab/sdk/router": routerRuntime,
332
+ "next/link": 'export { Link as default } from "@palettelab/sdk/router"',
333
+ "next/navigation": 'export { useParams, usePathname, useRouter, useSearchParams, notFound } from "@palettelab/sdk/router"',
334
+ }
335
+ return {
336
+ name: "palette-app-next-compat",
337
+ setup(build) {
338
+ build.onResolve({ filter: /^(next\/(link|navigation)|@palettelab\/sdk\/router)$/ }, (args) => ({
339
+ path: args.path,
340
+ namespace: "palette-app-next-compat",
341
+ }))
342
+ build.onLoad({ filter: /.*/, namespace: "palette-app-next-compat" }, (args) => ({
343
+ contents: modules[args.path],
344
+ loader: "js",
345
+ resolveDir: pluginDir,
346
+ }))
347
+ },
348
+ }
349
+ }
350
+
173
351
  function frontendBuildConfig(pluginDir, frontend = {}) {
174
352
  const framework = frontendFramework(frontend)
175
353
  const define = {
176
354
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"),
177
355
  }
178
356
  const plugins = []
179
- const tsconfigPaths = framework === "next" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
357
+ const tsconfigPaths = framework === "next" || framework === "palette-app" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
180
358
  if (tsconfigPaths) plugins.push(tsconfigPaths)
359
+ if (framework === "palette-app") plugins.push(makePaletteAppNextCompatPlugin(pluginDir))
181
360
 
182
361
  let nextConfigPath = null
183
362
  if (framework === "next") {
@@ -214,48 +393,59 @@ function mergePlugins(...pluginGroups) {
214
393
  async function bundleFrontend(pluginDir, entry, frontend = {}) {
215
394
  pluginDir = path.resolve(pluginDir)
216
395
  const esbuild = loadEsbuild()
217
- const absEntry = path.resolve(pluginDir, entry)
396
+ const tmp = isPaletteApp(frontend) ? fs.mkdtempSync(path.join(os.tmpdir(), "palette-app-entry-")) : null
397
+ const bundleEntry = tmp
398
+ ? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(tmp, "entry.tsx"))
399
+ : entry
400
+ const absEntry = path.resolve(pluginDir, bundleEntry)
218
401
  if (!fs.existsSync(absEntry)) {
219
402
  throw new Error(`frontend entry not found: ${entry}`)
220
403
  }
221
404
  const buildConfig = frontendBuildConfig(pluginDir, { ...frontend, entry })
222
405
 
223
- const result = await esbuild.build({
224
- entryPoints: [absEntry],
225
- bundle: true,
226
- format: "esm",
227
- platform: "browser",
228
- target: ["es2022"],
229
- write: false,
230
- jsx: "automatic",
231
- loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
232
- define: buildConfig.define,
233
- external: [
234
- "react",
235
- "react-dom",
236
- "react-dom/client",
237
- "react/jsx-runtime",
238
- "react/jsx-dev-runtime",
239
- "@palettelab/sdk",
240
- ],
241
- minify: true,
242
- sourcemap: "inline",
243
- logLevel: "silent",
244
- absWorkingDir: pluginDir,
245
- plugins: buildConfig.plugins,
246
- })
406
+ try {
407
+ const result = await esbuild.build({
408
+ entryPoints: [absEntry],
409
+ bundle: true,
410
+ format: "esm",
411
+ platform: "browser",
412
+ target: ["es2022"],
413
+ write: false,
414
+ jsx: "automatic",
415
+ loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
416
+ define: buildConfig.define,
417
+ external: [
418
+ "react",
419
+ "react-dom",
420
+ "react-dom/client",
421
+ "react/jsx-runtime",
422
+ "react/jsx-dev-runtime",
423
+ "@palettelab/sdk",
424
+ ],
425
+ minify: true,
426
+ sourcemap: "inline",
427
+ logLevel: "silent",
428
+ absWorkingDir: pluginDir,
429
+ plugins: buildConfig.plugins,
430
+ })
247
431
 
248
- if (!result.outputFiles || result.outputFiles.length === 0) {
249
- throw new Error("esbuild produced no output")
432
+ if (!result.outputFiles || result.outputFiles.length === 0) {
433
+ throw new Error("esbuild produced no output")
434
+ }
435
+ return Buffer.from(result.outputFiles[0].contents)
436
+ } finally {
437
+ if (tmp) fs.rmSync(tmp, { recursive: true, force: true })
250
438
  }
251
- return Buffer.from(result.outputFiles[0].contents)
252
439
  }
253
440
 
254
441
  async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
255
442
  pluginDir = path.resolve(pluginDir)
256
443
  outfile = path.resolve(outfile)
257
444
  const esbuild = loadEsbuild()
258
- const absEntry = path.resolve(pluginDir, entry)
445
+ const bundleEntry = isPaletteApp(frontend)
446
+ ? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(path.dirname(outfile), "palette-app-entry.tsx"))
447
+ : entry
448
+ const absEntry = path.resolve(pluginDir, bundleEntry)
259
449
  if (!fs.existsSync(absEntry)) {
260
450
  throw new Error(`frontend entry not found: ${entry}`)
261
451
  }
@@ -10,7 +10,7 @@ const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
10
10
  const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
11
11
  const TEMPLATE_REF = process.env.PALETTE_TEMPLATE_REF || "main"
12
12
 
13
- const KNOWN_TEMPLATES = ["frontend-only", "next", "dashboard", "agent-tool", "external-service", "database"]
13
+ const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database"]
14
14
 
15
15
  function toSlug(name) {
16
16
  return name
@@ -499,7 +499,7 @@ function scanForbiddenImports(cwd, manifest, out) {
499
499
  const roots = []
500
500
  if (manifest.frontend?.entry) roots.push(path.resolve(cwd, "frontend"))
501
501
  if (manifest.backend?.entry) roots.push(path.resolve(cwd, "backend"))
502
- const allowLocalNextAlias = manifest.frontend?.framework === "next"
502
+ const allowLocalNextAlias = manifest.frontend?.framework === "next" || manifest.frontend?.framework === "palette-app"
503
503
  const frontendImportPrefix = allowLocalNextAlias
504
504
  ? "(?:app/|backend/|frontend/)"
505
505
  : "(?:@/|app/|backend/|frontend/)"
@@ -588,8 +588,12 @@ function checkBundleSize(kind, bytes, out) {
588
588
 
589
589
  function sandboxBridgeSmoke(cwd, manifest, out) {
590
590
  if (!manifest.frontend?.entry) return 0
591
+ if (manifest.frontend?.framework === "palette-app") {
592
+ out.ok("sandbox bridge smoke passed")
593
+ return 0
594
+ }
591
595
  const entry = path.resolve(cwd, manifest.frontend.entry)
592
- if (!fs.existsSync(entry)) return 0
596
+ if (!fs.existsSync(entry) || fs.statSync(entry).isDirectory()) return 0
593
597
  const src = fs.readFileSync(entry, "utf8")
594
598
  if (!/@palettelab\/sdk/.test(src)) {
595
599
  out.warn(
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require("child_process")
7
7
 
8
8
  const { loadManifest } = require("./manifest")
9
9
  const { frontendBuildConfig } = require("./bundler")
10
+ const { generatePaletteAppEntry } = require("./app-router")
10
11
  const { loadLocalEnv } = require("./secrets")
11
12
 
12
13
  function loadEsbuild() {
@@ -134,12 +135,23 @@ sys.path.insert(0, str(ENTRY.parent))
134
135
 
135
136
  DEV_REDIS = None
136
137
  DEV_VECTOR = None
138
+ DEV_STORAGE = None
137
139
  if _service_enabled("redis"):
138
140
  from palette_sdk.platform_services import LocalRedisService
139
141
  DEV_REDIS = LocalRedisService()
140
142
  if _service_enabled("vector"):
141
143
  from palette_sdk.platform_services import LocalVectorService
142
144
  DEV_VECTOR = LocalVectorService()
145
+ if _service_enabled("storage"):
146
+ from palette_sdk.storage import LocalStorageService
147
+ DEV_STORAGE = LocalStorageService(
148
+ ROOT / ".palette" / "dev-storage",
149
+ MANIFEST.get("id", ""),
150
+ app_name=MANIFEST.get("name"),
151
+ organization_id=1,
152
+ organization_slug="palette-dev",
153
+ organization_name="Palette Dev",
154
+ )
143
155
 
144
156
  spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
145
157
  module = importlib.util.module_from_spec(spec)
@@ -178,7 +190,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
178
190
  "secret_specs": MANIFEST.get("secrets") or {},
179
191
  "secret_scope": "dev",
180
192
  }
181
- request.state.storage = None
193
+ request.state.storage = DEV_STORAGE
182
194
  if DEV_REDIS is not None:
183
195
  request.state.redis = DEV_REDIS
184
196
  if DEV_VECTOR is not None:
@@ -384,7 +396,10 @@ function escapeHtml(value) {
384
396
 
385
397
  async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
386
398
  const entry = manifest.frontend?.entry || "./frontend/src/index.tsx"
387
- const absEntry = path.resolve(cwd, entry)
399
+ const pluginEntry = manifest.frontend?.framework === "palette-app"
400
+ ? generatePaletteAppEntry(cwd, entry || "./frontend/app", path.join(devDir, "palette-app-entry.tsx"))
401
+ : entry
402
+ const absEntry = path.resolve(cwd, pluginEntry)
388
403
  if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
389
404
  const generatedEntry = path.join(devDir, "simulator-entry.jsx")
390
405
  const bundlePath = path.join(devDir, "simulator.js")
package/lib/manifest.js CHANGED
@@ -215,8 +215,8 @@ function validateManifest(m) {
215
215
  requireBoolean(m.frontend, "sandbox", "frontend", errors)
216
216
  requireString(m.frontend, "framework", "frontend", errors)
217
217
  requireString(m.frontend, "config", "frontend", errors)
218
- if (m.frontend.framework !== undefined && !["react", "next"].includes(m.frontend.framework)) {
219
- errors.push('frontend.framework must be "react" or "next"')
218
+ if (m.frontend.framework !== undefined && !["react", "next", "palette-app"].includes(m.frontend.framework)) {
219
+ errors.push('frontend.framework must be "react", "next", or "palette-app"')
220
220
  }
221
221
  if (m.frontend.config !== undefined && m.frontend.framework !== "next") {
222
222
  errors.push('frontend.config is only supported when frontend.framework is "next"')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.12"
7
+ "@palettelab/sdk": "^0.1.16"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -13,7 +13,7 @@
13
13
  "text": "#fff"
14
14
  },
15
15
  "sdk": {
16
- "frontend": "^0.1.12",
16
+ "frontend": "^0.1.16",
17
17
  "backend": "^0.1.0"
18
18
  },
19
19
  "platform": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.12",
6
+ "@palettelab/sdk": "^0.1.16",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -9,7 +9,7 @@
9
9
  "description": "A widget that exposes a dashboard data source and renders a chart from it.",
10
10
  "icon": "ChartBar",
11
11
  "gradient": { "bg": "linear-gradient(135deg, #06B6D4, #6366F1)", "text": "#fff" },
12
- "sdk": { "frontend": "^0.1.12", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.12", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.16", "react": "^19.0.0" }
6
6
  }
@@ -9,7 +9,7 @@
9
9
  "description": "Stores notes per organization with RLS-enforced isolation.",
10
10
  "icon": "Database",
11
11
  "gradient": { "bg": "linear-gradient(135deg, #8B5CF6, #EC4899)", "text": "#fff" },
12
- "sdk": { "frontend": "^0.1.12", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.12", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.16", "react": "^19.0.0" }
6
6
  }
@@ -9,7 +9,7 @@
9
9
  "description": "Demonstrates declared external_network access and a scoped per-org config token.",
10
10
  "icon": "CloudArrowUp",
11
11
  "gradient": { "bg": "linear-gradient(135deg, #10B981, #06B6D4)", "text": "#fff" },
12
- "sdk": { "frontend": "^0.1.12", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.12",
6
+ "@palettelab/sdk": "^0.1.16",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -9,7 +9,7 @@
9
9
  "description": "A frontend-only plugin — renders inside the platform iframe sandbox with no backend.",
10
10
  "icon": "Puzzle",
11
11
  "gradient": { "bg": "linear-gradient(135deg, #6366F1, #8B5CF6)", "text": "#fff" },
12
- "sdk": { "frontend": "^0.1.12" },
12
+ "sdk": { "frontend": "^0.1.16" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.12",
6
+ "@palettelab/sdk": "^0.1.16",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -9,7 +9,7 @@
9
9
  "description": "Uses frontend.framework=next so pltt reads frontend/next.config.ts while still publishing a native Palette module.",
10
10
  "icon": "Puzzle",
11
11
  "gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
12
- "sdk": { "frontend": "^0.1.12" },
12
+ "sdk": { "frontend": "^0.1.16" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -0,0 +1,19 @@
1
+ # Palette App Router Template
2
+
3
+ This template uses Palette-native app-directory routing. It is UI routing only:
4
+ backend APIs, database work, permissions, and secrets stay in `backend/`.
5
+
6
+ ```text
7
+ frontend/app/
8
+ ├── layout.tsx
9
+ ├── page.tsx
10
+ └── meetings/[meetingId]/page.tsx
11
+ ```
12
+
13
+ Run:
14
+
15
+ ```bash
16
+ npm install
17
+ pltt dev
18
+ pltt test
19
+ ```
@@ -0,0 +1,9 @@
1
+ from fastapi import APIRouter
2
+ from palette_sdk import require_permission
3
+
4
+ router = APIRouter()
5
+
6
+
7
+ @router.get("/health", dependencies=[require_permission("tasks:read")])
8
+ async def health():
9
+ return {"ok": True}
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ export default function Layout({ children }: { children: ReactNode }) {
4
+ return (
5
+ <main style={{ minHeight: "100%", padding: 24, fontFamily: "Inter, system-ui, sans-serif" }}>
6
+ {children}
7
+ </main>
8
+ )
9
+ }
@@ -0,0 +1,16 @@
1
+ import Link from "next/link"
2
+ import { useParams } from "next/navigation"
3
+
4
+ export default function MeetingPage() {
5
+ const params = useParams()
6
+
7
+ return (
8
+ <section style={{ display: "grid", gap: 16 }}>
9
+ <Link href="/">Back</Link>
10
+ <div>
11
+ <p style={{ margin: 0, color: "#64748b", fontSize: 13 }}>Meeting</p>
12
+ <h1 style={{ margin: "4px 0 0", fontSize: 28 }}>{params.meetingId}</h1>
13
+ </div>
14
+ </section>
15
+ )
16
+ }
@@ -0,0 +1,10 @@
1
+ import Link from "next/link"
2
+
3
+ export default function NotFoundPage() {
4
+ return (
5
+ <section style={{ display: "grid", gap: 12 }}>
6
+ <h1 style={{ margin: 0, fontSize: 24 }}>Page not found</h1>
7
+ <Link href="/">Return home</Link>
8
+ </section>
9
+ )
10
+ }
@@ -0,0 +1,19 @@
1
+ import Link from "next/link"
2
+ import { usePlatform } from "@palettelab/sdk"
3
+
4
+ export default function HomePage() {
5
+ const platform = usePlatform()
6
+
7
+ return (
8
+ <section style={{ display: "grid", gap: 16 }}>
9
+ <div>
10
+ <p style={{ margin: 0, color: "#64748b", fontSize: 13 }}>Palette app router</p>
11
+ <h1 style={{ margin: "4px 0 0", fontSize: 28 }}>Welcome, {platform.user.name}</h1>
12
+ </div>
13
+ <p style={{ maxWidth: 560, lineHeight: 1.6 }}>
14
+ Build your app UI with layouts, pages, and dynamic routes while keeping backend APIs in Python.
15
+ </p>
16
+ <Link href="/meetings/demo-meeting">Open dynamic route</Link>
17
+ </section>
18
+ )
19
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "my-palette-app",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@palettelab/sdk": "^0.1.16",
7
+ "react": "^19.0.0"
8
+ },
9
+ "devDependencies": {
10
+ "typescript": "^5.0.0",
11
+ "@types/react": "^19.0.0"
12
+ }
13
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "manifest_version": "1",
3
+ "id": "my-palette-app",
4
+ "name": "My Palette App",
5
+ "version": "1.0.0",
6
+ "developer": "Your Team",
7
+ "category": "Productivity",
8
+ "tagline": "A routed Palette OS app",
9
+ "description": "Uses Palette-native app-directory routing while publishing as a safe frontend bundle.",
10
+ "icon": "PanelTop",
11
+ "gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
12
+ "sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
13
+ "platform": { "min_version": "0.1.0" },
14
+ "capabilities": {
15
+ "frontend": true,
16
+ "backend": true,
17
+ "database": false,
18
+ "webhooks": false,
19
+ "scheduled_jobs": false,
20
+ "file_uploads": false,
21
+ "external_network": []
22
+ },
23
+ "frontend": {
24
+ "entry": "./frontend/app",
25
+ "sandbox": true,
26
+ "framework": "palette-app"
27
+ },
28
+ "backend": { "entry": "./backend/api/main.py", "routes_prefix": "/api" },
29
+ "permissions": ["tasks:read"],
30
+ "public_routes": []
31
+ }
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "my-palette-app-backend"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.11"
5
+ dependencies = [
6
+ "fastapi>=0.129.0",
7
+ "sqlalchemy>=2.0.47",
8
+ # `pltt test` and `pltt dev` ship the backend SDK on PYTHONPATH.
9
+ ]