Imagine you have built a nice forecasting model. Maybe it is Chronos-2 out of the box, maybe it is an sktime pipeline you tuned for weeks. Now another team wants forecasts from it, and their service is written in Go, or it is a dashboard, or an ERP system that can send HTTP requests and nothing else.

How do you give them a forecast without handing them your notebook?

The first idea is, of course, to write a small server. Wrap predict in a FastAPI endpoint, done in an afternoon. Right? Not so fast.

You have to load the model once at startup and keep it in memory, because loading Chronos-2 per request is slow. You have to define a request and a response format, turn whatever table the client sends into something your model accepts, and turn the forecast back into what the client expects. Then somebody wants a second model. With sktime, calling it is one line, but now two models share one process, the request has to say which one to use, and their dependencies have to fit into the same environment. And at the end, all of it goes into a Docker image that you now maintain.

None of this is hard. But it is plumbing that has nothing to do with forecasting, and every team that serves a forecasting model writes it again.

TServe does this job once. sktime already gives models such as Chronos-2, TimesFM, Moirai, TTM and TiRex one interface. TServe adds the serving part on top: it loads them once, keeps them warm, and answers forecast requests over HTTP. Below, you can follow it from the first curl call to serving your own models.

ANY HTTP CLIENTGo serviceDashboardERP systemPython clientPOST /predictJSON forecastTServeone process, on your hardwarevalidate requestroute by modelconvert tablessktime · fit(past) → predict(fh)chronos_2timesfm_2_5moirai_2my_pipelineloaded once at startup, warmed up, kept in memory ANY HTTP CLIENTGo serviceDashboardERP systemPython clientPOST /predictJSON forecastTServeone process, on your hardwarevalidaterouteconvertsktime · fit(past) → predict(fh)chronos_2timesfm_2_5moirai_2my_pipelineloaded once, warmed up, kept in memory
Every caller speaks the same JSON over HTTP. TServe checks the request, picks the model it names, and turns the table into what sktime expects. The models themselves sit in memory, loaded and warmed up once when the server starts, and your own sktime models live next to the catalog ones.
An animated terminal and browser session: TServe starts with timesfm_3,
chronos_bolt and ttm_r3, curl queries /models and /predict, and then the
dashboard runs a timesfm_3 forecast with a 90% interval on a retail sales
sample.
TServe in 40 seconds: start the server, ask for a forecast, open the dashboard.

Your first forecast

TServe ships as Docker images, one per model family, so the short path needs no local Python at all. The chronos image contains Chronos-2 and also the Hugging Face families TimesFM 2.x, Chronos Bolt and TTM:

docker run --rm -p 8000:8000 sktime/tserve:chronos chronos_2 timesfm_2_5

The same thing with pip, if you prefer (Python 3.12 or newer):

pip install "tserve[server,chronos]"
tserve chronos_2 timesfm_2_5

The first start downloads the weights you name. The server then loads every model, runs a warmup forecast, and only afterwards opens the port. On a laptop CPU, the log ends like this:

INFO:     [1/3] naive via sktime ........................ ready in 21.98s
INFO:     [2/3] chronos_2 via sktime .................... ready in 12.56s
INFO:     [3/3] timesfm_2_5 via sktime .................. ready in 57.05s
INFO:     3 models ready in 108.05s · CPU 516 MB

INFO:     Starting TServe
INFO:       Dashboard   http://0.0.0.0:8000/
INFO:       Swagger UI  http://0.0.0.0:8000/docs
INFO:       ReDoc       http://0.0.0.0:8000/redoc

naive always loads, so you can test a server before downloading anything. Now five days of sales, and we want the next three:

curl -s http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{
  "past": {
    "timestamp": ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"],
    "sales": [120, 135, 128, 142, 138]
  },
  "fh": 3,
  "model": "chronos_2"
}'
{
  "predictions": {
    "timestamp": ["2024-01-06T00:00:00", "2024-01-07T00:00:00", "2024-01-08T00:00:00"],
    "sales": [138.85, 137.86, 137.94]
  },
  "model": "chronos_2",
  "request_id": "d5cc9f50-d084-48e8-8c9f-78497c02e394",
  "quantiles": null
}

That’s it already. past is a table with one row per timestamp, fh is the number of steps ahead, and model picks one of the loaded models.

