OpenAI Batch API skill for Codex
Half-price bulk inference that rejoins cleanly. Codex builds Batch API jobs with stable custom_ids tied to source rows, validates the JSONL offline with a bundled script before spending money, and merges output and error files by custom_id rather than position. Failed and expired requests are written to a retry file automatically, and results are upserted so reruns never double-process.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- openai-batch-api Installs to ~/.agents/skills/openai-batch-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install openai-batch-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project openai-batch-api Installs to .agents/skills/openai-batch-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/openai-batch-api.zipunzip -o openai-batch-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) openai-batch-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $openai-batch-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Runs bulk OpenAI inference through the Batch API at half the synchronous price — building JSONL request files with custom_id, uploading with purpose batch, creating 24h jobs for /v1/responses, embeddings or images, polling status, joining output and error files back by custom_id, and resubmitting failed or expired requests. Use when classifying, embedding, summarizing or evaluating thousands of items offline, when a nightly job hits rate limits, or when a batch came back partially failed or expired.
Use it when
- Thousands of independent requests can wait up to 24 hours
- Nightly evals or labeling jobs hit synchronous rate limits
- A batch finished with failed or expired requests
- Cutting the cost of offline inference
Use something else when
- A user is waiting on the answer: use openai-responses-api
- The work is vectorizing documents and choosing dimensions: use openai-embeddings
How to check the result
The bundled check command passes on the input file, a handful of lines succeed synchronously, and the merge command reports every request succeeded or lists the remainder.
The mistake it prevents
Using the row index as custom_id, so any filter or reorder silently attaches results to the wrong records.
Done well means: the input file is validated offline before upload, every request has a
stable custom_id that maps back to a source row, results are joined by custom_id
(never by position), failures and expirations are resubmitted automatically, and the job
is idempotent so a rerun does not double-process.
When batch is the right tool
| Situation | Choice |
|---|---|
| Results needed within seconds/minutes for a user | Synchronous API |
| Thousands+ of independent requests, results within 24 h OK | Batch (50% cheaper, separate rate-limit pool) |
| Eval suite over a fixed dataset, run nightly | Batch |
| Steps depend on each other’s output (agent loops, multi-turn) | Synchronous, or chain batches stage by stage |
| Under ~100 requests | Synchronous with a concurrency limit; batch overhead not worth it |
Limits to design around: up to 50,000 requests and 200 MB per input file, one model per
file, one endpoint per batch, completion_window="24h" only, embeddings batches capped at
50,000 total inputs, and a per-model cap on enqueued prompt tokens (see the organization
limits page). Output files are deleted 30 days after completion — copy results to your
own storage.
Supported endpoints: /v1/responses, /v1/chat/completions, /v1/embeddings,
/v1/completions, /v1/moderations, /v1/images/generations, /v1/images/edits.
Workflow
- 1. Read
AGENTS.md; find where jobs, storage and secrets live - 2. Build the JSONL from source rows with deterministic
custom_ids - 3. Validate offline with
scripts/batch_jsonl.py check - 4. Upload, create the batch, persist the batch id
- 5. Poll until a terminal status
- 6. Download output and error files; merge by
custom_id - 7. Resubmit failures; write results idempotently
2. Build the input file
import hashlib, json, os
MODEL = os.environ["OPENAI_MODEL"] # check the models page; one model per file
SCHEMA = {...} # strict JSON Schema for the label, see openai-structured-outputs
def cid(row) -> str:
# Stable across reruns: source id + prompt version, not a random uuid or row index
return f"{row['id']}:{PROMPT_VERSION}"
with open("batch_input.jsonl", "w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps({
"custom_id": cid(row),
"method": "POST",
"url": "/v1/responses",
"body": {
"model": MODEL,
"instructions": INSTRUCTIONS, # identical prefix -> cache-friendly
"input": row["text"],
"text": {"format": {"type": "json_schema", "name": "label",
"strict": True, "schema": SCHEMA}},
"max_output_tokens": 300,
"store": False,
},
}) + "\n")
Split into multiple files when you exceed 50,000 lines or 200 MB, or when enqueued tokens would exceed the model’s batch queue limit (estimate input tokens per line × lines).
3. Validate before spending
python3 scripts/batch_jsonl.py check batch_input.jsonl
It checks JSON per line, unique custom_id, POST, a single supported endpoint, a
single model, no stream: true, and the line/size limits. Runs offline; exit code 1 on
any error. Also send 3–5 of the lines synchronously first to confirm the body works —
a schema typo fails every request in the batch 24 hours later.
4–5. Submit and poll
Submitting needs network and spends money; confirm the row count and model with the user before the first large submission.
from openai import OpenAI
client = OpenAI()
f = client.files.create(file=open("batch_input.jsonl", "rb"), purpose="batch")
batch = client.batches.create(
input_file_id=f.id,
endpoint="/v1/responses",
completion_window="24h",
metadata={"job": "ticket-labels", "prompt_version": PROMPT_VERSION},
)
save_job(batch.id) # persist; the poller may run in a different process
TERMINAL = {"completed", "failed", "expired", "cancelled"}
batch = client.batches.retrieve(batch_id)
if batch.status not in TERMINAL:
reschedule(minutes=10) # cron/queue; do not sleep-loop in a web worker
print(batch.status, batch.request_counts)
Status values: validating, failed (input invalid — check batch.errors),
in_progress, finalizing, completed, expired, cancelling, cancelled.
6. Collect and merge
def download(file_id, path):
if file_id:
with open(path, "w", encoding="utf-8") as fh:
fh.write(client.files.content(file_id).text)
download(batch.output_file_id, "out.jsonl")
download(batch.error_file_id, "err.jsonl")
python3 scripts/batch_jsonl.py merge batch_input.jsonl out.jsonl err.jsonl --retry retry.jsonl
Each output line has custom_id, response.status_code, response.body (the normal
endpoint response) and error. Output order does not match input order. The merge
command reports succeeded/failed/missing counts, failures by code, and writes every
request without a 200 result to retry.jsonl.
7. Retry and persist
- Resubmit
retry.jsonlas a new batch. Cap resubmissions (e.g. 3 rounds); after that, send the remainder synchronously or report them. batch_expiredlines are normal under load: completed requests are kept and billed, unfinished ones land in the error file.- Write results with an upsert keyed on
custom_idso reruns are idempotent. - Parse
response.bodywith the same code you use synchronously (outputitems,status == "incomplete"checks, refusals).
Gotchas
- Using the row index as
custom_id: any filter or reorder corrupts the join. - Mixing models or endpoints in one file fails validation for the whole batch.
- A batch that is
completedcan still contain many per-request errors — always readrequest_countsand the error file. - Changing the prompt between the first round and the retry round mixes versions; keep
the prompt version in
custom_idand metadata. - Cancelling takes up to ~10 minutes (
cancelling); requests already done are billed. - Large repeated instructions: put them first and identical across lines so prompt caching can apply where supported.
Verification
checkpasses and a synchronous dry run of 3–5 lines returns valid output.- After completion,
mergeshowssucceeded == requests(or a known remainder). - Spot-check 10 random results against inputs by
custom_id. - Compare cost from the usage dashboard with the synchronous estimate.
Output contract
**Job:** ticket-labels v3 — 42,180 requests, model from OPENAI_MODEL, /v1/responses
**Batches:** batch_abc (completed), batch_def (retry round 1, 37 requests)
**Result:** 42,180 succeeded, 0 outstanding; written to warehouse.ticket_labels
**Failures seen:** 29 batch_expired, 8 status 500 — all succeeded on retry
**Cost:** <batch cost> vs <synchronous estimate> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "OpenAI Batch API"
short_description: "Half-price bulk inference that rejoins cleanly"
default_prompt: "$openai-batch-api Label these 40,000 support tickets with the Batch API instead of synchronous calls" scripts/batch_jsonl.py
#!/usr/bin/env python3
"""Offline helper for OpenAI Batch API files (standard library only).
check INPUT.jsonl
Validate a batch input file before upload: JSON per line, unique custom_id,
method POST, one supported endpoint, one model, no stream=true, size and
line-count limits. Exits 1 on any error.
merge INPUT.jsonl OUTPUT.jsonl [ERRORS.jsonl] --retry RETRY.jsonl
Join results back to requests by custom_id. Writes every request that has
no successful result (error line, non-200 status, or missing entirely) to
RETRY.jsonl so it can be resubmitted. Exits 1 if anything needs a retry.
"""
import argparse
import json
import os
import sys
SUPPORTED = {
"/v1/responses", "/v1/chat/completions", "/v1/embeddings", "/v1/completions",
"/v1/moderations", "/v1/images/generations", "/v1/images/edits",
}
MAX_LINES = 50_000
MAX_BYTES = 200 * 1024 * 1024
def read_jsonl(path):
with open(path, encoding="utf-8") as fh:
for n, line in enumerate(fh, 1):
if line.strip():
yield n, line
def check(path):
errors, ids, endpoints, models = [], set(), set(), set()
size = os.path.getsize(path)
if size > MAX_BYTES:
errors.append(f"file is {size / 1e6:.1f} MB; limit is 200 MB")
count = 0
for n, line in read_jsonl(path):
count += 1
try:
obj = json.loads(line)
except json.JSONDecodeError as e:
errors.append(f"line {n}: invalid JSON ({e.msg})")
continue
cid = obj.get("custom_id")
if not isinstance(cid, str) or not cid:
errors.append(f"line {n}: missing custom_id")
elif cid in ids:
errors.append(f"line {n}: duplicate custom_id {cid!r}")
else:
ids.add(cid)
if obj.get("method") != "POST":
errors.append(f"line {n}: method must be POST")
url = obj.get("url")
endpoints.add(url)
if url not in SUPPORTED:
errors.append(f"line {n}: unsupported url {url!r}")
body = obj.get("body")
if not isinstance(body, dict):
errors.append(f"line {n}: body must be an object")
continue
models.add(body.get("model"))
if body.get("stream") is True:
errors.append(f"line {n}: stream=true is not allowed in batches")
if url == "/v1/moderations" and "input" not in body:
errors.append(f"line {n}: moderations body needs input")
if count == 0:
errors.append("file has no requests")
if count > MAX_LINES:
errors.append(f"{count} requests; limit is {MAX_LINES} per batch")
if len(endpoints) > 1:
errors.append(f"mixed endpoints {sorted(map(str, endpoints))}; one per batch")
if len(models) > 1:
errors.append(f"mixed models {sorted(map(str, models))}; one per input file")
for e in errors[:50]:
print(f"ERROR {e}")
if len(errors) > 50:
print(f"... {len(errors) - 50} more errors")
print(f"{count} requests, {size / 1e6:.2f} MB, endpoint={next(iter(endpoints), None)}, "
f"model={next(iter(models), None)}: {'FAIL' if errors else 'OK'}")
return 1 if errors else 0
def merge(inp, out, err, retry):
requests = {}
for _, line in read_jsonl(inp):
obj = json.loads(line)
requests[obj["custom_id"]] = line if line.endswith("\n") else line + "\n"
ok, failed = set(), {}
for path in [p for p in (out, err) if p]:
for _, line in read_jsonl(path):
obj = json.loads(line)
cid = obj.get("custom_id")
resp = obj.get("response") or {}
if obj.get("error") or resp.get("status_code") != 200:
code = (obj.get("error") or {}).get("code") or resp.get("status_code")
failed[cid] = code
else:
ok.add(cid)
missing = set(requests) - ok - set(failed)
unknown = (ok | set(failed)) - set(requests)
todo = [cid for cid in requests if cid not in ok]
with open(retry, "w", encoding="utf-8") as fh:
for cid in todo:
fh.write(requests[cid])
by_code = {}
for code in failed.values():
by_code[str(code)] = by_code.get(str(code), 0) + 1
print(f"requests={len(requests)} succeeded={len(ok)} failed={len(failed)} "
f"missing={len(missing)} unknown_ids={len(unknown)}")
if by_code:
print("failures by code: " + ", ".join(f"{k}={v}" for k, v in sorted(by_code.items())))
print(f"wrote {len(todo)} request(s) to {retry}")
return 1 if todo or unknown else 0
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("check")
c.add_argument("input")
m = sub.add_parser("merge")
m.add_argument("input")
m.add_argument("output")
m.add_argument("errors", nargs="?")
m.add_argument("--retry", required=True)
a = p.parse_args()
if a.cmd == "check":
return check(a.input)
return merge(a.input, a.output, a.errors, a.retry)
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.