> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Onboard a Client Through the Provisioning Portal

> An end-to-end, stage-by-stage operator walkthrough of the staff provisioning portal — from client profile through go-live — using the real API calls.

<Info>
  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](/deploy-security/client-provisioning) instead; the two are separate
  systems with separate identifiers (this guide's `client_id` is not an `organization_id`).
</Info>

<Warning>
  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](/deploy-security/provisioning-portal) for how staff access is granted.
</Warning>

<Steps>
  <Step title="01 — Create the client profile">
    Requires `staff:fde` or higher, feature `internal.provisioning`.

    ```bash theme={null}
    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.

    ```json theme={null}
    {"client": {"id": "cli_...", "status": "draft", "tenant_slug": "acme-mfg", "workspace_id": "ws_...", ...}}
    ```

    `client_profiles.status` starts at `draft`.
  </Step>

  <Step title="02 — Register and upload historical data">
    Register a data source, then upload the file:

    ```bash theme={null}
    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:

    ```bash theme={null}
    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.
  </Step>

  <Step title="03 — Choose training configuration">
    ```bash theme={null}
    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.
  </Step>

  <Step title="04 — Train the model">
    Requires `staff:ml_eng` or higher.

    ```bash theme={null}
    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:

    ```bash theme={null}
    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`).
  </Step>

  <Step title="05 — Evaluate">
    ```bash theme={null}
    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`:

    ```bash theme={null}
    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
    ```

    <Note>
      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](/deploy-security/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.
    </Note>

    <Warning>
      If a **hard** gate fails on data you've reviewed and consider acceptable, the remediation path
      is an override — never silently lowering a threshold:

      ```bash theme={null}
      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.)
    </Warning>
  </Step>

  <Step title="06 — Host an inference service">
    Requires `staff:ml_eng` or higher, feature `internal.host_manage` (`plan="pro"` additionally
    requires `staff:admin`).

    ```bash theme={null}
    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](/deploy-security/inference-and-queue) for exactly what FREE vs. PROD does
    and does not do.
  </Step>

  <Step title="07 — Go live">
    ```bash theme={null}
    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:

    ```bash theme={null}
    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.
  </Step>
</Steps>

## 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:

```bash theme={null}
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](/deploy-security/model-lifecycle) for the full state machine.

## Related pages

* [Provisioning Portal](/deploy-security/provisioning-portal) — the RBAC model behind every step above
* [Model Lifecycle](/deploy-security/model-lifecycle) — the state machines these calls drive
* [Evaluation Gates](/deploy-security/evaluation-gates) — what stage 05 actually checks, and the calibration gap
* [Inference & Queue](/deploy-security/inference-and-queue) — what "hosting" means on FREE vs. PROD
