@ingram-tech/pulumi-ingram-cloud
v0.7.1
Published
Pulumi dynamic resources for the Ingram Cloud /v1 API — projects, project tokens, agents, MCP servers, channels, webhooks, model keys.
Readme
@ingram-tech/pulumi-ingram-cloud
Pulumi dynamic resources for the Ingram Cloud /v1
API. Declare your tenant's IC configuration — agents, MCP servers, integrations,
webhooks, BYOK model keys — as Pulumi resources instead of imperative
register-*.ts / ensure-agent.ts scripts, so it lives in state and a change
is a pulumi up.
The library is published alongside the Ingram Cloud /v1 API so the wrapper
tracks the surface it wraps. The API version it targets is pinned per library
version (IC_API_VERSION).
Install
bun add @ingram-tech/pulumi-ingram-cloud @pulumi/pulumiHow the repos interact
cloud.ingram.tech/pulumi/ THIS LIBRARY — knows nothing about any consumer
(Agent, McpServer, TelegramBot, WhatsAppConfig,
Webhook, ModelKey, Secret)
app repos (thornhill, …) declare their AGENTS — the `instructions` are
app-owned content (prompts), so they stay in the app
repo, sourced from the app's own spec module. The app's
Pulumi program imports this library + those specs.
infra repo (~/src/infra) declares tenant INTEGRATION config (MCP, Telegram, WhatsApp,
webhook, model keys) alongside DNS/DB/Vercel, and can
read an app stack's outputs via pulumi.StackReference to
push IC-derived values (agent ids, webhook secret)
into Vercel env. No prompts ever live here.The hard rule: prompts never leave the app repo. This library carries no content; it only carries the machinery.
Connection
Every resource takes baseUrl + token (the tenant-admin bearer; IcProject /
IcProjectToken take an organization key instead — see below). Resolve them
once with connectionFromConfig() and spread:
import * as ic from "@ingram-tech/pulumi-ingram-cloud";
const conn = ic.connectionFromConfig(); // ingram-cloud:token (secret) / :baseUrl
// or env INGRAM_CLOUD_TOKEN|CLOUD_API_KEYSet the token as a stack secret:
pulumi config set --secret ingram-cloud:token tha_live_…Resources
IcAgent (declare in the APP repo)
Create-or-adopt by slug → publish a new immutable version only when the
content changed → roll it out. Mirrors the old ensure-agent.ts idempotency, so
the first pulumi up after switching from a script adopts the existing agent
(matched by slug) rather than recreating it.
slug is the immutable reconcile key; it defaults to the Pulumi resource name
(here "curator"), so existing stacks keep adopting the same agent. name is a
free display label you can rename without churning the agent. Changing slug
replaces it.
import { AGENT_SPECS } from "../src/lib/cloud/agent-spec"; // app-owned
const curator = new ic.IcAgent("curator", {
...conn,
// slug defaults to "curator"; pass `slug` to decouple the key from the name.
name: AGENT_SPECS.curator.name, // display label, freely renamable
instructions: AGENT_SPECS.curator.instructions,
model: AGENT_SPECS.curator.model,
autoMemory: AGENT_SPECS.curator.auto_memory,
// memoryConsolidation: true, // opt into background consolidation (billed)
// variables: [...], enabledHostedTools: [...], rolloutPercent: 100,
});
export const curatorAgentId = curator.agentId;Attaching existing smiths to a agent is a one-time fleet backfill — keep it as the app's migration script. New smiths attach at birth in app code. Pulumi does not own per-smith runtime state.
IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppConfig, IcEmail, IcWebhook, IcModelKey (declare in INFRA)
// Your own server (raw URL).
new ic.IcMcpServer("thornhill-mcp", {
...conn, serverName: "thornhill", url: `${APP_URL}/api/mcp`,
authKind: "static", secret: mcpSecret,
});
// An `oauth`-kind server forwards each end-user's own stored token; its
// provider record tells IC how tokens refresh. Webhook-delegation mode (a
// refreshWebhook and NO clientSecret): IC POSTs { connection_id } to the
// tenant near expiry and the tenant PATCHes a fresh pair back — the tenant
// owns the token format entirely.
new ic.IcMcpServer("thornhill-user-mcp", {
...conn, serverName: "thornhill-user", url: `${APP_URL}/api/user-mcp`,
authKind: "oauth", authProvider: "thornhill",
});
new ic.IcOauthProvider("thornhill-provider", {
...conn, provider: "thornhill",
refreshWebhook: `${APP_URL}/api/ic/refresh`,
});
// A curated third party: stamp from the catalog, gate which tools run, and
// require human approval on the scary ones. Per-smith OAuth tokens are NOT
// declared here — the runtime collects each end-user's identity (the hosted
// connect flow vaults it). `clientMode: "platform"` uses Ingram's OAuth client.
new ic.IcMcpServer("stripe", {
...conn, serverName: "stripe", catalog: "stripe",
toolAllowlist: ["get_balance", "list_charges", "create_refund"],
approvalPolicy: [{ match: "create_refund" }],
});
new ic.IcTelegramBot("thornhill-telegram", { ...conn, botToken });
// The agent's own email channel (BYO-Cloudflare transport). IC returns the
// inbound HMAC once — it becomes a secret output you flow into the inbound worker.
const email = new ic.IcEmail("thornhill-email", {
...conn, cloudflareAccountId, cloudflareApiToken, fromDomain: "mail.thornhill.app",
});
export const inboundEmailSecret = email.inboundSecret; // create-time secret output
const hook = new ic.IcWebhook("thornhill-events", {
...conn, url: `${APP_URL}/api/ic/events`,
events: ["run.completed", "approval.required", /* … */],
});
export const webhookSigningSecret = hook.secret; // whsec_… (create-only secret output)
new ic.IcModelKey("anthropic", { ...conn, provider: "anthropic", apiKey });IcWebhook.secret is the whsec_… signing secret IC returns exactly once — it
becomes a secret Pulumi output, so the infra stack can flow it straight into Vercel
env with no copy-paste.
IcProject + IcProjectToken (declare in the PLATFORM stack)
The tier above a project. These take an organization key
(organization:*, your account master key) — not a project token. Hand it to a
platform stack and it provisions projects and mints each one a project-scoped
tenant:* token, which you drop straight into that app's env. The org key reads
no run, memory, or smith itself; only the project tokens it mints can.
// In the PLATFORM stack, `ingram-cloud:token` is your ORGANIZATION key.
const project = new ic.IcProject("thornhill", { ...conn }); // adopt-by-name
const icToken = new ic.IcProjectToken("thornhill-admin", {
...conn,
project: project.projectId, // the project id == the tenant
});
// Flow the minted tenant token straight into the app's runtime env:
new vercel.ProjectEnvironmentVariable("th-ic-token", {
projectId: thornhillVercelId,
key: "INGRAM_CLOUD_TOKEN",
value: icToken.projectToken, // secret tenant:* token
targets: ["production"],
});
export const thornhillProjectId = project.projectId;IcProject adopts an existing project by projectName (defaults to the resource
name), so re-runs reconcile. IcProjectToken.projectToken is a secret output; it
re-mints on any change and revokes on destroy.
Importing resources a script already created
Agents and webhooks implement read, so you can adopt pre-existing ones:
pulumi import 'ingram-cloud:index:IcAgent' curator agt_123…(Agents also self-adopt by slug on first create, so an explicit import is
usually unnecessary — a plain pulumi up will reconcile the live agent.)
Notes
- Built as CommonJS to match the Pulumi Node.js runtime and the infra stack's
module: commonjstsconfig. tokenand every credential input are markedadditionalSecretOutputs, so they are encrypted in Pulumi state.- CRUD runs inline in the Pulumi process over the global
fetch(Node ≥18). readtreats a server-side 404 as "gone" (returns an empty id) sopulumi refreshprunes a resource deleted out-of-band — e.g. after an IC DB reset,pulumi refresh && pulumi uprecreates it, nopulumi state delete. Non-404 errors still throw, so a transient API blip can't drop it from state.
