A task of your own is your prompt template, your output schema and the models you allow, under one name. You call it by that name, and we choose which of its models answers. To choose well we first measure the models on your traffic. This cookbook creates one task, makes two calls to it, and shows where to read how far that measuring has come.

It does not run the task until it matures. How many calls that takes depends on the task, its models and the judge, so the page gives no figure for it. The numbers below are one run’s example: run the script yourself and yours will differ.

Everything in What the run printed below was printed by this cookbook’s own code, from a dated run against https://api.fronset.ai. None of it was typed. The inputs are two made-up emails, with no real people in them.

What you need

  • A management key (llmb_m_…) to create and read the task, and an inference key (llmb_i_…) for the calls. See the Quickstart for both.
  • The task runs on your own provider keys, so vault a key for the provider of its models first, as in Cookbook 5, step 1. The script uses two OpenAI models.
  • Python with pip install openai (httpx comes with it).

The script reads both keys from environment variables and never prints one:

export FRONSET_MANAGEMENT_KEY=llmb_m_...
export FRONSET_INFERENCE_KEY=llmb_i_...
python cb4_define_your_own_task.py

1. Create the task

A create carries four things:

FieldWhat it is
templatesthe prompt: a system and a user text, with {placeholders}. Every placeholder is declared in variables, with whether it is required and what it holds
output_formatjson_schema with an output_schema, or text
model_groupoptional: the models the task may use. Leave it out and every model you can run is in the pool
pricing_modebyok (your provider keys) or managed (ours, charged at cost)

Send the bare name. The create returns the full slug, which carries your account’s number as a prefix: that is the name you call.

import httpx, os

mgmt = {"Authorization": f"Bearer {os.environ['FRONSET_MANAGEMENT_KEY']}"}
r = httpx.post("https://api.fronset.ai/api/inference/v1/management/capabilities/",
               headers=mgmt, timeout=120, json={
    "slug": "cookbook-4-action-items",
    "display_name": "Cookbook 4: the action an email asks for",
    "pricing_mode": "byok",
    "templates": [{"key": "default",
                   "system": "You read business emails. Return the one action the email "
                             "asks the reader to take, in at most twelve words, and the "
                             "date it is due by as YYYY-MM-DD, or null when the email "
                             "gives no date.",
                   "user": "Subject: {subject}\n\n{body}",
                   "variables": [
                       {"name": "subject", "required": True,
                        "description": "The email's subject line."},
                       {"name": "body", "required": True,
                        "description": "The email's text."}]}],
    "output_format": "json_schema",
    "output_schema": {"type": "object",
                      "properties": {"action": {"type": "string"},
                                     "due_date": {"type": ["string", "null"]}},
                      "required": ["action", "due_date"],
                      "additionalProperties": False},
    "model_group": {"name": "cookbook-4-action-items-models",
                    "models": ["gpt-6-luna", "gpt-6-sol"]}})
task = r.json()
slug = task["slug"]                  # "<your account number>.cookbook-4-action-items"
print(task["exploration"])

The 201 response says how the task starts, in its exploration block:

FieldWhat it says
statusexploring while we are still establishing which of its models clear its bar
fans_out.native, fans_out.v1whether a call on that endpoint compares models while the task explores
judgingwho grades the comparisons. no_judge_keys means none of your vaulted providers can run a judge model: the task then never compares, and gains no evidence
noticethe same in a sentence, including what you are charged

A comparison needs two model families in the group. A group of one family never compares, and the response’s notices says so.

2. Call it

Call the task on /v1/chat/completions with the inference key. Your template holds the prompt, so messages stays empty and the values for its placeholders go in llmbench.prompt_vars. with_raw_response keeps the response headers:

from openai import OpenAI

client = OpenAI(base_url="https://api.fronset.ai/v1",
                api_key=os.environ["FRONSET_INFERENCE_KEY"], timeout=900, max_retries=0)
raw = client.chat.completions.with_raw_response.create(
    model=slug, messages=[],
    extra_body={"llmbench": {"prompt_vars": {
        "subject": "Thursday's supplier call",
        "body": "Could you book the projector for it? Best, Ana"}}})
h = raw.headers
for name in ["x-llmbench-model", "x-llmbench-band", "x-llmbench-phase",
             "x-llmbench-switch-in", "x-llmbench-cost-usd"]:
    print(name, h.get(name))

max_retries=0 matters while a task explores: a retried call runs its comparison again, and it runs on your keys again. See Prompt variables for how the template fills, and Lifecycle and what you pay for every header.

3. Read its progress

On each call

HeaderWhat it says
x-llmbench-phaseexplore while the task is still being measured; exploit or continuous after. It describes the task, not this call: while the task explores, a call may run several models and a judge, and explore does not say whether this one did
x-llmbench-bandhow much evidence we hold for the model that answered, on this task: LOW, MEDIUM, HIGH or RANKED
x-llmbench-switch-inan estimate of the judged calls still needed before the answering model’s band reaches its target. Left out when there is no estimate, and when the answer is zero: absent never means “no progress”
x-llmbench-cost-usdthe provider list cost of the model call that answered. On a call that compared, the other model and the judge are not in it: it is not what the call cost you. The route detail lists every model call

On the task

Read the task with the management key:

m = httpx.get(f"https://api.fronset.ai/api/inference/v1/management/capabilities/{slug}",
              headers=mgmt).json()["maturity"]
print(m["status"], m["mature_groups"], m["total_groups"], m["countdown_judged_calls"])
FieldWhat it says
maturity.statusexploring, or cost_optimised once enough of its model groups are evidenced
maturity.mature_groups, total_groupshow many of its model groups have reached threshold_band. The task is exploring while fewer than half have
maturity.countdown_judged_callsan estimate of the judged calls still needed. Can be absent: then there is no estimate

maturity itself can be null: read that as unknown, never as matured.

The same reading is in the console: Task models, then the task. Its Overview tab shows the state, the models at or above the bar and the judged calls still needed when there is an estimate; its Models tab shows each model’s evidence.

What the run printed

Printed by the cookbook's own code, run on 2026-10-20:

run: 2026-10-20 13:05 UTC against https://api.fronset.ai
task: 5.cookbook-4-action-items-c (created now)
  models: gpt-6-luna, gpt-6-sol
  stats_pooled: False
  exploration.status: exploring
  exploration.fans_out: native True, /v1 True
  exploration.judging: ready
  notice: This task has no evidence yet. Until its models are evidenced, each call runs
          several models from its pool and a judge picks the answer. You are charged for
          the tokens of every one of those model calls, the judge's included: passed
          through at cost on managed, or billed by your provider to your own keys on byok.
          The per-call fee is charged once per call in both modes, and not on a call that
          fails. Name a model_group to narrow the pool.
maturity before the calls:
  status exploring, 0 of 2 model groups at MEDIUM or better
  countdown_judged_calls (none: no estimate)
call 1: Brook kettle recall: stock count needed
  answer: {"action": "Count the Brook kettles in the Leeds warehouse and send the number.", "due_date": "2026-10-21"}
  x-llmbench-model:        gpt-6-luna
  x-llmbench-provider:     openai
  x-llmbench-band:         LOW
  x-llmbench-phase:        explore
  x-llmbench-switch-in:    (absent)
  x-llmbench-cost-usd:     0.00005440
call 2: Thursday's supplier call
  answer: {"action": "Book the projector for Thursday\u2019s supplier call.", "due_date": "2026-10-22"}
  x-llmbench-model:        gpt-6-sol
  x-llmbench-provider:     openai
  x-llmbench-band:         LOW
  x-llmbench-phase:        explore
  x-llmbench-switch-in:    3
  x-llmbench-cost-usd:     0.00170800
maturity after the calls:
  status exploring, 0 of 2 model groups at MEDIUM or better
  countdown_judged_calls 3

What the run shows

  • How the task started: exploring, with comparisons on both endpoints and a judge that runs on the vaulted key (judging: ready). None of its two model groups had evidence yet, so there was no countdown.
  • What the two calls’ headers said: both read explore and band LOW. The first call was answered by gpt-6-luna, the second by gpt-6-sol: while a task explores, the answering model can change from call to call. Each call also ran the other model on the same input and a judge, all on the account’s OpenAI key; the route detail of a call lists them (Cookbook 5, step 3). The first call to the new task also adapted its prompt, on Fronset’s keys, which is why it took longer (about 40 seconds).
  • The countdown: after the first call x-llmbench-switch-in was absent: there was not yet an estimate for gpt-6-luna. After the second it read 3 for gpt-6-sol, which then had two judgments and needs five before it can leave LOW.
  • The task’s maturity after the two calls: still exploring, 0 of 2 model groups at MEDIUM, and countdown_judged_calls 3. The task’s countdown is the smallest remainder among the models it still needs: here gpt-6-sol’s. The other model’s answers scored less evenly, so it needs more judgments.
  • What it cost: about $0.012 at list price on the account’s OpenAI key for the two calls, answers, alternatives and judges together, and no per-call fee (the account was inside its first 10,000 calls that month).

While the task is measured

  • Some calls run more than one model. Your call is answered, another model in the group answers the same input, and a judge compares the two. That is how the task gains evidence. Such a call takes longer than a plain one.
  • On your own keys, every one of those model calls is on your provider bill: the answer, the alternative and the judge. Your provider bills them to you. We charge one per-call fee for the call, in both modes, and none for a call that fails (Pricing Terms §1.4, §3 and §3.2).
  • On managed, the same calls are charged at cost: every model call in the chain is on your invoice at the provider’s rate, with no markup (Pricing Terms §1.2).
  • The judge must run on a provider you vaulted. If none of your providers can run a judge model, no call compares and the task gains no evidence; the create response says so with judging: "no_judge_keys".

Once it matures

  • maturity.status reads cost_optimised, and x-llmbench-phase stops reading explore.
  • Calls are routed on the evidence, and most run one model.
  • A sample of answers is still graded, so the task notices if a model’s quality changes. On your own keys those grading calls are on your provider bill too. To stop them, set judging_sample_per_10k to 0 on the task.
  • A task of your own is never put on a published rate. On managed it stays charged at cost for as long as it exists, however much evidence it holds (Pricing Terms §1.2).

The whole cookbook

#!/usr/bin/env python3
"""Fronset cookbook 4: define a task of your own, call it, and see its progress.

Creates one task of your own (or reuses it when it already exists), reads how
it starts, makes two calls to it on /v1/chat/completions, and reads the task's
maturity before and after. It does not run the task until it matures.

    pip install openai          # httpx comes with it
    export FRONSET_MANAGEMENT_KEY=llmb_m_...    # create and read the task
    export FRONSET_INFERENCE_KEY=llmb_i_...     # the two calls
    python cb4_define_your_own_task.py

The task runs on your own provider keys (pricing_mode "byok"), so vault a key
for the providers of its model group first (Cookbook 5, step 1).

Keys are read from the environment only. Nothing here prints a key. The inputs
are two made-up emails, with no real people in them.
"""
import argparse
import datetime as dt
import json
import os
import sys

import httpx
import openai
from openai import OpenAI

API = os.environ.get("FRONSET_API_BASE", "https://api.fronset.ai")
MGMT = f"{API}/api/inference/v1/management"
NAME = "cookbook-4-action-items"

# The task, as it is created. Two variables, a JSON output, two models from two
# model families on one provider.
TASK = {
    "slug": NAME,
    "display_name": "Cookbook 4: the action an email asks for",
    "description": "Read one email and return the action it asks the reader to "
                   "take, and the date it is due by, if the email gives one.",
    "pricing_mode": "byok",
    "templates": [{
        "key": "default",
        "system": "You read business emails. Return the one action the email asks "
                  "the reader to take, in at most twelve words, and the date it is "
                  "due by as YYYY-MM-DD, or null when the email gives no date.",
        "user": "Subject: {subject}\n\n{body}",
        "variables": [
            {"name": "subject", "required": True, "description": "The email's subject line."},
            {"name": "body", "required": True, "description": "The email's text."},
        ],
    }],
    "output_format": "json_schema",
    "output_schema": {
        "type": "object",
        "properties": {
            "action": {"type": "string"},
            "due_date": {"type": ["string", "null"]},
        },
        "required": ["action", "due_date"],
        "additionalProperties": False,
    },
    "model_group": {"name": f"{NAME}-models", "models": ["gpt-6-luna", "gpt-6-sol"]},
}

