Skip to main content
This is the staff-only portal walkthrough — onboarding a client’s ML model. If you’re looking for tenant setup (organization + workspace + owner account), see Client Provisioning instead; the two are separate systems with separate identifiers (this guide’s client_id is not an organization_id).
Every call below requires a staff JWT (staff_role in {fde, ml_eng, admin}, depending on the endpoint — see the RBAC table on each step) and PROVISIONING_PORTAL_ENABLED=true. With the portal disabled, every one of these routes returns 404, not 401/403. See Provisioning Portal for how staff access is granted.
1

01 — Create the client profile

Requires staff:fde or higher, feature internal.provisioning.
curl -X POST https://api.causeloop.ai/v1/provisioning/client-profiles \
  -H "Authorization: Bearer $STAFF_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Acme Manufacturing",
    "tenant_slug": "acme-mfg",
    "industry": "manufacturing",
    "primary_format": "xlsx_csv",
    "data_residency": "us",
    "notes": "Pilot — SRE-referred"
  }'
primary_format must be one of xlsx_csv, json, parquet, hl7_fhir. This call is idempotent on tenant_slug: a repeat call with the same slug returns 200 with the existing profile rather than creating a second one; a genuinely new client returns 201. Under the hood this also provisions a bare workspace (no users/memberships yet — the portal collects no owner email at this stage) that every later stage writes into.
{"client": {"id": "cli_...", "status": "draft", "tenant_slug": "acme-mfg", "workspace_id": "ws_...", ...}}
client_profiles.status starts at draft.
2

02 — Register and upload historical data

Register a data source, then upload the file:
curl -X POST https://api.causeloop.ai/v1/provisioning/client-profiles/$CLIENT_ID/data-sources \
  -H "Authorization: Bearer $STAFF_JWT" -H "Content-Type: application/json" \
  -d '{"kind": "upload"}'
# -> {"data_source": {"id": "ds_...", ...}}

curl -X POST https://api.causeloop.ai/v1/provisioning/client-profiles/$CLIENT_ID/data-sources/$DS_ID/upload \
  -H "Authorization: Bearer $STAFF_JWT" \
  -F "file=@historical_issues.xlsx"
kind is one of upload, s3, database. For database, the request accepts credential_ref only — any field that looks like password/secret/token/api_key is rejected with a 422; raw credentials are never stored on this row. Uploads are capped at UPLOAD_MAX_BYTES (50 MB default); a larger file returns 413.Then validate — this is where the corpus actually becomes usable:
curl -X POST https://api.causeloop.ai/v1/provisioning/client-profiles/$CLIENT_ID/data-sources/$DS_ID/validate \
  -H "Authorization: Bearer $STAFF_JWT" -H "Content-Type: application/json" \
  -d '{"schema_map": {"Ticket ID": "external_id", "Summary": "title", "Description": "body"}}'
schema_map maps your file’s column headers to issue-envelope@1 fields; unmapped columns survive as attributes. Validation does four real things in sequence: parses the uploaded xlsx/csv, materializes issue-envelope@1 objects, writes envelopes.jsonl to the upload store — and then ingests and forges each envelope into a real issues row in the client’s workspace (bounded concurrency, best-effort — a row-level failure doesn’t fail the whole request). This last step is what makes training in stage 04 actually have a corpus to train on; without it, an uploaded 250-row file would train on zero issues. client_profiles.status flips to data_validated only after this succeeds.
3

03 — Choose training configuration

curl https://api.causeloop.ai/v1/provisioning/model-registry \
  -H "Authorization: Bearer $STAFF_JWT"
Returns the available embedding models and cluster-config presets ({"data": [...]}), each optionally carrying param_ranges for a bounded-slider UI. Pick a preset — this stage is a read; nothing is submitted yet.
4

04 — Train the model

Requires staff:ml_eng or higher.
curl -X POST https://api.causeloop.ai/v1/provisioning/client-profiles/$CLIENT_ID/training-runs \
  -H "Authorization: Bearer $STAFF_JWT" -H "Content-Type: application/json" \
  -d '{"kind": "initial", "params": {}}'
# -> 201 {"training_run": {"id": "trn_...", "status": "queued", ...}}
kind is initial or retrain. This freezes the submitted params (with a hash) and launches training in the background; client_profiles.status flips to training. Watch it live:
curl -N -H "Authorization: Bearer $STAFF_JWT" \
  -H "Accept: text/event-stream" \
  https://api.causeloop.ai/v1/provisioning/training-runs/$RUN_ID/events
Or poll GET /v1/provisioning/training-runs/{run_id}/events without the SSE header for a cursor-paginated log. On success, client_profiles.status becomes trained and the training run’s manifest carries the wm@2 artifact reference (if WM2_ARTIFACTS=true).
5

05 — Evaluate

