Skip to content

Custom panels¶

A Custom panel is a dashboard panel whose contents you write in Python. The code runs in your own browser rather than on a server, so a panel redraws as fast as it can compute, and reads your model's data through the same endpoints the built-in panels use.

Add one from the dashboard: Add panel → Create your own panel → Custom.

print("Hello from Python")

Press Run code (or Cmd+Enter) and the panel prints it. Everything below is built on that: print() for output, st.* for controls, and comet_mpm for your model's data.

Note

Custom panels run Python in the browser through Pyodide. The first panel you open downloads the interpreter, which takes a few seconds; after that every panel in every tab shares it, so subsequent panels start immediately.

Streamlit-like and DataFrame-like, on purpose¶

Two of the APIs a panel uses are modelled on libraries you already know, so that what you know transfers and their documentation stays useful. Neither is the library itself, and neither is installed.

  • st.* follows Streamlit's names and signatures. st.button, st.text_input, st.selectbox, st.session_state and the rerun-on-interaction model all behave as Streamlit's do. What exists is the list under Controls and Layout and nothing else: of the layout API only st.columns and st.expander, no sidebar, no caching decorators, no dataframe or map widgets.
  • The data methods return a DataFrame-like frame, covering the part of the pandas interface panels actually use — see Working with the result. It is not a pandas DataFrame, and pandas is not loaded.

Both are that way for speed. Streamlit needs a server and pandas is a multi-megabyte download that roughly doubles a panel's memory, and a panel exists to redraw in milliseconds. Where a lookalike would be misleading, this guide says so rather than letting you find out: see Differences from a notebook.

Output¶

Anything a panel prints becomes part of the panel, in the order it was printed, interleaved with any widgets and charts around it. Output appears while the code is still running, so a long loop shows its progress rather than nothing at all.

for day in range(3):
    print("working on day", day)

The value of the code's last expression is shown too, the way a notebook cell echoes it.

Controls¶

Panels can render controls with a Streamlit-style st API. There is no import: st is already there.

feature = st.selectbox("Feature", ["response_time_ms", "basket_value"])
window = st.selectbox("Window", [7, 14, 30], index=1)
label = st.text_input("Chart title", "Response time", max_chars=60)
notes = st.text_area("Notes", "", height=150, max_chars=500)
smooth = st.checkbox("Smooth", True)

if st.button("Reset"):
    st.session_state.clear()

print(feature, window, smooth)
WidgetNotes
st.button(label, key=None)True on the one rerun its click triggered, False otherwise
st.text_input(label, value="", max_chars=None, key=None)max_chars caps the length and widens the box
st.text_area(label, value="", height=None, max_chars=None, key=None)height is in pixels
st.checkbox(label, value=False, key=None)Returns the checked state
st.selectbox(label, options, index=0, key=None)Returns the selected option, not its label
st.write(*args)An alias for print()
st.session_stateA dict that survives reruns, per panel
st.columns(spec, gap=None)Side-by-side regions -- see Layout
st.expander(label, expanded=False)A section that folds away -- see Layout
st.plotly_chart(figure)See Charts

Interacting with a widget reruns the whole panel, with that widget's new value in place — the same model Streamlit uses. Anything you want to keep between runs goes in st.session_state, which is separate for every panel on the dashboard.

Note

A text input or text area reports its value when you click away from it, not on every keystroke, so typing does not rerun the panel on each character.

Layout¶

A panel is as wide as the dashboard tile it sits in, which is not very wide. Two calls buy back room: st.columns() puts regions side by side, and st.expander() folds one away.

Both are used as context managers, and anything written inside lands in that region rather than at the end of the panel.

with st.expander("Controls", expanded=False):
    left, right = st.columns(2)
    with left:
        feature = st.selectbox("Feature", ["feature_amount", "feature_country"])
    with right:
        smooth = st.checkbox("Smooth", True)

print("charting", feature)

st.columns() takes a count for equal widths, or a list of weights for proportional ones -- st.columns([2, 1]) makes the first column twice the width of the second. It returns one region per column, and they nest: a column can hold an expander, and an expander can hold columns.

A region also has .write(), for when a with block is more than the one line is worth:

left, right = st.columns([2, 1])
left.write("the wide half")
right.write("the narrow one")

Note

Expanding or collapsing an expander is handled in the browser, so it costs no rerun and the panel's code is never told about it. That is also why st.expander() takes no key -- there is nothing to remember between runs. Pass expanded=True to have it start open.

A panel whose controls crowd out its chart is the usual reason to reach for these: putting the controls in a collapsed st.expander("Controls") gives the chart the whole tile until someone wants to change something.

Charts¶

st.plotly_chart() draws with the same Plotly build and the same axis fonts, gridlines and colours as the dashboard's built-in panels, and resizes with the panel.

The figure can be a plain dictionary, which needs no packages at all:

st.plotly_chart({
    "data": [{"type": "scatter", "mode": "lines", "x": dates, "y": values}],
    "layout": {"title": {"text": "Response time"}},
})

Or a real Plotly figure, if you would rather build it that way:

import plotly.graph_objects as go

figure = go.Figure(go.Scatter(x=dates, y=values, mode="lines"))
st.plotly_chart(figure)

Note

import plotly downloads the Plotly package the first time a panel uses it, so a dictionary figure draws sooner. A figure built with graph_objects also carries Plotly's own theme, which overrides the dashboard's plot background and gridlines — a dictionary figure matches the built-in panels exactly.

The chart types available are the ones the dashboard itself bundles: scatter, bar, box, violin and parcoords.

Warning

A trace of any other type — heatmap, histogram, pie — renders as nothing at all, with no error. If a chart comes back empty, check the trace type first.

Reading your model's data¶

Panels read data through comet_mpm, the same API the Python MPM SDK offers outside Comet. In a panel you need no API key — the panel already runs as you — and no await:

from comet_mpm import API

model = API().get_model()
metric = model.get_custom_metric("SELECT COUNT(*) FROM MODEL")

print(metric)
           timestamp   all
2026-08-25T00:00:00Z  4000
2026-08-26T00:00:00Z  4000
2026-08-27T00:00:00Z   nan

API().get_model() returns the model the dashboard is for. Every method fills in the dashboard's current date range, interval, model version and filters, so a panel follows the date picker and the filter bar without being told to. Pass any of them explicitly to override:

model.get_custom_metric(
    "SELECT COUNT(*) FROM MODEL",
    start_date="2026-01-01",
    end_date="2026-01-31",
    interval_type="HOURLY",
    filters=[],           # ignore the dashboard's filter bar
    model_version="v2",
)
MethodReturns
get_details()The model's metadata, features and configuration
get_numerical_features()Numerical feature names, prefixed (feature_age)
get_categorical_features()Categorical feature names, prefixed
get_label_names()Ground-truth column names, prefixed (label_response)
get_custom_metric(sql, ...)A metric over time, one column per filter
get_nb_predictions(...)Prediction counts over time
get_feature_drift(feature_name, algorithm="EMD", ...)Drift for one feature
get_feature_category_distribution(feature_name, normalize=False, ...)How a categorical column's values are distributed over time
get_classification_curve(score_name, label_name, curve_type="ROC_AUC", ...)ROC AUC, KS or Gini over time
get_gains_table(score_name, label_name, band_count=None, ...)Score bands with lift and cumulative gain, one row per filter and band

get_custom_metric() takes the same CometSQL a Custom metric panel takes.

get_feature_category_distribution() is also how a panel learns which values a categorical column actually holds -- nothing else reports them -- with one row per value and timestamp:

distribution = model.get_feature_category_distribution("feature_country")

print(sorted({row["value"] for row in distribution.to_dict("records")}))

The prefix on a column name carries its meaning, so pass names as the lists report them. get_feature_drift("feature_agility") reads an input column and get_feature_drift("prediction_score") an output one, without your having to say which; a name from get_label_names() asks for drift on the ground truth column instead:

model.get_feature_drift("label_response", algorithm="KS")

