Get a free API key

This page as Markdown, byte for byte: /model-builder.md

Composing a model#

Fifteen minutes, one terminal, no SDK. By the end you will have composed a parametric rainfall trigger model out of platform primitives, watched Craton backtest it over forty years of pinned public data, read its auto-generated model card, and used it to produce a technical price for a structure.

No code is involved anywhere. A v1 model is four things and nothing else:

Primitive What it is In this example
feed a pinned snapshot of public data, named by id and vintage era5-rain-bangkok@2026-08-11
measurement the variable the model reads from that feed daily_precipitation_mm
transform how the daily measurement becomes one index level rolling_sum over 3 days
payout how that level becomes a share of the limit a two-point step

That is the whole vocabulary. It is deliberately small: every model composed this way is deterministic against a pinned snapshot, so its result replays byte for byte, and Craton can validate it automatically before it exists.

Read the quickstart first if you have never called Craton — it covers CEDE_BASE_URL, signing up, and the job pattern this page assumes.


What you need#

export CEDE_BASE_URL="https://…"     # no trailing slash
export CEDE_API_KEY="…"              # optional: leave it unset and sign up below

1. See what you can build with: GET /primitives#

The builder's whole vocabulary in one call — every pinned feed, the measurements each one publishes, every transform and every payout function. A feed has to be pinned before a model can name it, so this is the call that tells you which id and version pairs exist; the pinned data feeds is the same list written out, with each feed's coverage and licence:

curl -sS --fail-with-body -H "Authorization: Bearer $CEDE_API_KEY" \
  "$CEDE_BASE_URL/primitives"

Abbreviated, the part this page uses:

{
  "schema_version": "0.1.0",
  "feeds": [
    {
      "id": "era5-rain-bangkok",
      "version": "2026-08-11",
      "name": "ERA5 daily precipitation — Bangkok grid point",
      "kind": "rainfall_daily",
      "licence": { "name": "CC-BY-4.0" },
      "period_of_record": { "start": "1986-01-01T00:00:00Z", "years": 40 },
      "measurements": [{ "variable": "daily_precipitation_mm", "unit": "mm" }]
    }
  ],
  "transforms": [
    { "type": "rolling_sum", "index_unit": "the measurement's own unit" },
    { "type": "rolling_maximum", "index_unit": "the measurement's own unit" },
    { "type": "threshold_day_count", "index_unit": "days" }
  ],
  "payout_functions": [{ "type": "step" }, { "type": "linear" }]
}

A feed with an empty measurements list is a snapshot the platform holds but has no reader for yet; the builder will refuse it by name rather than guess at its columns.


2. Compose the model: POST /models#

Send metadata and the four primitives. Nothing else — declared_inputs, the validation report and the model card are produced by Craton, not supplied by you.

curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metadata": {
      "name": "Bangkok 3-day excess rainfall",
      "version": "1.0.0",
      "license": "CC-BY-4.0",
      "peril": "flood",
      "region": {"country": "TH", "description": "Bangkok"}
    },
    "logic": {
      "feed": {"id": "era5-rain-bangkok", "version": "2026-08-11"},
      "measurement": {"variable": "daily_precipitation_mm"},
      "transform": {"type": "rolling_sum", "window_days": 3},
      "payout": {"type": "step", "points": [
        {"level": 100, "payout_ratio": 0.5},
        {"level": 150, "payout_ratio": 1}
      ]}
    }
  }' \
  "$CEDE_BASE_URL/models"

metadata.version and metadata.license are mandatory. author is not yours to send: Craton sets it from the account that composed the model.

The two payout functions differ in exactly one way, and it is the one worth being sure of before you compose. **step is flat between its points and never interpolates:** the ratio is the one attached to the highest point the index level reaches, so the points above pay 0.5 at 130 mm — the 100 mm point in full — and nothing between 0.5 and 1 is ever produced. Below the first point the ratio is zero. **linear is the ramp:** zero below attachment, maximum_payout_ratio at exhaustion and above, straight-line between the two, so the same two levels pay 0.6 at 130 mm. Composed over one feed the two publish columns that look alike and mean opposite things, so no result asks you to remember which one you sent — see below.

