Citation Management skill for Codex

One clean .bib, zero broken citations. Codex pulls reference metadata from the DOI registry or Crossref instead of typing it from memory, merges duplicate and preprint entries under stable keys, and wires citations correctly for biblatex, natbib, Pandoc citeproc, Quarto or Typst. A bundled stdlib linter cross-checks the .bib against your manuscript for missing keys, duplicates and malformed DOIs.

  • Research
  • $citation-management
  • 157 lines
  • 3 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- citation-management

Installs to ~/.agents/skills/citation-management/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $citation-management, 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

Manages references end to end - fetches clean BibTeX or CSL JSON by DOI, keeps one deduplicated .bib with stable keys (Zotero plus Better BibTeX), wires citations into LaTeX (biblatex/biber or natbib), Pandoc citeproc, Quarto and Typst, switches styles like APA, IEEE or Chicago with CSL, and lints the bibliography against the manuscript. Use when references are broken or inconsistent, a citation style must change, a .bib file needs cleaning, or citations must be added to a paper, thesis or docs.

Use it when

  • Citations show as ? or undefined in the built PDF
  • A paper, thesis or docs site must change citation style
  • A .bib file has grown messy with duplicates and hand-typed entries
  • You need correct BibTeX for a list of DOIs or arXiv IDs

Use something else when

How to check the result

bibtex_check.py exits 0 with --cited over every manuscript file, the build log reports no undefined citations, and sampled references match the DOI registry's formatted output.

The mistake it prevents

Hand-editing a .bib that Zotero auto-exports, so every fix disappears on the next sync.

~/.agents/skills/citation-management/SKILL.md

--- name: citation-management description: Manages references end to end - fetches clean BibTeX or CSL JSON by DOI, keeps one deduplicated .bib with stable keys (Zotero plus Better BibTeX),… license: MIT ---

The bibliography is right when every in-text citation resolves to exactly one entry, every entry’s metadata comes from the publisher or DOI registry rather than memory, keys never change once cited, and switching styles is a one-line change.

Inspect first

find . -name '*.bib' -o -name '*.csl' -o -name 'CITATION.cff' | grep -v node_modules
grep -rnE 'usepackage.*\{(biblatex|natbib)\}|addbibresource|\\bibliography(style)?\{' --include='*.tex' .
grep -nE '^(bibliography|csl|cite-method):' *.qmd *.md _quarto.yml 2>/dev/null
cat latexmkrc .latexmkrc 2>/dev/null

Identify the toolchain and the authoritative library (a Zotero auto-export, a hand-maintained .bib, a reference manager’s sync). Edit the source of truth, not a copy that will be overwritten on the next export. Check AGENTS.md for the build command.

Toolchain choice

Writing inDefaultNotes
LaTeX, new projectbiblatex + biberUnicode, date/journaltitle fields, flexible styles
LaTeX, journal templateWhatever the template uses (often natbib + BibTeX .bst)Never swap a publisher’s required setup
Markdown / Word outputPandoc --citeproc + CSL10,000+ CSL styles available
Quartobibliography: and csl: in YAMLSame citeproc engine
Typst#bibliography("refs.bib", style: "apa")Reads BibTeX or Hayagriva YAML
Library managementZotero with Better BibTeX auto-exportKeep-updated export to refs.bib

Getting metadata right

Never type an entry from memory. Fetch it (needs network; approve if the sandbox blocks it):

# BibTeX straight from the DOI registry via content negotiation
curl -sLH "Accept: application/x-bibtex" https://doi.org/10.1145/359545.359563
# CSL JSON (for Pandoc / Zotero import)
curl -sLH "Accept: application/vnd.citationstyles.csl+json" https://doi.org/10.1145/359545.359563
# A formatted reference in a given style, to compare against your output
curl -sLH "Accept: text/x-bibliography; style=apa" https://doi.org/10.1145/359545.359563
# Crossref record: venue, dates, update and retraction relations
curl -s https://api.crossref.org/works/10.1145/359545.359563 | jq '.message | {title, "container-title", published, type}'
  • arXiv papers: use the arXiv “Export BibTeX” output, then check whether a published version exists and prefer it (keep eprint and archivePrefix = {arXiv} fields).
  • Software and datasets: cite the version and DOI (Zenodo) or the project’s CITATION.cff; cffconvert -f bibtex converts it when installed.
  • Web pages: @online (biblatex) or @misc with url and urldate; archive important pages in the Wayback Machine.

