ModelRig Docs Leaderboard Console ↗ GitHub Sign up

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:

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 segmentState
still runningrunning
ended with wait(), before due (or no due)waiting
ended with wait(), past dueoverdue
ended with end()done
ended failedfailed

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

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