201, and the body is the model — including everything the automatic validation pass produced. The pass is not optional and not sampled. A model that fails any part of it is not created, and you get the reason instead:

  1. schema conformance — the composed model validates against the published model schema, model-object.v0.schema.json;
  2. determinism — the backtest is run twice and the two results are compared as bytes. validation.determinism.result_digest is the sha256 of those bytes;
  3. a full backtest — every whole year of the feed's record, year by year;
  4. degenerate-case flags — minimally always pays and never pays;
  5. the model card — assumptions, limitations, license and authorship, generated rather than written.

Abbreviated:

{
  "id": "mdl_…",
  "status": "private",
  "validation": {
    "determinism": { "passed": true, "evaluations": 2, "result_digest": "…" },
    "backtest": {
      "window": { "start": "1986-01-01", "end": "2025-12-31", "years": 40 },
      "burn_rate": 0.1,
      "triggering_years": 6,
      "maximum_index_level": 198.7,
      "payout_function": { "type": "step", "semantics": "flat_steps", "…": "" },
      "years": [{ "year": 1986, "index_level": 44.7, "payout_ratio": 0 }],
      "data_coverage": { "years_with_missing_days": [] }
    },
    "degenerate_flags": { "flags": [], "triggering_years": 6, "years": 40 }
  },
  "model_card": {
    "title": "Bangkok 3-day excess rainfall",
    "version": "1.0.0",
    "license": "CC-BY-4.0",
    "licence": "CC-BY-4.0",
    "authorship": { "author": "acct_…", "note": "…" },
    "…": "peril, region, inputs, method, assumptions, limitations, measurements"
  }
}

The card restates metadata.license under **model_card.license**, the same name you sent it under. model_card.licence is the same string under this API's older spelling of the word; it is kept so that nothing already reading it breaks, and the two can never disagree. Read license.

Models are private by default. GET /models/{id} returns yours; another account's model is not found.

validation.backtest.payout_function: the rule, not just the name#

Every year row is a level and a ratio, and the ratio alone does not say which function produced it. So the backtest carries the function it evaluated — the same block a Backtest artifact carries (backtesting a structure), from the same code — with every point, the attachment, the ceiling, and the rule for reading them in two forms:

A linear model publishes the identical shape; its two points are exactly its two ends, (attachment, 0) and (exhaustion, maximum_payout_ratio).

"payout_function": {
  "type": "linear",
  "semantics": "linear_interpolation",
  "unit": "mm",
  "points": [
    { "level": 100, "payout_ratio": 0 },
    { "level": 150, "payout_ratio": 1 }
  ],
  "maximum_payout_ratio": 1,
  "attachment_level": 100,
  "evaluation": "Straight-line interpolation between the two points, never flat. …"
}

The model card restates the rule in its assumptions, so a reader who has only the card still knows whether a level between two points steps or ramps.

Try composing a worthless one#

Worth doing once, because it is the check most model builders do not have. Move the first step down to 0 mm and the model pays every year of the record:

"payout": {"type": "step", "points": [{"level": 0, "payout_ratio": 1}]}

The model is still created — it is a legitimate thing to compose — but validation.degenerate_flags now carries always_pays at severity high, and the flag is restated in the model card's limitations, where anyone reading the card meets it. Push the level to 500 mm instead and you get never_pays.


3. Run it: POST /models/{id}/runs#

A model measures over a risk object's cover period and applies its payout ratio to that object's limit, so you need an object first. The model declares which fields it reads (declared_inputs.risk_object_fields) and reads those and nothing else.

curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"object_id": "…"}' \
  "$CEDE_BASE_URL/models/$model_id/runs"

