Ingram Cloud

Documentation

Vercel AI SDK

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 to streamText/generateText exactly 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 tools runs only those client tools (the agent still supplies instructions, but its server-side MCP tools sit out that turn). Memory composes: with a threadId the 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

TokenUseHow the smith is chosen
Smith token (sub = "<tenant>:<smith>")browser-safe; the defaultthe token is the smith
Tenant-admin tokenserver-side onlypass 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 openai SDK, or no extra dependencyOpenAI-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/native entry 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 SDKFlue, the same idea with @ingram-cloud/flue.
  • Where every surface fitsEcosystem & compatibility.