@palettelab/cli 0.3.48 → 0.3.50

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.
package/README.md CHANGED
@@ -533,10 +533,35 @@ Environment variables:
533
533
  | `PALETTE_DEV_LOG_TAIL` | `200` | Initial hosted log events shown by `pltt dev --sandbox` / `--cloud` |
534
534
  | `APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS` | `false` | Backend setting for hosted sandboxes; auto-approve passing preview publishes so developers can test full OS behavior without manual review |
535
535
 
536
- ### `pltt secrets`
536
+ ### `.env` and secrets
537
537
 
538
- Palette secrets are declared in `palette-plugin.json` and resolved through
539
- `ctx.secret("NAME")`.
538
+ For normal app development, put environment values in root `.env` files and run
539
+ the usual CLI command. No separate input is required:
540
+
541
+ ```bash
542
+ pltt dev
543
+ pltt sandbox --env staging
544
+ pltt preview --env staging
545
+ pltt publish --env staging
546
+ ```
547
+
548
+ The CLI automatically reads these files, with later files overriding earlier
549
+ ones:
550
+
551
+ ```text
552
+ .env
553
+ .env.local
554
+ .env.<env>
555
+ .env.<env>.local
556
+ ```
557
+
558
+ Server-only keys such as `OPENAI_API_KEY` or `STRIPE_SECRET` are uploaded during
559
+ hosted preview/publish as encrypted plugin secrets and resolved through
560
+ `ctx.secret("NAME")`. Public keys prefixed with `NEXT_PUBLIC_` are bundled into
561
+ the frontend and are not uploaded as backend secrets.
562
+
563
+ You can still declare secrets explicitly in `palette-plugin.json` when you need
564
+ install-scoped values or labels/help text:
540
565
 
541
566
  ```json
542
567
  {
@@ -549,7 +574,7 @@ Palette secrets are declared in `palette-plugin.json` and resolved through
549
574
  }
550
575
  ```
551
576
 
552
- Commands:
577
+ Optional management commands:
553
578
 
554
579
  ```bash
555
580
  pltt secrets init
@@ -559,10 +584,9 @@ pltt secrets list --env staging
559
584
  pltt publish --env staging --secrets-file plugin-secrets.env
560
585
  ```
561
586
 
562
- `dev` secrets live in `.palette/.env.local`, are loaded by `pltt dev`, and are
563
- never uploaded. `plugin` secrets are encrypted by the platform and attached to
564
- the plugin/environment. `install` secrets are filled by the installing org.
565
- Frontend bundles may only receive public values such as `NEXT_PUBLIC_*`.
587
+ `.palette/.env.local` remains supported for older projects. `plugin` secrets
588
+ are encrypted by the platform and attached to the plugin/environment.
589
+ `install` secrets are filled by the installing org.
566
590
 
567
591
  Managed platform services do not require developer Redis/Qdrant keys. Declare
568
592
  them and use scoped SDK clients:
@@ -737,6 +761,30 @@ pltt logs --json
737
761
 
738
762
  If no plugin ID is provided, the CLI uses the current `palette-plugin.json` or `.palette/last-publish.json`.
739
763
 
764
+ ### `pltt services`
765
+
766
+ Inspect and generate OS-broker service integrations declared through
767
+ `provides` and `consumes` in `palette-plugin.json`.
768
+
769
+ ```bash
770
+ pltt services list --env staging
771
+ pltt services add hr/v1#approvalChain.get --reason "Route approvals through HR"
772
+ pltt services add hr/v1#hierarchy.updated --event --optional
773
+ pltt services pull --env staging
774
+ pltt services scaffold approvalChain.get
775
+ ```
776
+
777
+ Subcommands:
778
+
779
+ - `list`: fetches `/api/v1/os-broker/catalog` from the selected environment and prints services/events available to the current org. Use `--json` for the raw catalog.
780
+ - `add <namespace/version#name>`: appends a consumed service target to `consumes.services`. Use `--event` or `--type event` for `consumes.events`, `--optional` for non-required dependencies, and `--reason <text>` to record why the dependency is needed.
781
+ - `pull`: reads `consumes.services` and `consumes.events`, fetches JSON Schemas from `/api/v1/os-broker/schemas`, and generates `.palette/types/services.ts` plus `.palette/python/services.py`.
782
+ - `scaffold <method>`: adds a provider method to `provides.services`, creates `schemas/<method>.in.json` and `schemas/<method>.out.json`, and writes a Python handler stub under `broker/`.
783
+
784
+ Generated TypeScript clients use the SDK's default `palette` client. Generated
785
+ Python clients call `palette_sdk.services(ctx)`, so backend routes still pass
786
+ through Palette's broker permission and install checks.
787
+
740
788
  ## Global Flags
