Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

24 Commits

Folders and files

Repository files navigation

resume-kit

A local document store, Word renderer, and validator - exposed as an MCP server so an LLM can tailor your resume without ever touching Word XML, and as a CLI so you can do everything by hand.

The .docx is a renderer. The JSON is the truth. The model never sees Word XML.

What it does

  • Stores resumes as structured JSON in a local SQLite database, in two kinds: references (one curated base resume per role shape) and tailors (per-company versions derived from a reference, with the job description stored alongside).
  • Renders any stored resume to a one-page .docx (or PDF via LibreOffice) using a content-free template that owns every layout decision.
  • Validates every save: ATS-hostile characters, malformed dates and phone numbers, broken markup, duplicate bullet ids, forbidden document features, and (optionally) that every claim traces back to your master corpus.
  • Measures fit: authoritative page count via LibreOffice plus a fast per-paragraph line estimator, so a tailoring loop knows exactly which bullet to shorten.
  • Speaks MCP: every mutating tool returns the full validation and fit report in the same turn, so an LLM gets actionable feedback immediately. The server decides what is legal; the model decides what to write.

How it works

Handing the model a stripped .docx is half the answer. The other half is that it should not receive a .docx at all. Once content lives in JSON:

  • Enhance and write from scratch are the same code path - load a dict or start from blank_resume (identity pre-filled), mutate, render.
  • Layout is unreachable. The model cannot break a style it cannot address.
  • Resumes are diffable. Two .docx files are not; two JSON documents are.
  • The template is edited by a human in Word, once, and the change applies to every resume ever rendered.

Keep one reference per role shape, seeded from resumes you already approved. A reference encodes phrasing and ordering decisions you already made, and it is a much better starting point for a tailoring pass than an empty skeleton. References only change when you explicitly ask; tailoring always lands in a separate tailor row.

A resume content document looks like this (see get_schema / resume_kit/model.py for the full JSON Schema):

{
  "name": "Jane Doe",
  "contact": {
    "location": "New York, NY",
    "email": "jane@example.com",
    "phone": "555-123-4567",
    "links": ["linkedin.com/in/janedoe", "github.com/janedoe"]
  },
  "summary": "One editable line under the name.",
  "sections": [
    {"kind": "skills", "title": "Skills",
     "rows": [{"label": "Languages", "values": "Python, C++, SQL"}]},
    {"kind": "entries", "title": "Experience", "entries": [
      {"title": "Acme Corp", "title_right": "Remote",
       "subtitle": "Backend Engineer", "subtitle_right": "May 2025 - Present",
       "bullets": [
         {"id": "acme-api", "text": "Shipped the **public API**, cutting p99 latency 40%",
          "source_ids": ["acme-backend"]}
       ]}
    ]},
    {"kind": "entries", "title": "Education", "entries": []}
  ]
}

Bullet text uses **double asterisks** for bold. Nothing else is markup. Bullets are addressed by id and entries by title, so small patch operations stay stable across edits.

Install

Requires Python 3.10+.

git clone https://github.com/jekuper/resume-kit.git
cd resume-kit
pip install -r requirements.txt      # python-docx, pillow, pypdf, mcp

# LibreOffice provides the authoritative page count and PDF export
sudo apt install libreoffice-writer                        # Debian/Ubuntu
# brew install --cask libreoffice                          # macOS
# winget install TheDocumentFoundation.LibreOffice         # Windows

Optional: pip install tiktoken for exact token counts in python -m resume_kit tokens.

Without LibreOffice everything still works, but the page count is estimated and --pdf delivery is unavailable.

Set up your workspace

The workspace is the directory RESUME_KIT_ROOT points at (default: the repo/current directory). Your personal data lives in gitignored files:

1. identity.json - the stable facts no job description changes. The model reads these and never invents them:

{
  "name": "Jane Doe",
  "contact": {
    "location": "New York, NY",
    "email": "jane@example.com",
    "phone": "555-123-4567",
    "links": ["linkedin.com/in/janedoe"]
  },
  "education": [
    {"title": "State University", "title_right": "2024",
     "subtitle": "B.S. Computer Science", "bullets": []}
  ]
}

