Ingram Cloud

Documentation

Tools & approvals

Tools & approvals

Tools are functions a smith can call. They come in two kinds: hosted (Ingram Cloud executes, in-process) and MCP tools (your own server executes, spoken over the Model Context Protocol). Ingram Cloud is the MCP client: you point it at your MCP server and it discovers and calls your tools. MCP tools can additionally be gated behind a human approval.

Hosted vs MCP

HostedMCP
Runs onIngram Cloud, in-processyour MCP server
Examplesweb_search, web_fetch, calculator, reasoning, read_budget, manage_schedules, file_search, run_commandanything: your DB, your APIs
Setupenable in the agent (or a smith's config)register one server URL per project
During a runinvisible: the run just continuesinvisible: Ingram Cloud calls your server and continues

One hosted tool is not in-process: run_command runs a shell in a disposable box rented for the run — see sandbox.

A skill attached to an agent brings three tools that let the model discover and read it for itself: skill (what's attached, name + description), skill_search (rank the body and reference files against a query), and skill_read (pull in a specific file's content). Scripts and assets are reached with run_command, at /skills/<name>/.

Hosted tools: list the catalog at GET /v1/tenant/hosted_tools, enable them on an agent or a smith via enabled_hosted_tools. Every one is off until you enable it. Their results just consume model tokens like any other context. (The console labels these Built-in tools on the Tools page.)

Each catalog entry carries three fields: name, the identifier you put in enabled_hosted_tools; title, what to call the tool in an interface; and description, one line on what it does.

nametitle
web_searchWeb search
web_fetchFetch a page
calculatorCalculator
reasoningScratchpad
read_budgetBudget check
manage_schedulesSchedules
file_searchKnowledge search
run_commandShell

A catalog entry is either a single function (web_search, web_fetch) or a bundle — one name that lights up a family of related functions: enabling calculator gives the smith arithmetic (add, subtract, multiply, divide, powers, roots, …), and reasoning gives it a think/analyze scratchpad for working through a problem before answering.

read_budget lets a smith read its own budgets for the month — the limit, spend-to-date, and remaining for each scope that applies to it (project, agent, smith, customer). It's read-only: the cap is enforced server-side at run start, and reading it never changes a limit. Enable it when you want a smith to pace itself against its cap or tell the user when it's running low. Today these caps meter inference (model) cost.

manage_schedules lets a smith create, list, and delete its own schedules — so "wake me up tomorrow at 8am" becomes a real cron entry the smith sets for itself. Every action is scoped to the calling smith: it can only ever see or change its own schedules, never another smith's. On create it must pass an IANA timezone, and the tool pushes the model to ask the user for theirs — a cron without the right zone fires at the wrong hour. Enable it per agent when you want self-scheduling. Because every fire is a billable run, creates through this tool are capped: a smith may hold 20 schedules, each firing at most once every 15 minutes. A tighter cron (* * * * *, */5 * * * *) is refused with a message the smith can relay, and so is a create past the twentieth. Neither cap applies to schedules you create yourself over the API. Budgets still apply on top. Firing is paced per smith: a smith's schedules fire at most twice a minute, so a smith holding many schedules that come due together sees the rest fire on the following minutes rather than all at once. Nothing is skipped — only deferred.

file_search is semantic search over the agent's attached vector stores — its knowledge base. Enable the tool and set vector_store_ids on the agent; the smith can then pull relevant passages from your documents on any channel. A smith can only search tenant-wide stores or stores owned by itself.

A third kind: client-side inline tools. The hosted and MCP tools above are server-side — Ingram Cloud executes them and the run just continues. On the OpenAI-compatible surface you can instead send a tools array on the request and run a client-side function-call loop: the model's calls come back for you to execute, you feed the results in, IC runs nothing and gates nothing. It's the literal OpenAI contract, stateless (re-send the conversation each turn), and a turn that sends tools runs only those client tools (the agent still supplies instructions; its MCP/hosted tools and memory sit out that turn). Reach for it when the tool already lives in your process; reach for MCP (below) when the tool is shared, remote, or needs approval gating. Full reference: OpenAI-compatible API → Tools.

MCP tools are project-global: every tool a registered server advertises is offered to every smith in the project (subject to the allow-list). There is no per-smith or per-agent scoping yet. Use the tool's own description and the instructions to steer who calls what, or split products into separate projects. (Who a tool can act as is per-smith; see auth modes below.)

Two ways to add an MCP server

Raw URLCurated catalog
You providea server URL + auth modeone catalog slug
Best foryour own backend, or any third partypopular third parties (Stripe, GitHub, Notion)
Authyou wire itthe OAuth endpoints, scopes, and a sane default policy ship pre-solved

Both produce the same kind of MCP server resource — the catalog just pre-fills the fields (see catalog).

Registering an MCP server (raw URL)

You host an MCP server (Streamable HTTP, stateless) that exposes your tools. Register its URL once per project; Ingram Cloud immediately discovers the tool manifest (tools/list) and caches it:

# Authorization: tenant-admin token (server-side only)
curl -X PUT https://api.cloud.ingram.tech/v1/tenant/mcp/acme \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://acme.example.com/mcp",
        "auth": { "kind": "oauth", "provider": "acme" } }'
# → 200 { "name": "acme", "url": "…", "auth": { "kind": "oauth", "provider": "acme" },
#         "tools": [ { "name": "book_expense", "requires_approval": true }, … ],
#         "tools_discovered": 7 }

Your server must be reachable on the public internet over https. Ingram Cloud refuses to fetch anything else: a non-https scheme, or a host that is or resolves to a private, loopback, link-local, or otherwise non-routable address is rejected on write with invalid_url (422), and the same check runs again on every request and every redirect hop. Register a public endpoint and put your own authentication in front of it — auth.kind covers that — rather than pointing at an internal address.

PUT is a full replace, so auth.kind is required unless a catalog preset supplies it — a body without it is rejected (auth_required, 422) rather than silently resetting a live registration to unauthenticated calls. Re-PUT (or POST /v1/tenant/mcp/{name}/refresh) whenever your manifest changes. GET /v1/tenant/mcp lists; GET /v1/tenant/mcp/{name} shows one; DELETE /v1/tenant/mcp/{name} removes. Every register, replace, and delete lands in the event log (mcp_server.created / mcp_server.updated / mcp_server.deleted) with the auth transition, so a change to how Ingram Cloud authenticates to your server is auditable, and can fire a webhook.

When discovery fails, the server says so. Registration is never rolled back — your server may simply not be reachable yet — but a failed tools/list (or a stored bearer secret that can no longer be decoded) is recorded on the server: status reads "degraded" and discovery_error carries the reason. The same is true if the edge breaks at run time (e.g. a secret that stops decoding) — the run loop skips only that server, keeps the rest, and flips it to degraded so the list stops reporting a broken edge as active. A clean POST /v1/tenant/mcp/{name}/refresh re-runs the probe end to end and clears it:

# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/tenant/mcp/acme/refresh \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01"
# → 200 { "name": "acme", "status": "active", "discovery_error": null,
#         "tools": [ … ], "tools_discovered": 7 }
# A still-broken edge instead returns the coded error `discovery_failed`.

Discovery self-heals — you rarely need to refresh by hand. Registering before your server is live (the natural order when you provision with IaC) leaves the edge degraded with no tools. You don't have to re-register once it comes up: a degraded or empty server re-runs tools/list automatically at the start of the next run that would use it, so the first run after your server is reachable picks up its tools. The retry is throttled (at most once a minute per server) so a persistently-down server never slows your runs. tools_refreshed_at records the last successful discovery — an empty tools list with a set tools_refreshed_at means "the server is up and exposes no tools", distinct from a discovery_error that never succeeded. tools_discovered counts what the registration holds, and reads the same on GET as on the register/refresh response that produced it, so a GET tells you whether the server holds any tools without probing it.

A manifest also expires on age: at most an hour old. The manifest a run is handed is the cached one, not a live tools/list — so when you change your server's tools, runs keep being offered the old set until something re-discovers. A server that discovered cleanly is not degraded by any measure, so nothing else would ever mark it stale. Instead it re-runs tools/list on the run path once its manifest is an hour old, which bounds the divergence: a tool you deploy is offered to runs within the hour, and an argument you add to an existing tool reaches the model in the same window. Two cases where that matters and nothing else would catch it:

  • One endpoint, many registrations. Adding a tool to an endpoint that several registered servers point at leaves every one of them short, and no single registration looks wrong.
  • The person who changed the tools isn't the one who registered the server — a different repo, a different pipeline, and nothing in the tool-side deploy has any reason to know a registration exists.

POST …/refresh is there for when an hour is too long: it re-discovers immediately, and is the right call at the end of a deploy that changed your tools.

Call failures are tracked separately from discovery. Discovery can succeed while every tools/call fails — a common shape is an open tools/list in front of an authenticated call path, where a wrong auth config 401s every call but the registration still looks healthy. A tools/call that fails at the transport level (network error or non-2xx HTTP status) is recorded on the server as last_call_error (+ last_call_error_at) and flips status to "degraded", so the break is visible on a GET instead of only surfacing as an agent whose tools "error". A later successful call — or a re-PUT — clears it.

In the console: the Tools page lists your MCP servers alongside the read-only built-in catalog. Register or edit one (URL + auth mode), and the discovered tool count is shown after the save probes tools/list; use Refresh to re-probe. A degraded server shows a red status badge and the discovery_error or last_call_error reason inline, so a broken edge is visible on the page rather than only as an agent whose tools error. Because bearer secrets are write-only, re-enter the shared token whenever you save a bearer-auth server. An agent's Configuration tab lists the registered servers and which of them that agent loads, so its tool surface is visible from the agent rather than only from here; the scope itself is set with mcp_servers over the API.

Per request instead of per tenant. On the Responses API a caller can mount a server for one response with OpenAI's {"type": "mcp", "server_url": …} tool — discovered on the request, its tools scoped and approval-gated by the declaration alone, gone when the response ends. See per-request MCP servers.

What Ingram Cloud speaks to your server

Ingram Cloud is a stateless MCP client on protocol revision 2026-07-28, and falls back to the 2025-06-18 initialize dialect when your server answers the first tools/list with 400, 404 or 405. Beyond tools/list and tools/call:

  • Pagination. tools/list is followed to the last nextCursor.
  • Freshness. A ttlMs on tools/list sets how long the cached manifest is trusted before the next run re-discovers it, between one minute and a day; without one, an hour.
  • Tasks. Every request declares the io.modelcontextprotocol/tasks extension. A tools/call answered with resultType: "task" is polled on tasks/get at your pollIntervalMs until it completes, for up to five minutes, then cancelled. A task at input_required is answered with tasks/update the same way a direct question is (below), then polled on.
  • Questions for the end-user. Every request declares form-mode elicitation. A tools/call answered resultType: "input_required" with one elicitation/create pauses the run as paused_for_approval: the approval.required event carries elicitation: { message, requested_schema }, and the console shows the question. Submit the answer as content on /submit (approve), or reject to decline; either way the call is retried with inputResponses and your requestState. Anything else a server asks for (several questions at once, URL-mode elicitation, the deprecated sampling and roots) is declined on the retry.
  • x-mcp-header. A primitive property of a tool's inputSchema marked "x-mcp-header": "Name" is mirrored into an Mcp-Param-Name request header on each call (base64-encoded when not header-safe). A tool whose annotations break the transport's rules is left out of the manifest and logged.

A tools/call result is flattened to text for the model: structuredContent as JSON when present, else the content blocks (text verbatim, resource links as name and URI, images and audio as a placeholder). isError results reach the model prefixed error: so it can recover.

Auth: how a tool call is authenticated

Each call Ingram Cloud makes to your server carries auth per the auth.kind you register:

kindIngram Cloud sendsUse when
nonenothingpublic tools, no per-smith data
bearerAuthorization: Bearer <secret> you set, plus X-IC-Smith-External-Id (the external_id you gave the smith), X-IC-Smith-Id, X-IC-Tenantyour server authorizes against your own store by user id
oauthAuthorization: Bearer <the end-user's access token> from that smith's connection for provider (refreshed if stale)the tool must run as the human, e.g. straight into a row-level-security context