Two more fields are optional: time names the time column, and target names the columns to forecast, for example "time": "timestamp", "target": ["sales"]. The request above leaves both out. Then TServe takes the first column as the time and forecasts every other column, which here is just sales.

And since this is plain JSON, the caller can be written in any language that can send a POST request.

The Python client

For Python, there is a client (pip install "tserve[client]"). It takes a dict, a pandas, polars or pyarrow table, and hands the forecast back in the same type you sent. Switching the model is one argument:

import polars as pl
from tserve.client import Client

past = pl.DataFrame(
    {
        "timestamp": ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"],
        "sales": [120, 135, 128, 142, 138],
    }
)

with Client("http://127.0.0.1:8000", timeout=300) as client:
    for model in ["chronos_2", "timesfm_2_5"]:
        result = client.predict(past=past, fh=3, model=model)
        print(model, type(result.predictions))
        print(result.predictions)
chronos_2 <class 'polars.dataframe.frame.DataFrame'>
shape: (3, 2)
┌─────────────────────┬────────────┐
│ timestamp           ┆ sales      │
│ ---                 ┆ ---        │
│ datetime[ns]        ┆ f32        │
╞═════════════════════╪════════════╡
│ 2024-01-06 00:00:00 ┆ 138.846771 │
│ 2024-01-07 00:00:00 ┆ 137.863358 │
│ 2024-01-08 00:00:00 ┆ 137.936951 │
└─────────────────────┴────────────┘
timesfm_2_5 <class 'polars.dataframe.frame.DataFrame'>
shape: (3, 2)
┌─────────────────────┬────────────┐
│ timestamp           ┆ sales      │
│ ---                 ┆ ---        │
│ datetime[ns]        ┆ f32        │
╞═════════════════════╪════════════╡
│ 2024-01-06 00:00:00 ┆ 135.892548 │
│ 2024-01-07 00:00:00 ┆ 136.191101 │
│ 2024-01-08 00:00:00 ┆ 136.596146 │
└─────────────────────┴────────────┘

Looking good! Polars in, polars out, and two foundation models from two different vendors behind the same call. Under the hood, the client converts your table into a narwhals frame, which is why it does not care whether you pass pandas, polars or pyarrow. It also checks the request right there: a missing time or target column, or an fh that is not positive, fails on your side before anything is sent. Whether the model is loaded, the server checks.

Why the long timeout

The example sets timeout=300 because it ran on a laptop CPU. There, Chronos-2 answered in well under a second, but TimesFM 2.5 took between 40 and 100 seconds per request, which is more than the client's default of 60 seconds. For anything serious, use a GPU image, see below.

117 checkpoints, one request format

The catalog has 117 checkpoints that you can load by name. Each family comes as a pip extra and as a Docker tag with the same name:

extra / tagfamiliesexample
hubChronos Bolt, Chronos T5, TTM, TimesFM 2.xchronos_bolt
chronosChronos-2chronos_2
moiraiMoirai 2, Moirai 1.x, Lag-Llamamoirai_2
timesfm3TimesFM 3timesfm_3
tirex, tirex2TiRex, TiRex-2tirex_2
totoToto-2toto_2_0_4m
graniteFlowStateflowstate
kronosKronos, WindFMkronos
mantisMantismantis_8m
t0, tafsutT0, Tafsutt0
fullall of the above

Every tag also has a GPU variant with a -gpu suffix, for example sktime/tserve:hub-gpu together with --gpus all. The full list with every checkpoint name is in the model catalog.

The families differ in what they can do. Some forecast several series jointly, some use covariates, some return quantiles. TServe does not guess here: it reads these capabilities from the sktime estimator itself, and the capabilities table lists them per family. Two examples.

Covariates

Say you know in advance when you run a promotion. A covariate goes into both past and future, and future covers the forecast horizon. Let us simulate 80 months of sales where promotions happen in random months and add about 40 to the sales, and then plan a promotion for November:

import numpy as np
import pandas as pd

rng = np.random.default_rng(1)
promo = (rng.random(80) < 0.25).astype(int)  # promotions in random months
past = pd.DataFrame(
    {
        "month": pd.date_range("2019-01-01", periods=80, freq="MS"),
        "sales": (100 + 40 * promo + rng.normal(0, 2, 80)).round(1),
        "promo": promo,
    }
)
future = pd.DataFrame(
    {
        "month": pd.date_range("2025-09-01", periods=4, freq="MS"),
        "promo": [0, 0, 1, 0],  # we plan a promotion in November
    }
)

with Client("http://127.0.0.1:8000") as client:
    result = client.predict(
        past=past, future=future, time="month", target=["sales"], fh=4, model="chronos_2"
    )

print(result.predictions)
       month       sales
0 2025-09-01   98.784592
1 2025-10-01   98.926384
2 2025-11-01  143.221909
3 2025-12-01   98.570877
past · 80 months, last 10 shownfuture · fh = 4monthsalespromo…Nov137.71Dec101.70Jan98.80Feb97.90Mar98.20Apr139.21May103.30Jun97.60Jul100.30Aug95.70Sep98.80Oct98.90Nov143.21Dec98.60 past · 80 monthsfuture · fh = 4monthsalespromo…Apr139.21May103.30Jun97.60Jul100.30Aug95.70Sep98.80Oct98.90Nov143.21Dec98.60
past carries the history of both sales and promo. future covers the four forecast months and carries only promo, since that is the part you already know. The model fills in sales for those months, the dashed bars, with the jump in November. Values are the simulated data and Chronos-2's forecast from the code above.

Nice! Chronos-2 puts the jump of about 40 exactly into November, the month where future plans the promotion. Move the 1 in future to another month, and the jump moves with it.

There is also a static field for one row of values that do not change over time, such as a store type.

Prediction intervals

A point forecast alone is often not enough to plan with. Add quantiles, and models that support them return the interval next to the point forecast. Here TimesFM 2.5 on two years of simulated monthly sales with a trend and a yearly season:

import numpy as np

rng = np.random.default_rng(0)
t = np.arange(24)
monthly = pd.DataFrame(
    {
        "month": pd.date_range("2023-01-01", periods=24, freq="MS"),
        "sales": (200 + 3 * t + 25 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 5, 24)).round(1),
    }
)

with Client("http://127.0.0.1:8000", timeout=300) as client:
    result = client.predict(past=monthly, fh=3, model="timesfm_2_5", quantiles=[0.1, 0.9])

print(result.predictions)
print(result.quantiles)
       month       sales
0 2025-01-01  255.137131
1 2025-02-01  260.312500
2 2025-03-01  265.058380
       month       0_0.1       0_0.9
0 2025-01-01  253.657379  265.641541
1 2025-02-01  259.207428  273.117676
2 2025-03-01  265.114136  279.472137

predictions stays the point forecast, and quantiles holds the 10% and 90% quantiles. Together, they form an 80% prediction interval: according to the model, January 2025 sales land between 253.7 and 265.6 with a probability of 80%, below 253.7 with 10%, and above 265.6 with 10%. How much you can trust these 80% depends, of course, on how well calibrated the model is on your data.

Many models name these columns sales_0.1 and sales_0.9. TimesFM 2.5 currently uses a positional prefix instead, which is why you see 0_0.1 here.

Bring your own sktime model

Foundation models are only half the story. Often the model you want to serve is one you built yourself: a configured estimator, a checkpoint the catalog does not name, or a pipeline you put together from sktime parts. TServe serves any sktime forecaster, and there are three ways to hand it one.

TServe fits on every request

TServe calls fit on the past table you send and then predict, so what you hand over is the model's configuration. If you fitted it before saving, that fitted state is replaced. For the foundation models in the catalog, which all run zero-shot, this is what you want: past is their context. A classical model learns from exactly the rows in the request.

The first one is a craft spec. sktime can build an estimator from a plain string that looks like Python code, for example 'NaiveForecaster(strategy="drift")', with the function sktime.registry.craft. The string is a class call with its arguments, and you need no imports. On the command line, you write it as id=spec, right next to the catalog names:

docker run --rm -p 8000:8000 sktime/tserve:chronos chronos_2 \
  'ttm_local=TinyTimeMixerForecaster(model_path="ibm-granite/granite-timeseries-ttm-r3", revision="52-16-dec-52-r3", fit_strategy="zero-shot")' \
  'drift=NaiveForecaster(strategy="drift")'

GET /models then lists all of them, and the source field tells you where each one came from:

{
  "models": [
    {"id": "naive", "executor": "sktime", "source": "registry"},
    {"id": "chronos_2", "executor": "sktime", "source": "registry"},
    {"id": "ttm_local", "executor": "sktime", "source": "craft"},
    {"id": "drift", "executor": "sktime", "source": "craft"}
  ]
}

A request with "model": "ttm_local" goes to your TTM configuration, exactly as chronos_2 goes to Chronos-2. The spec is only evaluated once, in your process at startup. It can never arrive through the model field of an HTTP request.

The second way is a saved model. Call save() on any sktime forecaster, put the .zip files in a directory, and point TServe at it:

from pathlib import Path
from sktime.forecasting.chronos import ChronosForecaster

Path("my-models").mkdir(exist_ok=True)
model = ChronosForecaster(model_path="amazon/chronos-bolt-tiny")
model.save("my-models/custom-model-1")  # writes my-models/custom-model-1.zip
tserve --models-dir my-models custom-model-1 chronos_bolt

TServe only loads the files you name on the command line, never the whole directory. In Docker, you mount the directory and pass the container path.

The third way is from Python, where you pass (id, estimator) pairs of objects that are already in memory:

from sktime.forecasting.chronos import ChronosForecaster
from tserve.server import Server

bolt = ChronosForecaster(model_path="amazon/chronos-bolt-mini", config={"device_map": "auto"})

Server(model=["chronos_bolt", ("bolt-mini-local", bolt)], host="127.0.0.1", port=8000).run()

This is also how you embed TServe in your own Python application. Catalog models, craft specs, saved zips and live objects mix freely in one list.

Your model's dependencies

TServe only installs the packages its extras declare. If your own model needs more, install it first. ThetaForecaster, for example, needs statsmodels, which none of the TServe extras declare. So either build your own image on top of the TServe one, or install TServe into an environment that already has everything your model needs.

The dashboard

Once the server runs, open http://127.0.0.1:8000/ in a browser. The dashboard shows whether the server is healthy, how many requests each model answered, and how fast. You pick a loaded model, set a horizon, optionally a prediction interval, and forecast one of the built-in sample series, or any CSV that you paste or drop in. The CSV is parsed in your browser, and the result can be downloaded as CSV again.

The TServe dashboard with timesfm_3 selected, a horizon of 19 and a 90%
prediction interval on the daily retail sales sample. The chart shows the
forecast as a line with a shaded band, the table below lists the point forecast
with the 5%, 50% and 95% quantiles, and cards on the right show health, memory,
request counts and latency per loaded
model.
TimesFM 3 forecasting retail sales with a 90% interval in the TServe dashboard.

For colleagues who do not write code, this is the fastest way to try a model on their own data. For you, it is a quick check that a freshly deployed server works. If you want the machine-readable view instead, the same numbers come from three endpoints:

routewhat it returns
GET /healthwhether the process is alive
GET /modelsthe models this process loaded (not the whole catalog)
GET /statsuptime, memory, and requests and latency per model

Swagger UI at /docs and ReDoc at /redoc document the full HTTP API, generated from the running server. If a request goes wrong, you get a status code that says why: 422 for a body that does not match the schema, 400 for a model that is not loaded or a column that is missing, both with a request_id and a message. From Python, asking for a model the server does not have looks like this:

RuntimeError model 'moirai_2' is not loaded on this server (loaded: 'chronos_2', 'naive', 'timesfm_2_5')

Why run it yourself?

TServe runs on your own hardware, from pip, from a Docker image, or inside your own Python application. There is no hosted TServe API. That has two consequences.

Your data never leaves your machine. And nobody can raise the price or switch off the model you depend on. Many forecasting foundation models are sold as gated cloud services, even when their weights are open and permissively licensed. Franz wrote about this in The Temporer Has No Clothes, and TServe is the server side of the same argument: running these models yourself takes one docker run.

The server itself is open source under the BSD 3-Clause license. Be aware that this license covers TServe, not the models. Every checkpoint comes with its own license from its vendor, so check it before you put a model into production.

What TServe does not do (yet)

TServe is at version 0.1.0, released on 24 September 2026. A few things to know before you build on it:

  • No authentication. Every route is open to whoever can reach the port, so put the server behind your own reverse proxy or inside a private network.
  • One series per request. past can have several target columns, but panel and hierarchical data are outside the request format for now.
  • No stable API yet. Until 1.0, a minor release may change the HTTP API, the Python client, or the command line.

Try it

TServe was created by Armaghan Shakir, who wrote nearly all of it. Thank you!

pip install "tserve[server,chronos]"
tserve chronos_2

If something breaks, or a model you need is missing, please open an issue. And if you want to run TServe in production with support behind it, our enterprise team can help.