@ingram-cloud/pulumi 2.1.1 → 3.0.0

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
@@ -1,14 +1,9 @@
1
1
  # @ingram-cloud/pulumi
2
2
 
3
3
  Pulumi dynamic resources for the [Ingram Cloud](https://cloud.ingram.tech) `/v1`
4
- API. Declare your tenant's IC configuration — agents, MCP servers, integrations,
5
- webhooks, BYOK model keys — as Pulumi resources instead of imperative
6
- `register-*.ts` / `ensure-agent.ts` scripts, so it lives in state and a change
7
- is a `pulumi up`.
8
-
9
- The library is published alongside the Ingram Cloud `/v1` API so the wrapper
10
- tracks the surface it wraps. The API version it targets is pinned per library
11
- version (`IC_API_VERSION`).
4
+ API: agents, MCP servers, integrations, webhooks, BYOK model keys and projects,
5
+ declared as Pulumi resources and applied with `pulumi up`. Each library version
6
+ pins the API version it targets (`IC_API_VERSION`).
12
7
 
13
8
  ## Install
14
9
 
@@ -16,39 +11,35 @@ version (`IC_API_VERSION`).
16
11
  bun add @ingram-cloud/pulumi @pulumi/pulumi
17
12
  ```
18
13
 
19
- ## How the repos interact
14
+ ## Where each resource belongs
15
+
16
+ This library carries no prompts or agent content; `instructions` is an input you
17
+ pass. A typical split:
20
18
 
21
19
  ```
22
- cloud.ingram.tech/pulumi/ THIS LIBRARY — knows nothing about any consumer
23
- (Agent, McpServer, TelegramBot, WhatsAppConfig,
24
- Webhook, ModelKey, Secret)
25
-
26
- app repos (thornhill, …) declare their AGENTS — the `instructions` are
27
- app-owned content (prompts), so they stay in the app
28
- repo, sourced from the app's own spec module. The app's
29
- Pulumi program imports this library + those specs.
30
-
31
- infra repo (~/src/infra) declares tenant INTEGRATION config (MCP, Telegram, WhatsApp,
32
- webhook, model keys) alongside DNS/DB/Vercel, and can
33
- read an app stack's outputs via pulumi.StackReference to
34
- push IC-derived values (agent ids, webhook secret)
35
- into Vercel env. No prompts ever live here.
36
- ```
20
+ app stack IcAgent, IcWebPage, IcSkill — instructions come from the app's
21
+ own source.
37
22
 
38
- The hard rule: **prompts never leave the app repo.** This library carries no
39
- content; it only carries the machinery.
23
+ infra stack IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppNumber,
24
+ IcEmail, IcWebhook, IcModelKey — tenant integration config. It can
25
+ read the app stack's outputs (agent ids, webhook secret) through
26
+ pulumi.StackReference and write them to the app's env.
27
+
28
+ platform stack IcProject, IcProjectToken — provisions projects and mints their
29
+ tokens with an organization key.
30
+ ```
40
31
 
41
32
  ## Connection
42
33
 
43
34
  Every resource takes `baseUrl` + `token` (the tenant-admin bearer; `IcProject` /
44
- `IcProjectToken` take an **organization** key instead — see below). Resolve them
45
- once with `connectionFromConfig()` and spread:
35
+ `IcProjectToken` take an organization key instead, see below). Resolve them once
36
+ with `connectionFromConfig()` and spread:
46
37
 
47
38
  ```ts
48
39
  import * as ic from "@ingram-cloud/pulumi";
49
40
 
50
41
  const conn = ic.connectionFromConfig(); // ingram-cloud:token (secret) / :baseUrl
51
- // or env INGRAM_CLOUD_TOKEN|CLOUD_API_KEY
42
+ // or env INGRAM_CLOUD_TOKEN|CLOUD_API_KEY
52
43
  ```
53
44
 
54
45
  Set the token as a stack secret:
@@ -59,132 +50,132 @@ pulumi config set --secret ingram-cloud:token tha_live_…
59
50
 
60
51
  ## Resources
61
52
 
62
- ### IcAgent (declare in the APP repo)
53
+ ### IcAgent (app stack)
63
54
 
64
- Create-or-adopt by **`slug`** → publish a new immutable version **only when the
65
- content changed** → roll it out. Mirrors the old `ensure-agent.ts` idempotency, so
66
- the first `pulumi up` after switching from a script **adopts** the existing agent
67
- (matched by slug) rather than recreating it.
55
+ Creates or adopts an agent by `slug`, publishes a new immutable version only when
56
+ the content changed, then rolls it out. The first `pulumi up` adopts an existing
57
+ agent with a matching slug rather than recreating it.
68
58
 
69
- `slug` is the immutable reconcile key; it defaults to the Pulumi resource name
70
- (here `"curator"`), so existing stacks keep adopting the same agent. `name` is a
71
- free display label you can rename without churning the agent. Changing `slug`
72
- replaces it.
59
+ `slug` is the reconcile key and defaults to the Pulumi resource name. `name` is a
60
+ display label you can change without churning the agent. Changing `slug` replaces
61
+ the agent.
73
62
 