Cleaning a .bib

  1. Run the linter (below) to find duplicate keys, missing required fields, duplicate DOIs and titles (often a preprint and its published version).
  2. Merge duplicates into one entry; keep the key that the manuscript already uses and replace the others in the text with a scripted search-and-replace.
  3. Normalise fields: bare DOI (10.1145/..., not a URL), full journal names or a consistent abbreviation list, page ranges with --, four-digit years.
  4. Protect capitalisation that styles must not lowercase: title = {{BERT}: Pre-training of Deep Bidirectional Transformers}. Wrap only the proper nouns and acronyms, not the whole title.
  5. Encode accents as Unicode with biber; use {\"u} style escapes only for legacy BibTeX.

Key convention (pick one, write it in AGENTS.md): authorYEARfirstword, e.g. lamport1978time. In Zotero with Better BibTeX, set the citation key formula once and pin keys so edits to metadata never change a cited key.

Wiring citations

% biblatex + biber
\usepackage[style=authoryear, backend=biber]{biblatex}
\addbibresource{refs.bib}
... as shown by \textcite{lamport1978time} and others \parencite{a2020, b2021}.
\printbibliography
% natbib + BibTeX (journal templates)
\usepackage{natbib}
... \citet{lamport1978time} ... \citep[p.~3]{a2020}
\bibliographystyle{plainnat}
\bibliography{refs}
latexmk -pdf paper.tex                       # runs biber/bibtex as needed
pandoc paper.md --citeproc --bibliography refs.bib --csl ieee.csl -o paper.docx
quarto render paper.qmd
typst compile paper.typ

Markdown citation syntax: [@lamport1978time], [@a2020; @b2021, p. 3], @a2020 says (narrative), [-@a2020] (suppress author). CSL style files come from the official CSL styles repository; store the .csl in the repo so builds are reproducible offline.

Style switches

TargetLaTeX (biblatex)Pandoc / Quarto
APA 7style=apa (biblatex-apa)apa.csl
IEEEstyle=ieee (biblatex-ieee)ieee.csl
Chicago author-datestyle=chicago-authordate (biblatex-chicago)chicago-author-date.csl
Numeric genericstyle=numeric-company numeric CSL

Check the journal’s author guidelines first: many require a specific .bst or CSL file.

Linting against the manuscript

python3 scripts/bibtex_check.py refs.bib
python3 scripts/bibtex_check.py refs.bib --cited paper.tex sections/*.tex notes.md

Errors (exit 1): duplicate keys, entries missing required fields for their type, keys cited in the text but absent from the .bib. Warnings: same DOI or title under two keys, DOI stored as a URL, malformed DOIs, odd years, entries never cited. Fix errors before building; decide with the user whether to delete uncited entries (a shared library may need them).

Gotchas

  • Biber and BibTeX caches: after changing backends, delete .bbl, .bcf and run latexmk -C before rebuilding, or stale references persist.
  • ? or [?] in the PDF means an unresolved key; check the .blg log, not just the PDF.
  • Zotero auto-export overwrites hand edits to the exported .bib. Fix data in Zotero.
  • Mixing BibTeX field names (journal, year) and biblatex ones (journaltitle, date) works with biber but not the other way around.
  • A retracted paper must not be cited as support. Check the publisher page and Crossref record for retraction or correction notices before final submission.
  • Never fabricate a DOI, page range or volume. Leave a field out rather than guess.
  • For writing the paper itself, use $academic-writing; for LaTeX build problems beyond citations, use $latex-documents.

Verification

  • bibtex_check.py exits 0 with --cited over every manuscript file.
  • Build log has no “undefined citations” or “Citation … undefined” warnings.
  • Spot-check five formatted references against the DOI registry’s formatted output.
  • Switching the style file changes formatting only; citation count stays the same.

Report template

**Bibliography:** refs.bib (142 entries -> 131 after merging 11 duplicates)
**Toolchain:** biblatex + biber, style=apa; build: latexmk -pdf main.tex
**Fixed:** 3 missing booktitle, 7 DOIs normalised, 2 cited keys added from DOI lookup
**Needs author input:** 'smith2019' has no DOI or venue; 4 uncited entries kept

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Citation Management"
  short_description: "One clean .bib, zero broken citations"
  default_prompt: "$citation-management Clean up refs.bib: merge duplicates and fix the entries missing fields"
scripts/bibtex_check.py165 lines, 6.3 KB
#!/usr/bin/env python3
"""Lint a BibTeX/biblatex file and cross-check it against the manuscript.

Errors (exit 1): duplicate keys, missing required fields, keys cited in the
manuscript but absent from the .bib.
Warnings: same DOI or same title under two keys, DOI stored as a URL, malformed
DOI, odd years, entries never cited.

Usage:
  python3 bibtex_check.py refs.bib
  python3 bibtex_check.py refs.bib --cited paper.tex chapter2.tex notes.md
"""
import argparse
import re
import sys
from pathlib import Path

REQUIRED = {
    "article": [("author",), ("title",), ("journal", "journaltitle"), ("year", "date")],
    "inproceedings": [("author",), ("title",), ("booktitle",), ("year", "date")],
    "incollection": [("author",), ("title",), ("booktitle",), ("year", "date")],
    "book": [("author", "editor"), ("title",), ("publisher",), ("year", "date")],
    "phdthesis": [("author",), ("title",), ("school", "institution"), ("year", "date")],
    "mastersthesis": [("author",), ("title",), ("school", "institution"), ("year", "date")],
    "techreport": [("author",), ("title",), ("institution",), ("year", "date")],
    "online": [("title",), ("url",)],
    "misc": [("title",)],
}
SKIP = {"comment", "string", "preamble"}
DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")
CITE_TEX = re.compile(r"\\[a-zA-Z]*cite[a-zA-Z]*\*?(?:\[[^\]]*\]){0,2}\{([^}]*)\}")
CITE_MD = re.compile(r"(?<![\w.])@([A-Za-z0-9_][\w:.#$%&+?<>~/-]*[A-Za-z0-9_])")


def read_value(text: str, i: int):
    """Return (value, next_index) for a field value starting at text[i]."""
    if text[i] == "{":
        depth, j = 0, i
        while j < len(text):
            if text[j] == "{":
                depth += 1
            elif text[j] == "}":
                depth -= 1
                if depth == 0:
                    return text[i + 1:j], j + 1
            j += 1
        raise ValueError("unbalanced braces")
    if text[i] == '"':
        j = i + 1
        while j < len(text) and not (text[j] == '"' and text[j - 1] != "\\"):
            j += 1
        return text[i + 1:j], j + 1
    m = re.match(r"[^,}\s]+", text[i:])
    return (m.group(0) if m else ""), i + (m.end() if m else 0)


def parse(text: str):
    entries = []
    for m in re.finditer(r"@(\w+)\s*\{", text):
        etype = m.group(1).lower()
        if etype in SKIP:
            continue
        line = text.count("\n", 0, m.start()) + 1
        i = m.end()
        km = re.match(r"\s*([^,\s]+)\s*,", text[i:])
        if not km:
            entries.append({"type": etype, "key": None, "fields": {}, "line": line})
            continue
        key, i = km.group(1), i + km.end()
        fields = {}
        while True:
            fm = re.match(r"\s*([A-Za-z][\w-]*)\s*=\s*", text[i:])
            if not fm:
                break
            name, i = fm.group(1).lower(), i + fm.end()
            parts = []
            while True:  # handle "a" # {b} concatenation
                value, i = read_value(text, i)
                parts.append(value)
                cm = re.match(r"\s*#\s*", text[i:])
                if not cm:
                    break
                i += cm.end()
            fields[name] = " ".join(p.strip() for p in parts)
            cm = re.match(r"\s*,", text[i:])
            if not cm:
                break
            i += cm.end()
        entries.append({"type": etype, "key": key, "fields": fields, "line": line})
    return entries


def norm_title(t: str) -> str:
    return re.sub(r"[^a-z0-9]", "", re.sub(r"[{}\\]", "", t.lower()))


def cited_keys(paths):
    keys = set()
    for p in paths:
        text = Path(p).read_text(encoding="utf-8", errors="replace")
        for group in CITE_TEX.findall(text):
            keys.update(k.strip() for k in group.split(",") if k.strip())
        if p.endswith((".md", ".qmd", ".rmd", ".Rmd")):
            keys.update(CITE_MD.findall(text))
    return keys


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("bib")
    ap.add_argument("--cited", nargs="*", default=[], help="manuscript files (.tex, .md, .qmd) to cross-check")
    args = ap.parse_args()

    entries = parse(Path(args.bib).read_text(encoding="utf-8", errors="replace"))
    errors, warns = [], []
    seen_key, seen_doi, seen_title = {}, {}, {}
    for e in entries:
        where = f"line {e['line']}"
        if not e["key"]:
            errors.append(f"{where}: entry without a citation key")
            continue
        k, f = e["key"], e["fields"]
        if k in seen_key:
            errors.append(f"{where}: duplicate key '{k}' (first at line {seen_key[k]})")
        seen_key.setdefault(k, e["line"])
        for alts in REQUIRED.get(e["type"], []):
            if not any(f.get(a) for a in alts):
                errors.append(f"{where}: @{e['type']}{{{k}}} missing {' or '.join(alts)}")
        doi = f.get("doi", "").strip()
        if doi:
            if re.match(r"https?://", doi):
                warns.append(f"{where}: '{k}' doi stored as URL; keep the bare 10.xxxx/... form")
                doi = re.sub(r"^https?://(dx\.)?doi\.org/", "", doi)
            if not DOI_RE.match(doi):
                warns.append(f"{where}: '{k}' doi '{doi}' does not look like a DOI")
            d = doi.lower()
            if d in seen_doi and seen_doi[d] != k:
                warns.append(f"{where}: '{k}' has the same DOI as '{seen_doi[d]}'")
            seen_doi.setdefault(d, k)
        title = norm_title(f.get("title", ""))
        if title:
            if title in seen_title and seen_title[title] != k:
                warns.append(f"{where}: '{k}' has the same title as '{seen_title[title]}' (preprint and published copy?)")
            seen_title.setdefault(title, k)
        year = f.get("year", "")
        if year and not re.fullmatch(r"\d{4}", year.strip()):
            warns.append(f"{where}: '{k}' year '{year}' is not a 4-digit year")

    if args.cited:
        cited = cited_keys(args.cited)
        for k in sorted(cited - set(seen_key)):
            errors.append(f"cited but not in {args.bib}: '{k}'")
        for k in sorted(set(seen_key) - cited):
            warns.append(f"never cited: '{k}'")

    for w in warns:
        print(f"warn: {w}")
    for x in errors:
        print(f"error: {x}")
    print(f"\n{len(entries)} entries, {len(errors)} error(s), {len(warns)} warning(s)")
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem