Actions
Actions are the code that changes records. Each runs in the app's own deployment; its changes to its record are saved in one transaction when it returns, then onSuccess runs.
Action files
A model's actions are files in api/models/<model>/actions/; the file name is the action's name. Each exports run, and optionally onSuccess and options.
import { applyParams, save, type ActionOnSuccess, type ActionOptions, type ActionRun } from "@inferencebureau/apps-sdk";
/** A shopper's review. It always starts pending, whatever the caller sent. */
export const run: ActionRun = async ({ params, record, api, logger }) => {
applyParams(params, record);
record.status = "pending";
record.rating = Math.round(Number(record.rating));
// Link the synced product when the app syncs shopifyProduct.
const product = await api.shopifyProduct.findFirst({ filter: { shopifyId: { equals: record.productGid } } });
record.product = product?.id ?? null;
await save(record);
};
export const onSuccess: ActionOnSuccess = async ({ record, logger }) => {
logger.info("New review waiting for approval", { id: record.id, rating: record.rating });
};
export const options: ActionOptions = {
actionType: "create",
};| Option | Meaning |
|---|---|
actionType | create, update, delete or custom. Without it, an action named create, update or delete is that type and anything else is custom. |
returnType | true sends what run returns back to the caller as result. Otherwise it's dropped. |
description | One line on what the action does, for the app's API reference, its generated types and its MCP server. Each param takes a description too. Like params, they're read from the file, so keep options a plain object literal. |
timeoutMS | How long run and onSuccess may take together, at most 15 minutes: 30 seconds unless it says, or five minutes as a background job. onSuccess gets what run left, and at least a second. Past a minute or so, prefer a job: a caller waiting on the answer may give up first. |
Options are read from the file's text, so write them as literals in export const options = { … }.
Action types
| Type | record is | Called with |
|---|---|---|
create | A new record, with only its id so far | No id |
update | The stored record | Its id |
delete | The stored record. Call deleteRecord(record) to remove it | Its id |
custom | The stored record | Its id |
The context
run and onSuccess receive one object:
| Property | What it is |
|---|---|
params | What the caller passed. A model action's fields may be at the top level or nested under the model's name ({ review: { … } }). |
record | The record the action works on (null in a global action). |
api | The app's client, as the environment. See Reading and writing records. |
logger | debug, info, warn and error, each (message, data?). Lines appear on the run in Logs. |
shop | The store the run is about (an install, a webhook, or the caller's shop), or null. |
shopify | The Admin API for that store, or null. See The Admin API. |
ai | Models through Inference Bureau's gateway, billed to the team: ai.generateText, ai.embed, or the AI SDK. See Calling models. |
signal | An AbortSignal that fires when the run is stopped: its job canceled, or its time up. Pass it to fetch and check signal.aborted in long loops to stop early. |
model, action, environment | Where the run is: the model (null for a global action), the action's name, and development or production. |
The action's record
Nothing is written from the app while run runs. Change the record's fields, then mark what should happen to it:
applyParams(params, record)copies the params onto the record (exceptid,createdAtandupdatedAt).await save(record)saves it whenrunreturns. A new record saves every field you set; an existing one saves only the fields that changed.await deleteRecord(record)deletes it whenrunreturns.
When run returns, Inference Bureau applies the save or delete in one transaction, with the model's validations. If run didn't call either, the record is left as it was. save in onSuccess writes nothing, and a warning in the run's logs says so.
Writing other records with the run
save and deleteRecord also take records the action's api read (findOne, findFirst, findMany, search), and new ones made with api.<model>.build(values). They're written with the action's own record in the same transaction, in the order they were saved: all of them, or none when one is refused or run throws. A built record has its id at once, so other records can point at it, and so does a create action's own record.
import { applyParams, save, type ActionRun } from "@inferencebureau/apps-sdk";
/** An order and its lines, and the stock they reserve: all written, or none. */
export const run: ActionRun = async ({ params, record, api }) => {
applyParams({ customer: params.customer }, record);
await save(record);
for (const line of params.lines as { sku: string; quantity: number }[]) {
await save(api.orderLine.build({ order: record.id, sku: line.sku, quantity: line.quantity }));
const stock = await api.stockLevel.findFirst({ filter: { sku: { equals: line.sku } } });
if (!stock || Number(stock.available) < line.quantity) throw new Error(`${line.sku} is out of stock`);
stock.available = Number(stock.available) - line.quantity;
await save(stock);
}
};- These are record writes, checked against each model's schema. They don't run the other model's actions;
api.<model>.run()does, outside the transaction. - An update writes only the fields changed since the record was read, without locking it while
runworks. Two runs adding to the same counter can both read 4 and write 5. - For a run about one store, a store-owned model's new records are that store's, and updates and deletes reach only its records.
- At most 500 other records per run. Queue a job per batch for more.
api.<model>.create,updateanddeletestill write at once, outside the transaction, and stand even if the action later fails.
A model's create, update and delete actions also run when a record is created, updated or deleted through the API, the client or the MCP server, so they are the place for rules every write must follow.
onSuccess
onSuccess runs after the record is committed, with the saved record. Use it for work that should only happen once the change stands: Shopify writes, calls to other services, model calls. If it throws, the run is marked partial in Logs and the record's changes stay.
Global actions
Actions that don't belong to one model live in api/actions/. Their record is null; use api to read and write.
// api/actions/weeklyDigest.ts
import type { ActionOptions, GlobalActionRun } from "@inferencebureau/apps-sdk";
export const run: GlobalActionRun = async ({ params, api, logger }) => {
const page = await api.review.findMany({ filter: { status: { equals: "pending" } }, first: 250 });
logger.info("Pending reviews", { count: page.records.length });
return { pending: page.records.length };
};
export const options: ActionOptions = { returnType: true };Calling an action
From the app's code, run an action with the client. From outside, use the REST route, the MCP server's run_action tool, or Run action on the action file in the dashboard.
import { createClient } from "@inferencebureau/apps-sdk";
const api = createClient();
// A model action on a record. shop gives the action its shopify client for that store.
const { record } = await api.review.run("approve", { id: "rec_…", shop: "inference-bureau.myshopify.com", params: { moderationNote: "Thanks!" } });
// A create action makes a new record.
await api.review.run("create", { params: { productGid, shop, rating: 5, body: "Fits well, washes well.", authorName: "Sam" } });
// A global action.
const { result } = await api.$global("weeklyDigest");The answer is the record as the action left it (null after a delete), result when the action returns one, and event, the run's id in Logs. shop must be a x.myshopify.com domain the environment is installed on.
How actions run
Actions run inside the app's own deployment, not in a sandbox per call. Every build copies api/ into the deployment, generates a registry of its action files, and adds a route, /_ib/actions. To run an action, Inference Bureau calls that route on production's deployment or development's dev server, signed with the environment's token; the route answers 404 to anything unsigned.
- Inference Bureau sends the action, its params and its record. The app runs
runand answers with what it asked to happen to the record, what it returned, and its log lines. - Inference Bureau applies the write in one transaction through the records engine.
- It calls the app again for
onSuccesswith the saved record.
A run is one action event in Logs, with its log lines, its params and the write under it. A new action file is live in development as soon as it syncs; in production, after the next deploy.
Failures
| What happened | Result |
|---|---|
run threw | Nothing is written. The caller gets 422 action_failed with the message, the run's event and its logs. Logs show the stack. |
run finished but a write was refused | Nothing is written, neither the record nor the others run saved. The caller gets 400 with each field's problem, and the run is logged as failed. |
onSuccess threw | The write stands. The run is logged as partial with the error. |
| The log has warnings | The run succeeds, and its row in Logs counts the warnings, so a refused Shopify write isn't hidden inside a success. |
| The app isn't answering | The caller gets 503. A sleeping dev server is woken; try again in a minute. Production must be deployed first. |
| It took too long | The run fails after its timeoutMS, and the caller gets 503. |