curl -X POST https://api.causeloop.ai/v1/provisioning/training-runs/$RUN_ID/evaluate \
  -H "Authorization: Bearer $STAFF_JWT"
# -> 202 {"eval_run": {"id": "evl_...", "status": "running"}}

curl https://api.causeloop.ai/v1/provisioning/training-runs/$RUN_ID/evaluation \
  -H "Authorization: Bearer $STAFF_JWT"
# -> {"eval_run": {...}, "gates": [...], "datasets": {...}, "diff": null}
Re-POSTing /evaluate while an eval already exists is idempotent — it returns the existing row (200) rather than starting a duplicate. Once eval_run.status is passed:
curl -X POST https://api.causeloop.ai/v1/provisioning/training-runs/$RUN_ID/approve \
  -H "Authorization: Bearer $STAFF_JWT"    # staff:ml_eng, internal.eval_approve
As of the 2026-07 calibration, a genuinely good clustering passes on real data — the e_sil/e_hold floors were recalibrated against a real qwen3-embedding corpus and E-AGR (the Leiden↔DSU agreement gate) is now advisory, because the union-find crosscheck it reads single-linkage-chains real text into one blob and carries no signal about partition quality (see Evaluation Gates → The calibration gap). The remaining hard gates (E-SIL, E-HOLD, E-COV, E-DEG, E-DET, E-PROBE, and E-GOLD when a golden set is uploaded) can still legitimately fail on genuinely sparse or low-quality pilot data.
If a hard gate fails on data you’ve reviewed and consider acceptable, the remediation path is an override — never silently lowering a threshold:
curl -X POST https://api.causeloop.ai/v1/provisioning/training-runs/$RUN_ID/override \
  -H "Authorization: Bearer $STAFF_JWT" -H "Content-Type: application/json" \
  -d '{"reason": "e_sil scored 0.18 on a genuinely sparse 40-issue pilot corpus; reviewed loop outputs manually and signed off."}'
staff:admin + internal.eval_override only, and the reason is required. This permanently flags the artifact’s manifest (eval.overridden=true) and is audited. (E-AGR being low never requires an override — it is advisory and does not block.)
6

06 — Host an inference service

Requires staff:ml_eng or higher, feature internal.host_manage (plan="pro" additionally requires staff:admin).
curl -X POST https://api.causeloop.ai/v1/provisioning/client-profiles/$CLIENT_ID/services \
  -H "Authorization: Bearer $STAFF_JWT" -H "Content-Type: application/json" \
  -d '{"plan": "starter", "min_instances": 1, "max_instances": 3, "auth_mode": "service_token"}'
# -> 201 {"service": {"id": "svc_...", "status": "provisioning"}}
Refuses with 409 if the artifact isn’t eval_passed/approved/overridden (the EVAL_REQUIRED guard). On the default FREE inference profile this registers routing to the shared cluster-inference service — no Render API call is made; see Inference & Queue for exactly what FREE vs. PROD does and does not do.
7

07 — Go live

curl -X POST https://api.causeloop.ai/v1/provisioning/services/$SERVICE_ID/activate \
  -H "Authorization: Bearer $STAFF_JWT"    # staff:ml_eng, internal.host_manage
Health-checks the service, then flips it to the active service for this client (deactivating any previous one) and client_profiles.status to live. Check the go-live summary:
curl https://api.causeloop.ai/v1/provisioning/client-profiles/$CLIENT_ID/summary \
  -H "Authorization: Bearer $STAFF_JWT"
Returns the endpoint, artifact content_hash, eval.overridden, and real flow.{ingest, assigned,outliers} counts — honest zeros if nothing has flowed through yet, never fabricated.

Promoting a later version (shadow → canary → active)

A version doesn’t have to go straight to active. For a retrain, or a candidate you want to compare against live traffic first:
curl -X POST https://api.causeloop.ai/v1/provisioning/models/$WORKSPACE_ID/$VERSION/shadow \
  -H "Authorization: Bearer $STAFF_JWT"   # staff:ml_eng, internal.host_promote — zero side effects

curl -X POST https://api.causeloop.ai/v1/provisioning/models/$WORKSPACE_ID/$VERSION/canary \
  -H "Authorization: Bearer $STAFF_JWT" -H "Content-Type: application/json" -d '{"pct": 10}'

curl https://api.causeloop.ai/v1/provisioning/models/$WORKSPACE_ID/$VERSION/promotion-stats \
  -H "Authorization: Bearer $STAFF_JWT"   # live agreement/abstention — honest nulls at 0 samples

curl -X POST https://api.causeloop.ai/v1/provisioning/models/$WORKSPACE_ID/$VERSION/promote \
  -H "Authorization: Bearer $STAFF_JWT"
promote is distinct from activate (step 07 above) — it flips which model version is the workspace’s registered active model, not which service receives traffic. See Model Lifecycle for the full state machine.