Problem Details Errors skill for Codex
One error shape clients can branch on. Codex inventories how your API fails today, defines a catalog of stable problem type URIs, and routes every error through one mapper that emits application/problem+json: validation failures as JSON Pointer lists, domain conflicts with useful extension members, 429s with Retry-After, and 5xx bodies that carry a trace ID but no internals. Works with Express, Fastify, FastAPI, Spring Boot, ASP.NET Core and Go.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- problem-details-errors Installs to ~/.agents/skills/problem-details-errors/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install problem-details-errors 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 problem-details-errors Installs to .agents/skills/problem-details-errors/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/problem-details-errors.zipunzip -o problem-details-errors.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))) problem-details-errors 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 $problem-details-errors, 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
Standardises HTTP API error responses on RFC 9457 Problem Details (application/problem+json, which replaced RFC 7807) - a catalog of problem type URIs, title/status/detail/instance usage, extension members for validation errors with JSON Pointers, trace IDs and retry hints, safe 5xx masking, and one central error mapper - in Express, Fastify, FastAPI, Spring Boot ProblemDetail, ASP.NET Core AddProblemDetails and Go. Use when API errors are inconsistent, clients parse error strings, adding validation error output, or documenting errors in OpenAPI.
Use it when
- Error responses differ between endpoints or framework layers
- Clients parse error message strings to decide what to do
- Validation errors need field-level detail
- Documenting error responses in an OpenAPI spec
Use something else when
- You are deciding endpoints and status codes overall: use rest-api-design
- The API is GraphQL, whose errors travel in the response errors array: use graphql-resolvers
How to check the result
curl a validation failure, an unknown route, malformed JSON and a forced exception: each returns application/problem+json with a known type, a matching status and no internal details.
The mistake it prevents
Converting domain errors but forgetting framework-generated ones such as 404 routing, body-parser and auth failures, which keep returning HTML or ad-hoc JSON.
Done well means: every error the API returns, from validation to rate limiting to crashes,
has the same application/problem+json shape; clients branch on a stable type URI, never
on English text; validation errors point at the exact field; and nothing internal leaks out
of a 5xx.
The shape
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://api.example.com/problems/insufficient-credit",
"title": "Insufficient credit",
"status": 409,
"detail": "This order costs 50.00 but the account has 30.00 available.",
"instance": "/orders/attempts/7f3c2a",
"balance": "30.00",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
| Member | Rule |
|---|---|
type | URI identifying the problem kind. Stable forever; clients switch on it. Should resolve to human docs. Omitted means about:blank |
title | Short summary of the type. Same for every occurrence (may be localised). With about:blank, use the HTTP reason phrase |
status | Must equal the actual HTTP status code. Advisory copy for clients that lose headers |
detail | This occurrence, written to help the client fix it. No stack traces, SQL, or internal hostnames |
instance | URI reference for this occurrence (request ID path or log lookup), optional |
| extensions | Extra top-level members (errors, balance, traceId). Clients must ignore unknown ones |
Build the catalog first
- Read
AGENTS.md, then inventory current errors:rg -n "status\((4|5)\d\d\)|HTTPException|ResponseStatusException|http.Error|res.status" src. - Group them into problem types. Typical set:
type suffix | Status | When |
|---|---|---|
validation-error | 422 (or 400 for malformed JSON) | Body or params fail schema validation |
malformed-request | 400 | Unparseable JSON, wrong content type |
unauthenticated | 401 | Missing or invalid credentials (send WWW-Authenticate) |
forbidden | 403 | Authenticated but not allowed |
not-found | 404 | Resource absent or hidden from this caller |
conflict / domain-specific (email-taken, insufficient-credit) | 409 | State conflict the client can resolve |
precondition-failed | 412 | If-Match ETag mismatch |
rate-limited | 429 | With Retry-After header |
internal-error | 500 | Unexpected failure; generic detail plus traceId |
service-unavailable | 503 | Maintenance or dependency down; Retry-After when known |
- Prefer specific domain types for errors clients act on differently (
email-takenvsusername-taken). Use genericabout:blankonly where the status code says everything. - Publish each type as a short docs page at its URI and list them in the OpenAPI spec. Type URIs are identifiers: never rename one; add a new type instead.
Validation errors
Use one validation-error type with an errors array of JSON Pointers into the request:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Request validation failed",
"status": 422,
"detail": "2 fields are invalid.",
"errors": [
{ "pointer": "#/email", "detail": "must be a valid email address" },
{ "pointer": "#/items/0/quantity", "detail": "must be at least 1" }
]
}
Use "parameter": "page" or "header": "If-Match" instead of pointer for query and header
problems. Return all field errors in one response, not the first one.
One central mapper
Throw typed errors in domain code; convert to problem+json in exactly one place.
TypeScript (Express 5 / Fastify)
export class Problem extends Error {
constructor(
public type: string, public status: number, public title: string,
public detail?: string, public extensions: Record<string, unknown> = {},
) { super(detail ?? title); }
}
const BASE = "https://api.example.com/problems/";
export function toProblem(err: unknown, traceId: string) {
if (err instanceof Problem) {
return { status: err.status, body: { type: BASE + err.type, title: err.title, status: err.status,
...(err.detail && { detail: err.detail }), ...err.extensions, traceId } };
}
if (isZodError(err)) {
return { status: 422, body: { type: BASE + "validation-error", title: "Request validation failed",
status: 422, errors: err.issues.map(i => ({ pointer: "#/" + i.path.join("/"), detail: i.message })), traceId } };
}
return { status: 500, body: { type: BASE + "internal-error", title: "Internal Server Error",
status: 500, detail: "An unexpected error occurred.", traceId } }; // never err.message
}
// Express 5: async errors reach this handler automatically
app.use((err, req, res, _next) => {
const { status, body } = toProblem(err, req.traceId);
if (status >= 500) req.log.error({ err, traceId: req.traceId }, "unhandled error");
res.status(status).type("application/problem+json").json(body);
});
In Fastify, do the same in setErrorHandler and in setNotFoundHandler so 404s match.
Escape ~ and / inside path segments (~0, ~1) when building pointers from keys that may contain them.
FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
PROBLEM = "application/problem+json"
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
errors = [{"pointer": "#/" + "/".join(str(p) for p in e["loc"][1:]), "detail": e["msg"]}
for e in exc.errors() if e["loc"][0] == "body"]
params = [{"parameter": str(e["loc"][-1]), "detail": e["msg"]}
for e in exc.errors() if e["loc"][0] in ("query", "path")]
return JSONResponse(status_code=422, media_type=PROBLEM, content={
"type": f"{BASE}validation-error", "title": "Request validation failed",
"status": 422, "errors": errors + params, "traceId": request.state.trace_id})
Also register handlers for StarletteHTTPException (covers 404/405) and Exception (500).
Spring Boot 3
spring.mvc.problemdetails.enabled=true
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler {
@ExceptionHandler(InsufficientCreditException.class)
ProblemDetail credit(InsufficientCreditException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setType(URI.create("https://api.example.com/problems/insufficient-credit"));
pd.setTitle("Insufficient credit");
pd.setProperty("balance", ex.balance().toPlainString());
return pd;
}
}
ASP.NET Core
builder.Services.AddProblemDetails(); then app.UseExceptionHandler(); app.UseStatusCodePages();.
Customise via AddProblemDetails(o => o.CustomizeProblemDetails = ctx => ...) to add traceId,
and return TypedResults.Problem(...) for domain errors. Note that built-in
ValidationProblemDetails emits errors as a { field: [messages] } dictionary; either keep
that shape consistently or map it to the pointer array, but do not mix both.
Go
Write a Problem struct with json:"type" etc. and a single writeProblem(w, p) that sets
Content-Type: application/problem+json before WriteHeader. Use map[string]any merging for extensions.
Workflow checklist
- Inventory existing error responses and their clients
- Define the problem type catalog with statuses and docs URIs
- Add typed domain errors and one central mapper per service
- Map framework errors too: validation, 404/405 routing, body-parser, auth middleware, 429
- Mask 5xx details; include
traceId; log the real error server-side - Document problem responses in OpenAPI (
content: application/problem+json) - Coordinate with client teams if the old error shape is consumed; keep both during a deprecation window (
api-versioning)
Verification
curl -si -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"items":[{"quantity":0}]}'
curl -si localhost:3000/does-not-exist
curl -si -X POST localhost:3000/orders -H 'content-type: application/json' -d '{bad json'
Each must show Content-Type: application/problem+json, a body status equal to the HTTP
status, a known type, and for 5xx no exception text. Add contract tests that assert the
shape for one error of every type, and a test that forces an unexpected exception and checks
the body contains only the generic detail and a traceId.
Gotchas
statusin the body disagreeing with the HTTP status after a proxy or middleware rewrite.- Framework defaults (404 pages, body-parser errors, auth middleware) still returning HTML or their own JSON. Hit them explicitly in tests.
- Clients matching on
titleordetail, which you will reword. Tell them to usetype. - Putting user-supplied input into
detailunescaped; it may be rendered in someone’s UI. - 404 vs 403 for resources in another tenant: prefer 404 so existence is not confirmed.
- For the status-code choices themselves, use
rest-api-design.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Problem Details Errors"
short_description: "One error shape clients can branch on"
default_prompt: "$problem-details-errors Make every error in this API return RFC 9457 problem+json" 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.