A name that matches nothing is refused, and the error names the methods that list the real ones. get_classification_curve() and get_gains_table() take their score and label columns either prefixed or bare.

Data calls belong at the top level¶

Comet adds the await a data call needs, and Python only allows await inside an async function. A panel's own code runs as one, so a call at the top level is fine -- but a call inside a plain def is not, and the panel refuses to run rather than misbehaving later:

def load():                                     # will not run
    return model.get_custom_metric(SQL)
line 2: get_custom_metric() reads the model from inside 'load', where it cannot be awaited.
Call it at the top level, or make that function 'async def'.

Either fix works. Read the data at the top level and pass it in:

metric = model.get_custom_metric(SQL)

def summarize(frame):                           # no data call inside
    return len(frame)

or mark the helper async def and call it with await:

async def load():
    return await model.get_custom_metric(SQL)

metric = await load()

This applies only to the methods in the table above. A panel's own helpers are ordinary functions and none of this touches them.

Working with the result¶

The data methods return a frame: a table indexed by timestamp with one column per dashboard filter, the unfiltered one named all.

metric = model.get_custom_metric("SELECT COUNT(*) FROM MODEL")

metric.columns                      # 'all', plus one per dashboard filter
metric.index                        # the timestamps
metric["all"]                       # that column's values
len(metric), metric.shape, metric.empty

for column in metric:               # column names, as in pandas
    print(column, metric[column])

for name, values in metric.items():
    print(name, list(values))

for stamp, row in metric.iterrows():
    print(stamp, row["all"])

for row in metric.itertuples():
    print(row.Index, row.all)

metric.to_dict("list")              # {'all': [4000, 4000, nan]}
metric.to_dict("records")           # one dict per row
metric.head(5)

A gains table is the exception: it describes the whole selected range as one population rather than a series over time, so its frame is long instead of wide — one row per filter and band, the filter in a predicateKey column, the band as the index, and each filter's totals under attrs["totals"]:

gains = model.get_gains_table("prediction_score", "label_response", band_count=10)

for row in gains.itertuples():
    print(row.Index, row.predicateKey, row.lift, row.cumulativeGain)

print(gains.attrs["totals"])       # {'all': {'total': 100, 'total_positives': 20, ...}}

A bucket with no data is nan, so test for one with math.isnan() rather than comparing to None:

import math

real = [value for value in metric["all"] if not math.isnan(value)]
print("average:", sum(real) / len(real))

Note

The frame implements the part of the pandas DataFrame interface shown above, and nothing more: no arithmetic, groupby, .loc or reindexing, and its index holds timestamps as strings. This is deliberate — loading pandas would add a multi-megabyte download and about twice the memory to every panel that reads data. A panel that needs the real thing can import pandas and build a DataFrame from metric.to_dict("list").

Differences from a notebook¶

The same script runs in a panel and in a notebook, provided it states the timeframe a panel would otherwise take from the dashboard. Four things differ:

  • API() needs no key in a panel and returns data for whoever is looking at the dashboard. Outside one, API(api_key=...) and get_model_by_name(workspace, model) are how you get a model; get_model() has no dashboard to read.
  • Frames are not DataFrames — see the note above. iterrows() yields a dict where pandas yields a Series.
  • await is not needed. A panel's calls reach the network, and Comet adds the await for you. Writing it yourself also works, so a script that has it keeps running. The one rule this imposes is that a data call cannot sit inside a plain def -- see Data calls belong at the top level.
  • st.plotly_chart() and the st widgets exist only in a panel. Everything under Reading your model's data is in the SDK too, with the same signatures and the same shapes.

Notes and limits¶

  • One interpreter is shared by every Custom panel in the browser, so panels run one at a time. A slow panel makes the others wait, including in another tab.
  • Each panel keeps its own variables between runs. Two panels can both use df without colliding.
  • Panels do not share variables with each other, and nothing a panel defines outlives the tab.
  • A panel's code is saved with the dashboard, so anyone who opens that dashboard runs it.
Oct. 19, 2026