74
63
  ```ts
75
64
  import { AGENT_SPECS } from "../src/lib/cloud/agent-spec"; // app-owned
76
65
 
77
66
  const curator = new ic.IcAgent("curator", {
78
- ...conn,
79
- // slug defaults to "curator"; pass `slug` to decouple the key from the name.
80
- name: AGENT_SPECS.curator.name, // display label, freely renamable
81
- instructions: AGENT_SPECS.curator.instructions,
82
- model: AGENT_SPECS.curator.model,
83
- autoMemory: AGENT_SPECS.curator.auto_memory,
84
- // memoryConsolidation: true, // opt into background consolidation (billed)
85
- // variables: [...], enabledHostedTools: [...], rolloutPercent: 100,
86
- // mcpServers: ["sheets"], // scope runs to these registered MCP servers (omit = all)
67
+ ...conn,
68
+ // slug defaults to "curator"; pass `slug` to decouple the key from the name.
69
+ name: AGENT_SPECS.curator.name, // display label, freely renamable
70
+ instructions: AGENT_SPECS.curator.instructions,
71
+ model: AGENT_SPECS.curator.model,
72
+ autoMemory: AGENT_SPECS.curator.auto_memory,
73
+ // memoryConsolidation: true, // opt into background consolidation (billed)
74
+ // variables: [...], enabledHostedTools: [...], rolloutPercent: 100,
75
+ // mcpServers: ["sheets"], // scope runs to these registered MCP servers (omit = all)
87
76
  });
88
77
 
89
78
  export const curatorAgentId = curator.agentId;
90
79
  ```
91
80
 
92
81
  **MCP Apps UI templates.** Pass `uiTemplates` to attach interactive HTML bundles
93
- (MCP Apps, SEP-1865). Each template's bytes upload when they (or the metadata)
94
- change, and its name + content hash + `tool`/`csp`/`permissions` fold into the
95
- content signature — so a template edit publishes and rolls out a new version like
96
- an instruction change. Provide the HTML as an absolute `htmlPath` or inline
97
- `html`; a `tool` binds it as a typed app-tool. Dropping a template from the array
98
- deletes it from the draft.
82
+ (MCP Apps, SEP-1865). A template's bytes upload when they or its metadata change.
83
+ Its name, content hash and metadata (`tool`, `csp`, `permissions`,
84
+ `prefersBorder`, `domain`) are part of the content signature, so editing a
85
+ template publishes and rolls out a new version. Provide the HTML as an absolute
86
+ `htmlPath` or inline `html`; `tool` binds it as a typed app-tool, and
87
+ `tool.visibility: ["app"]` hides that tool from the host's model so only the
88
+ rendered panel can call it. Removing a template from the array deletes it from
89
+ the draft.
99
90
 
100
91
  ```ts
101
92
  import { join } from "node:path";
102
93
 
103
94
  const bookkeeper = new ic.IcAgent("bookkeeper", {
104
- ...conn,
105
- instructions: BOOKKEEPER_INSTRUCTIONS,
106
- uiTemplates: [
107
- {
108
- name: "cash_chart",
109
- htmlPath: join(__dirname, "ui/cash-chart.html"),
110
- tool: {
111
- description: "Show an interactive cash-flow chart.",
112
- input_schema: { type: "object", properties: {}, required: [] },
113
- instruction: "Call get_cash_flow_history, then render_app with template cash_chart.",
114
- mutating: false,
115
- },
116
- },
117
- ],
95
+ ...conn,
96
+ instructions: BOOKKEEPER_INSTRUCTIONS,
97
+ uiTemplates: [
98
+ {
99
+ name: "cash_chart",
100
+ htmlPath: join(__dirname, "ui/cash-chart.html"),
101
+ tool: {
102
+ description: "Show an interactive cash-flow chart.",
103
+ input_schema: { type: "object", properties: {}, required: [] },
104
+ instruction:
105
+ "Call get_cash_flow_history, then render_app with template cash_chart.",
106
+ mutating: false,
107
+ },
108
+ },
109
+ ],
118
110
  });
119
111
  ```
120
112
 
121
- > Attaching **existing** smiths to a agent is a one-time fleet backfill —
122
- > keep it as the app's migration script. New smiths attach at birth in app code.
123
- > Pulumi does not own per-smith runtime state.
113
+ Pulumi does not own per-smith runtime state. New smiths attach to an agent in
114
+ app code; attaching existing smiths is a one-time migration, not a resource.
124
115
 
125
- ### IcWebPage (declare in the APP repo)
116
+ ### IcWebPage (app stack)
126
117
 
127
- A **hosted page** — a private, shareable web page that runs an agent (a `web`
128
- deployment). Each visitor gets their own smith; the 128-bit `dep_` id in the URL is
129
- the privacy boundary, with an optional shared `password` on top. Point it at an
130
- `IcAgent` and read back `pageUrl` — the link to hand out.
118
+ A hosted page: a private, shareable web page that runs an agent (a `web`
119
+ deployment). Each visitor gets their own smith. The 128-bit `dep_` id in the URL
120
+ is the access control, with an optional shared `password`. Point it at an
121
+ `IcAgent` and read back `pageUrl`.
131
122
 
132
123
  ```ts
133
124
  const page = new ic.IcWebPage("docs-page", {
134
- ...conn,
135
- agentId: curator.agentId,
136
- title: "Ingram Cloud docs assistant",
137
- greeting: "Ask me anything about Ingram Cloud.",
138
- // password: "optional-shared-secret",
125
+ ...conn,
126
+ agentId: curator.agentId,
127
+ title: "Ingram Cloud docs assistant",
128
+ greeting: "Ask me anything about Ingram Cloud.",
129
+ // password: "optional-shared-secret",
139
130
  });
140
131
 
141
132
  export const pageUrl = page.pageUrl; // https://cloud.ingram.tech/hosted/dep_…
142
133
  ```