741
789
 
742
790
  - `--json` emits machine-readable output for `package`, `publish`, `status`, `logs`, and `test`.
@@ -34,7 +34,14 @@ from palette_sdk.permissions import (
34
34
  is_known_permission,
35
35
  require_permission,
36
36
  )
37
+ from palette_sdk import events as events_module # also exposed as `palette_sdk.events`
37
38
  from palette_sdk.events import Event, EventPublisher, subscribe_event
39
+ from palette_sdk.services import (
40
+ BrokerCallError,
41
+ ServicesClient,
42
+ service,
43
+ services,
44
+ )
38
45
  from palette_sdk.config import get_config, require_config
39
46
  from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
40
47
  from palette_sdk.testing import route_permission_issues
@@ -77,6 +84,11 @@ __all__ = [
77
84
  "Event",
78
85
  "EventPublisher",
79
86
  "subscribe_event",
87
+ "events_module",
88
+ "service",
89
+ "services",
90
+ "ServicesClient",
91
+ "BrokerCallError",
80
92
  "get_config",
81
93
  "require_config",
82
94
  "sign_webhook",
@@ -64,3 +64,15 @@ class AppInteropClient:
64
64
  json_body=json,
65
65
  timeout=timeout,
66
66
  )
67
+
68
+ async def broker_call(self, target: str, payload: dict[str, Any] | None = None) -> Any:
69
+ """OS-broker RPC call. Used by the high-level `services(ctx).call(...)`."""
70
+ if self._adapter is None:
71
+ raise MissingAppServiceError("Palette OS broker is not available in this runtime")
72
+ return await self._adapter.broker_call(target, payload or {})
73
+
74
+ async def broker_emit(self, target: str, payload: dict[str, Any] | None = None) -> None:
75
+ """Emit an event through the OS broker."""
76
+ if self._adapter is None:
77
+ raise MissingAppServiceError("Palette OS broker is not available in this runtime")
78
+ await self._adapter.broker_emit(target, payload or {})
@@ -44,6 +44,13 @@ _pending: list[tuple[str, Handler]] = []
44
44
 
45
45
 
46
46
  def subscribe_event(topic: str, handler: Handler) -> None:
47
+ """Subscribe to a topic.
48
+
49
+ Accepts either a legacy bare topic (`"task.created"`) or a fully qualified
50
+ broker target (`"core/v1#task.created"` or `"org/v1#member.updated"`). Both
51
+ forms are routed to the same core bus; the broker decodes the qualified
52
+ form on dispatch.
53
+ """
47
54
  _pending.append((topic, handler))
48
55
 
49
56
 
@@ -54,8 +61,31 @@ def drain_pending() -> list[tuple[str, Handler]]:
54
61
  return out
55
62
 
56
63
 
64
+ def on(topic: str) -> Callable[[Handler], Handler]:
65
+ """Decorator form of `subscribe_event`.
66
+
67
+ Usage::
68
+
69
+ @events.on("org/v1#member.updated")
70
+ async def refresh(event):
71
+ ...
72
+ """
73
+
74
+ def decorator(fn: Handler) -> Handler:
75
+ subscribe_event(topic, fn)
76
+ return fn
77
+
78
+ return decorator
79
+
80
+
57
81
  class EventPublisher:
