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. The one thing the server
keeps is a CV job's session — the events it streams — until the client says it
has everything, or until it is older than ATS_SESSION_TTL_DAYS.
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 ATS_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 ATS_API_TOKEN is set, every /v1/... call 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 — every line it logged, every model call and its cost — is streamed by a CV job as events; for any other request it is in the server's own log, under the request id.
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}. For POST /v1/cv it is the session's id,
and names its directory under the work root, so anything else would be a way
to write where the server was not asked to.
Endpoints at a glance¶
| Method | Path | Auth | Costs | Returns |
|---|---|---|---|---|
GET |
/healthz |
no | nothing | ServerStatus |
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, or nothing for a session already there | 202 + CVSession |
GET |
/v1/cv/{id}/events |
yes | nothing | server-sent events |
DELETE |
/v1/cv/{id} |
yes | nothing | 200, no data |
DELETE |
/v1/cv?all |
yes | nothing | Cleanup |
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": "deepseek-v4.1-flash", "analysis": "qwen3.8-flash",
"letter": "qwen3.8-flash", "jd_evaluation": "deepseek-flash",
"profile_scoring": "deepseek-flash", "format_selection": "deepseek-flash",
"cv": "deepseek-flash", "validation": "deepseek-flash",
"highlight": "deepseek-flash"},
"auth_required": false
}}
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 (ATS_JD_MIN_CHARS …
_MAX_CHARS, 500–30000 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
cv_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 |
sys_prompt_analysis |
optional | Replace the analysis prompt |
analysis_extra_body |
optional | JSON object: the analysis model's settings for this request, instead of models.toml's (see POST /v1/cv) |
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}/events → DELETE /v1/cv/{id}¶
The expensive one: minutes of model calls. It does not wait. It starts the job as a session, which the caller follows as a stream of server-sent events and lets go of once it has stored the CV.
sequenceDiagram
participant C as caller
participant S as server
C->>S: POST /v1/cv (jd, profile, candidate_data, …)<br/>X-Request-Id: the session id
S-->>C: 202 {reattached: false}
C->>S: GET /v1/cv/{id}/events
loop while the job runs
S-->>C: status · artifact · log · usage
end
S-->>C: result {cv, detail, total} — and the stream closes
Note over C: stores the CV
C->>S: DELETE /v1/cv/{id}
S-->>C: 200
ID=$(sha256sum JD.txt | cut -c1-64)
curl -s -H "X-Request-Id: $ID" -F jd=@JD.txt \
-F candidate_profile=@candidate_profile.json \
-F candidate_data=@candidate_data.json localhost:8080/v1/cv
curl -sN localhost:8080/v1/cv/$ID/events # the job, to its last event
curl -s -X DELETE localhost:8080/v1/cv/$ID # once the CV is saved
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_jd_evaluation, sys_prompt_profile_scoring, sys_prompt_format_selection, sys_prompt_cv_body, sys_prompt_cv_duties, sys_prompt_validation_body, sys_prompt_validation_duties, sys_prompt_highlight |
optional | Replace a step's prompt for this request |
cv_body_schema, cv_duties_schema |
optional | Replace the JSON schema the writer is shown for the body, or for an experience |
template |
optional | Replace resume.tex.jinja, page counts included |
images |
optional | Repeatable, ≤10. Each part's file name is the name the template includes it under. |
jd_evaluation_extra_body, profile_scoring_extra_body, format_selection_extra_body, cv_extra_body, validation_extra_body, highlight_extra_body |
optional | JSON object: that step's model settings for this request (see below) |
pages |
optional | Page limit the CV must fit. Default 2, minimum 1. |
<role>_extra_body is a form field holding a JSON object, sent into the
request body as is. It supersedes every setting models.toml gives that role
— temperature, the thinking switch, reasoning effort, the output cap, its own
extra_body: besides the model and the messages, what you sent is all that
goes out ({"temperature": 0.7, "top_p": 0.9}, {"thinking": {"type":
"disabled"}}, …). The endpoint and the provider stay. A field that is not a
JSON object is a 400. An endpoint ignores the fields for roles it does not
have, so a client may send all of its own with every request. detect takes
none.
Answers 202 with {"request_id": "<id>", "ok": true, "data": {"reattached":
false}}.
The session id is the caller's. Send it as X-Request-Id. The client
uses the SHA-256 of the posting's text, so running the same posting again finds
the session it already has, and a client that died, or lost its connection,
loses nothing it paid for. When the id already has a session, nothing is
started — no slot is claimed either — and the answer says "reattached":
true: follow its events as for a new one. Sessions whose last event is older
than ATS_SESSION_TTL_DAYS (1 by default) are deleted first, so an old one
is not found. With no X-Request-Id, the server picks an id and every POST is
a new job. 429 too_many_jobs applies to a new job only.
Why candidate_data is required. The page limit is enforced by actually
compiling the CV and counting its pages, trimming duties until it fits. 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}/events¶
text/event-stream. Every event the session has — from the first, or from the
one after Last-Event-ID: n — then each new one as the job writes it, until
the last, after which the stream closes. A : ping line every 15 quiet seconds
keeps the connection open through a long model call. The session is files, so
a stream can be opened as often as you like, while the job runs or long after,
and a dropped one loses nothing: reconnect with the last id: you got.
id: 1
event: status
data: {"status": "jd_evaluation", "detail": ""}
id: 2
event: log
data: {"ts": "2026-01-15T09:30:53Z", "level": "INFO", "stage": "cv.jd_evaluation", "message": " ⏱ [jd_evaluation] deepseek-flash 21.2s | input=1847, thinking=7256, output=832, …"}
id: 3
event: usage
data: {"role": "jd_evaluation", "model": "deepseek-flash", "seconds": 21.2, "input_tokens": 1847, "thinking_tokens": 7256, "output_tokens": 832}
id: 4
event: artifact
data: {"name": "key_points", "data": [{"id": 1, "requirement": "Engineering manager", "keywords": ["Engineering Manager"], "importance": 100}, …]}
id: 5
event: status
data: {"status": "profile_scoring", "detail": "11 key points, tokens used=9935, thinking=7256, elapsed=21.3s"}
id: 97
event: result
data: {"cv": {"document": {…}, "tex": "\\documentclass…", "pdf_base64": "JVBERi0…"}, "detail": "done, 21 model calls, …", "total": {"calls": 21, "seconds": 961.4, …}}
| Event | Payload | When |
|---|---|---|
status |
CVStatus: status, detail |
Each step: jd_evaluation, profile_scoring, format_selection, cv, validation, highlight |
artifact |
CVArtifact: name, data |
Each step's result as it is ready: key_points, scored_profile, score_reasons, format_selection, cv_body, cv_experiences, checked_body, checked_experiences, highlighted_resume |
log |
LogEntry: ts, level, stage, message |
Every line the server logged for the job (at most 2000) |
usage |
ModelCall: role, model, seconds, input_tokens, thinking_tokens, output_tokens |
After every model call, failed attempts included |
result |
CVResult: cv, detail, total |
The job is done: the last event |
error |
CVFailure: http_status, error, total |
The job failed: the last event |
detail is written for a person: what the step that just finished produced,
and what it cost. An artifact is JSON, not a file: the client stores each
one as it likes — the ats-strike client as steps/<name>.json beside the CV.
No PDF is ever an artifact; the only one sent is the result's. total adds
up every model call of the run, so a failed job still says what it cost.
A failed job reports itself here, with the status its cause maps to
(502, 504, 422) and the cause in the same form as any other error. A job
the server restarted under ends with 503 unavailable. See
the CV pipeline for what each step does.
cv.document is one flat object — what the models wrote with your
candidate_data merged over the top, so a field a 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.
404 unknown_request means there is no session by that id: it was
acknowledged, it is older than the server keeps sessions, or it never started.
DELETE /v1/cv/{id}¶
Acknowledge: the caller has everything, and the server deletes the session.
Do it once the CV is stored, not before — until then the session is what lets
a caller that died get its CV back for nothing. 409 job_not_ready while the
job runs: its worker is still writing there, and a new session for the same
posting would be the same directory. 404 when there is no such session.
DELETE /v1/cv?all¶
Deletes every session whose job is not running, and answers
{"deleted": 3, "running": 1}. Without all it is a 400: not something to
do by accident.
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 — a CV job's cv.document, 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 |
letter_extra_body |
optional | JSON object: the letter model's settings for this request, instead of models.toml's — a web search it had there goes too, unless you send it again |
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 — one line, except that a
failed compile adds the end of the TeX log on the lines after it, because only
the TeX log says where the document breaks:
{"request_id": "b510f8dff047", "ok": false,
"error": "model_output [cv.profile_scoring]: profile_scoring: no usable reply in 3 attempts:\n- it is not a JSON list"}
| 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 |
ATS_API_TOKEN is set and the request had no matching token. Answers with WWW-Authenticate: Bearer |
404 |
unknown_request |
No CV session by that id: acknowledged, older than the server keeps sessions, or never started |
409 |
job_not_ready |
The CV job is still running, so its session cannot be acknowledged yet |
413 |
part_too_large |
A part exceeded ATS_MAX_PART_BYTES |
422 |
latex_compile |
The template or the .tex does not compile. The end of the TeX log follows the error line |
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.profile_scoring], [compile], [render]) says where in
the pipeline it happened. For a CV job, the attempts that led there — every
failed one included, with its token cost — are in the session's log and
usage events. For any other request, the server's own log (stdout) has every
line under the request id.
The wire format as a package¶
cv_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 cv_contracts import (
Envelope, CVStatus, RenderedCV, CoverLetter, ServerStatus,
JDAnalysis, JDDetection, LogEntry, static_jd_guess,
EVENT_PAYLOADS, CVArtifact, CVResult, CVFailure, CVSession, ModelCall, TokenUsage,
)
envelope = Envelope[RenderedCV].model_validate(response.json())
pdf_bytes = envelope.data.pdf_bytes() # decodes pdf_base64
result = EVENT_PAYLOADS["result"].model_validate_json(data) # a result event's data
It depends on pydantic and nothing else. The CV document itself deliberately
has no model there: it is whatever the models wrote merged with whatever
your candidate_data.json holds, and only your LaTeX template has an opinion
about it.