oauth is the strict per-smith model: the token is the end-user's own, so the smith inherits exactly that human's permissions. No service-role token is ever sent. The smith's token comes from a connection; the cleanest way to obtain one is the hosted connect flow — IC drives the consent dance and vaults the token for you (see connecting an end-user's account). If a smith has no connection yet, the tool call returns an error to the model and Ingram Cloud fires a connection.required event carrying a ready-to-use authorize_url so you can prompt them to connect; once connected, submit the paused run to continue.

Any OAuth-protected MCP server is registrable by URLauth.provider is optional and defaults to the server's name. On register (and every refresh) Ingram Cloud discovers how to obtain a token the way MCP specifies: the server's 401 names its protected resource metadata (RFC 9728), that names the authorization server, and its metadata (RFC 8414) names the endpoints. Ingram Cloud identifies itself to that server with its own Client ID Metadata Document when the server accepts one, else registers dynamically (RFC 7591) once per authorization server. Tokens are bound to the server (resource, RFC 8707), and a callback arriving under another issuer is refused (RFC 9207). When discovery fails, the server still registers — connection.required names what was missing — and a server that takes neither kind of client is brokered with a client you register yourself.

Whose OAuth client brokers consent is auth.client_mode: platform (an Ingram-owned client — the default for catalog presets, so the consent screen reads "Ingram Cloud") or tenant (your own client from PUT /v1/tenant/providers/{provider}, so it carries your brand). Either way IC owns the redirect and the token vault. The one redirect URI to register with any provider is https://api.cloud.ingram.tech/v1/oauth/callback.

bearer is the cheap path: the person's identity rides in X-IC-Smith-External-Id (the id you assigned), so your server maps it to your own user without any token plumbing.

Curated catalog (preloaded integrations)

For popular third parties, Ingram Cloud maintains a small catalog of presets so you don't re-solve the same OAuth + scope + risk config. GET /v1/catalog lists them; each entry pre-fills a server's URL, auth mode, OAuth endpoints, a default allow-list, and a default approval policy. Enable one by passing its catalog slug instead of a url:

# Authorization: tenant-admin token (server-side only)
curl -X PUT https://api.cloud.ingram.tech/v1/tenant/mcp/stripe \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "catalog": "stripe" }'
# → 200 { "name": "stripe", "origin": "catalog", "catalog_slug": "stripe",
#         "auth": { "kind": "oauth", "provider": "stripe", "client_mode": "platform" },
#         "tool_allowlist": ["list_charges", "get_balance", …],
#         "approval_policy": [ { "match": "create_refund", "require": "approval" }, … ],
#         "tools": [ … ], "tools_discovered": 7 }

The preset's fields are copied onto your server at enable time — editing the catalog later never silently re-points your live integration. You can override any copied field in the same PUT (e.g. send your own tool_allowlist, approval_policy, or auth.client_mode). Catalog OAuth presets default to client_mode: "platform", so a smith connects with one call and no OAuth client of your own.

Tool allow-list (default-deny)

A server's manifest is what it advertises; the allow-list is what your smiths may actually call. Set tool_allowlist to the exact tool names to expose — anything else is denied, including tools the server adds later:

# Authorization: tenant-admin token (server-side only)
curl -X PUT https://api.cloud.ingram.tech/v1/tenant/mcp/stripe \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "catalog": "stripe", "tool_allowlist": ["get_balance", "list_charges"] }'

Omit tool_allowlist (or send null) to expose every discovered tool. This is the supply-chain guard for third-party servers: because a server you don't own can rename or add tools under you, a non-null allow-list means a new tool is never auto-exposed to your smiths — you opt it in. In GET/PUT responses, each tool carries an enabled flag reflecting the allow-list.

Scoping servers to agents

Registered servers are project-wide, and by default every agent's smiths load all of them. To expose a server to only some agents, set mcp_servers on the agent — the list of server names its smiths load:

# Authorization: tenant-admin token (server-side only)
curl -X PATCH https://api.cloud.ingram.tech/v1/agents/agt_123 \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "mcp_servers": ["sheets"] }'

