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
| Hosted | MCP | |
|---|---|---|
| Runs on | Ingram Cloud, in-process | your MCP server |
| Examples | web_search, web_fetch, calculator, reasoning, read_budget, manage_schedules, file_search, run_command | anything: your DB, your APIs |
| Setup | enable in the agent (or a smith's config) | register one server URL per project |
| During a run | invisible: the run just continues | invisible: 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.
Hosted tools: list the catalog at GET /v1/tenant/hosted_tools, enable them on
an agent or a smith via
enabled_hosted_tools. Their results just consume model tokens like any other
context. (The console labels these Built-in tools on the Tools page.)
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. Off by default; 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 atoolsarray 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 sendstoolsruns 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 URL | Curated catalog | |
|---|---|---|
| You provide | a server URL + auth mode | one catalog slug |
| Best for | your own backend, or any third party | popular third parties (Stripe, GitHub, Notion) |
| Auth | you wire it | the 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 always auditable — and webhook-alertable.
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 "is
this server blind?" is one field on a read, not a probe.
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
at a glance rather than surfacing as an agent that silently has no tools —
whether discovery or the calls themselves are failing. Because bearer secrets
are write-only, re-enter the shared token whenever you save a bearer-auth
server.
Auth: how a tool call is authenticated
Each call Ingram Cloud makes to your server carries auth per the auth.kind you
register:
kind | Ingram Cloud sends | Use when |
|---|---|---|
none | nothing | public tools, no per-smith data |
bearer | Authorization: Bearer <secret> you set, plus X-IC-Smith-External-Id (the external_id you gave the smith), X-IC-Smith-Id, X-IC-Tenant | your server authorizes against your own store by user id |
oauth | Authorization: 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.
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.
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.
A catalog entry is curated by Ingram; the long tail of servers is covered by the raw-URL path. Both yield the same resource.
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:
- The server's own hint — mark a tool
annotations.destructiveHint: truein yourtools/listresponse. Good for servers you own. - 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=pendinglists what's waiting, project-wide (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, and the Chat Completions tool-message echo
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.