143
134
 
144
- > The API has no update path for a web deployment's title/greeting/password, so a
145
- > change to any of those (or to `agentId`) **replaces** the page and mints a fresh
146
- > `pageUrl`. The agent's instructions change on the `IcAgent`, not here.
135
+ The API has no update path for a web deployment's title, greeting or password,
136
+ so changing any of those (or `agentId`) replaces the page and mints a new
137
+ `pageUrl`. Instructions change on the `IcAgent`.
147
138
 
148
- ### IcSkill (declare in the APP repo)
139
+ ### IcSkill (app stack)
149
140
 
150
141
  An [Agent Skills](https://cloud.ingram.tech/docs/skills) bundle: a folder
151
- anchored by `SKILL.md`, uploaded whole and versioned server-side. Declared by
152
- **local directory** — the folder is the unit — like `IcAgent`'s `uiTemplates`
153
- and the vector-store-shaped file attachments elsewhere in this pattern. The
154
- bytes are read at `pulumi up`; any edit inside the folder publishes a new
155
- immutable version and moves `default_version` to it, because a version is
156
- immutable and nothing resolving by default would otherwise see the change.
142
+ anchored by `SKILL.md`, uploaded whole and versioned server-side. Declare it by
143
+ local directory. The bytes are read at `pulumi up`; any edit inside the folder
144
+ publishes a new immutable version and moves `default_version` to it.
157
145
 
158
146
  ```ts
159
147
  // The folder is the unit. Its basename is the skill's name, and any edit
160
148
  // inside it publishes a new immutable version and moves the default.
161
149
  const vatFiling = new ic.IcSkill("vat-filing", {
162
- ...conn,
163
- path: join(__dirname, "skills/vat-filing"),
150
+ ...conn,
151
+ path: join(__dirname, "skills/vat-filing"),
164
152
  });
165
153
 
166
- // Put it in front of a fleet. Order matters: a run that fills its search index
154
+ // Attach it to an agent. Order matters: a run that fills its search index
167
155
  // keeps the earlier entries and drops the rest.
168
156
  const biller = new ic.IcAgent("biller", {
169
- ...conn,
170
- instructions: AGENT_SPECS.biller.instructions,
171
- skills: [{ skill_id: vatFiling.skillId }],
172
- // A skill's scripts/ only run through the run_command hosted tool.
173
- enabledHostedTools: ["run_command"],
157
+ ...conn,
158
+ instructions: AGENT_SPECS.biller.instructions,
159
+ skills: [{ skill_id: vatFiling.skillId }],
160
+ // A skill's scripts/ only run through the run_command hosted tool.
161
+ enabledHostedTools: ["run_command"],
174
162
  });
175
163
  ```
176
164
 
177
- ### IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppConfig, IcEmail, IcWebhook, IcModelKey (declare in INFRA)
165
+ ### IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppNumber, IcEmail, IcWebhook, IcModelKey (infra stack)
178
166
 
179
167
  ```ts
180
168
  // Your own server (raw URL).
181
- new ic.IcMcpServer("thornhill-mcp", {
182
- ...conn, serverName: "thornhill", url: `${APP_URL}/api/mcp`,
183
- authKind: "bearer", secret: mcpSecret,
169
+ new ic.IcMcpServer("acme-mcp", {
170
+ ...conn,
171
+ serverName: "acme",
172
+ url: `${APP_URL}/api/mcp`,
173
+ authKind: "bearer",
174
+ secret: mcpSecret,
184
175
  });
185
176
 
186
- // The tool list is discovered at create, not on every `pulumi up` — nothing in
187
- // this stack changes when your *server's* tools do, so `toolsDiscovered` is what
177
+ // The tool list is discovered at create, not on every `pulumi up`: nothing in
178
+ // this stack changes when your server's tools do, so `toolsDiscovered` is what
188
179
  // was true at registration. Ingram Cloud re-discovers on the run path once a
189
180
  // manifest is an hour old; call `POST /v1/tenant/mcp/{serverName}/refresh` at the
190
181
  // end of a deploy that changed your tools if an hour is too long to wait.
@@ -192,104 +183,108 @@ new ic.IcMcpServer("thornhill-mcp", {
192
183
  // An `oauth`-kind server forwards each end-user's own stored token; its
193
184
  // provider record tells IC how tokens refresh. Webhook-delegation mode (a
194
185
  // refreshWebhook and NO clientSecret): IC POSTs { connection_id } to the
195
- // tenant near expiry and the tenant PATCHes a fresh pair back — the tenant
196
- // owns the token format entirely.
197
- new ic.IcMcpServer("thornhill-user-mcp", {
198
- ...conn, serverName: "thornhill-user", url: `${APP_URL}/api/user-mcp`,
199
- authKind: "oauth", authProvider: "thornhill",
186
+ // tenant near expiry and the tenant PATCHes a fresh pair back, so the tenant
187
+ // owns the token format.
188
+ new ic.IcMcpServer("acme-user-mcp", {
189
+ ...conn,
190
+ serverName: "acme-user",
191
+ url: `${APP_URL}/api/user-mcp`,
192
+ authKind: "oauth",
193
+ authProvider: "acme",
200
194
  });
201
- new ic.IcOauthProvider("thornhill-provider", {
202
- ...conn, provider: "thornhill",
203
- refreshWebhook: `${APP_URL}/api/ic/refresh`,
195
+ new ic.IcOauthProvider("acme-provider", {
196
+ ...conn,
197
+ provider: "acme",
198
+ refreshWebhook: `${APP_URL}/api/ic/refresh`,
204
199
  });
205
200
 
206
- // A curated third party: stamp from the catalog, gate which tools run, and
207
- // require human approval on the scary ones. Per-smith OAuth tokens are NOT
208
- // declared here — the runtime collects each end-user's identity (the hosted
201
+ // A catalog third party: stamp from the catalog, gate which tools run, and
202
+ // require human approval on the destructive ones. Per-smith OAuth tokens are
203
+ // NOT declared here; the runtime collects each end-user's identity (the hosted
209
204
  // connect flow vaults it). `clientMode: "platform"` uses Ingram's OAuth client.
210
205
  new ic.IcMcpServer("stripe", {
211
- ...conn, serverName: "stripe", catalog: "stripe",
212
- toolAllowlist: ["get_balance", "list_charges", "create_refund"],
213
- approvalPolicy: [{ match: "create_refund" }],
206
+ ...conn,
207
+ serverName: "stripe",
208
+ catalog: "stripe",
209
+ toolAllowlist: ["get_balance", "list_charges", "create_refund"],
210
+ approvalPolicy: [{ match: "create_refund" }],
214
211
  });
215
212
 
216
- new ic.IcTelegramBot("thornhill-telegram", { ...conn, botToken });
213
+ new ic.IcTelegramBot("acme-telegram", { ...conn, botToken });
217
214
 
218
215
  // The agent's own email channel (BYO-Cloudflare transport). IC returns the
219
- // inbound HMAC once — it becomes a secret output you flow into the inbound worker.
220
- const email = new ic.IcEmail("thornhill-email", {
221
- ...conn, cloudflareAccountId, cloudflareApiToken, fromDomain: "mail.thornhill.app",
216
+ // inbound HMAC once; it becomes a secret output you flow into the inbound worker.
217
+ const email = new ic.IcEmail("acme-email", {
218
+ ...conn,
219
+ cloudflareAccountId,
220
+ cloudflareApiToken,
221
+ fromDomain: "mail.acme.example",
222
222
  });
223
223
  export const inboundEmailSecret = email.inboundSecret; // create-time secret output
224
224
 
225
- const hook = new ic.IcWebhook("thornhill-events", {
226
- ...conn, url: `${APP_URL}/api/ic/events`,
227
- events: ["run.completed", "approval.required", /* … */],
225
+ const hook = new ic.IcWebhook("acme-events", {
226
+ ...conn,
227
+ url: `${APP_URL}/api/ic/events`,
228
+ events: ["run.completed", "approval.required" /* … */],
228
229
  });
229
230
  export const webhookSigningSecret = hook.secret; // whsec_… (create-only secret output)
230
231
 
231
232
  new ic.IcModelKey("anthropic", { ...conn, provider: "anthropic", apiKey });
232
233
  ```
233
234
 
234
- `IcWebhook.secret` is the `whsec_…` signing secret IC returns exactly once — it
235
- becomes a secret Pulumi output, so the infra stack can flow it straight into Vercel
236
- env with no copy-paste.
235
+ `IcWebhook.secret` is the `whsec_…` signing secret the API returns once. It is a
236
+ secret Pulumi output.
237
237
 
238
- ### IcProject + IcProjectToken (declare in the PLATFORM stack)
238
+ ### IcProject + IcProjectToken (platform stack)
239
239
 
240
- The tier **above** a project. These take an **organization key**
241
- (`organization:*`, your account master key) — not a project token. Hand it to a
242
- platform stack and it provisions projects and mints each one a project-scoped
243
- `tenant:*` token, which you drop straight into that app's env. The org key reads
244
- no run, memory, or smith itself; only the project tokens it mints can.
240
+ These take an organization key (`organization:*`, your account master key), not a
241
+ project token. `IcProject` provisions a project; `IcProjectToken` mints it a
242
+ project-scoped `tenant:*` token for the app's env. The organization key cannot
243
+ read runs, memory or smiths; only the project tokens it mints can.
245
244
 
246
245
  ```ts
247
246
  // In the PLATFORM stack, `ingram-cloud:token` is your ORGANIZATION key.
248
- const project = new ic.IcProject("thornhill", { ...conn }); // adopt-by-name
249
- const icToken = new ic.IcProjectToken("thornhill-admin", {
250
- ...conn,
251
- project: project.projectId, // the project id == the tenant
247
+ const project = new ic.IcProject("acme", { ...conn }); // adopt-by-name
248
+ const icToken = new ic.IcProjectToken("acme-admin", {
249
+ ...conn,
250
+ project: project.projectId, // the project id == the tenant
252
251
  });
253
252
 
254
- // Flow the minted tenant token straight into the app's runtime env:
255
- new vercel.ProjectEnvironmentVariable("th-ic-token", {
256
- projectId: thornhillVercelId,
257
- key: "INGRAM_CLOUD_TOKEN",
258
- value: icToken.projectToken, // secret tenant:* token
259
- targets: ["production"],
253
+ // Flow the minted tenant token into the app's runtime env:
254
+ new vercel.ProjectEnvironmentVariable("acme-ic-token", {
255
+ projectId: acmeVercelId,
256
+ key: "INGRAM_CLOUD_TOKEN",
257
+ value: icToken.projectToken, // secret tenant:* token
258
+ targets: ["production"],
260
259
  });
261
- export const thornhillProjectId = project.projectId;
260
+ export const acmeProjectId = project.projectId;
262
261
  ```
263
262
 
264
263
  `IcProject` adopts an existing project by `projectName` (defaults to the resource
265
- name), so re-runs reconcile. `IcProjectToken.projectToken` is a secret output; it
266
- re-mints on any change and revokes on destroy.
264
+ name). `IcProjectToken.projectToken` is a secret output; it re-mints on any change
265
+ and revokes on destroy.
267
266
 
268
- ## Importing resources a script already created
267
+ ## Importing existing resources
269
268
 
270
- Agents and webhooks implement `read`, so you can adopt pre-existing ones:
269
+ Agents and webhooks implement `read`, so you can import them:
271
270
 
272
271
  ```bash
273
272
  pulumi import 'ingram-cloud:index:IcAgent' curator agt_123…
274
273
  ```
275
274
 
276
- (Agents also self-adopt by slug on first `create`, so an explicit import is
277
- usually unnecessary — a plain `pulumi up` will reconcile the live agent.)
278
-
279
275
  ## Notes
280
276
 
281
- - Built as CommonJS to match the Pulumi Node.js runtime and the infra stack's
282
- `module: commonjs` tsconfig.
283
- - `token` and every credential input are marked `additionalSecretOutputs`, so they
284
- are encrypted in Pulumi state.
277
+ - Built as CommonJS for the Pulumi Node.js runtime.
278
+ - `token` and every credential input are `additionalSecretOutputs`, encrypted in
279
+ Pulumi state.
285
280
  - CRUD runs inline in the Pulumi process over the global `fetch` (Node ≥18).
286
- - `read` treats a server-side **404 as "gone"** (returns an empty id) so
287
- `pulumi refresh` prunes a resource deleted out-of-band — e.g. after an IC DB
288
- reset, `pulumi refresh && pulumi up` recreates it, no `pulumi state delete`.
289
- Non-404 errors still throw, so a transient API blip can't drop it from state.
290
- - `IcMcpServer` `read` also round-trips the live `url` / `authKind` /
291
- `authProvider` (raw registrations only — catalog presets supply these
292
- server-side), so `pulumi refresh && pulumi up` detects and repairs server-side
293
- drift such as a lost auth block. Write-only fields (`secret`) can't round-trip
294
- and stay diff-by-state. Dynamic providers run the code serialized into state,
295
- so drift detection starts after the first `pulumi up` with this version.
281
+ - `read` treats a server-side 404 as gone (empty id), so `pulumi refresh` prunes a
282
+ resource deleted out-of-band and `pulumi up` recreates it without
283
+ `pulumi state delete`. Other errors throw, so a transient API error cannot drop
284
+ a resource from state.
285
+ - `IcMcpServer` `read` round-trips the live `url` / `authKind` / `authProvider`
286
+ (raw registrations only; catalog presets supply these server-side), so
287
+ `pulumi refresh && pulumi up` repairs server-side drift such as a lost auth
288
+ block. Write-only fields (`secret`) cannot round-trip and diff by state.
289
+ Dynamic providers run the code serialized into state, so drift detection starts
290
+ after the first `pulumi up` with this version.
@@ -14,7 +14,7 @@
14
14
  * so declare these in the APP repo's Pulumi program, sourced from the app's