202 and a job, like every other verb. Poll GET /jobs/{id}; the result carries model_run:

{
  "run_id": "run-model-…",
  "model": { "id": "mdl_…", "name": "Bangkok 3-day excess rainfall", "version": "1.0.0" },
  "index": {
    "level": 198.7,
    "unit": "mm",
    "payout_ratio": 1,
    "measured_from": "2012-09-04",
    "measured_to": "2012-09-06"
  },
  "technical_price": { "basis": "technical", "amount": 873836.05, "currency": "THB" },
  "expected_loss": { "amount": 501369.86, "currency": "THB" },
  "assumptions": { "…": "inputs read, feed vintage, burn, loadings, limitations" }
}

run_id is content-addressed: the same model over the same period against the same snapshot produces the same id and the same bytes, every time.


The whole thing, in one block#

Run this in an empty directory. CEDE_BASE_URL is the only thing it needs from you.

set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"

# Read one field out of a JSON document on stdin: `field a.b.0.c`.
field() {
  python3 -c 'import json, sys
document = json.load(sys.stdin)
for step in sys.argv[1].split("."):
    document = document[int(step)] if step.isdigit() else document[step]
print(document)' "$1"
}

# 1 — A key, if you do not have one.
if [ -z "${CEDE_API_KEY:-}" ]; then
  account=$(curl -sS --fail-with-body \
    -H "Content-Type: application/json" \
    -d '{"label": "model builder"}' \
    "$CEDE_BASE_URL/signup")
  CEDE_API_KEY=$(printf '%s' "$account" | field api_key.secret)
  export CEDE_API_KEY
  printf 'your key (shown once): %s\n' "$CEDE_API_KEY"
fi
auth="Authorization: Bearer $CEDE_API_KEY"

# 2 — What can this environment build with?
primitives=$(curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/primitives")
feed=$(printf '%s' "$primitives" | field feeds.0.id)
vintage=$(printf '%s' "$primitives" | field feeds.0.version)
printf 'feed: %s@%s\n' "$feed" "$vintage"

# 3 — Compose the model. Four primitives; no code anywhere.
model=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  -d "{
    \"metadata\": {
      \"name\": \"Bangkok 3-day excess rainfall\",
      \"version\": \"1.0.0\",
      \"license\": \"CC-BY-4.0\",
      \"peril\": \"flood\",
      \"region\": {\"country\": \"TH\"}
    },
    \"logic\": {
      \"feed\": {\"id\": \"$feed\", \"version\": \"$vintage\"},
      \"measurement\": {\"variable\": \"daily_precipitation_mm\"},
      \"transform\": {\"type\": \"rolling_sum\", \"window_days\": 3},
      \"payout\": {\"type\": \"step\", \"points\": [
        {\"level\": 100, \"payout_ratio\": 0.5},
        {\"level\": 150, \"payout_ratio\": 1}
      ]}
    }
  }" \
  "$CEDE_BASE_URL/models")
model_id=$(printf '%s' "$model" | field id)

# 4 — What the automatic validation pass found. This is the backtest: it ran
#     on creation, over every whole year the feed covers.
printf 'model %s composed and validated\n' "$model_id"
printf '  backtest: %s years, %s of them triggering, burn rate %s\n' \
  "$(printf '%s' "$model" | field validation.backtest.window.years)" \
  "$(printf '%s' "$model" | field validation.backtest.triggering_years)" \
  "$(printf '%s' "$model" | field validation.backtest.burn_rate)"
printf '  determinism: replayed byte for byte, digest %s\n' \
  "$(printf '%s' "$model" | field validation.determinism.result_digest)"
printf '  worst year on record: %s mm over 3 days\n' \
  "$(printf '%s' "$model" | field validation.backtest.maximum_index_level)"

# 5 — Read the model back. Yours, and only yours: another account gets a 404.
#     The card restates the metadata you sent, under the names you sent it
#     under: model_card.license is the metadata.license from step 3.
card=$(curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/models/$model_id")
printf 'model card: %s, licensed %s\n' \
  "$(printf '%s' "$card" | field model_card.title)" \
  "$(printf '%s' "$card" | field model_card.license)"

# 6 — A structure to measure over: a cover period and a limit.
object=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_version": "0.1.0",
    "status": "draft",
    "exposure": {"kind": "index", "currency": "THB",
      "source_fidelity": {"unmapped_columns": [], "guessed_units": [],
                          "ambiguous_rows": []}},
    "peril": {"code": "flood", "region": {"countries": ["TH"]}},
    "financial_structure": {
      "limit": {"amount": 5000000, "currency": "THB"},
      "attachment": {"value": 100, "unit": "mm",
                     "index_ref": "bangkok-3-day-rainfall"}
    },
    "period": {"inception": "2012-01-01T00:00:00Z",
               "expiry": "2012-12-31T00:00:00Z",
               "timezone": "Asia/Bangkok"}
  }' \
  "$CEDE_BASE_URL/objects")
object_id=$(printf '%s' "$object" | field id)

# 7 — Run the model against it. A job, like every verb.
job_id=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  -d "{\"object_id\": \"$object_id\"}" \
  "$CEDE_BASE_URL/models/$model_id/runs" | field id)

status=queued
for _ in $(seq 1 60); do
  job=$(curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/jobs/$job_id")
  status=$(printf '%s' "$job" | field status)
  case "$status" in succeeded|failed) break ;; esac
  sleep 1
done
if [ "$status" != succeeded ]; then
  printf 'model run %s\n' "$status" >&2
  printf '%s\n' "$job" >&2
  exit 1
fi

printf 'index level: %s %s -> payout ratio %s\n' \
  "$(printf '%s' "$job" | field result.model_run.index.level)" \
  "$(printf '%s' "$job" | field result.model_run.index.unit)" \
  "$(printf '%s' "$job" | field result.model_run.index.payout_ratio)"
printf 'technical price: %s %s (%s basis)\n' \
  "$(printf '%s' "$job" | field result.model_run.technical_price.amount)" \
  "$(printf '%s' "$job" | field result.model_run.technical_price.currency)" \
  "$(printf '%s' "$job" | field result.model_run.technical_price.basis)"
printf 'produced by model %s version %s\n' \
  "$(printf '%s' "$job" | field result.model_run.model.id)" \
  "$(printf '%s' "$job" | field result.model_run.model.version)"

What just happened#

**GET /primitives** enumerated the builder's vocabulary from the same tables the composer enforces, so what it lists is exactly what POST /models accepts.

**POST /models** composed the model and validated it, synchronously. This is the one place Craton does not answer with a job: a model does not exist until the whole pass has accepted it, so there is no identifier to hand back early.

The backtest ran over the feed's whole forty-year record on creation. Years where the feed has missing days are disclosed in data_coverage, never filled in — a rainfall index computed from the days that happened to be present would read lower than the weather that actually fell.

**POST /models/{id}/runs** measured the index over the object's cover period, applied the payout function, and priced the structure off the model's own burn rate. The run is appended to the object's provenance.model_runs, carrying the model and version that produced it.

What Craton does not do with any of this#

Worth being explicit, because a trigger model invites the assumption:

What is not here yet#

Stated plainly so you do not go looking:

Reference#

Route What it does
GET /primitives every feed, measurement, transform and payout function you can compose with
POST /models compose a model; runs the automatic validation pass; 201 or the reason it was refused
GET /models/{id} a model, its validation report and its card
POST /models/{id}/runs run a model against a risk object; 202 and a job

Every one of these bodies has a published JSON Schema — model-primitives.v0.schema.json, model-object.v0.schema.json and model-run.v0.schema.json — in Craton's open-source schema repository, which you can consume without a Craton account and validate against without holding any Craton code. GET /health names the surfaces this environment serves; the published API contract maps each route and status to the schema that governs it.