The HTTP API¶
Everything is multipart/form-data in and JSON out. There is no database and
no accounts: every request carries what it needs and the server throws it away
when the request ends.
Base URL is wherever you run it — http://localhost:8080 by default. The
interactive OpenAPI page FastAPI generates is at /docs.
Conventions¶
Parts. Every text part can be sent as a file (-F jd=@JD.txt) or as a
plain field (-F jd_text=...); the file wins if you send both. JSON parts
(candidate_profile, candidate_data, document, analysis) must be files,
must parse, and must be JSON objects. A UTF-8 BOM is stripped. Any single
part over RESUMIX_MAX_PART_BYTES (2 MB) is refused with 413 before it is
read.
Images. The images part is repeatable, at most 10 per request. Each
part's file name is the name the template includes it under — that is the
whole interface. Path components are stripped.
Auth. If RESUMIX_API_TOKEN is set, every /v1/... call and
/logs/... must send Authorization: Bearer <token>; /healthz stays open so
a platform probe works. With no token set, the server is open to anyone who
can reach it, which is only reasonable on a private network.
The envelope. Every response, success or failure, has the same shape:
{
"request_id": "b510f8dff047", // also in the X-Request-Id header
"ok": true,
"data": { }, // the payload, when ok
"error": null // one line, when not
}
Fields that are null are omitted. Nothing else rides on a reply: what the
server did is behind GET /logs/{request_id}, so the common case pays
nothing for it.
Request ids. The server generates a 12-hex-character id per request and
echoes it in the X-Request-Id header. An inbound X-Request-Id is honoured
if it matches [A-Za-z0-9_-]{1,64} — the id names a directory under the work
root, so anything else would be a way to write where the server was not asked
to. Reusing an id that is already in use is a 400.
Endpoints at a glance¶
| Method | Path | Auth | Costs | Returns |
|---|---|---|---|---|
GET |
/healthz |
no | nothing | ServerStatus |
GET |
/logs/{request_id} |
yes | nothing | RequestLog |
POST |
/v1/jd/detect |
yes | 0–1 small model call | JDDetection |
POST |
/v1/jd/analysis |
yes | 1 small model call | JDAnalysis |
POST |
/v1/cv |
yes | minutes of model calls | 202 + request_id |
GET |
/v1/cv/{id}/status |
yes | nothing | CVStatus |
GET |
/v1/cv/{id} |
yes | nothing | RenderedCV |
POST |
/v1/cv/render |
yes | one pdflatex run |
RenderedCV |
POST |
/v1/letter |
yes | 1 small model call | CoverLetter |
GET /healthz¶
No auth. What this server is and whether it can do its job.
{"request_id": "…", "ok": true, "data": {
"status": "ok", // "degraded" when pdflatex is missing
"version": "0.2.0",
"pdflatex": true,
"models": {"detect": "qwen3.8-flash", "summary": "qwen3.8-flash",
"cv": "qwen3.8-max", "highlight": "qwen3.8-flash"},
"auth_required": false
}}
GET /logs/{request_id}¶
What the server did during one request, including a line per model call with its duration and token counts.
curl localhost:8080/logs/b510f8dff047
{"request_id": "b510f8dff047", "ok": true, "data": {
"request_id": "b510f8dff047",
"entries": [{"ts": "2026-01-15T09:30:53", "level": "INFO",
"stage": "cv.generate",
"message": " [cv] qwen-max 41.2s | prompt=7104, completion=1832"}]
}}
The log lives in the request's own directory, so a CV job still running
answers with what it has said so far, and a restart does not lose it. 404
unknown_request means the id was pruned (only the newest 200 are kept), never
did any work — a 400, 401, 413 or 429 fails before the pipeline
starts, and its error already carries the whole cause — or was served by
another instance.
POST /v1/jd/detect¶
Is this text a job posting?
curl -F jd=@JD.txt localhost:8080/v1/jd/detect
| Part | ||
|---|---|---|
jd / jd_text |
required | The text to check |
Structural checks run first — length band (RESUMIX_JD_MIN_CHARS …
_MAX_CHARS, 1000–10000 by default), no binary payload — and only if they
pass does it cost one small model call. The answer is
{"is_job_description": true|false}; why it was rejected is in the request's
log, not in the reply. The same structural check ships in
resumix_contracts.static_jd_guess, so a client can run it before spending
the call at all.
POST /v1/jd/analysis¶
Scores a posting against your profile and extracts its facts.
curl -F jd=@JD.txt \
-F candidate_profile=@candidate_profile.json \
-F pers_preferences=@candidate_preferences.md \
localhost:8080/v1/jd/analysis
| Part | ||
|---|---|---|
jd / jd_text |
required | The posting |
candidate_profile |
required | JSON object: everything you have done |
pers_preferences / pers_preferences_text |
required | What you want from a job, in prose |
temperature |
optional | Sampling temperature |
Returns a JDAnalysis:
| Field | Type | |
|---|---|---|
match_percentage |
int | 0–100, how well the posting fits the profile |
match_rationale |
str? | One line saying why |
job_title |
str | As extracted from the posting |
work_location |
str? | e.g. "Milan, Italy" |
work_mode |
str | full_remote | hybrid | on_site | not_specified |
expected_salary |
str? | As stated, e.g. "EUR 60k-80k" |
max_salary |
int | Upper bound, or -1 if not found |
salary_match |
bool? | Whether the JD's salary meets your preferences; null if the JD states none |
hard_skills, soft_skills |
str[] | Up to 4 each |
company_name |
str | The employer, or the agency |
posting_url |
str? | If the posting text carries one |
gaps |
str | Where you fall short of the posting |
pers_preferences |
str | How the posting reads against your preferences |
pers_preference_score |
float | The summed score of the preferences it satisfies |
should_apply |
str | NO (skip) | CHECK (human check) | YES (apply) — per the should_apply rules in your preferences, else salary_match |
should_apply_reason |
str | Why should apply |
company_evaluation |
str? | Only for CHECK: what online reviews (Trustpilot, Glassdoor) say about the hiring company |
POST /v1/cv → GET /v1/cv/{id}/status → GET /v1/cv/{id}¶
The expensive one: minutes of model calls. It does not wait.
sequenceDiagram
participant C as caller
participant S as server
C->>S: POST /v1/cv (jd, profile, candidate_data, …)
S-->>C: 202 {request_id}
loop every few seconds
C->>S: GET /v1/cv/{id}/status
S-->>C: 200 {status, detail}
end
Note over C,S: status reads END
C->>S: GET /v1/cv/{id}
S-->>C: 200 {document, tex, pdf_base64}
ID=$(curl -s -F jd=@JD.txt \
-F candidate_profile=@candidate_profile.json \
-F candidate_data=@candidate_data.json \
localhost:8080/v1/cv | jq -r .request_id)
curl -s localhost:8080/v1/cv/$ID/status # every few seconds
curl -s localhost:8080/v1/cv/$ID > cv.json # once it says END
POST /v1/cv¶
| Part | ||
|---|---|---|
jd / jd_text |
required | The posting |
candidate_profile |
required | JSON object — what the model tailors from |
candidate_data |
required | JSON object — what the template prints |
sys_prompt_cv, sys_prompt_highlight, sys_prompt_review |
optional | Replace a prompt for this request |
template |
optional | Replace resume.tex.jinja, page checks included |
images |
optional | Repeatable, ≤10. Each part's file name is the name the template includes it under. |
temperature |
optional | Sampling temperature |
pages |
optional | Page limit the CV must fit. Default 2, minimum 1. |
Answers 202 with {"request_id": "…", "ok": true} and no data. The id is
the job's handle for everything below.
Why candidate_data is required. The page limit is enforced by actually
compiling the CV and counting the pages, so the instruction fed back to the
model is grounded in a real overflow. A page count taken with the contact
block missing is not the page count of the CV you will send. No model is shown
candidate_data: it goes to the renderer and nowhere else.
GET /v1/cv/{id}/status¶
{"request_id": "b510f8dff047", "ok": true, "data": {
"status": "review",
"detail": "generation finished, tokens used=5341, thinking=610, elapsed=26.6s"
}}
status is one of generate, review, re-generate, page_check,
highlight, END. There is no history: poll it, print the status when it
changes, print detail when you want to know what it cost. detail is
written for a person and may span several lines — a rejected review quotes the
reviewer's complaints verbatim, and a failed page check quotes the condense
instruction verbatim, because those are the words the model is about to be
given.
A failed job reports itself here, with the status and cause the work
produced (502, 504, 422) and the job's own request id in the body — so a
poll is the only call a client has to handle failure on. See
the CV pipeline for what each step does.
GET /v1/cv/{id}¶
The finished CV.
{"request_id": "b510f8dff047", "ok": true, "data": {
"document": { }, // the merged CV content
"tex": "\\documentclass…",
"pdf_base64": "JVBERi0…"
}}
409 job_not_ready while the job is still running; 404 unknown_request once
its directory has been pruned.
document is one flat object — what the model wrote with your
candidate_data merged over the top, so a field the model invents can never
replace a real contact detail. It has no schema: what the template reads from
it is between you and the template. Store it, edit it, post it back to
/v1/cv/render.
POST /v1/cv/render¶
curl -F document=@cv.json localhost:8080/v1/cv/render
curl -F tex=@cv_edited.tex localhost:8080/v1/cv/render
| Part | ||
|---|---|---|
document |
one of the two | A CV document as JSON — what GET /v1/cv/{id} returned, or anything else your template can read |
tex |
one of the two | Ready LaTeX, compiled as-is |
template |
optional | Replace resume.tex.jinja |
images |
optional | Repeatable, ≤10 |
Sending both, or neither, is a 400. Returns {tex, pdf_base64}; document
is not echoed back, because whoever asked for the render already has it.
Cheap and deterministic: editing the content and re-rendering never costs a
model call.
The rendered .tex is dated at render time, so re-rendering yesterday's
document dates it today.
POST /v1/letter¶
curl -F jd=@JD.txt -F candidate_profile=@candidate_profile.json \
-F candidate_data=@candidate_data.json \
-F analysis=@analysis.json localhost:8080/v1/letter
| Part | ||
|---|---|---|
jd / jd_text |
required | The posting |
candidate_profile |
required | JSON object |
candidate_data |
required | JSON object — the header block's name, email, phone, LinkedIn |
analysis |
optional | A JDAnalysis — makes the letter more targeted, and enables the web research |
sys_prompt_letter |
optional | Replace the letter prompt |
temperature |
optional | Sampling temperature |
Returns {"text": "…", "words": 312}, validated between 180 and 450 words.
When the analysis names the employer and the
endpoint supports server-side web search, the model is told to research the
company; an endpoint that rejects the flag falls back to writing without it.
Errors¶
A failed request returns the same envelope with ok: false and one line
saying what went wrong, its kind and its stage:
{"request_id": "b510f8dff047", "ok": false,
"error": "model_output [cv.generate]: No CV passed review in 4 attempts."}
| Status | Kind | Means |
|---|---|---|
400 |
bad_request, missing_part, bad_part |
A required part is absent, empty, not UTF-8, not JSON, not an object, or mutually exclusive parts were both sent |
401 |
unauthorized |
RESUMIX_API_TOKEN is set and the request had no matching token. Answers with WWW-Authenticate: Bearer |
404 |
unknown_request |
The id was pruned, never did any work, or another instance served it |
409 |
job_not_ready |
The CV job is still running — poll /status until it says END |
413 |
part_too_large |
A part exceeded RESUMIX_MAX_PART_BYTES |
422 |
latex_compile |
The template or the .tex does not compile. The TeX log tail is in /logs/{id} |
422 |
validation_error |
An uploaded document does not match the expected schema |
429 |
too_many_jobs |
All job slots are busy. Answers with Retry-After: 30 |
502 |
model_output |
The model never produced usable output within the attempts allowed |
502 |
provider_error, provider_unreachable |
The model endpoint failed. The body is never echoed — it can carry the endpoint and the key |
504 |
latex_timeout, budget_exceeded, provider_timeout |
A compile, the whole run, or the provider hit its timeout |
500 |
internal_error |
A bug here. Everything else above is an answer, not a crash |
The stage in brackets ([cv.generate], [compile], [render]) says where in
the pipeline it happened. For the attempts that led there — including every
failed one, with its token cost — fetch GET /logs/{request_id}.
The wire format as a package¶
resumix_contracts is the same set of pydantic models the server answers
with, published as its own package so a client can import it instead of
hand-rolling the shapes:
from resumix_contracts import (
Envelope, CVStatus, RenderedCV, CoverLetter, ServerStatus,
JDAnalysis, JDDetection, RequestLog, LogEntry, static_jd_guess,
)
envelope = Envelope[RenderedCV].model_validate(response.json())
pdf_bytes = envelope.data.pdf_bytes() # decodes pdf_base64
It depends on pydantic and nothing else. The CV document itself deliberately
has no model there: it is whatever the CV model wrote merged with whatever
your candidate_data.json holds, and only your LaTeX template has an opinion
about it.