@palettelab/cli 0.3.26 → 0.3.28

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 (27) hide show
  1. package/README.md +12 -2
  2. package/backend-sdk/palette_sdk/db/alembic_env.py +4 -2
  3. package/docs/python-backend-sdk.md +641 -0
  4. package/lib/bundler.js +38 -18
  5. package/lib/commands/build.js +4 -0
  6. package/lib/dev-simulator.js +30 -1
  7. package/package.json +2 -1
  8. package/template-fallback/frontend/src/index.tsx +35 -23
  9. package/template-fallback/frontend/src/translations.ts +36 -0
  10. package/template-fallback/package.json +1 -1
  11. package/template-fallback/palette-plugin.json +1 -1
  12. package/template-fallback/templates/dashboard/frontend/src/index.tsx +5 -3
  13. package/template-fallback/templates/dashboard/frontend/src/translations.ts +12 -0
  14. package/template-fallback/templates/dashboard/package.json +1 -1
  15. package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
  16. package/template-fallback/templates/database/frontend/src/index.tsx +13 -12
  17. package/template-fallback/templates/database/frontend/src/translations.ts +30 -0
  18. package/template-fallback/templates/database/package.json +1 -1
  19. package/template-fallback/templates/database/palette-plugin.json +1 -1
  20. package/template-fallback/templates/external-service/frontend/src/index.tsx +5 -3
  21. package/template-fallback/templates/external-service/frontend/src/translations.ts +12 -0
  22. package/template-fallback/templates/external-service/package.json +1 -1
  23. package/template-fallback/templates/external-service/palette-plugin.json +1 -1
  24. package/template-fallback/templates/frontend-only/frontend/src/index.tsx +6 -3
  25. package/template-fallback/templates/frontend-only/frontend/src/translations.ts +12 -0
  26. package/template-fallback/templates/frontend-only/package.json +1 -1
  27. package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
package/README.md CHANGED
@@ -195,6 +195,10 @@ internal platform parity checks with `pltt dev --platform`.
195
195
 
196
196
  ## App-Owned Data, Migrations, And Python Backends
197
197
 
198
+ For a detailed Python backend SDK guide with route examples, app-owned data,
199
+ migrations, Data Rooms, config, secrets, lifecycle hooks, and hosted sandbox
200
+ testing, see [Python Backend SDK Developer Guide](./docs/python-backend-sdk.md).
201
+
198
202
  Palette apps can own their own Python backend, database tables, migrations, and
199
203
  organization-scoped data. The generated database template uses this structure:
200
204
 
@@ -366,13 +370,19 @@ By default this starts:
366
370
 
367
371
  - A small local app shell on the first available port starting at http://localhost:3000
368
372
  - A local FastAPI backend runner on the first available port starting at http://localhost:8000
369
- - A mock Palette platform context for `usePlatform()`, toasts, org/user data, and authenticated API calls
373
+ - A mock Palette platform context for `usePlatform()`, OS language, toasts, org/user data, and authenticated API calls
370
374
  - A plugin-local SQLite database under `.palette/dev/` when `capabilities.database` or `database` is enabled
371
375
 
372
376
  Your frontend entry is bundled and watched. Your backend entry is loaded under
373
377
  `/api/v1/plugins/<your-id>/*` with a dev `PluginContext`, so normal SDK calls
374
378
  work without Docker or platform source.
375
379
 
380
+ The simulator also provides the same language fields that Palette OS provides:
381
+ `language`, `fallbackLanguage`, `supportedLanguages`, and `setLanguage()`.
382
+ Generated frontend templates include app-owned `frontend/src/translations.ts`
383
+ files wired through `usePluginTranslations()`, so apps can switch when the OS
384
+ language changes without storing copy in the platform.
385
+
376
386
  For Python apps with database tables, `ctx.db` is an async SQLAlchemy session in
377
387
  local dev. The simulator imports `backend/api/models.py` when present and creates
378
388
  tables from `palette_sdk.db.PluginBase.metadata`. Production installs still use
@@ -495,7 +505,7 @@ If no publish ID is provided, the CLI uses `.palette/last-publish.json` when ava
495
505
  Fetch or stream telemetry events for a plugin.
496
506
 
497
507
  ```bash
498
- pltt logs hello-sdk --env staging
508
+ pltt logs my-plugin --env staging
499
509
  pltt logs --tail 100
500
510
  pltt logs --follow
501
511
  pltt logs --json
@@ -11,8 +11,10 @@ invokes Alembic:
11
11
  - ``PALETTE_PLUGIN_ID`` — plugin id; determines the target schema
12
12
  (``app_<sanitized_id>``) and version table schema.
13
13
  - ``PALETTE_DB_URL`` — SQLAlchemy async URL for the plugin's DB.
14
- - ``PALETTE_PLUGIN_ROLE`` (optional) — role the migration should run as;
15
- defaults to the connecting user (so DDL works).
14
+ - ``PALETTE_PLUGIN_ROLE`` (optional) — schema-limited role the migration
15
+ should run as; the hosted platform sets this to
16
+ ``plugin_migrator_<sanitized_id>``. Local/manual
17
+ runs default to the connecting user.
16
18
 
17
19
  Plugin devs running ``alembic upgrade head`` locally without those vars get
18
20
  a helpful error rather than a scribbled-on main DB.
@@ -0,0 +1,641 @@
1
+ # Palette Python Backend SDK Developer Guide
2
+
3
+ This guide is for developers building Palette apps with a Python/FastAPI
4
+ backend. Frontend code uses the npm package `@palettelab/sdk`; Python backend
5
+ code uses `palette_sdk`, which is embedded in `@palettelab/cli` for local dev
6
+ and provided by the Palette runtime in hosted sandbox and production.
7
+
8
+ ## 1. Where The Backend Runs
9
+
10
+ Palette supports three backend development modes:
11
+
12
+ | Mode | Command | Uses real Palette OS services | Docker |
13
+ |---|---|---:|---:|
14
+ | Local SDK simulator | `pltt dev` | No | No |
15
+ | Hosted sandbox preview | `pltt dev --sandbox --env staging` | Yes | No |
16
+ | Internal local platform parity | `pltt dev --platform` | Local platform container | Yes |
17
+
18
+ Use `pltt dev` for the fastest loop while writing code. Use hosted sandbox
19
+ when the app must use real OS features such as login, organization context,
20
+ Data Rooms, storage, install state, review previews, logs, and platform APIs.
21
+
22
+ ## 2. Generated App Structure
23
+
24
+ A Python backend app normally looks like this:
25
+
26
+ ```text
27
+ my-app/
28
+ palette-plugin.json
29
+ frontend/
30
+ src/index.tsx
31
+ backend/
32
+ api/
33
+ main.py
34
+ models.py
35
+ migrations/
36
+ env.py
37
+ versions/
38
+ 001_init.py
39
+ ```
40
+
41
+ The backend entry is declared in `palette-plugin.json`:
42
+
43
+ ```json
44
+ {
45
+ "id": "finance-tools",
46
+ "name": "Finance Tools",
47
+ "frontend": { "entry": "./frontend/src/index.tsx" },
48
+ "backend": { "entry": "./backend/api/main.py" },
49
+ "permissions": [
50
+ "resources:read",
51
+ "resources:write",
52
+ "data_rooms:read",
53
+ "data_rooms:write"
54
+ ],
55
+ "capabilities": { "database": true },
56
+ "database": {
57
+ "schema": "app_finance_tools",
58
+ "migrations": "./backend/migrations"
59
+ }
60
+ }
61
+ ```
62
+
63
+ Declare only the permissions your app actually needs. Backend routes should
64
+ also gate access with `require_permission(...)`.
65
+
66
+ ## 3. Basic Backend Route
67
+
68
+ Every backend file exports a `PluginRouter`. Routes are mounted by the platform
69
+ under `/api/v1/plugins/<plugin-id>`.
70
+
71
+ ```python
72
+ from fastapi import Depends
73
+ from palette_sdk import PluginRouter, PluginContext, get_plugin_context
74
+
75
+ router = PluginRouter(tags=["finance-tools"])
76
+
77
+
78
+ @router.get("/me")
79
+ async def me(ctx: PluginContext = Depends(get_plugin_context)):
80
+ return {
81
+ "user_id": ctx.user_id,
82
+ "organization_id": ctx.organization_id,
83
+ "org_role": ctx.org_role,
84
+ "plugin_id": ctx.plugin_id,
85
+ }
86
+ ```
87
+
88
+ If the app id is `finance-tools`, this route is available at:
89
+
90
+ ```text
91
+ GET /api/v1/plugins/finance-tools/me
92
+ ```
93
+
94
+ ## 4. PluginContext Reference
95
+
96
+ `PluginContext` is injected into each route:
97
+
98
+ ```python
99
+ async def route(ctx: PluginContext = Depends(get_plugin_context)):
100
+ ...
101
+ ```
102
+
103
+ Available context values:
104
+
105
+ | Field/helper | Purpose |
106
+ |---|---|
107
+ | `ctx.db` | Async SQLAlchemy session for the app backend |
108
+ | `ctx.user_id` | Authenticated Palette user id |
109
+ | `ctx.organization_id` | Current organization id |
110
+ | `ctx.org_role` | User role in the current organization |
111
+ | `ctx.plugin_id` | Current app/plugin id |
112
+ | `ctx.permissions` | Permissions granted from `palette-plugin.json` |
113
+ | `ctx.storage` | Runtime storage service, when available |
114
+ | `ctx.data_rooms` | Backend Data Room client |
115
+ | `ctx.config` | App install/config values |
116
+ | `ctx.logger` | Runtime logger for backend code |
117
+ | `ctx.repo(Model)` | Org-safe CRUD helper for app-owned tables |
118
+ | `ctx.has_permission(permission)` | Check one declared permission |
119
+ | `ctx.has_any_permission([...])` | Check at least one permission |
120
+ | `ctx.has_all_permissions([...])` | Check every listed permission |
121
+ | `ctx.config_value(key, default)` | Read optional config |
122
+ | `ctx.require_config(key)` | Read required config or raise |
123
+ | `ctx.secret(key, default)` | Read a secret from app config or environment |
124
+
125
+ ## 5. Permissions
126
+
127
+ Use route-level permission guards for normal APIs:
128
+
129
+ ```python
130
+ from fastapi import Depends
131
+ from palette_sdk import (
132
+ PluginRouter,
133
+ PluginContext,
134
+ get_plugin_context,
135
+ require_permission,
136
+ )
137
+
138
+ router = PluginRouter(tags=["finance-tools"])
139
+
140
+
141
+ @router.get("/reports", dependencies=[require_permission("resources:read")])
142
+ async def reports(ctx: PluginContext = Depends(get_plugin_context)):
143
+ return {"items": []}
144
+ ```
145
+
146
+ Use inline checks only when the route supports multiple behavior paths:
147
+
148
+ ```python
149
+ @router.get("/dashboard", dependencies=[require_permission("resources:read")])
150
+ async def dashboard(ctx: PluginContext = Depends(get_plugin_context)):
151
+ can_write = ctx.has_permission("resources:write")
152
+ can_sync = ctx.has_all_permissions(["data_rooms:read", "data_rooms:write"])
153
+
154
+ return {
155
+ "can_write": can_write,
156
+ "can_sync": can_sync,
157
+ }
158
+ ```
159
+
160
+ Known permissions currently include:
161
+
162
+ ```text
163
+ tasks:read, tasks:write
164
+ data_rooms:read, data_rooms:write
165
+ agents:read, agents:write
166
+ chat:read, chat:write
167
+ routines:read, routines:write
168
+ members:read
169
+ resources:read, resources:write
170
+ ```
171
+
172
+ ## 6. App-Owned Data
173
+
174
+ Apps can ship their own tables and migrations. Use `OrgScopedTable` for data
175
+ that belongs to an organization.
176
+
177
+ `backend/api/models.py`:
178
+
179
+ ```python
180
+ from sqlalchemy import Numeric, String
181
+ from sqlalchemy.orm import Mapped, mapped_column
182
+ from palette_sdk import OrgScopedTable
183
+
184
+
185
+ class Invoice(OrgScopedTable):
186
+ __tablename__ = "invoices"
187
+
188
+ id: Mapped[int] = mapped_column(primary_key=True)
189
+ customer_name: Mapped[str] = mapped_column(String(255), nullable=False)
190
+ amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
191
+ status: Mapped[str] = mapped_column(String(32), default="draft")
192
+ ```
193
+
194
+ `OrgScopedTable` adds `organization_id` automatically. Do not manually set
195
+ `organization_id` in route code; `ctx.repo(Model)` handles it.
196
+
197
+ ### Migration Example
198
+
199
+ `backend/migrations/versions/001_init.py`:
200
+
201
+ ```python
202
+ from alembic import op
203
+ import sqlalchemy as sa
204
+ from palette_sdk.db import ensure_org_rls
205
+
206
+
207
+ revision = "001_init"
208
+ down_revision = None
209
+
210
+
211
+ def upgrade():
212
+ op.create_table(
213
+ "invoices",
214
+ sa.Column("id", sa.Integer(), primary_key=True),
215
+ sa.Column("organization_id", sa.BigInteger(), nullable=False),
216
+ sa.Column("customer_name", sa.String(length=255), nullable=False),
217
+ sa.Column("amount", sa.Numeric(12, 2), nullable=False),
218
+ sa.Column("status", sa.String(length=32), nullable=False),
219
+ )
220
+ op.create_index("ix_invoices_org", "invoices", ["organization_id"])
221
+ ensure_org_rls(op, "invoices")
222
+
223
+
224
+ def downgrade():
225
+ op.drop_table("invoices")
226
+ ```
227
+
228
+ `ensure_org_rls(op, "invoices")` enables row-level security so each
229
+ organization can only access its own rows.
230
+
231
+ ## 7. Repository CRUD
232
+
233
+ Use `ctx.repo(Model)` instead of writing repeated org filters.
234
+
235
+ ```python
236
+ from fastapi import Depends, HTTPException
237
+ from pydantic import BaseModel
238
+ from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
239
+ from models import Invoice
240
+
241
+ router = PluginRouter(tags=["finance-tools"])
242
+
243
+
244
+ class InvoiceIn(BaseModel):
245
+ customer_name: str
246
+ amount: float
247
+
248
+
249
+ @router.get("/invoices", dependencies=[require_permission("resources:read")])
250
+ async def list_invoices(ctx: PluginContext = Depends(get_plugin_context)):
251
+ rows = await ctx.repo(Invoice).list(order_by="-id", limit=100)
252
+ return [
253
+ {
254
+ "id": row.id,
255
+ "customer_name": row.customer_name,
256
+ "amount": float(row.amount),
257
+ "status": row.status,
258
+ }
259
+ for row in rows
260
+ ]
261
+
262
+
263
+ @router.post("/invoices", dependencies=[require_permission("resources:write")])
264
+ async def create_invoice(
265
+ body: InvoiceIn,
266
+ ctx: PluginContext = Depends(get_plugin_context),
267
+ ):
268
+ invoice = await ctx.repo(Invoice).create(
269
+ customer_name=body.customer_name,
270
+ amount=body.amount,
271
+ )
272
+ return {"id": invoice.id}
273
+
274
+
275
+ @router.patch("/invoices/{invoice_id}", dependencies=[require_permission("resources:write")])
276
+ async def update_invoice(
277
+ invoice_id: int,
278
+ body: InvoiceIn,
279
+ ctx: PluginContext = Depends(get_plugin_context),
280
+ ):
281
+ invoice = await ctx.repo(Invoice).update(invoice_id, **body.model_dump())
282
+ if invoice is None:
283
+ raise HTTPException(status_code=404, detail="invoice not found")
284
+ return {"id": invoice.id}
285
+
286
+
287
+ @router.delete("/invoices/{invoice_id}", dependencies=[require_permission("resources:write")])
288
+ async def delete_invoice(
289
+ invoice_id: int,
290
+ ctx: PluginContext = Depends(get_plugin_context),
291
+ ):
292
+ deleted = await ctx.repo(Invoice).delete(invoice_id)
293
+ if not deleted:
294
+ raise HTTPException(status_code=404, detail="invoice not found")
295
+ return {"deleted": True}
296
+ ```
297
+
298
+ Repository methods:
299
+
300
+ ```python
301
+ await ctx.repo(Model).list(filters=None, order_by=None, limit=100, offset=0)
302
+ await ctx.repo(Model).count(filters=None)
303
+ await ctx.repo(Model).get(id)
304
+ await ctx.repo(Model).require(id)
305
+ await ctx.repo(Model).create(**values)
306
+ await ctx.repo(Model).update(id, **values)
307
+ await ctx.repo(Model).delete(id)
308
+ ```
309
+
310
+ ## 8. Data Rooms From Python
311
+
312
+ Use `ctx.data_rooms` when backend code needs to create folders, find files, or
313
+ read/write documents in Palette Data Rooms.
314
+
315
+ ### Create Or Find A Room
316
+
317
+ ```python
318
+ @router.post("/setup-data-room", dependencies=[require_permission("data_rooms:write")])
319
+ async def setup_data_room(ctx: PluginContext = Depends(get_plugin_context)):
320
+ room = await ctx.data_rooms.ensure_room(
321
+ "Finance",
322
+ description="Finance documents used by the Finance Tools app",
323
+ )
324
+ return room
325
+ ```
326
+
327
+ ### Create Custom Folders By Name
328
+
329
+ ```python
330
+ @router.post("/setup-folders", dependencies=[require_permission("data_rooms:write")])
331
+ async def setup_folders(ctx: PluginContext = Depends(get_plugin_context)):
332
+ room = await ctx.data_rooms.ensure_room("Finance")
333
+
334
+ folder = await ctx.data_rooms.resolve_folder_path(
335
+ room["id"],
336
+ "Clients/Acme/Invoices/2026",
337
+ create=True,
338
+ )
339
+
340
+ return {
341
+ "room_id": room["id"],
342
+ "folder_id": folder["id"],
343
+ "folder_name": folder["name"],
344
+ }
345
+ ```
346
+
347
+ `resolve_folder_path(..., create=True)` creates missing folders in the path.
348
+ Pass a list instead of a string when folder names can contain `/`:
349
+
350
+ ```python
351
+ folder = await ctx.data_rooms.resolve_folder_path(
352
+ room["id"],
353
+ ["Clients", "Acme/International", "Invoices"],
354
+ create=True,
355
+ )
356
+ ```
357
+
358
+ ### Upload A File
359
+
360
+ ```python
361
+ @router.post("/write-summary", dependencies=[require_permission("data_rooms:write")])
362
+ async def write_summary(ctx: PluginContext = Depends(get_plugin_context)):
363
+ room = await ctx.data_rooms.ensure_room("Finance")
364
+ folder = await ctx.data_rooms.resolve_folder_path(
365
+ room["id"],
366
+ "Reports/Monthly",
367
+ create=True,
368
+ )
369
+
370
+ file = await ctx.data_rooms.upload_file(
371
+ room["id"],
372
+ "summary.txt",
373
+ b"Generated from Python backend",
374
+ folder_id=folder["id"],
375
+ content_type="text/plain",
376
+ )
377
+
378
+ return file
379
+ ```
380
+
381
+ ### Find And Read A File
382
+
383
+ ```python
384
+ @router.get("/read-summary", dependencies=[require_permission("data_rooms:read")])
385
+ async def read_summary(ctx: PluginContext = Depends(get_plugin_context)):
386
+ room = await ctx.data_rooms.require_room_by_name("Finance")
387
+ folder = await ctx.data_rooms.resolve_folder_path(room["id"], "Reports/Monthly")
388
+
389
+ if folder is None:
390
+ return {"found": False}
391
+
392
+ file = await ctx.data_rooms.find_file_by_name(
393
+ room["id"],
394
+ "summary.txt",
395
+ folder_id=folder["id"],
396
+ )
397
+
398
+ if file is None:
399
+ return {"found": False}
400
+
401
+ content = await ctx.data_rooms.read_file_bytes(file["id"])
402
+ return {
403
+ "found": True,
404
+ "text": content.decode("utf-8"),
405
+ }
406
+ ```
407
+
408
+ ### List Room Contents
409
+
410
+ ```python
411
+ @router.get("/room-contents", dependencies=[require_permission("data_rooms:read")])
412
+ async def room_contents(ctx: PluginContext = Depends(get_plugin_context)):
413
+ room = await ctx.data_rooms.require_room_by_name("Finance")
414
+ contents = await ctx.data_rooms.contents(room["id"])
415
+ return contents
416
+ ```
417
+
418
+ Available Data Room helpers:
419
+
420
+ ```python
421
+ await ctx.data_rooms.list()
422
+ await ctx.data_rooms.create(name, description=None)
423
+ await ctx.data_rooms.find_room_by_name(name, case_sensitive=False)
424
+ await ctx.data_rooms.require_room_by_name(name, case_sensitive=False)
425
+ await ctx.data_rooms.ensure_room(name, description=None)
426
+ await ctx.data_rooms.contents(room_id, folder_id=None)
427
+ await ctx.data_rooms.create_folder(room_id, name, parent_folder_id=None)
428
+ await ctx.data_rooms.find_folder_by_name(
429
+ room_id,
430
+ name,
431
+ parent_folder_id=None,
432
+ case_sensitive=False,
433
+ )
434
+ await ctx.data_rooms.ensure_folder(room_id, name, parent_folder_id=None)
435
+ await ctx.data_rooms.resolve_folder_path(
436
+ room_id,
437
+ "A/B/C",
438
+ create=False,
439
+ case_sensitive=False,
440
+ )
441
+ await ctx.data_rooms.find_file_by_name(
442
+ room_id,
443
+ name,
444
+ folder_id=None,
445
+ case_sensitive=False,
446
+ )
447
+ await ctx.data_rooms.read_file_bytes(file_id)
448
+ await ctx.data_rooms.upload_file(
449
+ room_id,
450
+ filename,
451
+ content,
452
+ folder_id=None,
453
+ content_type=None,
454
+ )
455
+ ```
456
+
457
+ In local unit tests outside the Palette runtime, inject a fake Data Room service
458
+ if you need to test routes that call `ctx.data_rooms`. In hosted sandbox and
459
+ real OS runtime, the platform injects the real service.
460
+
461
+ ## 9. Config And Secrets
462
+
463
+ Use config for app install settings and secrets for sensitive values.
464
+
465
+ ```python
466
+ @router.get("/sync-config", dependencies=[require_permission("resources:read")])
467
+ async def sync_config(ctx: PluginContext = Depends(get_plugin_context)):
468
+ timezone = ctx.config_value("timezone", "UTC")
469
+ required_workspace = ctx.require_config("workspace_id")
470
+ api_key = ctx.secret("FINANCE_API_KEY")
471
+
472
+ return {
473
+ "timezone": timezone,
474
+ "workspace_id": required_workspace,
475
+ "has_api_key": bool(api_key),
476
+ }
477
+ ```
478
+
479
+ `ctx.secret("KEY")` first checks app config secrets and then falls back to the
480
+ process environment variable named `KEY`.
481
+
482
+ ## 10. Lifecycle Hooks
483
+
484
+ Lifecycle hooks let an app seed defaults or clean up app-owned data when the
485
+ platform installs, updates, enables, disables, or uninstalls the app.
486
+
487
+ ```python
488
+ from palette_sdk import LifecycleHooks, PluginContext
489
+ from models import DefaultSetting
490
+
491
+ lifecycle = LifecycleHooks()
492
+
493
+
494
+ @lifecycle.on_install
495
+ async def seed_defaults(ctx: PluginContext):
496
+ await ctx.repo(DefaultSetting).create(name="currency", value="USD")
497
+
498
+
499
+ @lifecycle.on_enable
500
+ async def on_enable(ctx: PluginContext):
501
+ ctx.logger.info("finance tools enabled for org %s", ctx.organization_id)
502
+ ```
503
+
504
+ ## 11. Calling Backend APIs From Frontend
505
+
506
+ Frontend code should call backend routes through the platform API helper, not by
507
+ hardcoding backend origins.
508
+
509
+ ```tsx
510
+ import { createPaletteClient } from "@palettelab/sdk"
511
+
512
+ const palette = createPaletteClient()
513
+
514
+ async function loadInvoices() {
515
+ const res = await palette.apiFetch("/api/v1/plugins/finance-tools/invoices")
516
+ return res.json()
517
+ }
518
+ ```
519
+
520
+ During `pltt dev`, the simulator routes this to the local backend. In hosted
521
+ sandbox or OS runtime, it goes through the real platform shell and auth context.
522
+
523
+ ## 12. Local Development
524
+
525
+ Create and run an app:
526
+
527
+ ```bash
528
+ npx --yes @palettelab/cli@latest init finance-tools --template database
529
+ cd finance-tools
530
+ npm install
531
+ npx --yes @palettelab/cli@latest dev
532
+ ```
533
+
534
+ Useful local checks:
535
+
536
+ ```bash
537
+ npx --yes @palettelab/cli@latest test
538
+ npx --yes @palettelab/cli@latest package
539
+ ```
540
+
541
+ The CLI checks manifest shape, SDK compatibility, frontend bundling, backend
542
+ imports, backend route permission gates, declared permissions, migration safety,
543
+ package dependency policy, and backend package size.
544
+
545
+ ## 13. Test In Real Palette OS Without Docker
546
+
547
+ Configure the hosted sandbox once:
548
+
549
+ ```bash
550
+ npx --yes @palettelab/cli@latest login \
551
+ --env staging \
552
+ --url https://YOUR-PALETTE-STAGING-URL \
553
+ --token pltt_xxxxx
554
+ ```
555
+
556
+ Then publish a sandbox preview:
557
+
558
+ ```bash
559
+ npx --yes @palettelab/cli@latest dev --sandbox --env staging
560
+ ```
561
+
562
+ The CLI packages the app, uploads it to the staging Palette environment,
563
+ creates a preview publish, and prints a real OS preview URL. Open that URL to
564
+ test the app with OS shell, auth context, organization context, Data Rooms,
565
+ storage, logs, install/review behavior, and platform APIs.
566
+
567
+ For internal test sandboxes, the backend environment can set:
568
+
569
+ ```bash
570
+ APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS=true
571
+ ```
572
+
573
+ That lets passing preview publishes become active without manual approval.
574
+
575
+ ## 14. Common Mistakes
576
+
577
+ - Do not import `@palettelab/sdk` from Python. That package is for frontend
578
+ JavaScript/React code only.
579
+ - Do not hardcode backend URLs in frontend code. Use the platform API helper.
580
+ - Do not manually set `organization_id` when using `ctx.repo(Model).create`.
581
+ - Do not query cross-org data. The platform runtime and RLS are designed around
582
+ the current organization context.
583
+ - Do not skip `require_permission(...)` on backend routes unless the route is
584
+ intentionally configured as public in the manifest.
585
+ - Do not commit publish tokens, app secrets, or generated `.palette/` local
586
+ state.
587
+ - Use hosted sandbox when you need real Data Rooms, login, organization,
588
+ install, logs, and OS behavior without Docker.
589
+
590
+ ## 15. Complete Minimal Example
591
+
592
+ `backend/api/main.py`:
593
+
594
+ ```python
595
+ from fastapi import Depends, HTTPException
596
+ from pydantic import BaseModel
597
+ from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
598
+ from models import Note
599
+
600
+ router = PluginRouter(tags=["notes-app"])
601
+
602
+
603
+ class NoteIn(BaseModel):
604
+ body: str
605
+
606
+
607
+ @router.get("/notes", dependencies=[require_permission("resources:read")])
608
+ async def list_notes(ctx: PluginContext = Depends(get_plugin_context)):
609
+ rows = await ctx.repo(Note).list(order_by="-id")
610
+ return [{"id": row.id, "body": row.body} for row in rows]
611
+
612
+
613
+ @router.post("/notes", dependencies=[require_permission("resources:write")])
614
+ async def create_note(
615
+ body: NoteIn,
616
+ ctx: PluginContext = Depends(get_plugin_context),
617
+ ):
618
+ if not body.body.strip():
619
+ raise HTTPException(status_code=400, detail="body required")
620
+
621
+ note = await ctx.repo(Note).create(body=body.body)
622
+
623
+ room = await ctx.data_rooms.ensure_room("Notes")
624
+ folder = await ctx.data_rooms.resolve_folder_path(
625
+ room["id"],
626
+ "Created From Backend",
627
+ create=True,
628
+ )
629
+ await ctx.data_rooms.upload_file(
630
+ room["id"],
631
+ f"note-{note.id}.txt",
632
+ note.body.encode("utf-8"),
633
+ folder_id=folder["id"],
634
+ content_type="text/plain",
635
+ )
636
+
637
+ return {"id": note.id, "body": note.body}
638
+ ```
639
+
640
+ This route stores app-owned data in the app database and writes a related file
641
+ into Palette Data Rooms from the Python backend.
package/lib/bundler.js CHANGED
@@ -132,28 +132,48 @@ async function bundleBackend(pluginDir) {
132
132
  pluginDir = path.resolve(pluginDir)
133
133
  const { spawnSync } = require("child_process")
134
134
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-bundle-"))
135
+ const stage = path.join(tmp, "stage")
135
136
  const outPath = path.join(tmp, "backend.tar.gz")
136
137
 
137
- const includes = []
138
- if (fs.existsSync(path.join(pluginDir, "backend"))) includes.push("backend")
139
- for (const metadataFile of ["package.json", "pyproject.toml"]) {
140
- if (fs.existsSync(path.join(pluginDir, metadataFile))) includes.push(metadataFile)
141
- }
142
- includes.push("palette-plugin.json")
138
+ try {
139
+ fs.mkdirSync(stage, { recursive: true })
140
+ const copy = (from, to) => {
141
+ fs.cpSync(from, to, {
142
+ recursive: true,
143
+ filter(src) {
144
+ const parts = src.split(path.sep)
145
+ return !parts.includes("__pycache__") && !parts.includes(".venv")
146
+ },
147
+ })
148
+ }
143
149
 
144
- const result = spawnSync(
145
- "tar",
146
- ["-czf", outPath, ...includes.flatMap((f) => ["--exclude=__pycache__", "--exclude=.venv"]), ...includes],
147
- { cwd: pluginDir, stdio: "pipe" },
148
- )
149
- if (result.status !== 0) {
150
- throw new Error(
151
- `tar failed (exit ${result.status}): ${result.stderr?.toString() || "unknown error"}`,
152
- )
150
+ const backendDir = path.join(pluginDir, "backend")
151
+ if (fs.existsSync(backendDir)) copy(backendDir, path.join(stage, "backend"))
152
+ for (const metadataFile of ["package.json", "pyproject.toml", "palette-plugin.json"]) {
153
+ const src = path.join(pluginDir, metadataFile)
154
+ if (fs.existsSync(src)) fs.copyFileSync(src, path.join(stage, metadataFile))
155
+ }
156
+
157
+ const sdkDir = path.resolve(__dirname, "..", "backend-sdk", "palette_sdk")
158
+ const targetSdkDir = path.join(stage, "backend", "palette_sdk")
159
+ if (fs.existsSync(sdkDir) && fs.existsSync(path.join(stage, "backend"))) {
160
+ copy(sdkDir, targetSdkDir)
161
+ }
162
+
163
+ const includes = fs.readdirSync(stage)
164
+ const result = spawnSync("tar", ["-czf", outPath, ...includes], {
165
+ cwd: stage,
166
+ stdio: "pipe",
167
+ })
168
+ if (result.status !== 0) {
169
+ throw new Error(
170
+ `tar failed (exit ${result.status}): ${result.stderr?.toString() || "unknown error"}`,
171
+ )
172
+ }
173
+ return fs.readFileSync(outPath)
174
+ } finally {
175
+ fs.rmSync(tmp, { recursive: true, force: true })
153
176
  }
154
- const buf = fs.readFileSync(outPath)
155
- fs.rmSync(tmp, { recursive: true, force: true })
156
- return buf
157
177
  }
158
178
 
159
179
  module.exports = { bundleFrontend, bundleBackend, watchFrontend }
@@ -13,6 +13,10 @@ const BANNED_PATTERNS = [
13
13
  { re: /\bpublic\./, reason: "plugin migrations must not reference the platform's public schema" },
14
14
  { re: /\bSET\s+ROLE\b/i, reason: "SET ROLE in migrations escalates out of the plugin sandbox" },
15
15
  { re: /\bALTER\s+ROLE\b/i, reason: "ALTER ROLE is reserved for the platform" },
16
+ { re: /\bGRANT\b/i, reason: "GRANT is reserved for the platform" },
17
+ { re: /\bCREATE\s+EXTENSION\b/i, reason: "CREATE EXTENSION is reserved for the platform" },
18
+ { re: /\bALTER\s+DATABASE\b/i, reason: "ALTER DATABASE is not allowed in plugin migrations" },
19
+ { re: /\bDROP\s+SCHEMA\b/i, reason: "DROP SCHEMA is not allowed in plugin migrations" },
16
20
  ]
17
21
 
18
22
  function lintMigrationFile(absPath) {
@@ -229,7 +229,7 @@ async function apiFetch(path, init) {
229
229
  return fetch(target, init)
230
230
  }
231
231
 
232
- const platform = {
232
+ const basePlatform = {
233
233
  user: {
234
234
  id: "dev-user",
235
235
  email: "developer@palette.local",
@@ -263,6 +263,11 @@ const platform = {
263
263
  },
264
264
  }
265
265
 
266
+ function normalizePaletteLanguage(language, fallback = "en") {
267
+ const value = String(language || "").trim().toLowerCase()
268
+ return value ? value.split("-")[0] : fallback
269
+ }
270
+
266
271
  function Toasts() {
267
272
  const [items, setItems] = React.useState([])
268
273
  React.useEffect(() => {
@@ -280,6 +285,30 @@ function Toasts() {
280
285
  }
281
286
 
282
287
  function Shell() {
288
+ const [language, updateLanguage] = React.useState(() => normalizePaletteLanguage(navigator.language))
289
+ const platform = React.useMemo(() => ({
290
+ ...basePlatform,
291
+ language,
292
+ fallbackLanguage: "en",
293
+ supportedLanguages: ["en", "ko"],
294
+ setLanguage: (nextLanguage) => {
295
+ const normalized = normalizePaletteLanguage(nextLanguage)
296
+ updateLanguage(normalized)
297
+ document.documentElement.lang = normalized
298
+ window.dispatchEvent(new CustomEvent("palette:language-change", { detail: { language: normalized } }))
299
+ },
300
+ }), [language])
301
+
302
+ React.useEffect(() => {
303
+ document.documentElement.lang = language
304
+ }, [language])
305
+
306
+ React.useEffect(() => {
307
+ const onLanguageChange = () => updateLanguage(normalizePaletteLanguage(navigator.language))
308
+ window.addEventListener("languagechange", onLanguageChange)
309
+ return () => window.removeEventListener("languagechange", onLanguageChange)
310
+ }, [])
311
+
283
312
  return React.createElement(PluginProvider, { value: platform },
284
313
  React.createElement("main", { className: "palette-local-shell" },
285
314
  React.createElement(Plugin, { platform }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "bin",
10
10
  "backend-sdk",
11
+ "docs",
11
12
  "lib",
12
13
  "platform-dev",
13
14
  "template-fallback",
@@ -5,46 +5,58 @@
5
5
  *
6
6
  * This is the root component that the platform renders when your plugin is opened.
7
7
  * It receives a `platform` prop with access to the authenticated user, API client,
8
- * navigation, and toast notifications.
8
+ * navigation, toast notifications, and Palette OS language.
9
9
  *
10
10
  * Available hooks from @palettelab/sdk:
11
- * - usePlatform() access user, org, apiFetch, navigate, showToast
12
- * - usePluginTasks() CRUD tasks
13
- * - usePluginDataRooms() browse data rooms and files
14
- * - usePluginChat() chat with agents
11
+ * - usePlatform() - access user, org, API, language, navigate, showToast
12
+ * - usePluginTranslations() - translate app-owned resource files using OS language
13
+ * - usePluginTasks() - CRUD tasks
14
+ * - usePluginDataRooms() - browse data rooms and files
15
+ * - usePluginChat() - chat with agents
15
16
  */
16
17
 
17
- import { usePlatform, usePluginTasks } from "@palettelab/sdk"
18
+ import { usePlatform, usePluginTasks, usePluginTranslations } from "@palettelab/sdk"
18
19
  import type { PluginComponentProps } from "@palettelab/sdk"
20
+ import { translations } from "./translations"
19
21
 
20
- export default function MyPlugin({ platform }: PluginComponentProps) {
22
+ export default function MyPlugin(_props: PluginComponentProps) {
21
23
  const { user, showToast } = usePlatform()
22
24
  const { tasks, loading, createTask } = usePluginTasks()
25
+ const { t, language, setLanguage } = usePluginTranslations(translations)
23
26
 
24
27
  return (
25
28
  <div className="p-6 space-y-6">
26
- <div>
27
- <h1 className="text-2xl font-bold">My Plugin</h1>
28
- <p className="text-muted-foreground mt-1">
29
- Hello, {user.name}! This is your plugin template.
30
- </p>
29
+ <div className="flex flex-wrap items-start justify-between gap-3">
30
+ <div>
31
+ <h1 className="text-2xl font-bold">{t("title")}</h1>
32
+ <p className="text-muted-foreground mt-1">
33
+ {t("greeting", { name: user.name })}
34
+ </p>
35
+ </div>
36
+ <button
37
+ className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
38
+ onClick={() => setLanguage(language === "ko" ? "en" : "ko")}
39
+ aria-label={t("language")}
40
+ >
41
+ {language === "ko" ? "EN" : "KO"}
42
+ </button>
31
43
  </div>
32
44
 
33
45
  <div className="rounded-lg border p-4 space-y-3">
34
- <h2 className="font-semibold">Quick Start</h2>
46
+ <h2 className="font-semibold">{t("quickStart")}</h2>
35
47
  <ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
36
- <li>Edit <code>frontend/src/index.tsx</code> to build your UI</li>
37
- <li>Edit <code>backend/api/main.py</code> to add API endpoints</li>
38
- <li>Use <code>usePlatform()</code> to access the authenticated user and API</li>
39
- <li>Use <code>usePluginTasks()</code> to manage tasks</li>
40
- <li>Use <code>usePluginDataRooms()</code> to browse files</li>
48
+ <li>{t("editUi")}</li>
49
+ <li>{t("editApi")}</li>
50
+ <li>{t("usePlatform")}</li>
51
+ <li>{t("useTasks")}</li>
52
+ <li>{t("useDataRooms")}</li>
41
53
  </ul>
42
54
  </div>
43
55
 
44
56
  <div className="rounded-lg border p-4">
45
- <h2 className="font-semibold mb-2">Tasks ({loading ? "..." : tasks.length})</h2>
57
+ <h2 className="font-semibold mb-2">{t("tasks")} ({loading ? "..." : tasks.length})</h2>
46
58
  {!loading && tasks.length === 0 && (
47
- <p className="text-sm text-muted-foreground">No tasks yet.</p>
59
+ <p className="text-sm text-muted-foreground">{t("noTasks")}</p>
48
60
  )}
49
61
  {tasks.slice(0, 5).map(task => (
50
62
  <div key={task.id} className="flex items-center justify-between py-1 text-sm">
@@ -55,11 +67,11 @@ export default function MyPlugin({ platform }: PluginComponentProps) {
55
67
  <button
56
68
  className="mt-3 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
57
69
  onClick={async () => {
58
- await createTask({ title: "Sample task from plugin", priority: "medium" })
59
- showToast("Task created!", "success")
70
+ await createTask({ title: t("sampleTaskTitle"), priority: "medium" })
71
+ showToast(t("taskCreated"), "success")
60
72
  }}
61
73
  >
62
- Create Sample Task
74
+ {t("createSampleTask")}
63
75
  </button>
64
76
  </div>
65
77
  </div>
@@ -0,0 +1,36 @@
1
+ import type { TranslationResources } from "@palettelab/sdk"
2
+
3
+ export const translations = {
4
+ en: {
5
+ title: "My Plugin",
6
+ greeting: "Hello, {{name}}! This plugin follows the Palette OS language.",
7
+ quickStart: "Quick Start",
8
+ editUi: "Edit frontend/src/index.tsx to build your UI",
9
+ editApi: "Edit backend/api/main.py to add API endpoints",
10
+ usePlatform: "Use usePlatform() to access the authenticated user and API",
11
+ useTasks: "Use usePluginTasks() to manage tasks",
12
+ useDataRooms: "Use usePluginDataRooms() to browse files",
13
+ tasks: "Tasks",
14
+ noTasks: "No tasks yet.",
15
+ createSampleTask: "Create Sample Task",
16
+ sampleTaskTitle: "Sample task from plugin",
17
+ taskCreated: "Task created!",
18
+ language: "Language",
19
+ },
20
+ ko: {
21
+ title: "내 플러그인",
22
+ greeting: "안녕하세요, {{name}}님! 이 플러그인은 Palette OS 언어를 따릅니다.",
23
+ quickStart: "빠른 시작",
24
+ editUi: "frontend/src/index.tsx를 수정해 UI를 만드세요",
25
+ editApi: "backend/api/main.py를 수정해 API 엔드포인트를 추가하세요",
26
+ usePlatform: "usePlatform()으로 인증 사용자와 API에 접근하세요",
27
+ useTasks: "usePluginTasks()로 작업을 관리하세요",
28
+ useDataRooms: "usePluginDataRooms()로 파일을 둘러보세요",
29
+ tasks: "작업",
30
+ noTasks: "아직 작업이 없습니다.",
31
+ createSampleTask: "샘플 작업 만들기",
32
+ sampleTaskTitle: "플러그인에서 만든 샘플 작업",
33
+ taskCreated: "작업이 생성되었습니다!",
34
+ language: "언어",
35
+ },
36
+ } satisfies TranslationResources
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.10"
7
+ "@palettelab/sdk": "^0.1.11"
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.0",
16
+ "frontend": "^0.1.11",
17
17
  "backend": "^0.1.0"
18
18
  },
19
19
  "platform": {
@@ -1,13 +1,15 @@
1
1
  "use client"
2
2
 
3
3
  import { useEffect, useState } from "react"
4
- import { usePlatform } from "@palettelab/sdk"
4
+ import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
5
5
  import type { PluginComponentProps } from "@palettelab/sdk"
6
+ import { translations } from "./translations"
6
7
 
7
8
  type Series = { label: string; value: number }
8
9
 
9
10
  export default function DashboardWidget(_props: PluginComponentProps) {
10
11
  const { apiFetch } = usePlatform()
12
+ const { t } = usePluginTranslations(translations)
11
13
  const [data, setData] = useState<Series[]>([])
12
14
  const [loading, setLoading] = useState(true)
13
15
 
@@ -22,9 +24,9 @@ export default function DashboardWidget(_props: PluginComponentProps) {
22
24
 
23
25
  return (
24
26
  <div className="p-6 space-y-4">
25
- <h1 className="text-2xl font-bold">Dashboard Widget</h1>
27
+ <h1 className="text-2xl font-bold">{t("title")}</h1>
26
28
  {loading ? (
27
- <p className="text-muted-foreground">Loading…</p>
29
+ <p className="text-muted-foreground">{t("loading")}</p>
28
30
  ) : (
29
31
  <div className="space-y-2">
30
32
  {data.map((d) => (
@@ -0,0 +1,12 @@
1
+ import type { TranslationResources } from "@palettelab/sdk"
2
+
3
+ export const translations = {
4
+ en: {
5
+ title: "Dashboard Widget",
6
+ loading: "Loading...",
7
+ },
8
+ ko: {
9
+ title: "대시보드 위젯",
10
+ loading: "불러오는 중...",
11
+ },
12
+ } satisfies TranslationResources
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.10",
6
+ "@palettelab/sdk": "^0.1.11",
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.0", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.11", "backend": "^0.1.0" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -1,8 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import { useEffect, useState } from "react"
4
- import { usePlatform } from "@palettelab/sdk"
4
+ import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
5
5
  import type { PluginComponentProps } from "@palettelab/sdk"
6
+ import { translations } from "./translations"
6
7
 
7
8
  type Note = { id: number; body: string }
8
9
 
@@ -96,6 +97,7 @@ const styles = `
96
97
 
97
98
  export default function NotesApp(_props: PluginComponentProps) {
98
99
  const { apiFetch, showToast, user } = usePlatform()
100
+ const { t } = usePluginTranslations(translations)
99
101
  const [notes, setNotes] = useState<Note[]>([])
100
102
  const [body, setBody] = useState("")
101
103
  const [loading, setLoading] = useState(true)
@@ -105,7 +107,7 @@ export default function NotesApp(_props: PluginComponentProps) {
105
107
  setLoading(true)
106
108
  try {
107
109
  const response = await apiFetch("/api/v1/plugins/my-db-plugin/notes")
108
- if (!response.ok) throw new Error("Could not load notes")
110
+ if (!response.ok) throw new Error(t("loadError"))
109
111
  setNotes(await response.json())
110
112
  } finally {
111
113
  setLoading(false)
@@ -126,9 +128,9 @@ export default function NotesApp(_props: PluginComponentProps) {
126
128
  headers: { "Content-Type": "application/json" },
127
129
  body: JSON.stringify({ body: cleaned }),
128
130
  })
129
- if (!response.ok) throw new Error("Could not save note")
131
+ if (!response.ok) throw new Error(t("saveError"))
130
132
  setBody("")
131
- showToast("Note saved", "success")
133
+ showToast(t("saved"), "success")
132
134
  await load()
133
135
  } finally {
134
136
  setSaving(false)
@@ -139,11 +141,10 @@ export default function NotesApp(_props: PluginComponentProps) {
139
141
  <div className="notes-app">
140
142
  <style>{styles}</style>
141
143
  <main className="notes-shell">
142
- <p className="notes-kicker">Palette DB App</p>
143
- <h1 className="notes-title">Org Notes</h1>
144
+ <p className="notes-kicker">{t("kicker")}</p>
145
+ <h1 className="notes-title">{t("title")}</h1>
144
146
  <p className="notes-copy">
145
- Built for {user.name}. This app uses a Python backend and the Palette
146
- SDK database session exposed as <code>ctx.db</code>.
147
+ {t("copy", { name: user.name })}
147
148
  </p>
148
149
  <section className="notes-form">
149
150
  <input
@@ -153,7 +154,7 @@ export default function NotesApp(_props: PluginComponentProps) {
153
154
  onKeyDown={(event) => {
154
155
  if (event.key === "Enter") void add()
155
156
  }}
156
- placeholder="New note..."
157
+ placeholder={t("placeholder")}
157
158
  />
158
159
  <button
159
160
  type="button"
@@ -161,14 +162,14 @@ export default function NotesApp(_props: PluginComponentProps) {
161
162
  disabled={saving || body.trim().length === 0}
162
163
  className="notes-button"
163
164
  >
164
- {saving ? "Saving" : "Add Note"}
165
+ {saving ? t("saving") : t("add")}
165
166
  </button>
166
167
  </section>
167
168
  <section className="notes-list">
168
169
  {loading ? (
169
- <p className="notes-empty">Loading notes...</p>
170
+ <p className="notes-empty">{t("loading")}</p>
170
171
  ) : notes.length === 0 ? (
171
- <p className="notes-empty">No notes yet.</p>
172
+ <p className="notes-empty">{t("empty")}</p>
172
173
  ) : (
173
174
  notes.map((note) => (
174
175
  <p key={note.id} className="notes-item">
@@ -0,0 +1,30 @@
1
+ import type { TranslationResources } from "@palettelab/sdk"
2
+
3
+ export const translations = {
4
+ en: {
5
+ kicker: "Palette DB App",
6
+ title: "Org Notes",
7
+ copy: "Built for {{name}}. This app uses a Python backend and the Palette SDK database session exposed as ctx.db.",
8
+ placeholder: "New note...",
9
+ add: "Add Note",
10
+ saving: "Saving",
11
+ loading: "Loading notes...",
12
+ empty: "No notes yet.",
13
+ loadError: "Could not load notes",
14
+ saveError: "Could not save note",
15
+ saved: "Note saved",
16
+ },
17
+ ko: {
18
+ kicker: "Palette DB 앱",
19
+ title: "조직 노트",
20
+ copy: "{{name}}님을 위해 생성되었습니다. 이 앱은 Python 백엔드와 ctx.db로 제공되는 Palette SDK 데이터베이스 세션을 사용합니다.",
21
+ placeholder: "새 노트...",
22
+ add: "노트 추가",
23
+ saving: "저장 중",
24
+ loading: "노트를 불러오는 중...",
25
+ empty: "아직 노트가 없습니다.",
26
+ loadError: "노트를 불러올 수 없습니다",
27
+ saveError: "노트를 저장할 수 없습니다",
28
+ saved: "노트가 저장되었습니다",
29
+ },
30
+ } satisfies TranslationResources
@@ -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.10", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.11", "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.0", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.11", "backend": "^0.1.0" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -1,16 +1,18 @@
1
1
  "use client"
2
2
 
3
3
  import { useState } from "react"
4
- import { usePlatform } from "@palettelab/sdk"
4
+ import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
5
5
  import type { PluginComponentProps } from "@palettelab/sdk"
6
+ import { translations } from "./translations"
6
7
 
7
8
  export default function ExternalServiceWidget(_props: PluginComponentProps) {
8
9
  const { apiFetch } = usePlatform()
10
+ const { t } = usePluginTranslations(translations)
9
11
  const [result, setResult] = useState<string>("")
10
12
 
11
13
  return (
12
14
  <div className="p-6 space-y-3">
13
- <h1 className="text-2xl font-bold">External Service</h1>
15
+ <h1 className="text-2xl font-bold">{t("title")}</h1>
14
16
  <button
15
17
  className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm"
16
18
  onClick={async () => {
@@ -18,7 +20,7 @@ export default function ExternalServiceWidget(_props: PluginComponentProps) {
18
20
  setResult(await r.text())
19
21
  }}
20
22
  >
21
- Call api.example.com
23
+ {t("call")}
22
24
  </button>
23
25
  {result && <pre className="text-xs">{result}</pre>}
24
26
  </div>
@@ -0,0 +1,12 @@
1
+ import type { TranslationResources } from "@palettelab/sdk"
2
+
3
+ export const translations = {
4
+ en: {
5
+ title: "External Service",
6
+ call: "Call api.example.com",
7
+ },
8
+ ko: {
9
+ title: "외부 서비스",
10
+ call: "api.example.com 호출",
11
+ },
12
+ } satisfies TranslationResources
@@ -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.10", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.11", "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.0", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.11", "backend": "^0.1.0" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -1,15 +1,18 @@
1
1
  "use client"
2
2
 
3
- import { usePlatform } from "@palettelab/sdk"
3
+ import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
4
4
  import type { PluginComponentProps } from "@palettelab/sdk"
5
+ import { translations } from "./translations"
5
6
 
6
7
  export default function FrontendOnlyPlugin(_props: PluginComponentProps) {
7
8
  const { user } = usePlatform()
9
+ const { t } = usePluginTranslations(translations)
10
+
8
11
  return (
9
12
  <div className="p-6">
10
- <h1 className="text-2xl font-bold">Hello, {user.name}</h1>
13
+ <h1 className="text-2xl font-bold">{t("title", { name: user.name })}</h1>
11
14
  <p className="text-muted-foreground mt-2">
12
- This plugin runs entirely in the browser sandbox — no backend.
15
+ {t("body")}
13
16
  </p>
14
17
  </div>
15
18
  )
@@ -0,0 +1,12 @@
1
+ import type { TranslationResources } from "@palettelab/sdk"
2
+
3
+ export const translations = {
4
+ en: {
5
+ title: "Hello, {{name}}",
6
+ body: "This plugin runs entirely in the browser sandbox and follows the Palette OS language.",
7
+ },
8
+ ko: {
9
+ title: "안녕하세요, {{name}}님",
10
+ body: "이 플러그인은 브라우저 샌드박스에서만 실행되며 Palette OS 언어를 따릅니다.",
11
+ },
12
+ } satisfies TranslationResources
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.10",
6
+ "@palettelab/sdk": "^0.1.11",
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.0" },
12
+ "sdk": { "frontend": "^0.1.11" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,