15
15
  * own spec module. Never copy prompts into the infra repo.
16
16
  * - **Integrations / tenant config** (`IcMcpServer`, `IcOauthProvider`,
17
- * `IcTelegramBot`, `IcWhatsAppConfig`, `IcWebhook`, `IcModelKey`) —
17
+ * `IcTelegramBot`, `IcWhatsAppNumber`, `IcWebhook`, `IcModelKey`) —
18
18
  * tenant-level wiring; declare in the infra repo alongside DNS/DB/Vercel.
19
19
  *
20
20
  * Not modelled here, on purpose:
@@ -51,8 +51,8 @@ export interface UiCsp {
51
51
  frameDomains?: string[];
52
52
  baseUriDomains?: string[];
53
53
  }
54
- /** A typed app-tool bound to a template (Rung 2): the host's model calls it and
55
- * the template renders the result. */
54
+ /** A typed app-tool bound to a template: the host's model (or the rendered
55
+ * panel) calls it and the template renders the result. */
56
56
  export interface UiResourceTool {
57
57
  description: string;
58
58
  /** JSON Schema for the tool's arguments. */
@@ -61,6 +61,9 @@ export interface UiResourceTool {
61
61
  instruction?: string | null;
62
62
  /** Writes gate through approval; reads flow freely. */
63
63
  mutating?: boolean;
64
+ /** MCP Apps `_meta.ui.visibility`: `["app"]` hides the tool from the host's
65
+ * model so only the rendered panel can call it. Omitted means both. */
66
+ visibility?: ("model" | "app")[];
64
67
  }
65
68
  /** One UI template declared on an `IcAgent`. Provide the HTML via `htmlPath`
66
69
  * (read at `pulumi up`, so use an absolute path) or inline `html`. */
@@ -74,6 +77,12 @@ export interface UiTemplate {
74
77
  csp?: UiCsp;
75
78
  /** Permissions-Policy-style host permissions the template requests. */
76
79
  permissions?: Record<string, unknown>;
80
+ /** Whether the host draws its own border around the panel; omitted leaves it
81
+ * to the host. */
82
+ prefersBorder?: boolean;
83
+ /** A stable sandbox origin for the panel, in the host's own format (Claude:
84
+ * `{sha256(connector URL)[:32]}.claudemcpcontent.com`). */
85
+ domain?: string;
77
86
  tool?: UiResourceTool;
78
87
  }
79
88
  export interface IcAgentArgs {
@@ -239,7 +248,7 @@ export interface IcOauthProviderArgs {
239
248
  export declare class IcOauthProvider extends pulumi.dynamic.Resource {
240
249
  constructor(name: string, args: IcOauthProviderArgs, opts?: pulumi.CustomResourceOptions);
241
250
  }
242
- export interface IcWhatsAppConfigArgs {
251
+ export interface IcWhatsAppNumberArgs {
243
252
  baseUrl: pulumi.Input<string>;
244
253
  token: pulumi.Input<string>;
245
254
  phoneNumberId: pulumi.Input<string>;
@@ -247,10 +256,12 @@ export interface IcWhatsAppConfigArgs {
247
256
  /** The WhatsApp Business Account id — IC subscribes its app to it so inbound flows. */
248
257
  wabaId: pulumi.Input<string>;
249
258
  }
250
- export declare class IcWhatsAppConfig extends pulumi.dynamic.Resource {
251
- readonly displayPhoneNumber: pulumi.Output<string>;
259
+ /** One of the tenant's WhatsApp numbers. A tenant may register several; bind one to
260
+ * an agent with a `whatsapp` deployment whose `address` is its `phoneNumberId`. */
261
+ export declare class IcWhatsAppNumber extends pulumi.dynamic.Resource {
262
+ readonly displayPhoneNumber: pulumi.Output<string | null>;
252
263
  readonly webhookUrl: pulumi.Output<string>;
253
- constructor(name: string, args: IcWhatsAppConfigArgs, opts?: pulumi.CustomResourceOptions);
264
+ constructor(name: string, args: IcWhatsAppNumberArgs, opts?: pulumi.CustomResourceOptions);
254
265
  }
255
266
  export interface IcEmailArgs {
256
267
  baseUrl: pulumi.Input<string>;
package/dist/resources.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppConfig = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcVectorStore = exports.IcSkill = exports.IcAgent = exports.IC_API_VERSION = void 0;
36
+ exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppNumber = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcVectorStore = exports.IcSkill = exports.IcAgent = exports.IC_API_VERSION = void 0;
37
37
  /**
38
38
  * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
39
39
  *
@@ -50,7 +50,7 @@ exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModel
50
50
  * so declare these in the APP repo's Pulumi program, sourced from the app's
51
51
  * own spec module. Never copy prompts into the infra repo.
52
52
  * - **Integrations / tenant config** (`IcMcpServer`, `IcOauthProvider`,
53
- * `IcTelegramBot`, `IcWhatsAppConfig`, `IcWebhook`, `IcModelKey`) —
53
+ * `IcTelegramBot`, `IcWhatsAppNumber`, `IcWebhook`, `IcModelKey`) —
54
54
  * tenant-level wiring; declare in the infra repo alongside DNS/DB/Vercel.
55
55
  *
56
56
  * Not modelled here, on purpose:
@@ -150,6 +150,23 @@ function makePutResource(cfg) {
150
150
  },
151
151
  };
152
152
  }
153
+ const UI_META_KEYS = [
154
+ "csp",
155
+ "permissions",
156
+ "prefers_border",
157
+ "domain",
158
+ "tool",
159
+ ];
160
+ /** The declared metadata keys of `obj`, in canonical order, absent ones dropped —
161
+ * so a resource input and a stored entry compare byte-equal under `eq`. */
162
+ function pickUiMeta(obj) {
163
+ const src = obj;
164
+ const out = {};
165
+ for (const k of UI_META_KEYS)
166
+ if (src[k] != null)
167
+ out[k] = src[k];
168
+ return out;
169
+ }
153
170
  // Read each template's HTML bundle (from `htmlPath` or inline `html`), hash the
154
171
  // bytes with the same sha256-hex the API stores as `content_hash`, and carry the
155
172
  // metadata through. Module-scope + node built-ins required inside so Pulumi's
@@ -171,9 +188,13 @@ function resolveUiTemplates(templates) {
171
188
  name: t.name,
172
189
  bytes,
173
190
  contentHash: createHash("sha256").update(bytes).digest("hex"),
174
- csp: t.csp,
175
- permissions: t.permissions,
176
- tool: t.tool,
191
+ meta: pickUiMeta({
192
+ csp: t.csp,
193
+ permissions: t.permissions,
194
+ prefers_border: t.prefersBorder,
195
+ domain: t.domain,
196
+ tool: t.tool,
197
+ }),
177
198
  };
178
199
  });
179
200
  }
@@ -183,14 +204,7 @@ function resolveUiTemplates(templates) {
183
204
  async function uploadUiTemplate(baseUrl, token, aid, t) {
184
205
  const form = new FormData();
185
206
  form.set("file", new File([new Uint8Array(t.bytes)], `${t.name}.html`, { type: "text/html" }));
186
- const meta = { name: t.name };
187
- if (t.csp)
188
- meta.csp = t.csp;
189
- if (t.permissions)
190
- meta.permissions = t.permissions;
191
- if (t.tool)
192
- meta.tool = t.tool;
193
- form.set("metadata", JSON.stringify(meta));
207
+ form.set("metadata", JSON.stringify({ name: t.name, ...t.meta }));
194
208
  const res = await fetch(`${baseUrl}/v1/agents/${aid}/ui`, {
195
209
  method: "POST",
196
210
  headers: { Authorization: `Bearer ${token}`, "IC-Api-Version": exports.IC_API_VERSION },
@@ -205,16 +219,12 @@ async function uploadUiTemplate(baseUrl, token, aid, t) {
205
219
  // Reconcile the agent's UI templates against its draft `current` state: upload any
206
220
  // whose bytes or metadata drifted (or are new), delete any the resource no longer
207
221
  // declares. The draft this leaves behind is what the publish step snapshots.
222
+ const eq = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
208
223
  async function syncUiTemplates(i, id, current) {
209
224
  const desired = resolveUiTemplates(i.uiTemplates);
210
- const eq = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
211
225
  for (const t of desired) {
212
226
  const cur = current.find((e) => e?.name === t.name);
213
- const unchanged = cur &&
214
- cur.content_hash === t.contentHash &&
215
- eq(cur.tool, t.tool) &&
216
- eq(cur.csp, t.csp) &&
217
- eq(cur.permissions, t.permissions);
227
+ const unchanged = cur && cur.content_hash === t.contentHash && eq(pickUiMeta(cur), t.meta);
218
228
  if (!unchanged)
219
229
  await uploadUiTemplate(i.baseUrl, i.token, id, t);
220
230
  }
@@ -258,20 +268,14 @@ function agentBody(i) {
258
268
  };
259
269
  }
260
270
  // Canonical, name-sorted signature of an agent's frozen UI templates: each
261
- // entry's name + content hash + tool/csp/permissions metadata. Both the resource
262
- // inputs (hash computed from local bytes) and a published version's snapshot
263
- // (hash already stored) map onto this shape, so a template edit moves the
264
- // signature exactly one way.
271
+ // entry's name + content hash + declared metadata. Both the resource inputs
272
+ // (hash computed from local bytes) and a published version's snapshot (hash
273
+ // already stored) map onto this shape, so a template edit moves the signature
274
+ // exactly one way.
265
275
  function uiResourcesSig(list) {
266
276
  return (list ?? [])
267
- .map((e) => ({
268
- name: e.name,
269
- content_hash: e.content_hash,
270
- tool: e.tool ?? null,
271
- csp: e.csp ?? null,
272
- permissions: e.permissions ?? null,
273
- }))
274
- .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
277
+ .map((e) => ({ name: e.name, content_hash: e.content_hash, ...pickUiMeta(e) }))
278
+ .toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
275
279
  }
276
280
  // Stable signature of the *content* that warrants a new published version
277
281
  // (name and rollout% deliberately excluded — a rename/restage is not new content).
@@ -279,7 +283,7 @@ function agentContentSig(s) {
279
283
  const base = {
280
284
  instructions: s.instructions ?? null,
281
285
  model: s.model ?? null,
282
- tools: [...(s.enabled_hosted_tools ?? [])].sort(),
286
+ tools: (s.enabled_hosted_tools ?? []).toSorted(),
283
287
  auto_memory: s.auto_memory ?? null,
284
288
  memory_consolidation: s.memory_consolidation ?? null,
285
289
  variables: s.variables ?? [],
@@ -292,10 +296,10 @@ function agentContentSig(s) {
292
296
  base.ui_resources = ui;
293
297
  // Same discipline for mcp_servers: unset (= all) stays out of the signature.
294
298
  if (s.mcp_servers != null)
295
- base.mcp_servers = [...s.mcp_servers].sort();
299
+ base.mcp_servers = s.mcp_servers.toSorted();
296
300
  // And for vector stores: omit when empty so agents without any keep a
297
301
  // byte-identical signature to pre-vectorStoreIds releases.
298
- const vs = [...(s.vector_store_ids ?? [])].sort();
302
+ const vs = (s.vector_store_ids ?? []).toSorted();
299
303
  if (vs.length)
300
304
  base.vector_store_ids = vs;
301
305
  // Skills, deliberately NOT sorted: reference order is the run's index-ceiling
@@ -329,9 +333,7 @@ function inputsToSnapshot(i) {
329
333
  ui_resources: resolveUiTemplates(i.uiTemplates).map((t) => ({
330
334
  name: t.name,
331
335
  content_hash: t.contentHash,
332
- tool: t.tool,
333
- csp: t.csp,
334
- permissions: t.permissions,
336
+ ...t.meta,
335
337
  })),
336
338
  };
337
339
  }
@@ -432,7 +434,7 @@ const agentProvider = {
432
434
  if (e?.status === 409)
433
435
  throw new Error(`Agent ${id} can't be archived while smiths still run it. Delete or ` +
434
436
  `re-point those smiths first, or set { retainOnDelete: true } on the ` +
435
- `resource to leave the agent in place. ${e}`);
437
+ `resource to leave the agent in place. ${e}`, { cause: e });
436
438
  throw e;
437
439
  }
438
440
  },
@@ -531,7 +533,7 @@ function resolveSkillFiles(dir) {
531
533
  walk(nodePath.resolve(dir), "");
532
534
  if (!out.some((f) => f.rel === `${root}/SKILL.md`))
533
535
  throw new Error(`skill: ${dir} has no SKILL.md`);
534
- return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
536
+ return out.toSorted((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
535
537
  }
536
538
  /** A content signature over the whole bundle — what `diff` compares. */
537
539
  function skillSig(dir) {
@@ -724,7 +726,7 @@ function vsFilesSig(files) {
724
726
  content_hash: f.contentHash,
725
727
  chunking: f.chunkingStrategy ?? null,
726
728
  }))
727
- .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)));
729
+ .toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)));
728
730
  }
729
731
  const vectorStoreProvider = {
730
732
  async create(i) {
@@ -945,22 +947,21 @@ class IcOauthProvider extends pulumi.dynamic.Resource {
945
947
  }
946
948
  exports.IcOauthProvider = IcOauthProvider;
947
949
  const whatsappProvider = makePutResource({
948
- path: () => "/v1/tenant/whatsapp",
949
- id: () => "whatsapp",
950
- body: (i) => ({
951
- phone_number_id: i.phoneNumberId,
952
- access_token: i.accessToken,
953
- waba_id: i.wabaId,
954
- }),
950
+ path: (i) => `/v1/tenant/whatsapp/${encodeURIComponent(i.phoneNumberId)}`,
951
+ id: (i) => i.phoneNumberId,
952
+ body: (i) => ({ access_token: i.accessToken, waba_id: i.wabaId }),
955
953
  outs: (_i, res) => ({
956
954
  displayPhoneNumber: res.display_phone_number,
957
955
  webhookUrl: res.webhook_url,
958
956
  }),
959
957
  diff: {
960
- scalarKeys: ["phoneNumberId", "accessToken", "wabaId", "token", "baseUrl"],
958
+ replaceKeys: ["phoneNumberId"],
959
+ scalarKeys: ["accessToken", "wabaId", "token", "baseUrl"],
961
960
  },
962
961
  });
963
- class IcWhatsAppConfig extends pulumi.dynamic.Resource {
962
+ /** One of the tenant's WhatsApp numbers. A tenant may register several; bind one to
963
+ * an agent with a `whatsapp` deployment whose `address` is its `phoneNumberId`. */
964
+ class IcWhatsAppNumber extends pulumi.dynamic.Resource {
964
965
  constructor(name, args, opts) {
965
966
  super(whatsappProvider, name, {
966
967
  displayPhoneNumber: undefined,
@@ -972,7 +973,7 @@ class IcWhatsAppConfig extends pulumi.dynamic.Resource {
972
973
  });
973
974
  }
974
975
  }
975
- exports.IcWhatsAppConfig = IcWhatsAppConfig;
976
+ exports.IcWhatsAppNumber = IcWhatsAppNumber;
976
977
  const emailProvider = makePutResource({
977
978
  path: () => "/v1/tenant/email",
978
979
  id: () => "email",
@@ -1044,8 +1045,8 @@ const webhookProvider = {
1044
1045
  },
1045
1046
  async diff(_id, olds, news) {
1046
1047
  const replaces = olds.url !== news.url ? ["url"] : [];
1047
- const eventsChanged = JSON.stringify([...(olds.events ?? [])].sort()) !==
1048
- JSON.stringify([...(news.events ?? [])].sort());
1048
+ const eventsChanged = JSON.stringify((olds.events ?? []).toSorted()) !==
1049
+ JSON.stringify((news.events ?? []).toSorted());
1049
1050
  return { changes: replaces.length > 0 || eventsChanged, replaces };
1050
1051
  },
1051
1052
  // Enables `pulumi import` of a webhook a script already created (id is the
package/package.json CHANGED
@@ -1,38 +1,42 @@
1
1
  {
2
2
  "name": "@ingram-cloud/pulumi",
3
- "version": "2.1.1",
3
+ "version": "3.0.0",
4
4
  "description": "Pulumi dynamic resources for the Ingram Cloud /v1 API — projects, project tokens, agents, MCP servers, channels, webhooks, model keys.",
5
- "license": "MIT",
6
- "homepage": "https://cloud.ingram.tech",
7
5
  "keywords": [
8
- "pulumi",
6
+ "ingram",
9
7
  "ingram-cloud",
10
- "ingram"
8
+ "pulumi"
11
9
  ],
12
- "main": "dist/index.js",
13
- "types": "dist/index.d.ts",
10
+ "homepage": "https://github.com/ingram-technologies/ingram-cloud-sdks/tree/main/packages/pulumi#readme",
11
+ "bugs": "https://github.com/ingram-technologies/ingram-cloud-sdks/issues",
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/ingram-technologies/ingram-cloud-sdks.git",
16
+ "directory": "packages/pulumi"
17
+ },
14
18
  "files": [
15
19
  "dist"
16
20
  ],
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
17
23
  "publishConfig": {
18
24
  "access": "public"
19
25
  },
20
26
  "scripts": {
21
27
  "build": "tsc -p tsconfig.json",
22
- "type-check": "tsc -p tsconfig.json --noEmit",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
23
29
  "lint": "oxlint",
24
30
  "format": "oxfmt --write .",
25
- "prepublishOnly": "npm run build"
31
+ "prepack": "bun run build"
32
+ },
33
+ "devDependencies": {
34
+ "@pulumi/pulumi": "^3.260.0"
26
35
  },
27
36
  "peerDependencies": {
28
37
  "@pulumi/pulumi": "^3.0.0"
29
38
  },
30
- "devDependencies": {
31
- "@ingram-tech/oxlint-config": "^0.2.2",
32
- "@pulumi/pulumi": "^3.255.0",
33
- "@types/node": "^26.1.2",
34
- "oxfmt": "0.62.0",
35
- "oxlint": "^1.77.0",
36
- "typescript": "^7.0.2"
39
+ "engines": {
40
+ "node": ">=20"
37
41
  }
38
42
  }