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:
- 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.
- 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/deploymentslists 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, Slackteam:user, emailFrom). 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.
- Create the bot. Message @BotFather, send
/newbot, and pick a display name plus a username ending inbot. BotFather replies with a token like123456789:AA…. (Optional:/setuserpic,/setdescriptionto brand it.) - 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. - 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 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.
- 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. - Bind a smith with a
wa.melink (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 theevents_urlthe response returns.client_id+client_secret: the same app's OAuth client; this enables theoauth_installsetup below. Add the returnedoauth_redirect_urlto 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 viasetup.return_url) to send the installer's browser back to your product with?slack=connected|cancelled|errorappended.
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).
- 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.
- 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 awebhook_url. - In the Discord Developer Portal, set the app's Interactions Endpoint URL to
that
webhook_urland register a slash command. Ingram Cloud verifies every interaction's Ed25519 signature with yourpublic_key— there's no shared secret. Signed timestamps more than 5 minutes old are rejected, so a captured interaction can't be replayed. - 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.
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_url — POST /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;DELETEremoves it.POST …/schedules/{schid}/run_nowqueues an immediate fire and returns202 { 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_scheduleshosted 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" }
sourceis the channel;subjectis the address it reached;datais the provider's payload as received.idempotency_keyis 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_idis""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 thedeployment.inboundevent carries asinbound_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.