Processes
(needs modelrig 0.13.0 or newer) A process is long-running business work that your code drives over days or weeks: chase 14 overdue purchase orders, answer an escalation, onboard a customer. It wakes up many times — a dashboard button, a vendor's reply webhook, a nightly cron — talks to several people, and waits on them in between.
rig.process makes every such process visible on one page: what it is waiting on, what is overdue, what it has cost, and each time it woke up.
ModelRig records; it does not run anything. Your code (or Temporal, Inngest, n8n, a cron) sends the email, places the order and resumes the work. There is no workflow editor, scheduler or notification here.
1. Set up
You need a ModelRig API key in the environment of every process that wakes the work up (the webhook handler, the cron, the dashboard's backend):
Mint a key in the org you want the data in. Sign in at https://app.modelrig.ai, check the organization name in the header badge, and create a key on the API keys page. The rig_sk_ secret is shown once. One key = one org: every row the key exports lands in the organization it was minted in, whichever organization you happen to be viewing in the console. Set it on every service, worker and cron that creates a rig; your provider keys stay where they are.
# Wherever your app reads configuration: .env.local, your platform's env vars, a secret manager. Never in code.
MODELRIG_API_KEY=rig_sk_…
If your code calls providers directly (no route files), build a raw-lane rig:
No routes yet? Build the rig raw-only. Observing a pipeline, or recording calls your code makes itself, needs no route files. Construct the rig with routesDir: null: rig.runRaw, rig.runRawStream and rig.recordCall work at once, and rig.run / rig.runBundle refuse with a teaching error until you add routes. Without it, createRig loads ./modelrig/routes: a directory you configured that is missing throws, and a missing default directory falls back to raw-only with a one-line warning.
import { createRig, loadConfigFromEnv } from "modelrig";
// Raw-only on purpose: no route files needed. rig.runRaw, rig.runRawStream and
// rig.recordCall work; rig.run needs routes (add them later, drop routesDir: null).
const rig = createRig({ ...loadConfigFromEnv(), routesDir: null });
2. One run per wake-up
Each time the process wakes up, call rig.process(id, { name }). It opens one new run — a segment — with pipeline = name and episode_key = id. Every rig.run / rig.runRaw / rig.recordCall after it on the same request records into that segment. End the segment one of two ways:
segment.wait({ party, due?, reason })— the work now waits on someone. The segment endssucceededwithwaiting_on = { party, since: now, due, reason }.segment.end()— this step finished with nothing to wait for (segment.end("failed")if it failed). When the latest segment ended this way, the process is done (or failed).
rig.process is stateless: it never looks for an open run. The next webhook calls rig.process(id) again and gets the next segment of the same process. That works on serverless platforms and across workers with no shared store.
const segment = rig.process(id, {
name: "po-followup", // the process name (its pipeline)
trigger: "webhook", // process (default) | dashboard | schedule | webhook
origin: { surface: "po-dashboard", ref: "chase-button" }, // where it was started (identifiers)
skill: { id: "vendor-po-followup", version: "3" }, // the Agent Skill it runs, if any
party: "vendor", // employee | vendor | customer | internal
counterparty: vendorRef, // an opaque ref of that vendor — never a name or email
limits: { maxUsd: 0.5 }, // run limits for this segment (see Conversations, outcomes and limits)
});
party and counterparty become the reserved party / counterparty tags on every call in the segment. A call can override them — one segment may email the vendor and then notify an employee:
await rig.runRaw({ ...draftVendorEmail }); // party: vendor
await rig.runRaw({ ...summarizeForApprover, tags: { party: "employee", counterparty: approverRef } });
reason is an identifier such as awaiting_vendor_quote, never a sentence: run metadata never carries free text. A free-text reason, an unknown party or a due that is not a date throws a teaching error and leaves the segment open.
3. Started from a dashboard button
Your dashboard's button posts to your backend; the handler opens the first segment, does the work, and records who the process now waits on.
// POST /api/po/:id/chase — your dashboard's "Chase vendor" button
export async function chase(poId: string, vendorRef: string) {
const segment = rig.process(poId, {
name: "po-followup",
trigger: "dashboard",
origin: { surface: "po-dashboard", ref: "chase-button" },
party: "vendor",
counterparty: vendorRef,
});
const draft = await rig.runRaw({ provider: "openai", model: "gpt-4o-mini", apiKey, systemPrompt, userPrompt });
await sendEmail(vendorRef, draft.text); // your code sends; ModelRig never does
segment.wait({ party: "vendor", due: inDays(3), reason: "awaiting_vendor_quote" });
}
4. A vendor's reply arrives
The vendor replies days later. Your inbound-email webhook opens the next segment of the same process — same name, same id:
// POST /webhooks/vendor-reply
export async function onVendorReply(poId: string, vendorRef: string, body: string) {
const segment = rig.process(poId, { name: "po-followup", trigger: "webhook", party: "vendor", counterparty: vendorRef });
const quote = await rig.runRaw({ ...classifyQuote(body) });
if (quote.needsApproval) {
await notifyApprover(poId);
segment.wait({ party: "employee", due: inDays(1), reason: "approval_needed" });
} else {
await placeOrder(poId);
segment.end(); // done
}
}
A cron that re-checks stuck work uses trigger: "schedule" the same way.
5. Waiting, due, overdue, stalled
The process's state comes from its latest segment:
| Latest segment | State |
|---|---|
| still running | running |
ended with wait(), before due (or no due) | waiting |
ended with wait(), past due | overdue |
ended with end() | done |
ended failed | failed |
A waiting or overdue process is also stalled once it has waited longer than your organization's window — 3 days by default (an operator setting for now).
A run whose waiting_on was written without the SDK and does not validate reads as waiting on party unknown ("wait not readable"), never as done.
With a to (or from) filter, the state is the state as of the window: it comes from the latest segment inside the window, for both the rows and the header counts. Runs, calls and cost always cover the whole process.
Segments are never closed for you. Conversations close themselves after an idle window; a process segment you never end stays running, so end every segment.
6. Outcomes: what actually happened
Post the business result against the process id with the outcome key { conversation: <process id> } — the same key conversations use, because a process id is the segments' episode_key. The outcome joins the whole process, whenever it arrives:
rig.outcome({ key: { conversation: poId }, name: "po_signed", score: true, idempotencyKey: eventId });
rig.outcome({ key: { conversation: invoiceId }, name: "invoice_paid", value: 12500, scale: { max: 12500 } });
rig.outcome({ key: { conversation: poId }, name: "days_to_close", value: 4, scale: { max: 14 } });
The key needs the ingest scope; POST /v1/outcomes and the MCP record_outcome tool do the same.
Keep process ids unique across process names and conversations. The outcome key carries only the id, so an outcome posted for PO-1001 shows on every process and conversation that uses PO-1001. When that happens, the reads mark the outcome outcome_shared: true (the console shows "shared id"). Prefix ids if two kinds of work could collide, e.g. po:PO-1001 and invoice:PO-1001. See Conversations, outcomes and limits.
7. Cost per process
A process's cost is the sum of every call in every segment, so a process that woke up five times over two weeks shows one total. The list shows it per process; the detail shows it per segment and per call, with the model, latency and party of each call. Filter by name to compare processes of one kind.
8. Read it
- Console → See → Processes. The header reads "Waiting on vendor (6) · Waiting on employee (3) · Overdue (2)"; each count links to its filter. Open a process for its segment timeline.
- HTTP.
GET /v1/read/processeswith your key (filtersname,state,party,counterparty,skillas<id>or<id>@<version>,from,to,cursor,limit), andGET /v1/read/processes/<id>for one process (add?name=when two process names share an id).partymatches who the process is waiting on or any call's party. - Filters and paging. Filters apply before paging, newest activity first. One read examines at most 2,000 processes and scans at most 5,000 runs: if it stops at either bound before the window is exhausted, the response says
partial: true— the page is not the whole answer, even when it is empty — andnextCursorcontinues from where it stopped.nextCursoris opaque; pass it back ascursor. More may follow whenevernextCursoris set;partial: falsewithnextCursor: nullmeans there are no more matches. The first page's header counts carrycounts_scope:window(every process in the window was counted) orpartial(only the most recent part was). The console shows "None found yet in the scanned range — keep looking ▸" for a partial empty page. - MCP. The oracle's
query_processesanswers "what's stuck?" for your coding agent.
Everything is metadata — states, parties, reasons, costs. Message content is never part of these reads.
Where approvals show. A process's grades here are run-level only. Employee approvals on a process's drafts (rig.approval) are grades on the call or artifact, so they appear under Grade → Approvals, on the task's Review-gate card, and in the skill receipt — not in this list.
Skill filter. The skill filter reads RunMeta.skill (set by rig.process({ skill }) or by a run started inside rig.skill). A segment opened before entering rig.skill has skill-tagged calls but shows no skill here; the skill receipt still counts those calls.
9. Reference
rig.process(id, opts: ProcessOptions): ProcessSegment—idis your process id (≤ 200 chars),opts.nameis required and must matchPROCESS_NAME_PATTERN(letters, digits, spaces and_ . : - /, at most 128 characters;isValidProcessNamechecks it). Any other name throws.ProcessSegment—runId,processId,trigger,wait(opts: ProcessWaitOptions),end(status?, { failureReason? }). Inert (no rows) when telemetry is off.segment.wait({ party, due?, reason })—dueis aDateor an ISO-8601 date-time with a zone.PROCESS_TRIGGERS—process,dashboard,schedule,webhook(all inRUN_TRIGGERS;conversationis not a process trigger).PROCESS_PARTIES—employee,vendor,customer,internal.RunMetaV1.waiting_on: RunWaitingOnV1—{ party, since, due?, reason };RunMetaV1.origin: RunOriginV1—{ surface, ref? }; `RunMetaV1.skill: RunSkillV1—{ id, version }` (version ≤ 32 chars). Every string is an identifier (≤ 128 chars, no whitespace) or an ISO timestamp.cleanRunMetaFieldsapplies these rules to every run's meta (a field that does not clean is dropped).- Reserved tags:
party,counterparty,skill(<id>@<version>). GET /v1/read/processes/GET /v1/console/processesand/:id— the same JSON on both; MCPquery_processes.