# CVX content schema — complete reference

This document is self-contained: with only this file and a person's CV facts, you can generate a complete, valid `cv-content/` folder for [CVX](https://github.com/hrtips/cvx). It is written for both humans and AI assistants.

## How CVX works

- `npx @hrtips/cvx init` scaffolds `cv-content/` with a complete example CV (Bruce Wayne's).
- `npx @hrtips/cvx validate` checks every file at once and reports exact errors with suggested fixes (`--strict` also fails on unknown keys, `--json` for machine-readable output; exit `0` ok / `2` problems found). Run it after every edit.
- `npx @hrtips/cvx build` renders `cv-content/` to a PDF in the current directory.
- `npx @hrtips/cvx build --ats` renders an ATS-safe single-column variant instead.
- Every content file is validated against the [canonical JSON Schema](../schema/v1/cvx.schema.json); the scaffolded files carry `$schema` headers for editor autocomplete.
- The output PDF is named from `personal.yaml`'s `name`: lowercased, spaces → hyphens (`Bruce Wayne` → `bruce-wayne.pdf`; the ATS variant appends `-ats`).
- Every `.yaml` file in `cv-content/` is auto-discovered by filename. A missing file, an empty file, or `[]` simply drops that section from the CV — no error. (Required in practice: `personal.yaml`, `summary.yaml`, `experience.yaml` — `validate` checks this.)
- `init` is a convenience, not a prerequisite — `build` renders any `cv-content/` folder with valid YAML; built-in themes and layouts need no extra files. A missing photo renders fine (the sidebar simply omits it).
- All rendering is local; no network calls, no accounts.

## YAML rules that matter here

- Quote any string containing a colon (`"Director: Operations"`) or starting with a special character.
- Periods/date ranges are free text — the convention in the example content is an en-dash: `2005 – Present`, `Jan 2022 – Dec 2023`.
- Do not invent facts. Every entry must be truthful to the source CV; this especially matters for `keywords.yaml` (ATS parsers cross-check keywords against body text).

---

## File: `personal.yaml` (object)

Identity header + contact block. Only the keys listed here render — arbitrary extra keys are ignored.

| Key | Type | Required | Renders |
|---|---|---|---|
| `name` | string | **yes** | Header name; also derives the output filename |
| `title` | string | recommended | Job title line under the name |
| `company` | string | optional | Company line under the title |
| `phone` | string | optional | Contact row (phone icon) |
| `phoneHref` | string | optional | Makes the phone row clickable, e.g. `"tel:+12015552283"` |
| `email` | string | optional | Contact row (envelope icon); clickable automatically via `mailto:` |
| `linkedin` | string | optional | Contact row (LinkedIn icon), display text e.g. `linkedin.com/in/brucewayne` |
| `linkedinHref` | string | optional | Makes the LinkedIn row clickable (full URL) |
| `facebook` | string | optional | Contact row (Facebook icon) |
| `facebookHref` | string | optional | Makes the Facebook row clickable |
| `location` | string | optional | Contact row (pin icon), e.g. `"Gotham City, USA"` |
| `links` | list of `{label, href}` | optional | Extra contact rows — blog, portfolio, GitHub, personal site. `label` is optional and falls back to the URL. Keeps working alongside the built-in `linkedin`/`facebook` rows |

Contact rows appear only for keys that are present, with any `links` last. The ATS layout's header uses `phone`, `email`, `linkedin`, `location` (not `facebook`) and appends any `links`.

```yaml
name: Bruce Wayne
title: Founder & Field Commander – Gotham Operations
company: Wayne Enterprises
phone: "+1 (201) 555-2283"
phoneHref: "tel:+12015552283"
email: bruce.wayne@wayne-enterprises.com
linkedin: linkedin.com/in/brucewayne
linkedinHref: "https://www.linkedin.com/in/brucewayne"
links:
  - label: Wayne Foundation
    href: "https://www.wayne-foundation.org"
```

## File: `summary.yaml` (list of bullets)

The professional-summary bullets at the top of page 1. Aim for 3–6 bullets, each a single sentence focused on scope, specialisation, or headline achievements.

```yaml
- "Strategic operations leader with 20+ years' experience, progressing from solo field operative to Field Commander of a citywide security network."
- "Specialised in recruiting and developing elite field talent."
```

A bullet is usually a plain string, but any bullet (here or in `experience[].bullets`) may instead be an object embedding a clickable link: `text` (before the link), `link` — an object of `href` + `label` (the clickable part) — and optional `suffix` (after it):

```yaml
- text: "Published the "
  link: { href: "https://example.com/report", label: "annual security report" }
  suffix: " read by 40k+ practitioners."
```

## File: `experience.yaml` (list of entries)

Work history, most recent first. Keep each role its own entry — ATS keywords are auto-derived from `role` and `progression` titles, so roles merged into one "earlier roles" entry (or mentioned only in bullet text) drop out of the keyword metadata. Older roles can simply carry fewer bullets. Per entry:

| Key | Type | Required | Renders |
|---|---|---|---|
| `role` | string | **yes** | Bold role headline |
| `company` | string | recommended | Left side of the meta line |
| `period` | string | recommended | Right side of the meta line, free text |
| `location` | string | optional | Muted line under the meta line |
| `description` | string | optional | One-line italic company/role description |
| `progression` | list of `{title, period}` | optional | Indented title-history block (promotions within the role) |
| `bullets` | list of bullets | optional | Impact bullets — start with a verb, quantify where truthful; 3–6 per recent role, fewer for older ones. Plain strings, or the `{text, link, suffix}` object form (see `summary.yaml`) |

```yaml
- role: Founder & Field Commander – Gotham Operations
  company: The Batman
  period: 2005 – Present
  description: Self-directed vigilante operation safeguarding Gotham City.
  progression:
    - title: Commander, Batman Incorporated
      period: 2011 – Present
    - title: Solo Operative, The Dark Knight
      period: 2005 – 2008
  bullets:
    - Established and scaled a citywide security operation from a solo initiative to a franchised network.
    - Reduced organised-crime activity in Gotham by an estimated 60% through data-driven surveillance.
```

## File: `education.yaml` (list of entries)

Most recent first. Per entry: `degree` (string), `institution` (string), `period` (string).

```yaml
- degree: BSc, Criminology & Chemistry
  institution: Gotham University
  period: 1994 – 1998
```

## File: `certifications.yaml` (list of entries)

Professional certifications, most recent first — kept separate from `achievements.yaml` (awards). Per entry: `name` (**required**), `issuer` (string), `year` (string). Omit the file if there are none.

```yaml
- name: "LFD259: Kubernetes for Developers"
  issuer: The Linux Foundation
  year: "2023"
```

## File: `publications.yaml` (list of entries)

Publications and written work, most recent first. Per entry: `title` (**required**), `venue` (string), `year` (string). Omit the file if there are none.

```yaml
- title: "Applied Deterrence: Non-Lethal Intervention at Scale"
  venue: Gotham Journal of Criminology
  year: "2021"
```

## File: `languages.yaml` (list of entries)

Languages spoken. Per entry: `language` (**required**), `proficiency` (string, free text — e.g. Native, Professional, Conversational). Omit the file if there are none.

```yaml
- language: English
  proficiency: Native
- language: Japanese
  proficiency: Professional
```

## File: `competencies.yaml` (list of strings)

Skill tags rendered as pills in the sidebar. Keep each 1–3 words; 6–12 items reads best.

```yaml
- Strategic Planning
- Criminal Investigation
- Crisis Response
```

## File: `achievements.yaml` (list of entries)

Awards and recognitions. Field names are historical: `year` is the **bold headline** (usually the award name, not a year), `text` is the muted attribution line, conventionally `"— Year, Awarding Organisation"`.

```yaml
- year: Key to the City
  text: "— Office of the Mayor, Gotham City"
```

## File: `referees.yaml` (list of entries, or `[]`)

Per entry: `name`, `title`, `company`, `email`, `phone` (all strings). An explicit empty list `[]` prints "available upon request".

```yaml
- name: Diana Prince
  title: Founding Member, Justice League
  company: Themysciran Embassy
  email: d.prince@justiceleague.org
  phone: "+1 (202) 555-0177"
```

## File: `keywords.yaml` (optional; list of strings, or map of group → list)

Extra ATS/AI-parser keywords embedded in the PDF's `Keywords` metadata field (never printed on the page). Competencies and job titles are auto-derived already, so list only what those don't cover. Groups are flattened; headings are not printed. **Keep every keyword truthful to the CV body.**

```yaml
- Operations Management
- Risk Management
# or grouped:
Leadership: [Executive Leadership, Team Building]
```

## File: `config.yaml` (object)

| Key | Type | Default | Meaning |
|---|---|---|---|
| `schemaVersion` | integer | `1` | Content schema major version — new keys may appear within a major; inert keys can be removed (the CHANGELOG names them) |
| `theme` | `teal` \| `coral` \| `mono` | `teal` | Colour scheme (`mono` is black-and-white, ATS-optimised) |
| `layout` | `two-column` \| `single-column` \| custom layout filename | `two-column` | Page structure |
| `atsKeywords.enabled` | boolean | `true` | Master switch for keyword metadata |
| `atsKeywords.autoDerive` | boolean | `true` | Also derive keywords from competencies + job titles |
| `atsKeywords.max` | integer | all | Cap on embedded keywords (body-derived terms kept first) |

## File: `images/profile.<ext>`

Square photo, 400×400px or larger. Extensions auto-detected in precedence order: `jpg`, `jpeg`, `png`, `webp` (case-insensitive). Used by the two-column layout; the ATS variant has no photo.

## Directory: `layouts/` (optional custom layouts)

A `.yaml` file here becomes selectable as `layout: <filename>`. Structure:

```yaml
template: two-column        # or single-column — the page shell to use
pages:
  first:                    # main: page 1. sidebar: start of the flow.
    sidebar: [identity-photo, contact, achievements]
    main:    [summary, spacer: 27, experience]
  continuation:             # main: middle pages. sidebar: flow continues.
    sidebar: [identity-compact, education, certifications, competencies, languages, publications]
    main:    [experience:continued]
  last:                     # main: the closing page. sidebar: end of the flow.
    sidebar: [identity-compact, referees]
    main:    [experience:continued]
```

> **How the three page buckets are read (changed in the layout-engine work).**
> The **sidebar** lists across `first` / `continuation` / `last` are concatenated
> into **one ordered flow**, and the engine measures that flow and decides which
> page each section lands on. So `last.sidebar: [referees]` means *"referees
> comes last in the sidebar"*, **not** *"referees renders on the final page"* — on
> a CV whose sidebar fits in two pages, a section declared under `continuation`
> may well render on page 3, and one declared under `last` on page 2. The buckets
> are how you express **order**; pagination is measured, not declared. (Identity
> slots are the exception: `identity-photo`/`identity-compact` are injected at the
> top of every page's sidebar, `first`'s on page 1 and `continuation`'s
> thereafter, and are never packed.)
>
> The **main** lists are still per-page-kind: `first.main` renders on page 1,
> `last.main` on the final page, `continuation.main` in between.

Valid section keys: `identity-photo`, `identity-compact`, `contact`, `achievements`, `education`, `certifications`, `publications`, `languages`, `competencies`, `referees` (sidebar); `summary`, `experience`, `experience:continued`, `header-ats` (main); `spacer: N` (N points of vertical space, either slot).

The lists above say where each section is *normally* placed, not where it is *allowed*: any section key is legal in either slot. One caveat if you move one into `main` — the planner measures only `summary` and `experience` there, so a section you place in a main slot renders correctly but is not counted in the page count or the overflow figures. The build says so with the `main-slot-unmeasured` fact, and compares the finished PDF's sheet count against the plan (`physical-pages-exceed-plan`) so an unmeasured spill cannot pass silently.

---

## Checklist for generating a complete cv-content/

1. `personal.yaml` — name (required), title, contact details with `*Href` links where known.
2. `summary.yaml` — 3–6 single-sentence bullets.
3. `experience.yaml` — every role, most recent first, with quantified truthful bullets.
4. `education.yaml`, `certifications.yaml`, `publications.yaml`, `languages.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml` — or `[]` / omit to drop.
5. `keywords.yaml` — only truthful terms not already covered by competencies/titles.
6. `config.yaml` — usually just `theme` + `layout`. Pagination is automatic (the old page-1 keys were removed).
7. Ask for the photo in your **first** message to the user (it can't be generated) — but never block on it; the CV renders cleanly without one. If you ran `init`, replace or delete the scaffolded example photo at `images/profile.jpg` (it's Bruce Wayne's) before building. Then run `npx @hrtips/cvx build`.