null (the default) loads all registered servers; [] loads none. The value is a behaviour-config field like enabled_hosted_tools: publishing freezes it into the version, and a smith may override it (PATCH the smith; null clears the override and re-inherits). A listed name that matches no active server is reported in the run's tool inventory (run.metadata.tools.errors) and on the run's warnings, not silently skipped. The allow-list above still applies within each server.

The MCP loop

When a smith calls one of your tools, Ingram Cloud performs a live MCP tools/call against your server, with the auth above, and feeds the result (content / structuredContent) back to the model, all in-process, no pause. A tool that returns an MCP error result is surfaced to the model as text, so it can recover or explain rather than the run hanging.

Approval gating

A tool call can be gated behind a human approval two ways:

  1. The server's own hint — mark a tool annotations.destructiveHint: true in your tools/list response. Good for servers you own.
  2. Your approval_policy — a list of rules on the server resource, independent of what the server advertises. This is the path for third-party servers whose annotations you can't change:
# Authorization: tenant-admin token (server-side only)
curl -X PUT https://api.cloud.ingram.tech/v1/tenant/mcp/stripe \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "catalog": "stripe",
        "approval_policy": [ { "match": "create_refund", "require": "approval" },
                             { "match": "delete_*",      "require": "approval" } ] }'

Each rule's match is a glob over the tool name; a match gates the call. The effective gate is the server hint OR any policy match. (Arg-conditional thresholds — e.g. "only refunds over $500" — are a planned extension; today match is a name glob, and a rule with a when clause is rejected at register time rather than silently ignored.)

Either way, every gated call pauses the run (paused_for_approval) and creates an approval (a first-class resource that can outlive the run) before Ingram Cloud calls your server:

  • The stream emits approval.required ({ approval_id, tool, args, tool_call_id }), and the event feed + webhooks carry the same.
  • GET /v1/approvals?status=pending lists what's waiting, project-wide. An approval settles expired if its run ends before anyone answers — whether the pause timed out or the run was cancelled under it — and that settlement arrives as approval.resolved with decision: "expired" (a smith token sees only its own smith's). Each approval carries everything needed to act on it:
{ "id": "apr_…", "run_id": "run_…", "smith_id": "smt_…",
  "tool_call_id": "tc_…", "tool": "book_expense", "args": { },
  "status": "pending", "actor": null, "reason": null,
  "created_at": "…", "resolved_at": null }

Resolve by submitting via /submit:

# Authorization: tenant-admin token (server-side only)
curl https://api.cloud.ingram.tech/v1/smiths/smt_…/runs/run_…/submit \
  -H "Authorization: Bearer $IC_TOKEN" \
  -H "IC-Api-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "approval_decision", "approval_id": "apr_…",
        "decision": "approve", "actor": "ops@example.com" }'

"approve" resumes the run and Ingram Cloud then calls your MCP server for that tool. "reject" completes the run with stop_reason: "approval_rejected". The tool never runs. Pass actor so the audit trail says who decided.

Who may approve: any token holding approvals:write and access to the run, so a tenant-admin token always can, and a smith token can approve its own smith's calls (that's deliberate: approval gating doubles as end-user consent, e.g. the Telegram inline buttons). For operator-only gates, simply don't include approvals:write in the smith tokens you mint — a token without it can still run the smith, but every way of deciding an approval answers 403 insufficient_scope: this endpoint, the Responses mcp_approval_response item, the Chat Completions tool-message echo, and the MCP server's in-band inputResponses / tasks/update alike. The run stays paused_for_approval.

In-channel Approve/Reject buttons render where everyone in the chat can see them, so a tap only counts when it comes from the smith's own principal — the caller a per-sender smith was minted for, or whoever bound the channel (see verified sender identity). Anyone else gets a private "not yours to decide" reply and the decision stays pending.

The approvals queue in the console

Observe → Approvals is the human side of the same queue: pending calls with their arguments, one-click approve/reject, and a resolved log (tool, decision, actor, when). The sidebar badge counts pending approvals so a paused run is never invisible.