Ingram Cloud

Documentation

Deployments & schedules

Deployments & schedules

Runs your code doesn't start. A deployment binds a surface — Slack, Telegram, WhatsApp, email, an MCP server, or a private hosted web page — to a target, so an inbound message runs the target automatically and the reply goes back to the same conversation. Schedules fire runs on cron.

Integrations vs. deployments

Two layers, kept separate on purpose:

  1. Integrations (per project): provider credentials — a Slack app, a Telegram bot, a Cloudflare email domain — configured once under Settings → Integrations. Ingram Cloud receives their webhooks for you.
  2. Deployments (per smith or agent): the binding that says who answers. A deployment is a top-level resource (/v1/deployments), not nested under a smith — GET /v1/deployments lists every deployment in the project.

Targets: a smith, or an agent

Every deployment names a target:

  • smith — one fixed smith answers. The classic binding: this chat / this inbox ⇆ this smith.
  • agent — the kind's catch-all. Every inbound sender that doesn't match an exact smith binding gets their own freshly minted smith of that agent, keyed by their provider identity (Telegram user, WhatsApp number, Slack team:user, email From). Same person next time → same smith, with its own memory and threads. One agent catch-all per (kind) — exact smith bindings always win.
{ "target": { "type": "smith", "id": "smt_…" }, "kind": "telegram", "setup": { "mode": "start_token" } }
{ "target": { "type": "agent", "id": "agt_…" }, "kind": "whatsapp" }

A tenant token may target any smith or agent in the project; a smith token may only target its own smith.

Once a deployment is bound, the smith also gains the matching outbound send tool automatically (telegram_send_message / whatsapp_send_message / slack_send_message / email_send) so scheduled or proactive runs can reach out first.

Verified sender identity

Smiths are reachable by other people (anyone can DM a Slack app or email an inbox), so every deployment-initiated run carries a verified sender block, in the run's metadata.sender and as a platform line the model sees before the message:

{ "is_owner": true, "channel": "slack", "identity": "slack:U0123ABC",
  "display": "Dana Vex", "verified": true }

identity is always the channel-authenticated id, never a display name. is_owner says whether the sender is the principal behind the smith that answers. A smith minted per caller from an agent target is owned by the caller it was minted for, so they read as the owner. On a binding to one fixed smith, the owner is whoever bound it — the OAuth installer (Slack), the user who redeemed the deep link (Telegram), or the address in owner_email with DMARC passing (email; verified carries the auth verdict). Everyone else in a group chat or shared server channel reads as a visitor. Lines in the inbound text that mimic the platform preamble are stripped before the real one is prepended, so a visitor can't type their way into owner status. Build gatekeeper behaviour on this: act for the owner, take messages from visitors.

Set the owner's address on an email deployment at provision ("setup": { "owner_email": "dana@example.com" }) or later via PATCH /v1/deployments/{id}.

Renaming a deployment's identity

PATCH /v1/deployments/{id} with { "display_name": "…" } renames the smith where the deployment shows a name: a provisioned Slack app is renamed in Slack itself (apps.manifest.update), and an email deployment changes its From display name. Other kinds return 422 rename_unsupported.

Quick-reply chips

A smith on a chat channel can offer quick replies — short tappable labels shown alongside its message. Tapping one sends that label back as the user's next message. The agent calls a suggest_replies tool (auto-enabled whenever the smith has a Telegram or WhatsApp deployment); pass replies as a few short labels:

// the agent's tool call, mid-run
{ "name": "suggest_replies", "replies": ["Yes, book it", "Not now"] }

Declare them once and each channel renders natively — no per-channel layout in your app:

  • Telegram — a one-time reply keyboard; the tap sends the label as a normal message.
  • WhatsApp — interactive reply buttons (up to 3; more than 3 fall back to a text list).
  • Slack / email — no native chip widget, so the labels degrade to a short bulleted list under the message (the user just types their pick).

The offered labels also ride the run record: run.output.suggested_replies and the message.completed event carry them, so a tap is auditable. The proactive send tools (telegram_send_message, whatsapp_send_message) take the same suggested_replies field, so an outbound reminder can carry chips too.

Chips are for clear choices (a Yes/No, a short menu), not open questions — keep labels under 20 characters.

Telegram

Telegram is fully self-serve: one bot per project, shared by every smith you bind to it.

  1. Create the bot. Message @BotFather, send /newbot, and pick a display name plus a username ending in bot. BotFather replies with a token like 123456789:AA…. (Optional: /setuserpic, /setdescription to brand it.)
  2. Connect it once for the project under Settings → Integrations → Telegram, or PUT /v1/tenant/telegram { "bot_token": "123456789:AA…" }. Ingram Cloud registers the webhook with Telegram for you — no polling, nothing to host. Rotating the token is the same call again.
  3. Bind a smith by deep link:
# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "smith", "id": "smt_…" },
        "kind": "telegram", "setup": { "mode": "start_token" } }'
# → { "deep_link": "https://t.me/your_bot?start=…", … }

Send that link to the person; tapping it and pressing Start binds their Telegram account to their smith 1:1 and fires deployment.bound. To let anyone who DMs the bot get their own smith instead, deploy an agent catch-all — no link, no setup: POST /v1/deployments { "target": { "type": "agent", "id": "agt_…" }, "kind": "telegram" }.

While the smith works the chat shows a typing indicator, so a multi-second run isn't silent until the reply lands. An approval pause arrives as an inline keyboard with ✅ Approve, ❌ Reject, and ✏️ Edit; the tap routes back through /submit. Edit rejects the proposed call, then runs your next message as a correction in the same thread.

WhatsApp

WhatsApp numbers run on Ingram Cloud's shared WhatsApp Business Platform app — you don't create, verify, or manage a Meta app of your own. Onboarding a number is done together with Ingram.

  1. Register the number. A WhatsApp Business number is attached with PUT /v1/tenant/whatsapp { "phone_number_id": "…", "access_token": "…", "waba_id": "…" } — the Cloud API phone-number id, a permanent access token (a temporary one lapses within 24 hours), and the WhatsApp Business Account (WABA) id. Ingram Cloud validates the number with Meta and subscribes its app to your WABA, so inbound flows to the one shared webhook — nothing to paste back into Meta.
  2. Bind a smith with a wa.me link (WhatsApp has no /start, so the link's prefilled text carries a connect token):
# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "smith", "id": "smt_…" },
        "kind": "whatsapp", "setup": { "mode": "start_token" } }'
# → { "deep_link": "https://wa.me/15550100?text=Connect%20my%20account%20…", … }

The person's first message (carrying the prefilled token) binds their number to their smith and fires deployment.bound. An agent catch-all ("target": { "type": "agent", … }, no setup) mints a smith per phone number instead. An approval pause is delivered as WhatsApp interactive Approve / Reject buttons routed back through /submit.

24-hour window. Freeform sends work inside WhatsApp's 24-hour customer-service window. Outside it, Meta requires a pre-approved template; template sending is not built yet, so a freeform send outside the window fails and surfaces the Graph error.

Slack

Slack supports two identity models on the one slack kind. Shared bot: one Slack app speaks for the project; many smiths bind to it, one per Slack conversation. Per-smith app: each smith gets its own Slack app (its own name, avatar, and DM) minted on demand from your manifest template.

Project setup

Configure either or both blocks with PUT /v1/tenant/slack:

# Authorization: tenant-admin token (server-side only)
curl -X PUT https://api.cloud.ingram.tech/v1/tenant/slack \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "bot_token": "xoxb-…", "signing_secret": "…",
        "client_id": "…", "client_secret": "…" }'
  • bot_token + signing_secret: the shared app. Point its Events API request URL at the events_url the response returns.
  • client_id + client_secret: the same app's OAuth client; this enables the oauth_install setup below. Add the returned oauth_redirect_url to the app's OAuth settings.
  • factory: a separate block enabling per-smith apps, minted from your own Slack account's App Configuration Tokens. Apps minted from it belong to your Slack account; the template is fixed, and creation substitutes only ${display_name} and ${owner_name}, with event/redirect URLs always forced to Ingram Cloud. Add "return_url": "https://yourapp.example/dashboard" (top-level or per deployment via setup.return_url) to send the installer's browser back to your product with ?slack=connected|cancelled|error appended.

Binding a smith

# Authorization: tenant-admin token (server-side only)
# 1. OAuth install (shared bot), "Add to Slack", no ids to copy:
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "smith", "id": "smt_…" },
        "kind": "slack", "setup": { "mode": "oauth_install" } }'
# → 201 { "status": "pending", "install_url": "https://slack.com/oauth/v2/authorize?…", … }
# Authorization: tenant-admin token (server-side only)
# 2. Provision a per-smith app (needs the factory block):
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "smith", "id": "smt_…" },
        "kind": "slack", "setup": { "mode": "provision", "display_name": "Alice'\''s PA" } }'
# → 201 { "status": "pending", "slack_app_id": "A0…", "install_url": "…", … }

Send the install_url to the person. One consent grants both tokens; then the deployment flips active bound to their DM, and slack.install + deployment.bound fire. Install links are one-time. To install the same minted app into another workspace, create another deployment with "setup": { "mode": "provision", "app_id": "A0…" }. Minting is capped per project (50 apps by default; 429 slack_app_cap_reached beyond it).

  1. Or bind a literal conversation id (the original way): POST /v1/deployments { "target": { "type": "smith", "id": "smt_…" }, "kind": "slack", "address": "C…" }.

What wakes the smith

DMs always wake the bound smith. In group channels the bot answers only when @-mentioned; a deployment with "provider_metadata": { "wake": "all_messages" } opts back into waking on every message. Replies are thread-aware. Provisioned apps can enable Slack's assistant pane (assistant:write + assistant_thread_started), with a per-deployment greeting and suggested prompts via provider_metadata.

Slack tools the smith gains

Every bound deployment adds slack_send_message. An installed app also adds, gated by the tokens it holds: slack_search (search.messages, user token), slack_read_messages, slack_list_channels. Approvals pause runs and post Approve/Reject buttons in the conversation, resolved through /submit (Tools & approvals).

GET /v1/deployments lists deployments; DELETE /v1/deployments/{id} unbinds. If a workspace uninstalls an app, the deployment flips revoked and slack.uninstalled fires.

Discord

Discord rides Discord's HTTP Interactions endpoint, so there's no gateway socket to keep open.

  1. Create a Discord application, add a bot, and configure it once in Settings → Integrations (PUT /v1/tenant/discord { "application_id": "…", "public_key": "…", "bot_token": "…" }). The response returns a webhook_url.
  2. In the Discord Developer Portal, set the app's Interactions Endpoint URL to that webhook_url and register a slash command. Ingram Cloud verifies every interaction's Ed25519 signature with your public_key — there's no shared secret. Signed timestamps more than 5 minutes old are rejected, so a captured interaction can't be replayed.
  3. Bind a channel as an agent catch-all (no per-channel setup):
# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "agent", "id": "agt_…" }, "kind": "discord" }'

Running the slash command mints a smith per Discord user and wakes the agent; the reply edits the command's deferred response. The bound smith gains discord_send_message to write to the channel first. An approval.required pause renders ✅ Approve / ❌ Reject buttons; clicking one routes back through /submit.

Email

Each smith can own a real inbox on your domain (BYO Cloudflare). Configure the sending domain once (PUT /v1/tenant/email), then mint an inbox for a smith: POST /v1/deployments { "target": { "type": "smith", "id": "smt_…" }, "kind": "email", "setup": { "mode": "provision", "handle": "jo" } } → an active deployment at jo@your-domain. Inbound mail wakes the smith; its threaded reply comes back from the same address; email_send lets it write first. An agent catch-all ("target": { "type": "agent", … }, no setup) answers any inbound mail that reaches Ingram Cloud, minting a smith per From address.

MCP server

Deploy a smith or agent as an MCP server so any Model Context Protocol client (Claude Desktop, an IDE agent, another framework) can call it as a tool. It's the mirror of registering an MCP tool server: there Ingram Cloud is the MCP client; here it's the server.

# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "agent", "id": "agt_…" }, "kind": "mcp" }'
# → 201 { "id": "dep_…", "kind": "mcp", "mcp_url": "https://api.cloud.ingram.tech/v1/deployments/dep_…/mcp", … }

The response carries mcp_url — the connectable endpoint to hand a client (or paste into Claude / ChatGPT). It's on the create response and every later GET, so read it rather than templating the route yourself.

The server lives at that mcp_urlPOST /v1/deployments/{id}/mcp (Streamable HTTP, JSON-RPC 2.0). Point an MCP client there with an Ingram bearer token. It exposes:

  • one tool, ask — send a message, get the smith's reply (the smith keeps its memory and history across calls);
  • read-only resources — the smith's memory entries (memory://…).

Every call runs as one smith — the one the caller names. A smith token is that smith. A tenant-admin token names one with an IC-Smith-Id: smt_… header, the same header the OpenAI-compatible surface takes; on a smith target it can also name none and get the target. Either way the smith must run the deployment's agent, so distinct callers get isolated smiths.

An admin token calling an agent target without that header gets 400 smith_unresolved: it names no one, and the API will not guess. A smith that doesn't fit the deployment gets 422 smith_mismatch.

The target choice is also your concurrency knob. A smith runs one turn at a time, so on a smith target — where every caller shares one smith and one thread — parallel ask calls get 400 conversation_locked and should be retried. Point a deployment that serves several callers, or one host that calls tools in parallel, at an agent target: each caller gets its own smith, so their turns run at the same time and neither sees the other's history.

Auth is either a configured Ingram bearer token, or — for hosts that drive OAuth themselves, like Claude and ChatGPT connectors — the endpoint's own OAuth 2.1 discovery: see Connectors.

Interactive UI (MCP Apps)

Attach a UI template to the agent and this endpoint serves it as an MCP App (SEP-1865): the server advertises the io.modelcontextprotocol/ui extension, links templates from tool descriptors via _meta.ui.resourceUri, and serves each bundle as a ui:// resource (text/html;profile=mcp-app). A host that supports the extension renders the panel inline; one that doesn't ignores the metadata and shows the text reply. See MCP Apps.

Hosted page

Deploy an agent as a private, shareable web page — a web deployment. Put an agent online and hand the link to teammates or testers; they open it in a browser and chat, no code and no channel setup. Each visitor gets their own smith (its own memory and history), and the reply streams back live.

# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/deployments \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "target": { "type": "agent", "id": "agt_…" }, "kind": "web",
        "provider_metadata": { "title": "Support bot", "greeting": "Hi — ask me anything." },
        "secrets": { "password": "optional-shared-password" } }'
# → 201 { "id": "dep_…", "kind": "web", "page_url": "https://cloud.ingram.tech/hosted/dep_…", … }

The response carries page_url — the link to share. It's on the create response and every later GET, so read it rather than templating it yourself. title and greeting (both optional) label the page; password (optional) gates it.

The page is private by unguessable link: the dep_ id is a 128-bit token, so the URL is the privacy boundary. Add a password secret to gate it further — visitors enter it once before chatting. There's no per-deployment budget; cost runs against your project budget like any other run.

A smith target answers as one shared assistant; an agent target (above) mints a persistent smith per visitor, keyed on a per-browser id.

Schedules

Cron-triggered runs, per smith (check-ins, digests, reminders):

# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/smiths/smt_…/schedules \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "name": "morning briefing", "cron": "0 8 * * *",
        "timezone": "Europe/Athens",
        "input": [{ "role": "user",
          "content": "Compose my morning briefing: calendar, news, reminders." }] }'

input is what the smith receives when the cron fires, same shape as a run's input. The reply reaches the person through their bound deployment's send tool (or sits on the run record if none is bound).

cron is a standard 5-field expression read in timezone — any IANA zone (Europe/Athens, America/New_York), defaulting to UTC. Weekday takes 0 or 7 for Sunday. The zone is DST-aware: 0 8 * * * stays at 08:00 local across the spring/autumn switch, and fires once per local time — when the clocks go back and 02:30 happens twice, 30 2 * * * still runs once that day.

An unknown zone is rejected with 422 invalid_timezone, and a cron that would never fire — one that doesn't parse, or names a date outside the search window like 0 0 29 2 * — with 422 invalid_cron. Both write paths refuse it, so a stored schedule always has a next fire.

  • PATCH …/schedules/{schid} { "enabled": false } pauses one; DELETE removes it.
  • POST …/schedules/{schid}/run_now queues an immediate fire and returns 202 { schedule_id, delivery_id, status: "queued" } at once — it doesn't wait for the run. Like a cron fire, it runs on the smith's serial lane (so it can't race a turn already in flight); watch the outcome in Runs or on the feed.
  • A scheduled run is a normal run: it shows up in Runs, streams events to the feed, and (via send tools) can message the person on its own.
  • Firing is paced per smith: at most two of one smith's schedules fire per minute. Anything over that fires on the following minutes, oldest first — deferred, never skipped. It only bites when many of a single smith's schedules come due together; the same cron across many smiths is unaffected.
  • A smith can manage its own schedules from inside a conversation — enable the manage_schedules hosted tool and "remind me every Monday at 9" becomes a cron entry the smith creates for itself, in the user's timezone. Off by default. A smith may hold 20 schedules, each firing at most once every 15 minutes; the caps are on that tool only, not on the endpoints above.

Inspecting what arrived

Every inbound message is recorded before anything interprets it — before the sender is resolved to a smith, before a run starts. That log is readable:

# Authorization: tenant-admin token (server-side only)
curl "https://api.cloud.ingram.tech/v1/inbound_events?source=email&limit=20" \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01"
{ "id": "iev_…", "source": "email", "type": "email.received",
  "subject": "support@your-domain", "idempotency_key": "<CAJ…@mail.example>",
  "smith_id": "smt_…", "data": { … }, "created_at": "2026-07-26T…Z" }
  • source is the channel; subject is the address it reached; data is the provider's payload as received.
  • idempotency_key is the provider's own delivery id. A re-delivery of the same key records once and wakes the smith once, so this log is also the answer to "did that arrive twice?".
  • smith_id is "" when the sender matched no smith. Those rows are why "the message arrived and nothing happened" is answerable — the arrival is here even though no run exists. (They belong to no smith, so only a tenant-admin token sees them; a smith token sees its own arrivals.)
  • GET /v1/inbound_events/{ievid} fetches one. This is the id the deployment.inbound event carries as inbound_event_id.

Filter with ?source=, ?smith_id= and ?since=. The log is append-only and read-only — there is no way to edit or replay an arrival through it.

In the console

Deployments (in the sidebar) lists every deployment in the project with its target, kind, address, and status. A smith's Deployments tab creates Telegram deep links, Slack install links (both setup modes), and email inboxes; its Schedules tab manages cron entries with a run-now button. An agent's Hosted page card publishes a web page in one click and shows the copyable share link. You connect each provider once for the whole project under Settings → Integrations (per-smith Slack identities, the factory block, live there too).

Observe → Inbound reads the log above: every arrival with its payload and delivery id, filterable by channel, with an unmatched filter for messages that reached you and woke nobody.