App Contract
Every app registered in the marketplace must supply two files at the root of its repository: manifest.json and function.py. Together they form the app contract — the interface between the marketplace and your model.
manifest.json
The manifest describes your app and declares its inputs so the marketplace can auto-generate the run form.
{
"name": "My Model",
"description": "Short description shown on the marketplace card.",
"inputs": {
"field_key": {
"type": "number",
"label": "Human-readable label",
"min": 0,
"max": 100,
"step": 1,
"default": 50,
"unit": "%"
}
}
}
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | ✓ | Display name on the marketplace card |
| description | string | — | Short description (shown on card and app page) |
| inputs | object | — | Map of input field keys to field specs |
Input field spec
Each key under inputs maps to a field spec object:
| Field | Type | Required | Description |
|---|---|---|---|
| type | "number" | ✓ | Field type — currently number is supported |
| label | string | ✓ | Label shown above the form slider |
| min | number | — | Minimum slider value (default: 0) |
| max | number | — | Maximum slider value (default: 100) |
| step | number | — | Slider step increment (default: 1) |
| default | number | — | Initial value when the form loads |
| unit | string | — | Unit suffix appended to the displayed value (e.g. " yrs", "%") |
Only "number" type is currently supported. Number fields are rendered as range sliders in the UI. Additional types (text, select, boolean) are forward-compatible — you can add them to the manifest and implement support in the JobForm component.
Validation on registration
When POST /apps/register is called, the backend:
- Clones the repository
- Checks that
manifest.jsonexists at the repo root — returns 422 if missing - Checks that
function.pyexists at the repo root — returns 422 if missing - Parses
manifest.jsonand readsname,description,inputs - Persists an
Approw withfunction_pathpointing to the cloned directory
The manifest JSON is not schema-validated beyond being parseable — extra fields are silently ignored.
function.py
function.py must export exactly one coroutine:
async def run(inputs: dict) -> dict:
...
| Requirement | Detail |
|---|---|
| Must be async | The backend uses await fn(inputs) inside an async context |
| Argument | inputs: dict — keys match the field keys in your manifest |
| Return value | Any JSON-serialisable dict — stored as JSONB and forwarded to the frontend |
What you can do inside run
- Use
asyncio.sleep()for deliberate delays (simulating long computations) - Import third-party libraries (
numpy,scipy,pandas, etc.) — they must be available in the backend container's Python environment - Raise exceptions freely — the backend catches them and sets status to
FAILED
What the return dict should contain
The frontend (ResultPanel) renders the result dict. The existing components support:
| Key | Type | Purpose |
|---|---|---|
| series | [{x, y}] | Rendered as a line chart |
| table | [{col: val, …}] | Rendered as a data table |
| columns | string[] | Column order for the table |
| summary | {label: value} | Key-value summary cards |
You can return any subset of these keys, or add custom keys — the frontend will render what it recognises and ignore the rest.
Complete worked example
Here is a full Surrender Rate Model from scratch.
manifest.json
{
"name": "Surrender Rate Model",
"description": "Projects policyholder surrender rates under duration and rate stress scenarios.",
"inputs": {
"duration": {
"type": "number",
"label": "Policy duration",
"min": 1,
"max": 30,
"step": 1,
"default": 10,
"unit": " yrs"
},
"rate_stress": {
"type": "number",
"label": "Interest rate stress",
"min": -300,
"max": 300,
"step": 25,
"default": 0,
"unit": " bps"
}
}
}
function.py
import asyncio
import numpy as np
async def run(inputs: dict) -> dict:
await asyncio.sleep(3) # simulate computation time
duration = int(inputs.get("duration", 10))
rate_stress_bps = float(inputs.get("rate_stress", 0))
years = np.arange(1, duration + 1)
# Base lapse rate follows a decreasing curve
base_lapse = 0.15 * np.exp(-0.18 * years) + 0.02
# Rate stress adds a spread-driven component (higher rates → more surrenders)
rate_factor = 1 + max(0, rate_stress_bps) / 10000 * 5
lapse = np.clip(base_lapse * rate_factor, 0, 0.99)
# Cumulative in-force
in_force = np.cumprod(1 - lapse)
series = [{"x": int(y), "y": round(float(s) * 100, 2)} for y, s in zip(years, in_force)]
table = [
{
"year": int(y),
"lapse_rate": f"{float(l)*100:.2f}%",
"in_force": f"{float(s)*100:.1f}%",
}
for y, l, s in zip(years, lapse, in_force)
]
return {
"columns": ["Year", "Lapse Rate", "In-Force"],
"table": table,
"series": series,
"summary": {
"duration": f"{duration} years",
"rate_stress": f"{int(rate_stress_bps):+d} bps",
"terminal_in_force": f"{float(in_force[-1])*100:.1f}%",
"avg_annual_lapse": f"{float(np.mean(lapse))*100:.2f}%",
},
}
Keep function.py focused on computation. Heavy data loading (CSV, database) should happen at the top of the file as module-level initialisation — importlib re-executes the module for each run, so module-level code runs each time. If startup cost matters, cache expensive resources using a module-level variable.
Third-party libraries imported in function.py must be installed in the backend container. Add them to backend/requirements.txt and rebuild the Docker image.