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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- openai-batch-api

Installs to ~/.agents/skills/openai-batch-api/ for every project on this machine.

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

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.

~/.agents/skills/openai-batch-api/SKILL.md

--- name: openai-batch-api description: Runs bulk OpenAI inference through the Batch API at half the synchronous price — building JSONL request files with custom_id, uploading with purpose… license: MIT ---

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

SituationChoice
Results needed within seconds/minutes for a userSynchronous API
Thousands+ of independent requests, results within 24 h OKBatch (50% cheaper, separate rate-limit pool)
Eval suite over a fixed dataset, run nightlyBatch
Steps depend on each other’s output (agent loops, multi-turn)Synchronous, or chain batches stage by stage
Under ~100 requestsSynchronous 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.jsonl as a new batch. Cap resubmissions (e.g. 3 rounds); after that, send the remainder synchronously or report them.
  • batch_expired lines 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_id so reruns are idempotent.
  • Parse response.body with the same code you use synchronously (output items, 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 completed can still contain many per-request errors — always read request_counts and the error file.
  • Changing the prompt between the first round and the retry round mixes versions; keep the prompt version in custom_id and 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

  1. check passes and a synchronous dry run of 3–5 lines returns valid output.
  2. After completion, merge shows succeeded == requests (or a known remainder).
  3. Spot-check 10 random results against inputs by custom_id.
  4. 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.yamlgenerated from this page

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.py136 lines, 5.1 KB
#!/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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem