Cookbook: Define a Task of Your Own, Call It, and See Its Progress
Create a task of your own with its prompt template, output schema and model group, make two calls to it, and read where it stands: the headers on each call and the task’s maturity.
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:
| Field | What it is |
|---|---|
templates | the 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_format | json_schema with an output_schema, or text |
model_group | optional: the models the task may use. Leave it out and every model you can run is in the pool |
pricing_mode | byok (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:
| Field | What it says |
|---|---|
status | exploring while we are still establishing which of its models clear its bar |
fans_out.native, fans_out.v1 | whether a call on that endpoint compares models while the task explores |
judging | who 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 |
notice | the 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
| Header | What it says |
|---|---|
x-llmbench-phase | explore 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-band | how much evidence we hold for the model that answered, on this task: LOW, MEDIUM, HIGH or RANKED |
x-llmbench-switch-in | an 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-usd | the 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"])
| Field | What it says |
|---|---|
maturity.status | exploring, or cost_optimised once enough of its model groups are evidenced |
maturity.mature_groups, total_groups | how many of its model groups have reached threshold_band. The task is exploring while fewer than half have |
maturity.countdown_judged_calls | an 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 3What 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
exploreand bandLOW. The first call was answered bygpt-6-luna, the second bygpt-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-inwas absent: there was not yet an estimate forgpt-6-luna. After the second it read3forgpt-6-sol, which then had two judgments and needs five before it can leaveLOW. - The task’s maturity after the two calls: still exploring, 0 of 2 model groups at
MEDIUM, andcountdown_judged_calls3. The task’s countdown is the smallest remainder among the models it still needs: heregpt-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.statusreadscost_optimised, andx-llmbench-phasestops readingexplore.- 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_10kto0on the task. - A task of your own is never put on a published rate. On
managedit 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 startllmb_. 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.