Vercel AI SDK
@ingram-cloud/ai-sdk is the batteries-included way to drive a smith from
the Vercel AI SDK. It is a thin, idiomatic extension: a
pre-configured provider plus small helpers for the three things Ingram Cloud adds
on top — smith identity, server-side memory, and human-in-the-loop approvals.
It stands on the Responses API, so your app speaks the
OpenAI Responses wire format end to end and the smith's full agentic turn
reaches your stream as standard AI SDK parts: text, the agent's
server-executed tool calls (tool-call/tool-result), and approval pauses
(tool-approval-request). There is no custom SSE envelope to parse and no
streamText replacement to learn — the smith looks like any other model. If
you want the raw wire format, read that page; this one is the shortcut.
npm install @ingram-cloud/ai-sdk ai
The provider
createIngramCloud() returns a normal AI SDK model factory. The token names the
smith (the end-user's instance), and the agent is the one that smith runs.
The model id is the upstream inference LLM: pass "" to use the agent's configured
model, or a model id like openai.gpt-5.5 to override the LLM for that call.
import { createIngramCloud } from "@ingram-cloud/ai-sdk";
import { streamText } from "ai";
// A per-smith token already names one smith — browser-safe.
const ingram = createIngramCloud({ apiKey: SMITH_TOKEN });
const result = streamText({ model: ingram(""), prompt: "Reset my password?" });
for await (const delta of result.textStream) process.stdout.write(delta);
It composes with everything in the SDK — streamText, generateText, structured
output, agents — because it is just @ai-sdk/openai's Responses model with
Ingram Cloud's defaults (the IC-Api-Version header, identity, and memory)
wired in. On the wire, ingram("") is exactly:
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/responses \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "model": "", "stream": true, "input": "Reset my password?" }'
Server-side tool steps
When the agent's own MCP tools run inside a turn, each call
reaches your stream as a tool-call part (named mcp.<tool>, marked
providerExecuted) followed by a tool-result part, slotted between the text
runs. A multi-step turn — did X → explains → did Y → explains — arrives as
steps, not one unbroken wall of text, so a chat UI can render activity chips and
split bubbles on the boundaries:
const result = streamText({ model: ingram(""), messages });
for await (const part of result.fullStream) {
if (part.type === "tool-call") showStep(part.toolName, part.input);
if (part.type === "tool-result") completeStep(part.toolCallId);
if (part.type === "text-delta") appendText(part.text);
}
In a useChat UI the same parts arrive as tool invocations on the message —
no extra wiring. Non-streaming calls carry the same information: the executed
calls are content parts on the generateText result.
In the console
A streaming chat route plus a browser hook is the whole integration. Your server holds the token and picks the smith; the browser is plain AI SDK pointed at that route.
The route (app/api/chat/route.ts) — resolve the smith from the session,
never from the client:
import { createIngramCloud } from "@ingram-cloud/ai-sdk";
import { convertToModelMessages, streamText } from "ai";
export async function POST(req: Request) {
const { messages, conversationId } = await req.json();
const { smithId } = await resolveVisitor(req); // your auth → smt_…
const ingram = createIngramCloud({
apiKey: process.env.IC_TOKEN!, // tenant-admin token, server-side only
smithId, // sent as IC-Smith-Id
threadId: `chat_${conversationId}`, // sent as IC-Thread-Id (memory)
});
const result = streamText({
model: ingram(""),
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
The browser — the transport points at your route; approvalsSettled makes a
paused turn resume on its own once every approval has a decision:
"use client";
import { useChat } from "@ai-sdk/react";
import { ingramCloudTransport, approvalsSettled } from "@ingram-cloud/ai-sdk/react";
export function Chat() {
const { messages, sendMessage } = useChat({
transport: ingramCloudTransport({ api: "/api/chat" }),
sendAutomaticallyWhen: approvalsSettled,
});
// …render messages, call sendMessage({ text })
}
Memory
A stateless call sends the whole context in messages. Pass a threadId and
Ingram Cloud holds the conversation server-side (the IC-Thread-Id header) —
you send only the new turn and memory is in play, exactly like a
native run's thread_id. One thread per conversation; reuse it across turns.
This holds with client-side tools too: the thread replays the prior turns,
tool-call linkage included. Use a cnv_ conversation id
as the threadId and the chat's transcript accrues on the conversation.
const ingram = createIngramCloud({ apiKey: SMITH_TOKEN, threadId: `chat_${id}` });
Structured outputs
generateObject works out of the box: the SDK sends your schema as a strict
text.format and Ingram Cloud enforces it — you get conforming JSON or an
error, never a best-effort guess:
import { generateObject } from "ai";
import { z } from "zod";
const { object } = await generateObject({
model: ingram(""),
schema: z.object({
invoice_number: z.string().nullable(),
total: z.number().nullable(),
}),
prompt: "Invoice #A-1, total 100 EUR.",
});
The schema'd call is a stateless one-shot — no tools, no memory — so use a
provider without threadId for it (a threadId provider is rejected with a
400). Details and the raw wire shape: structured
outputs.
Approvals
A tool the agent marks destructiveHint pauses the run for a human decision. On
this surface the pause is a standard tool-approval-request content part
whose approvalId is "<run_id>::<tool_call_id>". Pull the pending approvals
off the result and resume by appending a decision:
import { getApprovalRequests, approvalResponseMessage } from "@ingram-cloud/ai-sdk";
import { generateText } from "ai";
const first = await generateText({ model: ingram(""), messages });
const approvals = getApprovalRequests(first.content);
if (approvals.length) {
const decided = await askTheHuman(approvals); // your UI or policy
await generateText({
model: ingram(""),
messages: [
...messages,
...first.response.messages,
...decided.map((d) => approvalResponseMessage(d.request, d.ok ? "approve" : "reject")),
],
});
}
On approve, Ingram Cloud runs the tool itself and continues — the executed
call arrives as a tool-result part like any other server-side step; on
reject, the run completes with stop_reason: "approval_rejected" and nothing
executes. In a useChat UI the same pause shows up as a tool awaiting approval;
answer it and approvalsSettled resubmits the decision for you. This is the
same approval a deployment confirmation or /submit drives — one
mechanism, several front doors.
Tools
Two models, both standard:
-
Client-side tools — define them with the AI SDK's
tool()and pass them tostreamText/generateTextexactly as with any provider. They're sent on the request; the model's calls come back as tool calls for you to run, and the SDK loops by re-sending the conversation. Ingram Cloud executes nothing — your loop owns it. This is the literal OpenAI function-call contract; no Ingram-specific setup.import { tool } from "ai"; import { z } from "zod"; const result = streamText({ model: ingram(""), messages, tools: { get_weather: tool({ description: "…", inputSchema: z.object({ city: z.string() }) }) }, });A turn that sends
toolsruns only those client tools (the agent still supplies instructions, but its server-side MCP tools sit out that turn). Memory composes: with athreadIdthe loop is stateful and you send only the new turn. -
Server-side tools (MCP) — Ingram Cloud calls your MCP server and runs the tools for you, with approval gating (see Approvals). Don't pass
tools; register the server once and it's available to the smith automatically. Every call is visible on the stream (see Server-side tool steps). Use this for shared/remote tools you don't want to run in the client.
Identity & tokens
| Token | Use | How the smith is chosen |
|---|---|---|
Smith token (sub = "<tenant>:<smith>") | browser-safe; the default | the token is the smith |
| Tenant-admin token | server-side only | pass smithId (sent as IC-Smith-Id) |
The agent is the one the smith runs — chosen by the smith, never by an argument.
The model argument is the upstream inference LLM: pass "" to use the agent's
configured model, or a model id (e.g. openai.gpt-5.5) to override the LLM for that call.
Without a resolvable smith the call returns 400 smith_unresolved.
A tenant-admin token can skip provisioning entirely: send the OpenAI-standard
user field (your own user id, the smith's external_id) plus an IC-Agent-Id
header, and the smith for that (external_id, agent) pair is lazily created on
first touch — the same idempotent semantics as POST /v1/smiths. Bring-your-own-auth
apps need no provisioning call:
const ingram = createIngramCloud({
apiKey: process.env.IC_TENANT_TOKEN!, // server-side only
headers: { "IC-Agent-Id": "agt_…" },
});
const result = streamText({
model: ingram(""),
messages,
providerOptions: { openai: { user: visitor.id } }, // external_id — sent as `user`
});
Per-request system
A system prompt passed to streamText/generateText is appended to the
agent's configured instructions for that one turn — the agent's persona and
guardrails stay authoritative, and system carries live per-request context.
Bringing your own prompt, set per request and versioned with your app? Leave the
agent's instructions empty: appending to nothing makes your system the whole
prompt.
Rendering panels
An agent with MCP Apps templates can call the hosted
render_app tool to ask for an interactive panel. That call reaches you as an
ordinary server-side tool step — nothing proprietary — so getRenderApps reads
the intent off a finished turn or a useChat message:
import { getRenderApps } from "@ingram-cloud/ai-sdk";
for (const panel of getRenderApps(message)) {
panel.template; // "cash_chart" — the name you uploaded
panel.data; // the props the smith passed
panel.toolCallId; // stable key for this panel
}
Fetch the bundle server-side — …/ui/{name}/content takes a tenant-admin
token, so proxy it the same way you proxy the chat route. It's ETagged by
content hash, so a repeat fetch is a 304:
// app/api/panel/[name]/route.ts
import { IngramCloud } from "@ingram-cloud/sdk/client";
const ic = new IngramCloud({ token: process.env.IC_TOKEN! });
export async function GET(_req: Request, { params }) {
const html = await ic.agents.ui.content("agt_…", (await params).name);
return new Response(html, { headers: { "content-type": "text/html" } });
}
Then run the host side with the official
@modelcontextprotocol/ext-apps
package — it ships AppBridge, the same host the panel already expects. Point
it at a sandboxed iframe and hand it the panel's data:
import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
function Panel({ template, data }: { template: string; data: unknown }) {
const iframe = useRef<HTMLIFrameElement>(null);
const [html, setHtml] = useState("");
useEffect(() => {
fetch(`/api/panel/${template}`)
.then((r) => r.text())
.then(setHtml);
}, [template]);
useEffect(() => {
const win = iframe.current?.contentWindow;
if (!html || !win) return;
// No MCP client: the AI SDK already ran the tool, so there's no server to
// forward calls to — hence `null` and no `serverTools` capability.
const bridge = new AppBridge(
null,
{ name: "acme-chat", version: "1.0.0" },
{ openLinks: {}, logging: {} },
{ hostContext: { theme: "dark" } },
);
bridge.oninitialized = () => {
// The protocol requires the input before the result, exactly once.
bridge.sendToolInput({ arguments: { template, data } });
// `structuredContent` is what the panel reads — the same field the MCP
// deployment feeds it, so one bundle behaves identically on both.
bridge.sendToolResult({
content: [{ type: "text", text: "rendered" }],
structuredContent: data as Record<string, unknown>,
});
};
bridge.connect(new PostMessageTransport(win, win));
return () => void bridge.close();
}, [html, template, data]);
return <iframe ref={iframe} srcDoc={html} sandbox="allow-scripts" />;
}
sandbox="allow-scripts" (without allow-same-origin) is load-bearing: it
gives the bundle an opaque origin, so it can't reach your app's cookies or
storage. postMessage still works, and AppBridge validates the iframe by
window reference rather than origin.
When you need more
- Raw wire format, the
openaiSDK, or no extra dependency → OpenAI-compatible API. - The instant a tool starts executing — the standard parts surface each
server-side call once it completes. If you need the in-flight moment itself,
the adapter's opt-in
@ingram-cloud/ai-sdk/nativeentry point parses the native run envelope (tool.executing) into a UI message stream. Prefer the standard provider otherwise. - The Flue harness instead of the AI SDK →
Flue, the same idea with
@ingram-cloud/flue. - Where every surface fits → Ecosystem & compatibility.