Key takeaways
- Every action on Apps gets an ai client. Its calls are ordinary gateway requests on the team's Apps AI key, so there's no provider key to keep and no second bill.
- The team's Directives, budgets and balance apply to those calls as they do to any key, and a refused call throws an ApiError with the gateway's message.
- Each call is an ai event in the app's Logs, under the action run that made it, with its model, tokens and estimated cost. The settled charge is on the request in the gateway's Logs.
- Pick the model by catalog id or by a team alias, so you can change what runs without a deploy.
Say your Shopify app collects product reviews, and the brand wants a two-sentence summary at the top of each product's reviews: what shoppers like, and what they complain about. The model call is the easy part. The usual way to ship it adds a provider account, an API key in the app's secrets, and a second invoice that follows none of the spending rules you've set for the rest of your model traffic.
On Apps, an action calls the model through the same gateway as the rest of your team's requests. This post shows the call, how to choose the model, how to cap what it spends and where its cost shows up.
The ai client
Every action's context has ai. ai.generateText takes a prompt (with an optional system) or messages, and answers with the model's text. There's no key to set. The client sends the environment's own token, and Inference Bureau runs the call on the team's Apps AI key, a key it makes in the team's default project the first time an app calls a model.
From the gateway's side, nothing about the request is special. It's admitted, held, settled and logged like any other request, under the team's Directives. In the app's server code outside actions, createAiClient() makes the same client from the environment's variables.
Summarizing reviews from an action
This builds on the reviews example, where approving a review republishes the product's approved reviews to its $app:reviews metafields. The summary is a separate global action. It reads the newest approved reviews, asks a small model for a summary in JSON and writes it to a third metafield beside list and summary:
// api/actions/summarizeReviews.ts
import { ApiError, type GlobalActionRun } from "@inferencebureau/apps-sdk";
export const run: GlobalActionRun = async ({ params, api, ai, shopify, logger }) => {
const productGid = String(params.productGid);
const page = await api.review.findMany({
filter: { shop: { equals: String(params.shop) }, productGid: { equals: productGid }, status: { equals: "approved" } },
sort: { moderatedAt: "Descending" },
first: 40,
});
if (page.records.length < 3) return; // too few to summarize
let text: string;
try {
({ text } = await ai.generateText({
model: "org/fast",
system: 'Summarize these product reviews for shoppers in at most two sentences: what people like, then any common complaint. Answer {"summary": "..."}.',
prompt: page.records.map((r) => `${r.rating}/5: ${String(r.body).slice(0, 600)}`).join("\n\n"),
maxTokens: 200,
json: true,
}));
} catch (e) {
// A spent budget or a model Directives don't allow: keep the old summary, don't retry.
if (e instanceof ApiError && e.status < 500) return logger.warn("No summary this time", { status: e.status, message: e.message });
throw e;
}
const { summary } = JSON.parse(text) as { summary: string };
await shopify!.graphql(
`mutation Highlights($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { userErrors { message } } }`,
{ metafields: [{ ownerId: productGid, namespace: "$app:reviews", key: "highlights", type: "single_line_text_field", value: summary }] },
);
};
approve's onSuccess queues it instead of calling the model inline:
await api.enqueue("summarizeReviews", { shop: record.shop, productGid: record.productGid }, {
shop: String(record.shop),
concurrencyKey: `summary:${record.productGid}`,
});
Model calls take seconds. As a background job the call has five minutes instead of 30 seconds, and the moderator doesn't wait on it. The concurrencyKey runs one summary per product at a time. The block can then show product.metafields["$app:reviews"].highlights in Liquid, rendered with the page like the reviews.
Choosing the model
model takes any catalog id your Directives allow, such as openai/gpt-5-mini or anthropic/claude-haiku-5.5, or one of the team's aliases. The example uses org/fast, an alias you define once in Directives. It can name a single model, or a preset with fallback models and defaults. Changing what the alias runs changes every app that uses it, with no deploy.
Summaries like this one are a good fit for a small, cheap model. The input is short, the output is capped at 200 tokens, and json: true asks for a JSON object so the code doesn't have to parse prose. The answer's model field says which model actually answered. That's the one you asked for, unless a Directives fallback stepped in.
Capping what it spends
The Apps AI key is an ordinary key, so the rules you've already written apply to it:
- Allowed and denied models. A model your Directives don't allow is refused before anything is sent to a provider.
- Budgets. The team's budgets, and those of the default project the key lives in, cap these calls with everything else. A call that doesn't fit is refused with
402 budget_exceeded. The same goes for a balance that's too low. - Rate limits. Per-minute request and token limits hold back a burst, such as a catalog-wide backfill of summaries.
A refusal throws an ApiError with the gateway's message. That's why the action above catches 4xx errors and keeps the old summary. A job that retried a spent budget would only be refused again. If you need to turn model calls off for every app at once, revoke the Apps AI key and the client answers 403. The app's vector-field embeddings run on a key of their own and carry on.
Where the cost shows up
There are two places to look, and they answer different questions.
The app's Logs. Each call is an ai event, filed under the action run that made it. So a summarizeReviews job shows its chat org/fast call underneath it, with the model, the input and output tokens, and the estimated cost at the gateway's price with your team's markup. The query charge > 0 lists every model call in the app, and type = ai AND state = failed lists the refused ones.
The gateway's Logs. The same call is a request in the default project's Logs, under the Apps AI key, with the settled charge. The ai event carries the gateway's requestId, so you can go from one to the other.
On the bill, these are inference charges on the same team balance as the rest of your requests. Plans and usage counts them as the app's, like its embeddings.
Testing without spending anything
In tests, the test app answers model calls itself. app.ai.on(match, answer) answers any prompt containing match, and app.ai.calls lists what was asked. You can check the action's parsing and its metafield write without a single real request:
// …after creating and approving three reviews of productGid
app.shopify.on("Highlights", { metafieldsSet: { userErrors: [] } });
app.ai.on("5/5", '{"summary": "Shoppers love the fit. A few say it runs small."}');
await app.run("summarizeReviews", { shop: TEST_SHOP, params: { shop: TEST_SHOP, productGid } });
expect(app.ai.calls[0]!.model).toBe("org/fast");Frequently asked questions
Do I need an OpenAI or Anthropic account to call models from an app?
No. The ai client uses the environment's token, and Inference Bureau runs each call on the team's Apps AI key, billed at the gateway's price to the team balance.
Do my Directives apply to an app's model calls?
Yes. The calls are ordinary requests on the Apps AI key, so the team's allowed and denied models, budgets, rate limits and balance apply. A refused call throws an ApiError with the gateway's message.
Where do I see what an app's model calls cost?
Each call is an ai event in the app's Logs, under the action run that made it, with tokens and an estimated cost. The settled charge is on the matching request in the gateway's Logs, found by the event's requestId.
Can I use the AI SDK instead of generateText?
Yes. createInferenceBureau(ai) from @inferencebureau/ai-sdk-provider turns the action's ai client into an AI SDK provider, with streaming, structured output and tools. The calls are billed and logged the same way.