2. corpus/*.md - your master brag document, one file per job or project, in as much detail as you like. This is what tailoring draws facts from. corpus/paths.txt (optional) lists paths to .md files or folders elsewhere on disk, pulled into the corpus without copying them. Item ids are slugified filename stems; files sharing a stem get their parent folder as a prefix (D:\Acme\RESUME.md -> acme-resume).

3. A first reference resume - either let the model draft one over MCP (blank_resume arrives with identity pre-filled), or write the content JSON by hand and load it:

python -m resume_kit add backend backend.json
python -m resume_kit report backend

4. The template (optional) - the tracked assets/template.example.docx (Palatino Linotype, 10 pt, half-inch margins, US Letter) is used out of the box. To restyle, build your own assets/template.docx (gitignored, takes precedence when present) from any .docx whose look you like - its content is dropped; only styles, numbering and page setup survive:

python -c "from resume_kit import template; template.build('your-resume.docx', 'assets/template.docx')"

Use with an LLM (MCP)

Register with any MCP client:

{
  "mcpServers": {
    "resume-kit": {
      "command": "python",
      "args": ["/path/to/resume-kit/server.py"],
      "env": { "RESUME_KIT_ROOT": "/path/to/resume-kit" }
    }
  }
}

or with Claude Code:

claude mcp add resume-kit -e RESUME_KIT_ROOT=/path/to/resume-kit -- python /path/to/resume-kit/server.py

Then just talk: "tailor my backend reference for Acme" - the model reads the backend reference once, calls copy_resume("backend", "acme-backend", job_description=...), then patches the copy against the job description - each patch a handful of ops, not a resent document - iterating until the report is clean. "render it" -> deliver("acme-backend") puts the submittable .docx in out/.

The tools:

get_schema          -> what to write and what is illegal
get_identity        -> name, contact, education - never invented
list_resumes        -> the references only; old tailors are history for you,
                       never context for the model
load_resume         -> one resume's content (or blank_resume for a new draft)
list_corpus         -> master corpus ids
read_corpus <id>    -> full text of one item
copy_resume         -> reference -> new tailor, server-side, no content resent;
                       takes the job description text and stores it with the
                       tailor (optional, highly encouraged)
job_description     -> what a tailor was written against, read back
validate_content    -> check a draft without saving or rendering
patch_resume        -> apply a small op list (set_bullet by id, add/remove
                       entry by title, set summary, upsert a skills row...)
                       instead of resending the document; same report back
save_tailor         -> full-document save for rewrites, renders, validates,
                       measures. Returns errors, warnings, page count, and
                       the slots eating the most lines
save_reference      -> same, for base resumes; explicit user request only
report_resume       -> re-render and re-check an already-saved resume
diff_resumes        -> bullet-level diff between two resumes
fit_detail          -> per-paragraph line counts when you need to trim precisely
extracted_text      -> what an ATS actually reads back out
delete_resume       -> drop a stale tailor
deliver             -> renders one resume to out/, as .docx or (pdf=true,
                       needs LibreOffice) .pdf, named after the identity name
                       ("Jane Doe.pdf") unless a filename is given;
                       overwrites its own file silently, never deletes other
                       files; refuses if errors remain; explicit user request
                       only. Checking always runs on the .docx - pdf is just
                       the presentation format

save_tailor returning the full report in the same turn is the point. The model changes something and immediately learns whether it broke a rule and how many lines it has left, without a second round trip.

Document types

A resume is one page and ATS-safe. An academic CV is not, and a statement of purpose is not a list at all. Rather than loosening the resume until it can be all three, each is a document type with its own rules.

resume cv letter
for job applications PhD and Masters applications cover letters, statements of purpose
length exactly 1 page 2 to 6 pages 1 to 2 pages, 250 to 1200 words
sections skills, entries, one-liners + publications with statuses flowing prose
markup **bold** **bold**, *italic* **bold**, *italic*
dates May 2025 - Present + Expected May 2027, Fall 2024 as resume

Pick the type when you create a document:

list_doctypes                       -> what each type demands
blank_document(doctype="cv")        -> a fresh skeleton for that type
save_reference(..., doctype="cv")   -> save as that type
copy_resume(source, name)           -> the copy keeps its source's type

The type is stored with the document and is what it is checked against from then on. There is no parameter anywhere that relaxes a document's rules; the only choice is which type it is, and every report says which one is in force.

A CV keeps publications in a citations block, one item per paper, with the publication status in its own field:

{"kind": "citations", "title": "Publications", "items": [
  {"id": "p1", "text": "Lovelace, A. (1843). Notes on the Engine. *Sci. Mem.*, 3."},
  {"id": "p2", "text": "Lovelace, A. On Bernoulli numbers. *J. Comp.*",
   "status": "under review"}
]}

A letter is paragraphs, each with an id so a patch can rewrite one of them. Leave a section's title empty and it renders with no heading, which is what a cover letter wants.

Applications, not just documents

A PhD application is one programme call feeding several documents, submitted together. That is four tools:

save_target(label, body, kind="programme")   -> store the call once
attach_target(name, target_id)               -> many documents, one target
create_packet(name, documents, target_id)    -> the submission
deliver_packet(name, pdf=True)               -> all of it, into one folder

Each document in a packet is still gated by its own type's rules, so a packet cannot be used to get out a document that would be refused on its own; anything refused comes back with the reason and the rest are still written.

list_revisions and read_revision show what a document said before, since every save keeps the previous content. list_artifacts shows what was actually delivered, from which revision, hashed, which is the only way to answer "what did that programme receive" weeks later.

Not losing the resume

The resume's strictness is the point of this tool, and adding document types is exactly the change that could erode it. Three things stop that, and they are tests rather than intentions:

  • No default severity. Every document type must name a severity for every rule in the registry. A rule missing from any type's table raises at import, so adding a rule forces a decision per type instead of quietly inheriting a lenient default.
  • A frozen table. tests/resume_severities.json records the resume's severity for every rule. Changing one without changing that file fails the suite, so relaxing the resume is a reviewable diff.
  • A strictness invariant. No document type may be laxer than the resume on any rule unless the relaxation is listed in DOCUMENTED_RELAXATIONS with a reason. There are three, all argued in place.

On top of that, all 55 stored documents have a recorded behavioural fingerprint, and 34 of the 36 rules have their exact message and path pinned against synthetic fixtures. Run the lot with:

python tests/test_strictness.py     # verify
python tests/capture.py             # re-record, only when a change is intended

The goldens hold no document content: messages are hashed for real documents and stored verbatim only for the invented fixtures.

Use by hand (CLI)

Everything the server does is also a CLI - no LLM required:

cd resume-kit         # or set RESUME_KIT_ROOT / pass --root
python -m resume_kit list                                 # tailors included
python -m resume_kit show backend > backend.json          # inspect / export
python -m resume_kit add backend backend.json             # upsert a reference
python -m resume_kit add acme x.json --kind tailor --base backend --jd posting.txt
python -m resume_kit jd acme                              # print the stored JD
python -m resume_kit rename current software-engineer     # tailor bases follow
python -m resume_kit delete acme-backend
python -m resume_kit report backend                       # exit 1 on errors
python -m resume_kit report backend --strict              # + provenance checks
python -m resume_kit tokens                               # corpus size per item
python -m resume_kit deliver backend Jane_Doe.docx
python -m resume_kit deliver backend --pdf                # needs LibreOffice
python -m resume_kit pdf any/path/resume.docx [dest.pdf]  # convert any .docx

show -> edit the JSON -> add under the same name is the modify loop. identity.json and the corpus are plain files; edit them directly.

Project structure

server.py               the MCP server; every mutating tool returns the report
resume_kit/
  model.py              content JSON Schema, blank skeleton, patch operations
  store.py              Workspace: SQLite resume store, identity, corpus, deliver
  render.py             content dict + template.docx -> resume.docx
  template.py           builds a template .docx: styles only, zero content
  validate.py           every rule the reports enforce
  measure.py            fit estimator (PIL) + page count / text (LibreOffice, pypdf)
  cli.py                python -m resume_kit ...
assets/
  template.example.docx tracked default template
test_validator.py       planted-fault test: the validator must catch every one

Files the workspace creates and git ignores - these hold your personal data and never leave your machine:

identity.json        name, contact, education - the facts no job changes
corpus/*.md          your master brag document (corpus/README.md is tracked)
corpus/paths.txt     optional pointers to corpus files elsewhere on disk
assets/template.docx your private template override
resumes.db           every resume: references and tailors
build/*.docx         rendered attempts (throwaway)
out/                 the deliverables; `deliver` adds a file on explicit
                     request only, and never deletes anything - prune by hand

On first run an empty resumes.db is seeded from any legacy variants/*.json as references; after that the db owns the resumes.

What the validator enforces

Characters - smart quotes, en/em dashes, non-breaking and zero-width spaces, soft hyphens, ligatures, emoji, tabs inside a slot, and any private-use codepoint. That last one is not theoretical: Word writes its bullet as U+F0B7 in the Symbol font, and extractors that skip font mapping emit that raw character into every bullet line. template.build rewrites it to U+2022.

Shape - date ranges (May 2025 - Present), phone as 000-000-0000, links as bare visible domains rather than hyperlink text, balanced **bold** markup, unique bullet ids, no bullet glyph typed into bullet text, parenthetical-free name line, section titles drawn from the set parsers dictionary-match.

Document - no tables, columns, images, text boxes, headers or footers; every paragraph carries a template style; no w:caps formatting (literal capitals are fine, Word's All-caps checkbox is not, because the stored text differs from what you see); no bold=False overriding a style.

Fit - LibreOffice renders to PDF for the authoritative page count. A fast PIL word-wrap estimator gives per-slot line counts so a fitting loop can shorten the right bullet instead of guessing.

Provenance (opt-in via strict_provenance) - every source_ids entry must resolve to a corpus item, and every number in a bullet must appear somewhere in the corpus. Invented metrics are the expensive failure mode.

Font metrics

measure.py tries Palatino Linotype first (C:\Windows\Fonts\pala.ttf on Windows, the Supplemental folder on macOS) and falls back to a serif substitute. font_exact: false in a fit report means line counts are approximate - the LibreOffice page count is still correct. If the estimator and the renderer disagree, run measure.calibrate() and adjust LINE_FACTOR.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages