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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- citation-management Installs to ~/.agents/skills/citation-management/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install citation-management The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project citation-management Installs to .agents/skills/citation-management/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/citation-management.zipunzip -o citation-management.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) citation-management PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The issue is the LaTeX build or template itself: use latex-documents
- You need help writing or structuring the paper: use academic-writing
- You need to find the papers in the first place: use literature-review
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.
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 in | Default | Notes |
|---|---|---|
| LaTeX, new project | biblatex + biber | Unicode, date/journaltitle fields, flexible styles |
| LaTeX, journal template | Whatever the template uses (often natbib + BibTeX .bst) | Never swap a publisher’s required setup |
| Markdown / Word output | Pandoc --citeproc + CSL | 10,000+ CSL styles available |
| Quarto | bibliography: and csl: in YAML | Same citeproc engine |
| Typst | #bibliography("refs.bib", style: "apa") | Reads BibTeX or Hayagriva YAML |
| Library management | Zotero with Better BibTeX auto-export | Keep-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
eprintandarchivePrefix = {arXiv}fields). - Software and datasets: cite the version and DOI (Zenodo) or the project’s
CITATION.cff;cffconvert -f bibtexconverts it when installed. - Web pages:
@online(biblatex) or@miscwithurlandurldate; archive important pages in the Wayback Machine.
Cleaning a .bib
- Run the linter (below) to find duplicate keys, missing required fields, duplicate DOIs and titles (often a preprint and its published version).
- 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.
- Normalise fields: bare DOI (
10.1145/..., not a URL), full journal names or a consistent abbreviation list, page ranges with--, four-digit years. - 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. - 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
| Target | LaTeX (biblatex) | Pandoc / Quarto |
|---|---|---|
| APA 7 | style=apa (biblatex-apa) | apa.csl |
| IEEE | style=ieee (biblatex-ieee) | ieee.csl |
| Chicago author-date | style=chicago-authordate (biblatex-chicago) | chicago-author-date.csl |
| Numeric generic | style=numeric-comp | any 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,.bcfand runlatexmk -Cbefore rebuilding, or stale references persist. ?or[?]in the PDF means an unresolved key; check the.blglog, 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.pyexits 0 with--citedover 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.yaml
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.py
#!/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.