58
- """Publish app-owned events declared in palette-plugin.json provides.events."""
82
+ """Publish app-owned events declared in palette-plugin.json provides.events.
83
+
84
+ Accepts both legacy topics (`task.created`) and qualified broker targets
85
+ (`org/v1#member.updated`). The adapter (injected by the platform) routes
86
+ the call through the broker, which checks that the caller is the declared
87
+ provider and validates the payload against its schema.
88
+ """
59
89
 
60
90
  def __init__(self, adapter: Any = None):
61
91
  self._adapter = adapter
@@ -64,3 +94,7 @@ class EventPublisher:
64
94
  if self._adapter is None:
65
95
  raise RuntimeError("Palette event publisher is not available in this runtime")
66
96
  await self._adapter.publish(topic, payload or {})
97
+
98
+ # Alias: `events.emit(...)` reads more naturally for app-owned events.
99
+ async def emit(self, topic: str, payload: dict[str, Any] | None = None) -> None:
100
+ await self.publish(topic, payload)
@@ -4,9 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from pathlib import Path
7
- from typing import Literal
7
+ from typing import Any, Literal
8
8
 
9
- from pydantic import BaseModel, ConfigDict, Field
9
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
10
10
 
11
11
 
12
12
  class AgentDefinition(BaseModel):
@@ -99,6 +99,22 @@ class AppServiceRouteSpec(BaseModel):
99
99
  description: str = ""
100
100
 
101
101
 
102
+ class AppServiceMethodSpec(BaseModel):
103
+ model_config = ConfigDict(populate_by_name=True)
104
+
105
+ id: str = Field(validation_alias=AliasChoices("id", "name"))
106
+ scope: str | None = None
107
+ version: str | None = None
108
+ label: str | None = None
109
+ description: str = ""
110
+ input_schema: dict[str, Any] | str | None = None
111
+ input: dict[str, Any] | str | None = None
112
+ output_schema: dict[str, Any] | str | None = None
113
+ output: dict[str, Any] | str | None = None
114
+ route_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] | None = None
115
+ route_path: str | None = None
116
+
117
+
102
118
  class ProvidedAppServiceSpec(BaseModel):
103
119
  id: str
104
120
  version: str | None = None
@@ -106,6 +122,22 @@ class ProvidedAppServiceSpec(BaseModel):
106
122
  description: str = ""
107
123
  permissions: list[str] = Field(default_factory=list)
108
124
  routes: list[AppServiceRouteSpec] = Field(default_factory=list)
125
+ methods: list[AppServiceMethodSpec] = Field(default_factory=list)
126
+
127
+
128
+ class ProvidedEventSpec(BaseModel):
129
+ model_config = ConfigDict(populate_by_name=True)
130
+
131
+ id: str = Field(validation_alias=AliasChoices("id", "topic"))
132
+ version: str | None = None
133
+ label: str | None = None
134
+ description: str = ""
135
+ payload_schema: dict[str, Any] | str | None = None
136
+ schema_: dict[str, Any] | str | None = Field(
137
+ default=None,
138
+ validation_alias=AliasChoices("schema", "schema_"),
139
+ serialization_alias="schema",
140
+ )
109
141
 
110
142
 
111
143
  class AppDependencySpec(BaseModel):
@@ -123,9 +155,16 @@ class ServiceDependencySpec(BaseModel):
123
155
  provider: str | None = None
124
156
 
125
157
 
158
+ class ConsumedBrokerTargetSpec(BaseModel):
159
+ target: str
160
+ required: bool = True
161
+ reason: str = ""
162
+
163
+
126
164
  class ProvidesSpec(BaseModel):
165
+ namespace: str | None = None
127
166
  services: list[ProvidedAppServiceSpec] = Field(default_factory=list)
128
- events: list[str] = Field(default_factory=list)
167
+ events: list[str | ProvidedEventSpec] = Field(default_factory=list)
129
168
 
130
169
 
131
170
  class RequiresSpec(BaseModel):
@@ -134,6 +173,11 @@ class RequiresSpec(BaseModel):
134
173
  events: list[str] = Field(default_factory=list)
135
174
 
136
175
 
176
+ class ConsumesSpec(BaseModel):
177
+ services: list[str | ConsumedBrokerTargetSpec] = Field(default_factory=list)
178
+ events: list[str | ConsumedBrokerTargetSpec] = Field(default_factory=list)
179
+
180
+
137
181
  class PlatformServiceSpec(BaseModel):
138
182
  required: bool = False
139
183
  billing: Literal["org_wallet", "plugin_owner", "platform"] | None = None
@@ -167,6 +211,7 @@ class PluginManifest(BaseModel):
167
211
  connections: list[ConnectionSpec] = Field(default_factory=list)
168
212
  provides: ProvidesSpec | None = None
169
213
  requires: RequiresSpec | None = None
214
+ consumes: ConsumesSpec | None = None
170
215
  platform_services: list[Literal["llm", "redis", "storage", "vector"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
171
216
  rating: float = 0.0
172
217
  reviews: int = 0
@@ -0,0 +1,147 @@
1
+ """OS-broker service helpers - provider + consumer side.
2
+
3
+ Provider example::
4
+
5
+ from palette_sdk import service, PluginContext
6
+
7
+ @service("members.list", scope="org/members.read")
8
+ async def list_members(ctx: PluginContext, payload: dict) -> dict:
9
+ return {"items": [...]}
10
+
11
+ Consumer example::
12
+
13
+ from palette_sdk import services
14
+
15
+ members = await services(ctx).call("org/v1#members.list", {"team_id": 7})
16
+
17
+ The decorator captures handlers at import time; the platform's plugin loader
18
+ binds them into the broker registry when the plugin is mounted, prefixing the
19
+ method names with the plugin's declared namespace/version.
20
+
21
+ The consumer accessor goes through `ctx.apps.broker_call()`, which the
22
+ platform injects with the same signed-internal-call transport used elsewhere.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Awaitable, Callable
28
+ from dataclasses import dataclass
29
+ from typing import Any
30
+
31
+ ServiceHandler = Callable[[Any, dict[str, Any]], Awaitable[Any]]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ServiceDecl:
36
+ name: str
37
+ handler: ServiceHandler
38
+ scope: str | None = None
39
+ label: str | None = None
40
+ description: str | None = None
41
+ input_schema: dict | None = None
42
+ output_schema: dict | None = None
43
+
44
+
45
+ _pending: list[ServiceDecl] = []
46
+
47
+
48
+ def service(
49
+ name: str,
50
+ *,
51
+ scope: str | None = None,
52
+ label: str | None = None,
53
+ description: str | None = None,
54
+ input_schema: dict | None = None,
55
+ output_schema: dict | None = None,
56
+ ) -> Callable[[ServiceHandler], ServiceHandler]:
57
+ """Mark an async function as a broker-callable service method."""
58
+
59
+ def decorator(fn: ServiceHandler) -> ServiceHandler:
60
+ _pending.append(
61
+ ServiceDecl(
62
+ name=name,
63
+ handler=fn,
64
+ scope=scope,
65
+ label=label,
66
+ description=description,
67
+ input_schema=input_schema,
68
+ output_schema=output_schema,
69
+ )
70
+ )
71
+ return fn
72
+
73
+ return decorator
74
+
75
+
76
+ def drain_pending_services() -> list[ServiceDecl]:
77
+ """Platform-only. Drain and return services registered since last drain."""
78
+ out = list(_pending)
79
+ _pending.clear()
80
+ return out
81
+
82
+
83
+ class BrokerCallError(RuntimeError):
84
+ """Raised when the broker rejects or fails a service call."""
85
+
86
+
87
+ class ServicesClient:
88
+ """Returned by `services(ctx)`. Use `.call(target, payload)` or sugar `.proxy(...)`."""
89
+
90
+ def __init__(self, adapter: Any | None):
91
+ self._adapter = adapter
92
+
93
+ async def call(self, target: str, payload: dict[str, Any] | None = None) -> Any:
94
+ if self._adapter is None:
95
+ raise BrokerCallError(
96
+ "Palette OS broker is not available in this runtime. "
97
+ "This call must run inside a plugin request handler."
98
+ )
99
+ return await self._adapter.broker_call(target, payload or {})
100
+
101
+ async def emit(self, target: str, payload: dict[str, Any] | None = None) -> None:
102
+ if self._adapter is None:
103
+ raise BrokerCallError("Palette OS broker is not available in this runtime.")
104
+ await self._adapter.broker_emit(target, payload or {})
105
+
106
+ def proxy(self, namespace_version: str) -> "_Proxy":
107
+ return _Proxy(self, namespace_version)
108
+
109
+
110
+ class _Proxy:
111
+ """Dotted-attribute syntax to broker target string."""
112
+
113
+ def __init__(self, client: ServicesClient, namespace_version: str, segments: tuple[str, ...] = ()):
114
+ if "/" not in namespace_version:
115
+ raise ValueError("namespace_version must look like 'org/v1'")
116
+ self._client = client
117
+ self._ns = namespace_version
118
+ self._segments = segments
119
+
120
+ def __getattr__(self, item: str) -> "_Proxy":
121
+ return _Proxy(self._client, self._ns, self._segments + (item,))
122
+
123
+ async def __call__(self, payload: dict[str, Any] | None = None) -> Any:
124
+ if not self._segments:
125
+ raise BrokerCallError("missing method name on proxy call")
126
+ method = ".".join(self._segments)
127
+ return await self._client.call(f"{self._ns}#{method}", payload)
128
+
129
+
130
+ def services(ctx_or_adapter: Any) -> ServicesClient:
131
+ """Resolve a `ServicesClient` from either a `PluginContext` or an adapter."""
132
+ if ctx_or_adapter is None:
133
+ return ServicesClient(None)
134
+ apps = getattr(ctx_or_adapter, "apps", None)
135
+ if apps is not None and hasattr(apps, "broker_call"):
136
+ return ServicesClient(apps)
137
+ return ServicesClient(ctx_or_adapter)
138
+
139
+
140
+ __all__ = [
141
+ "BrokerCallError",
142
+ "ServiceDecl",
143
+ "ServicesClient",
144
+ "drain_pending_services",
145
+ "service",
146
+ "services",
147
+ ]
@@ -114,7 +114,7 @@ Available context values:
114
114
  | `ctx.data_rooms` | Backend Data Room client |
115
115
  | `ctx.connections` | Palette-managed third-party connection client |
116
116
  | `ctx.members` | Current organisation member client |
117
- | `ctx.apps` | Governed app-to-app calls through declared `requires` contracts |
117
+ | `ctx.apps` / `services(ctx)` | Governed app-to-app calls through declared `consumes` contracts |
118
118
  | `ctx.events` | Publish event topics declared in `provides.events` |
119
119
  | `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
120
120
  | `ctx.vector` | Plugin/org-scoped vector service when `platform_services` includes `vector` |
@@ -141,13 +141,15 @@ These are the public Python helpers exported by `palette_sdk`.
141
141
  | `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
142
142
  | `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
143
143
  | `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
144
- | `AppInteropClient`, `ctx.apps` | Call required apps/services without direct database access |
144
+ | `PluginConnectionsClient`, `ConnectionStatus`, `MissingConnectionError`, `ctx.connections` | Third-party connection status and token helpers |
145
+ | `AppInteropClient`, `AppServiceClient`, `MissingAppServiceError`, `ctx.apps` | Call required apps/services without direct database access |
146
+ | `service(...)`, `services(ctx)`, `ServicesClient`, `BrokerCallError` | Provide and consume OS-broker service methods/events |
145
147
  | `OrgRepository`, `ctx.repo(Model)` | Org-safe convenience CRUD for app-owned models |
146
148
  | `PluginBase`, `OrgScopedTable` | SQLAlchemy declarative bases for plugin-owned tables |
147
149
  | `ensure_org_rls(op, table)` | Alembic helper that enables org row-level security |
148
150
  | `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)` | Manifest id to database-safe naming helpers |
149
151
  | `get_config(ctx, key)`, `require_config(ctx, key)` | Functional form of config reads when not using `ctx.config_value(...)` |
150
- | `LocalRedisService`, `LocalVectorService` | Local `pltt dev` service emulators and test fakes |
152
+ | `LocalRedisService`, `LocalVectorService`, `LocalStorageService` | Local `pltt dev` service emulators and test fakes |
151
153
  | `PlatformServiceUnavailable`, `UnavailablePlatformService` | Clear errors when an undeclared platform service is used |
152
154
  | `LifecycleHooks` | Install/update/enable/disable/uninstall callbacks |
153
155
  | `Event`, `EventPublisher`, `subscribe_event(...)` | In-process event subscriptions and declared app event publishing |
@@ -631,9 +633,10 @@ Declare secret ownership in `palette-plugin.json`:
631
633
  ```
632
634
 
633
635
  `ctx.secret("KEY")` resolves declared secrets from the configured scope:
634
- install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
635
- during `pltt dev`. Undeclared keys still fall back to the process environment
636
- for local compatibility.
636
+ install config, plugin-scope encrypted secrets, or local root `.env` files
637
+ during `pltt dev`. During hosted preview/publish, server-only `.env` keys are
638
+ uploaded automatically as encrypted plugin secrets. Undeclared keys still fall
639
+ back to the process environment for local compatibility.
637
640
 
638
641
  Managed Redis, vector, and storage services are declared in the manifest:
639
642
 
@@ -689,57 +692,84 @@ token = await ctx.connections.access_token("google_calendar")
689
692
 
690
693
  ## 11. App-To-App Services
691
694
 
692
- Apps can expose governed services and depend on services from other installed
693
- apps. Declare provider capabilities with `provides`:
695
+ Apps can expose governed broker services and events, then consume them from
696
+ other installed apps without knowing another app's URL.
697
+
698
+ Provider apps declare a namespace and callable methods with `provides`:
694
699
 
695
700
  ```json
696
701
  {
697
702
  "provides": {
703
+ "namespace": "hr",
698
704
  "services": [
699
705
  {
700
- "id": "org.hierarchy",
701
- "version": "1.0.0",
702
- "routes": [
703
- { "method": "GET", "path": "/hierarchy/approval-chain/{user_id}" }
706
+ "id": "hr.directory",
707
+ "methods": [
708
+ { "name": "approvalChain.get", "input_schema": "schemas/approval-chain.input.json" }
704
709
  ]
705
710
  }
706
711
  ],
707
- "events": ["hierarchy.updated"]
712
+ "events": [{ "topic": "hierarchy.updated" }]
708
713
  }
709
714
  }
710
715
  ```
711
716
 
712
- Consumers declare dependencies with `requires`:
717
+ Expose the handler with `@service`:
718
+
719
+ ```python
720
+ from palette_sdk import PluginContext, service
721
+
722
+ @service("approvalChain.get")
723
+ async def approval_chain(ctx: PluginContext, payload: dict) -> dict:
724
+ return {"approvers": [{"user_id": ctx.user_id, "step": 1}]}
725
+ ```
726
+
727
+ Consumers declare qualified targets with `consumes`:
713
728
 
714
729
  ```json
715
730
  {
716
- "requires": {
731
+ "consumes": {
717
732
  "services": [
718
- { "id": "org.hierarchy", "version": "^1.0.0", "reason": "Approval routing" }
733
+ {
734
+ "target": "hr/v1#approvalChain.get",
735
+ "required": true,
736
+ "reason": "Route leave approvals through the HR hierarchy"
737
+ }
738
+ ],
739
+ "events": [
740
+ { "target": "hr/v1#hierarchy.updated", "required": false }
719
741
  ]
720
742
  }
721
743
  }
722
744
  ```
723
745
 
724
- Then call through `ctx.apps`; never read another app's database directly. If
725
- the same app emits `leave.requested`, declare that topic under its own
726
- `provides.events` first:
746
+ The CLI can update the manifest and generate typed clients for those targets:
727
747
 
728
- ```python
729
- chain = await ctx.apps.service("org.hierarchy").get(f"/hierarchy/approval-chain/{ctx.user_id}")
730
- await ctx.events.publish("leave.requested", {"next_approver_id": chain["next_approver_id"]})
748
+ ```bash
749
+ pltt services add hr/v1#approvalChain.get --reason "Route leave approvals through HR"
750
+ pltt services add hr/v1#hierarchy.updated --event --optional
751
+ pltt services pull --env staging
731
752
  ```
732
753
 
733
- Local mocks live in `.palette/app-services.local.json`:
754
+ `pltt services pull` writes `.palette/types/services.ts` for frontend code and
755
+ `.palette/python/services.py` for backend code from the schemas returned by the
756
+ selected Palette environment.
734
757
 
735
- ```json
736
- {
737
- "org.hierarchy GET /hierarchy/approval-chain/dev-user": {
738
- "next_approver_id": "manager-1"
739
- }
740
- }
758
+ Then call through the OS broker; never read another app's database directly.
759
+ If the same app emits `leave.requested`, declare that topic under its own
760
+ `provides.events` first and emit with `ctx.events.emit`:
761
+
762
+ ```python
763
+ from palette_sdk import services
764
+
765
+ chain = await services(ctx).call("hr/v1#approvalChain.get", {"user_id": ctx.user_id})
766
+ await ctx.events.emit("leave/v1#leave.requested", {"approval_chain": chain})
741
767
  ```
742
768
 
769
+ At install time, Palette checks required `consumes` targets and can show the
770
+ provider apps that must also be installed. The org-level grant is saved on the
771
+ install and the broker checks it on every call, emit, or event stream.
772
+
743
773
  App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
744
774
 
745
775
  Palette scopes storage the same way. Files written through `ctx.storage` or the
@@ -886,7 +916,7 @@ point = ctx.vector.scoped_point(
886
916
  await client.upsert(collection_name=collection, points=[point])
887
917
  ```
888
918
 
889
- ## 10. Lifecycle Hooks
919
+ ## 12. Lifecycle Hooks
890
920
 
891
921
  Lifecycle hooks let an app seed defaults or clean up app-owned data when the
892
922
  platform installs, updates, enables, disables, or uninstalls the app.
@@ -908,7 +938,7 @@ async def on_enable(ctx: PluginContext):
908
938
  ctx.logger.info("finance tools enabled for org %s", ctx.organization_id)
909
939
  ```
910
940
 
911
- ## 11. Calling Backend APIs From Frontend
941
+ ## 13. Calling Backend APIs From Frontend
912
942
 
913
943
  Frontend code should call backend routes through the platform API helper, not by
914
944
  hardcoding backend origins.
@@ -925,7 +955,7 @@ async function loadInvoices() {
925
955
  During `pltt dev`, the simulator routes this to the local backend. In hosted
926
956
  sandbox or OS runtime, it goes through the real platform shell and auth context.
927
957
 
928
- ## 12. Local Development
958
+ ## 14. Local Development
929
959
 
930
960
  Create and run an app:
931
961
 
@@ -947,7 +977,7 @@ The CLI checks manifest shape, SDK compatibility, frontend bundling, backend
947
977
  imports, backend route permission gates, declared permissions, migration safety,
948
978
  package dependency policy, and backend package size.
949
979
 
950
- ## 13. Test In Real Palette OS Without Docker
980
+ ## 15. Test In Real Palette OS Without Docker
951
981
 
952
982
  Configure the hosted sandbox once:
953
983
 
@@ -977,7 +1007,7 @@ APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS=true
977
1007
 
978
1008
  That lets passing preview publishes become active without manual approval.
979
1009
 
980
- ## 14. Common Mistakes
1010
+ ## 16. Common Mistakes
981
1011
 
982
1012
  - Do not import `@palettelab/sdk` from Python. That package is for frontend
983
1013
  JavaScript/React code only.
@@ -992,7 +1022,7 @@ That lets passing preview publishes become active without manual approval.
992
1022
  - Use hosted sandbox when you need real Data Rooms, login, organization,
993
1023
  install, logs, and OS behavior without Docker.
994
1024
 
995
- ## 15. Complete Minimal Example
1025
+ ## 17. Complete Minimal Example
996
1026
 
997
1027
  `backend/api/main.py`:
998
1028