EMAILS = [
    {"subject": "Brook kettle recall: stock count needed",
     "body": "Hi team, the Brook kettle recall goes out next week. Please count the "
             "Brook kettles in the Leeds warehouse and send me the number by "
             "2026-10-21. Thanks, Sam"},
    {"subject": "Thursday's supplier call",
     "body": "Hello, the supplier call on Thursday has moved to the small meeting "
             "room. Could you book the projector for it? Best, Ana"},
]


class Transcript:
    """What the run printed: the page shows `lines`; ids go to `record` only."""

    def __init__(self):
        self.lines, self.record = [], {}

    def say(self, line=""):
        print(line)
        self.lines.append(line)


def mgmt_headers():
    return {"Authorization": f"Bearer {os.environ['FRONSET_MANAGEMENT_KEY']}"}


def refused(what, r):
    """Stop with the API's own reason. Never a key."""
    try:
        body = r.json()
    except ValueError:
        body = {}
    if not isinstance(body, dict):
        body = {}
    sys.exit(f"{what}: HTTP {r.status_code}, code {body.get('code')}: {body.get('error')}")


# --- Step 1. Create the task, or reuse it --------------------------------------

def find_task(name):
    """The task's full slug when it already exists and is active, else None.
    The slug is `<account id>.<name>`; the account read gives the prefix. A
    paused task is never reused: a call to it would 404."""
    r = httpx.get(MGMT + "/account", headers=mgmt_headers(), timeout=30)
    if r.status_code != 200:
        refused("account read refused", r)
    slug = r.json()["task_prefix"] + name          # e.g. "3.cookbook-4-action-items"
    r = httpx.get(f"{MGMT}/capabilities/{slug}", headers=mgmt_headers(), timeout=30)
    if r.status_code == 404:
        return None
    if r.status_code != 200:
        refused("task lookup refused", r)
    if not r.json()["is_active"]:
        sys.exit(f"task {slug} exists but is paused; pass a new --name")
    return slug


def create_task(t, name):
    body = dict(TASK, slug=name,
                model_group=dict(TASK["model_group"], name=f"{name[:41]}-models"))
    r = httpx.post(MGMT + "/capabilities/", headers=mgmt_headers(), json=body, timeout=120)
    if r.status_code != 201:
        refused("task create refused", r)
    task = r.json()
    ex = task.get("exploration") or {}
    t.say(f"task: {task['slug']} (created now)")
    t.say(f"  models: {', '.join(c['model_slug'] for c in task.get('cells') or [])}")
    t.say(f"  stats_pooled: {task.get('stats_pooled')}")
    t.say(f"  exploration.status: {ex.get('status')}")
    fo = ex.get("fans_out") or {}
    t.say(f"  exploration.fans_out: native {fo.get('native')}, /v1 {fo.get('v1')}")
    t.say(f"  exploration.judging: {ex.get('judging')}")
    for line in wrap(ex.get("notice") or "", "  notice: "):
        t.say(line)
    for n in task.get("notices") or []:
        for line in wrap(n, "  notices: "):
            t.say(line)
    return task["slug"]


def wrap(text, first, width=92):
    """Short lines for the page; the words are the API's own."""
    out, line = [], first
    for word in text.split():
        if len(line) + len(word) + 1 > width and line.strip():
            out.append(line.rstrip())
            line = " " * len(first)
        line += word + " "
    if line.strip():
        out.append(line.rstrip())
    return out


# --- Step 2 and 4. The task's progress, from the management API ---------------

def progress(t, slug, when):
    r = httpx.get(f"{MGMT}/capabilities/{slug}", headers=mgmt_headers(), timeout=60)
    if r.status_code != 200:
        refused("task read refused", r)
    m = r.json().get("maturity")
    t.say(f"maturity {when}:")
    if m is None:
        t.say("  (null: not computable right now; read it as unknown, never as matured)")
        return None
    t.say(f"  status {m.get('status')}, {m.get('mature_groups')} of {m.get('total_groups')} "
          f"model groups at {m.get('threshold_band')} or better")
    cd = m.get("countdown_judged_calls")
    t.say(f"  countdown_judged_calls {cd if cd is not None else '(none: no estimate)'}")
    return m


# --- Step 3. Two calls on /v1/chat/completions --------------------------------

HEADERS = ["x-llmbench-model", "x-llmbench-provider", "x-llmbench-band",
           "x-llmbench-phase", "x-llmbench-switch-in", "x-llmbench-cost-usd"]


def call(t, client, slug, email, n):
    """One call in template mode: the task's template renders `prompt_vars`, and
    `messages` stays empty. The x-llmbench-* headers say how the call ran."""
    t.say(f"call {n}: {email['subject']}")
    try:
        raw = client.chat.completions.with_raw_response.create(
            model=slug,
            messages=[],
            extra_body={"llmbench": {"prompt_vars": email}},
        )
    except openai.APIStatusError as e:
        h = e.response.headers
        t.record.setdefault("diagnostics", []).append(h.get("x-llmbench-diagnostic"))
        t.say(f"  failed: HTTP {e.status_code}, {h.get('x-llmbench-error-code')} "
              f"(whose problem: {h.get('x-llmbench-error-class')})")
        return
    except openai.APIConnectionError as e:
        # No response came back. The call may still have run on the server.
        t.say(f"  failed: no response ({type(e).__name__}); the call may still have run")
        return
    h = raw.headers
    answer = json.loads(raw.parse().choices[0].message.content)
    t.say(f"  answer: {json.dumps(answer)}")
    for name in HEADERS:
        t.say(f"  {name + ':':<24} {h.get(name, '(absent)')}")
    t.record.setdefault("request_ids", []).append(h.get("x-llmbench-request-id"))


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--name", default=NAME, help="the task's name (a new name makes a new task)")
    p.add_argument("--page-dir", help="write the transcript here as JSON")
    p.add_argument("--record-json", help="write the task slug and request ids here")
    a = p.parse_args()

    now = dt.datetime.now(dt.timezone.utc)
    t = Transcript()
    t.say(f"run: {now:%Y-%m-%d %H:%M} UTC against {API}")

    slug = find_task(a.name)
    if slug:
        t.say(f"task: {slug} (already there, reused)")
    else:
        slug = create_task(t, a.name)
    t.record["task"] = slug

    progress(t, slug, "before the calls")

    client = OpenAI(base_url=f"{API}/v1", api_key=os.environ["FRONSET_INFERENCE_KEY"],
                    timeout=900, max_retries=0)
    for n, email in enumerate(EMAILS, start=1):
        call(t, client, slug, email, n)

    progress(t, slug, "after the calls")

    if a.page_dir:
        with open(os.path.join(a.page_dir, "cb4_own_task.json"), "w") as f:
            json.dump({"run_date": f"{now:%Y-%m-%d}", "transcript": t.lines}, f, indent=2)
    if a.record_json:
        with open(a.record_json, "w") as f:
            json.dump(t.record, f, indent=2)


if __name__ == "__main__":
    main()

Download cb4_define_your_own_task.py

Worth knowing

  • The script reuses the task when it already exists and is active, so running it twice does not create a second one.
  • On a task of your own you can stop comparing for one call, or for the whole task: see Cookbook 5, step 4. With comparison off, a task whose group holds more than one model family may never mature.
  • The header names start x-llmbench- and the keys start llmb_. Those are the wire names and they stay as they are.

The Terms of Service, Privacy Policy and Pricing Terms govern use of the API. Where anything on this page differs from those, those govern.