Rendering NIH Specific Aims pages from structured data

The one-page Specific Aims document is the highest-leverage page of a National Institutes of Health (NIH) R01: it is the first thing every reviewer reads, the frame through which the rest of the application is judged, and — critically for automation — a hard one-page attachment that eRA Commons rejects if it runs long or drops below the 11-point font floor. Rendering it by hand each resubmission invites exactly the failures that lose a cycle: a paragraph that reflows onto a second page after an edit, a margin quietly shaved to 0.4 inches to buy a line, an aims list whose numbering drifts. This guide, part of narrative template rendering, shows how to render the Specific Aims page deterministically from a typed data model into a one-page DOCX and PDF that lands inside the 11-point font and 0.5-inch margin envelope so it passes the NIH 12-page-limit and formatting enforcement checks on the first attempt.

Phase 1 — Decompose the Specific Aims structure and set up

A Specific Aims page is not free prose; it has a conventional five-part skeleton, and modeling that skeleton is what lets rendering stay deterministic and length stay predictable. The parts are an opening paragraph that establishes the gap, a paragraph stating the central hypothesis and long-term goal, two to four numbered aims, and a closing significance-and-impact paragraph. Each maps to a field in a typed model, and each has a soft length budget that together must fit one page.

Decomposition steps:

  1. Identify the fixed regions. The opening, hypothesis, aims block, and significance paragraph are always present; only the aim count varies. Fix their order in the template so structure never depends on data.
  2. Bound the aim count. An R01 Specific Aims page carries two to four aims in practice — one reads thin, five overflows. Encode the bound in the model so a malformed record fails at construction, not at layout.
  3. Budget the length. Assign each region a soft character budget derived from the 11-point single-column line metric, so overflow can be predicted before rendering rather than discovered in Word.
  4. Pin the environment. Reproducibility requires locked dependencies, so a page rendered today is identical when re-rendered after a resubmission edit.
bash
python -m venv .venv
source .venv/bin/activate
pip install "jinja2==3.1.4" "docxtpl==0.18.0" "pydantic>=2.6"

The one-page constraint here is stricter than the 12-page Research Strategy ceiling because there is no headroom: the page either fits or it is rejected, so the model captures both the content and the formatting envelope it must render into.

Phase 2 — Build the typed context and the Specific Aims template

The context model declares every field the template reads and enforces the aim-count bound and the formatting envelope at construction. Because the model is built from data that already passed NIH FOA schema mapping, rendering is a pure transformation — no field is discovered missing mid-render — and StrictUndefined in the Jinja2 environment turns any template typo into an immediate error rather than a blank region.

python
from pydantic import BaseModel, Field, field_validator

class Aim(BaseModel):
    number: int
    title: str = Field(min_length=1, max_length=140)   # one-line aim heading
    detail: str = Field(min_length=1)

class SpecificAims(BaseModel):
    """Typed model for a one-page NIH Specific Aims attachment."""
    opportunity_id: str
    project_title: str
    opening: str                       # the gap / problem paragraph
    hypothesis: str                    # central hypothesis + long-term goal
    aims: list[Aim] = Field(min_length=2, max_length=4)  # R01 norm: 2-4 aims
    significance: str                  # closing impact paragraph
    font_pt: float = 11.0              # rendered body font size
    margin_in: float = 0.5            # rendered margin on every edge

    @field_validator("font_pt")
    @classmethod
    def _font_floor(cls, v: float) -> float:
        if v < 11.0:                   # NIH hard minimum for R-series
            raise ValueError("Specific Aims body font must be >= 11 pt")
        return v

    @field_validator("aims")
    @classmethod
    def _sequential(cls, v: list[Aim]) -> list[Aim]:
        # Aims must be numbered 1..n with no gaps, so rendering is stable.
        if [a.number for a in v] != list(range(1, len(v) + 1)):
            raise ValueError("aims must be numbered sequentially from 1")
        return v

The DOCX template carries the fixed skeleton with Jinja2 tags for the variable content. The margin and font are set as document properties on the template itself, not injected as data, so the envelope is a property of the presentation layer that the content cannot violate:

python
from docxtpl import DocxTemplate
from jinja2 import Environment, StrictUndefined

def render_specific_aims(model: SpecificAims, template: str, out: str) -> None:
    """Render a one-page Specific Aims DOCX deterministically.

    The template fixes margins/font; the loop over model.aims emits the
    numbered block. StrictUndefined makes any missing tag fail the build.
    """
    env = Environment(undefined=StrictUndefined, autoescape=True,
                      trim_blocks=True, lstrip_blocks=True)  # no stray blank lines
    doc = DocxTemplate(template)
    doc.render(model.model_dump(), jinja_env=env)  # dict order is stable
    doc.save(out)

Inside the template, the aims render through {% for aim in aims %}Aim {{ aim.number }}. {{ aim.title }}{% endfor %}, with trim_blocks and lstrip_blocks ensuring the loop adds no blank paragraphs that would silently consume vertical space. The flow from typed model to a length-checked page is small enough to hold in one diagram.

Rendering a one-page Specific Aims document from a typed model through a template to a page-fit gate A typed Aims model carrying an aims list, a hypothesis, and a significance blurb flows into a Jinja2 and docxtpl Specific Aims template, which renders a one-page DOCX and PDF set at 11 point with half-inch margins. That rendered page flows down into a page-fit gate that asks whether it fits one page; if not, the flow re-flows or trims and re-renders. Structured aims data renders into a fixed one-page envelope, then is gated for fit Typed Aims model aims[] · hypothesis · significance blurb Jinja2 / docxtpl Specific Aims template fixed envelope One-page DOCX / PDF 11 pt · 0.5 in margins byte-stable Fits one page? else re-flow / trim & re-render overflow → adjust data
The model renders into a template whose margins and font are fixed, and the output is gated for one-page fit; overflow routes back to a data adjustment, never a margin or font edit.

Phase 3 — Edge cases and agency-specific overrides

The one-page envelope is unforgiving, so the rendering path has to anticipate the ways real content breaks it:

  • One-page overflow. The most common failure is a page that renders at 1.05 pages. The fix is never to shrink the font below 11 point or trim the margin below 0.5 inch — both are hard rejections. Predict overflow from the per-region character budgets before rendering, and surface it as a content-length error the writer resolves by tightening prose, not as a silent formatting compromise.
  • Figure or schematic in the aims. Many Specific Aims pages embed a small conceptual figure. An image consumes vertical space that no character budget captures, so treat a figure as a fixed height subtracted from the page budget and re-check fit with the reduced remaining space.
  • Aim overflow. A fourth aim with a long detail block frequently tips the page. Because the model caps aims at four and numbers them sequentially, the renderer can report which aim’s detail exceeds its budget rather than producing an over-length page.
  • Agency and mechanism overrides. The one-page Specific Aims convention is an R01 norm, not a universal rule. A fellowship (F-series) folds specific aims into a differently bounded research strategy, and other mechanisms vary the expectation, so the page envelope must be read from the opportunity record rather than hard-coded. Sibling agencies do not use a Specific Aims page at all — the National Science Foundation (NSF) and Department of Defense (DoD) structure their narratives differently — which is exactly why this template is selected per mechanism.
Rendering input NIH R01 NIH R21 NIH fellowship (F31/F32)
Specific Aims length 1 page 1 page Folded into research plan
Aim count rendered 2–4 2–3 Mechanism-specific
Body font floor 11 pt 11 pt 11 pt
Margin envelope 0.5 in 0.5 in 0.5 in
Envelope authority Activity-code FOA Activity-code FOA Fellowship FOA

Keep the envelope data-driven and resolved from the FOA record, and let the numbers live in configuration so a policy change is a data edit rather than a code change.

Phase 4 — Validation and one-page verification

Two properties must hold before a rendered Specific Aims page is trusted: the render is deterministic, and the output genuinely fits one page at the required font and margins. The first is a byte comparison; the second requires measuring the produced PDF, because a DOCX does not commit to a page count until it is laid out.

python
import hashlib
import io
import pytest
from docxtpl import DocxTemplate
from pypdf import PdfReader

def _render(model: SpecificAims) -> bytes:
    doc = DocxTemplate("templates/specific_aims.docx")
    doc.render(model.model_dump())
    buf = io.BytesIO()
    doc.save(buf)
    return buf.getvalue()

def test_render_is_deterministic(r01_aims: SpecificAims) -> None:
    a, b = _render(r01_aims), _render(r01_aims)
    assert hashlib.sha256(a).digest() == hashlib.sha256(b).digest()

def test_fits_one_page(r01_aims_pdf: str) -> None:
    # r01_aims_pdf is the DOCX converted to PDF by the packaging fixture.
    reader = PdfReader(r01_aims_pdf)
    assert len(reader.pages) == 1, "Specific Aims overflowed one page"

def test_font_below_floor_rejected() -> None:
    with pytest.raises(ValueError):
        SpecificAims(  # 10.5 pt violates the 11 pt floor at construction
            opportunity_id="PA-25-123", project_title="X", opening="o",
            hypothesis="h", aims=[Aim(number=1, title="t", detail="d"),
                                  Aim(number=2, title="t", detail="d")],
            significance="s", font_pt=10.5)

def test_aims_must_be_sequential() -> None:
    with pytest.raises(ValueError):
        SpecificAims(
            opportunity_id="PA-25-123", project_title="X", opening="o",
            hypothesis="h", aims=[Aim(number=1, title="t", detail="d"),
                                  Aim(number=3, title="t", detail="d")],  # gap
            significance="s")

A manual acceptance pass closes the gap the fixtures leave: confirm the rendered page holds 11-point body text with 0.5-inch margins on every edge, that the aims are numbered without gaps, that any embedded figure did not push content off the page, and that the one-page PDF is the exact artifact handed to PDF package generation and re-measured by the NIH formatting enforcement checks. Only when the render is both reproducible and one-page-clean should it be released into the assembled application.

Frequently asked questions

Why render the Specific Aims page from a data model instead of editing a Word file directly?

Because a hand-edited Word file is not reproducible or auditable. Rendering from a typed model guarantees that the same content always produces the same bytes, that the aim count and font floor are enforced at construction, and that every submitted paragraph traces back to a specific data record and template version. A manual edit can silently shrink a margin or drop an aim; a deterministic render cannot.

How do I keep the page from overflowing without shrinking the font or margins?

Never adjust the font below 11 point or the margin below 0.5 inch — both are hard NIH rejections. Instead, give each region (opening, hypothesis, aims, significance) a character budget derived from the 11-point line metric, predict overflow before rendering, and surface it as a content-length error the writer resolves by tightening prose. If a figure is present, subtract its fixed height from the page budget first.

Is the one-page limit the same across NIH mechanisms?

No. The one-page Specific Aims page is an R01 norm. An R21 typically carries two to three aims on the same one page, and a fellowship folds specific aims into a differently bounded research plan. Read the envelope from the Funding Opportunity Announcement (FOA) record via NIH FOA schema mapping rather than hard-coding it, and select the template per mechanism.

How do I actually verify the rendered DOCX is one page?

A DOCX does not commit to a page count until it is laid out, so convert it to PDF in the packaging step and count the pages with a PDF reader, asserting exactly one. Pair that with a byte-level determinism test on the DOCX so a template or data change that alters output is caught in review rather than at eRA Commons.

What happens to the rendered page after this stage?

It flows into PDF package generation, which assembles it with the rest of the application, and is then measured by the page-limit and font enforcement engine that reads the same 11-point and 0.5-inch envelope this render targeted. Rendering to the envelope up front is what makes enforcement pass on the first attempt instead of after a rejection.

Up one level: Narrative Template Rendering