---
name: property-preval
description: Use when an operator wants a pre-valuation pack to pitch for a UK property listing — a 7-page A4 PDF that combines subject facts, postcode market context, market depth, vendor-side "what gets the best price" prep tips, full-width sold + asking comparables, and the agent's recent successes as the sales close. Available in two style variants (premium = editorial Cormorant/Lora/Inter lock; on-brand = follows the agent's DESIGN.md fonts end-to-end). Triggers include "preval <address>", "pre-valuation for <address>", "valuation pitch for <address>", "what's <address> worth and pitch us to list it", "pre-appraisal pack for <address>".
---

# Property Preval

A 7-page A4 PDF that pitches the agent for the listing: cover (full-bleed subject hero + agent byline), subject facts, market context, market depth, vendor-side prep tips ("What gets the best price"), full-width comparables, and the agent's recent successes + disclaimer. **Preval is the data-and-comparables half of the pitch — the human agent values the property in person.** The pack never quotes a numeric estimate or range; that's the agent's job, supported by the comparables on page 6.

## Style variants — premium (default) or on-brand

The pack renders in one of two variants, selected via the top-level `variant` field on `inputs.json`. The two variants share the same content and layout — only typography differs.

### `variant: "premium"` (default — editorial folio)

Typography lock — never substituted:

- **Display** — Cormorant Garamond (italic for headlines, KPI values, prices, addresses)
- **Body** — Lora
- **Sans / labels** — Inter (eyebrows, KPI labels, chips, footer meta)

The pack reads as an editorial / luxury document across every brand. The agent's identity comes through the **palette and logo only**. The agent's `DESIGN.md` font tokens are ignored.

### `variant: "on-brand"` (follows the agent's DESIGN.md end-to-end)

Renderer reads `brand.font_display` and `brand.font_body` from `inputs.json` (sourced from the agent's `DESIGN.md` font tokens) and overrides the `--display` / `--body` / `--sans` CSS variables. Headlines, body copy, eyebrows and labels all switch to the agent's identity. Colour tokens + logo apply as in premium.

Schema requires `brand.font_display` to be set for `on-brand` to engage — a missing display font silently falls back to the premium lock (no broken render). `brand.font_body` defaults to `brand.font_display` if not supplied.

### Decision rule (mirrors `property-brochure`)

1. Operator said "premium" / "super-premium" / "editorial" → premium (regardless of DESIGN.md).
2. Operator said "on-brand" / "branded" / "follow our DESIGN.md" → on-brand.
3. Operator silent + `DESIGN.md` provides both font tokens → on-brand (default for branded shops).
4. Otherwise → premium.

## Inputs (never invented)

- **`address`** — full UK address ending in a valid postcode (e.g. `9 Phoenix Drive, Bishop's Stortford, CM23 2UJ`). Skill derives the postcode by matching the UK postcode regex on the final token group; stops if no match.
- **`brand`** — agent design tokens, same shape as property-market-report: `name`, `tagline`, hex tokens `primary` / `primary_dark` / `accent` / `paper` / `paper_banded` / `rule`. Optional `logo_light` / `logo_dark` filesystem paths. Resolved from the agent's `DESIGN.md` (e.g. `/estate-agents/muvin/DESIGN.md`); read the canonical hex values directly, never substitute "close" colours. Only colour tokens are consumed; typography/spacing/radius tokens in the agent's `DESIGN.md` are ignored by design.
- **`subject`** — paste-in details about the property being valued:
  - `cover_hero` (filesystem path or URL to a photo of the subject — required for the cover).
  - Optional `beds`, `type`, `tenure`, `condition` (well / average / needs work).
  - `adjustments[]` — operator-curated array of `{label, delta_pct}` covering esoteric factors that shift the headline range (e.g. `{"label":"South-facing garden","delta_pct":3}`, `{"label":"Needs full reno","delta_pct":-8}`). Deltas sum linearly into the final range.
  - **EPC certificate fields are pasted by the operator before invocation.** The three required fields are `subject.total_floor_area_m2`, `subject.current_energy_rating`, and `subject.lodgement_date` — copied verbatim from the EPC certificate. The renderer derives `sqft` from `total_floor_area_m2 × 10.7639`. There is no mid-run paste prompt and no benchmark-only render — a null in any of the three aborts the run with `reason=epc-certificate-not-supplied`.
- **`agent_listings`** — the agent's recent sold / under-offer / live listings to feature on the closing page. Up to 6 entries of `{url, address, price, beds, type, image, status, slug}` where `status ∈ {"Sold","Under offer","For sale"}`. Operator picks the comps that make the strongest pitch; this is *not* the PropertyData sample.

## Composed by the agent from the fan-out (never operator-supplied)

Two prose fields are written by the agent from the 12-call PropertyData fan-out plus the EPC and council-tax records — the operator is never prompted for them.

- **`subject.narrative`** — one short paragraph (1–3 sentences) under the valuation panel. Composed from: EPC sqft + rating, `lodgement_date` vs today (age), subject council-tax band (from the per-property `council-tax.properties[]` entry that matches the subject's house number), street context (count of dwellings on the road from the same response), plus the operator's `subject.adjustments[]` labels. Pure facts only — never speculate about orientation, decor, or interior condition that the data doesn't carry.
- **`market_summary`** — `headline` (one display-italic line), `paragraphs[]` (2–3 short paragraphs), `demand_gauge_position` (percentage string like `"62%"`), `price_growth_note`, `demand_note`. Every value composed from the 12-call fan-out output. Mapping: `headline` summarises the postcode in one line (e.g. demand rating + scarcity + £/sqft direction); paragraph 1 covers £/sqft band (sold vs asking) + Land Registry sample window; paragraph 2 covers demand (for-sale count, sales/mo, MoI, DoM, turnover) + property-type mix; paragraph 3 covers growth (£ + £/sqft YoY) + rental pillar. `demand_gauge_position` maps the demand rating to a percentage: Buyer's market ≈ 20–30%, Balanced market ≈ 45–55%, Seller's market ≈ 75–85%, tuned within band by MoI/DoM.

## Tools the skill calls

Parallel fan-out keyed on the derived postcode + the subject address:

**PropertyData (12 calls — credit cost 12):**
1. `property-data-prices` — asking £ distribution + raw asking comps.
2. `property-data-prices-per-sqf` — asking £/sqft.
3. `property-data-sold-prices` — Land Registry sold transactions + raw sold comps.
4. `property-data-sold-prices-per-sqf` — achieved £/sqft (plus 70pc band used for the valuation range).
5. `property-data-growth` — 7y price series.
6. `property-data-growth-psf` — 7y £/sqft series.
7. `property-data-demand` — for-sale, sales/mo, turnover, MoI, DoM, demand rating.
8. `property-data-demand-rent` — rental-side demand pillar.
9. `property-data-property-types` — mix (detached / semi / terraced / flat).
10. `property-data-crime` — area crime rating + breakdown.
11. `property-data-flood-risk` — area flood risk (full postcode only).
12. `property-data-council-tax` — band D + per-property bands (full postcode only).

EPC certificate fields are operator paste-in — see `subject.total_floor_area_m2`, `subject.current_energy_rating`, `subject.lodgement_date` in the Inputs section. There is no EPC MCP fan-out.

`address-match-uprn` and `planning-applications` are deliberately omitted (CRM hygiene and dev-precedent reports respectively).

## Output

Directory under `~/estate-agents/<brand-slug>/preval/<address-slug>/` (e.g. `~/estate-agents/muvin/preval/11-phoenix-drive-cm23-2uj/`) containing:

- `index.html` — single self-contained file.
- `img/` — copied logos and the subject hero image.
- `<slug>-<date>.pdf` — exactly **7** A4 pages. Renderer asserts via `pdfinfo` and raises if ≠ 7.
- `property-data.md` — markdown dump of the 12 raw PropertyData fan-out responses for transparency / vendor audit (operator may share or strip before sending).
- `inputs.json` + `preval.log` — the assembled input shape + the `[preval]` start/done/aborted audit lines.

Output is agent-centric so each brand owns its own preval history alongside its `properties/`, `reports/` and `DESIGN.md`. Return the PDF path to the operator.

## Pages

1. **Cover** — full-bleed `subject.cover_hero` with vertical gradient scrim; light logo top-left; eyebrow "`<brand.name>` · Pre-valuation"; address in display-italic; postcode chip; four-up meta strip (Prepared by · Address · Postcode · Generated). When `brand.agent_name` is set, "Prepared by" displays that name (ties the cover photo to the individual agent pitching).
2. **About the property** — KPI strip (sqft, beds, EPC rating, type, tenure). Agent-composed `subject.narrative` under the KPIs (drawn from EPC + council-tax records — see "Composed by the agent from the fan-out"). Optional "Notable features" bullet list rendered from `subject.adjustments[].label` (label-only — `delta_pct` is held in the schema for backward compat but no longer rendered). **No estimate, no range, no adjustments-with-running-£**. `sqft` is required — a null value aborts the run, never renders a benchmark-only panel.
3. **Market context** — Market-summary headline + 2–3 paragraphs (dropcap on first); KPI cube (avg asking, avg sold, £/sqft); two-col panels (price growth + demand gauge). The 7-year chart used to live here; it now has its own page (page 4) to give it space.
4. **Market depth** — Full-width 7-year price-trend chart (£ headline solid + £/sqft dashed gold, ≈ 48mm tall). Below: two-col panels — output-area stock mix (`demand_trend.type_dist` bars) on the left, rental pillar on the right (`demand_trend.rating` pill + 3-up KPIs for for-rent count, DoM, monthly take-up).
5. **What gets the best price** — Vendor-facing prep advice condensed from `real-agent/listings/home-preparation/staging-guide.md`. Two-col panel: "Five high-impact moves" (declutter / deep-clean / open the light / fix the small things / define each room) on the left; "Common mistakes" (air fresheners / personal items / cold house / overgrown garden / wardrobes) on the right. Closes with a line attributing the room-by-room walkthrough to the agent ("`<agent_name>` can walk you through this room-by-room ahead of photo day."). Content is hard-coded into the template — not per-pack-editable; for per-property customisation operators should swap to the brochure flow.
6. **Comparables (full-width, stacked)** — Nearest sold table (top 6, columns Date · Address · Type · Beds · Price) full-width across the page; Live asking tile grid (top 4) full-width below. Vertically stacked, never side-by-side — corrects the cramped two-column layout in the v1 preval where the asking tiles got pushed to ~40mm columns of italic addresses.
7. **Recently with `<brand.name>` + sources** — agent's own listings as a clickable tile grid (`<a>` per card, status badge: Sold / Under offer / For sale), then the sources / methodology / accuracy / investment block. Closes with `<brand.name> · Generated <date> · <postcode>` in the footer.

## How to execute

1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop if no match.
2. Run the 12 PropertyData fan-out tools in parallel. Every failure is fail-loud — no mid-run paste prompts, no benchmark-only render, no degraded pack on disk:
   - **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
   - **PropertyData `http-429`** → back off and retry the single throttled tool **once**. A second `http-429` aborts `reason=property-data-rate-limited`.
   - **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
   - **Watchdog: any of the 12 PropertyData log lines missing** within 60 s of `[preval] start` → `render.py` aborts `reason=observability-missing tool=<name>` naming the silent tool. A silent tool drop must never proceed to render.
3. Derive `sqft` from `round(subject.total_floor_area_m2 × 10.7639)` for the page-2 KPI strip. The pack does not compute a £-valuation; the £/sqft band lives on page 3 (market context) and the comparables on page 4 — the human agent values from those in person.
4. Compose `subject.narrative` and `market_summary` from the fan-out output per the rules in "Composed by the agent from the fan-out". No operator prompt for either.
5. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
6. `python3 references/render.py inputs.json`. Renderer fills the template, copies logos + hero into `img/`, drives Chrome `--print-to-pdf`, asserts page count == 7.
7. Return the PDF path.

## Adjustments — label-only

`subject.adjustments[]` survives in the schema as `{label, delta_pct}` but the renderer uses only the `label` field — rendered as a "Notable features" bullet list on page 2. `delta_pct` is held for backward compat (it used to drive a numeric range that this skill no longer publishes). The human agent factors notable features into their valuation conversation; the pack just surfaces them as facts.

## Observability

- `[preval] start address="<addr>" postcode=<pc>` at run start.
- `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
- `[preval] aborted reason=<cause> address="<addr>" postcode=<pc> ms=<n>` on any abort (one line, then exit 2; no `done`).
- Each fan-out call emits its own `[property-data] tool=<name>` line in `server.log` (inherited contract; no new instrumentation here). `render.py`'s observability watchdog tails these lines and aborts with `observability-missing tool=<name>` if any of the 12 expected names is absent 60 s after `[preval] start`.

## Hard breakpoints

Each row aborts the run with the named cause. No PDF or `index.html` is left on disk. Operator sees a single-line cause + one-line remediation hint, nothing else.

| Forbidden state | `reason=` cause |
|---|---|
| `subject.total_floor_area_m2` / `current_energy_rating` / `lodgement_date` null or missing | `epc-certificate-not-supplied` (operator pastes the three fields from the EPC certificate before re-invoking) |
| `subject.cover_hero` missing, unreachable, below 1200×800, or symlinked into another property's folder | `subject-cover-hero-missing` / `subject-cover-hero-unreadable` / `subject-cover-hero-too-small` / `subject-cover-hero-symlinked-foreign` |
| `brand` not resolved from an on-disk `DESIGN.md` (required token missing) | `brand-unresolved` |
| `agent_listings` empty | `agent-listings-empty` |
| `market_summary.paragraphs` < 2 entries | `market-summary-too-thin` |
| PropertyData any-call `http-429` after 1 retry | `property-data-rate-limited` |
| PropertyData any-call `key-not-registered` | `property-data-key-not-registered` (operator runs `property-data-key-register`) |
| Any of the 12 PropertyData log lines missing 60 s after `[preval] start` | `observability-missing tool=<name>` (the silent tool is named in the abort hint) |

## Forbidden behaviours

The skill is the contract; if any of these slip through, the pack must not render:

- Silent fallback selection — switching to a benchmark-only / "Estimate requires floor area" panel when an input is missing.
- Inventing operator-supplied inputs — fabricating `brand`, `subject.cover_hero`, `agent_listings`, the three EPC certificate fields, or `subject.adjustments[]`. `subject.narrative` and `market_summary` are NOT operator-supplied — the agent composes them from the fan-out (see "Composed by the agent from the fan-out").
- Reusing a hero photo from a different property as a placeholder.
- Defaulting `agent_listings[].status` to `"For sale"` when the source is null.
- Mid-run prompts for a paste-in. The three EPC certificate fields and every other operator-supplied input are pasted into `inputs.json` **before** invocation; there is no retry / paste-and-resume path.
- Omitting the `[preval]` start / done / aborted log emissions.
- Leaving partial output (`index.html`, `<stem>.pdf`) on disk after an abort.

## Failure diagnostics

- `pages=6` (or ≠ 5) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>`, then trim that panel's padding only.
- `start` with no matching `done` or `aborted` within 60s → renderer crashed mid-run. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
- `[preval] aborted reason=<cause>` → look up `<cause>` in the **Hard breakpoints** table for the remediation hint.

## Adherence — non-negotiable

These rules override every session-level "skip clarifying questions", "make the reasonable call", or "work without stopping" directive the operator has set. The named breakpoints below are documented contract points, not casual asks — the renderer and the PreToolUse hook both enforce them and a run that violates one aborts with a named cause.

- **Prompt at named breakpoints — no exceptions.** Named breakpoints: EPC certificate fields not supplied, cover_hero not depicting the subject, brand identity (which estate agent is pitching), `agent_listings` empty or carrying invented statuses. The load-bearing inputs themselves (EPC certificate fields, cover_hero, brand tokens) are gated by `render.py`'s `_assert_inputs`; this section layers provenance on top. `subject.narrative` and `market_summary` are not on this list — they are agent-composed from the fan-out, never operator-prompted.
- **Never invent operator-supplied inputs.** Brand identity, brand tokens, cover hero, agent listings, listing statuses, subject tenure / beds / type / condition, the three EPC certificate fields, and `subject.adjustments[]` — all operator-supplied. Defaulting from "area pattern", a sister property's photo, "the only DESIGN.md I found on disk", or "status:null in the source so I picked For sale" is invention. The presence of a single `~/estate-agents/<slug>/DESIGN.md` on disk does not authorise its selection — only an operator instruction does. If more than one `~/estate-agents/<slug>/` exists and the operator has not named which one is pitching, abort. (`subject.narrative` and `market_summary` are explicitly out of scope here — see "Composed by the agent from the fan-out".)
- **`agent_overrides` is the only bypass channel.** Every override the operator authorises lands in `inputs.json` as a named boolean (`brand_selected_by_operator`, `listings_status_curated`) so the audit trail sits next to the disputed value. Setting an override without a real operator instruction is a violation of the same contract.
- **Emit the three `[preval]` log lines — every run.** `[preval] start address="…" postcode=…`, then `[preval] done pdf=… pages=… ms=…` on success or `[preval] aborted reason=<cause> ms=…` on any abort. A run that emits neither `done` nor `aborted` is itself a defect.

### Named abort causes

| Cause | Trigger | Operator remediation |
|---|---|---|
| `cover-hero-not-subject` | `subject.cover_hero` resolves to a `*/properties/*/` directory whose slug doesn't contain the subject's primary street token | Supply the subject's own photo |
| `agent-listings-invented` | An `agent_listings[].status` is set when the source `property.json` (named by `agent_listings[].origin`) carries `status: null`, and `agent_overrides.listings_status_curated` is not `true` | Operator signs off on each listing status, then sets the override |
| `brand-invented` | `brand.name` is set without `agent_overrides.brand_selected_by_operator: true` | Operator names which estate agent is pitching; set the override |
| `epc-certificate-not-supplied` | Any of `subject.total_floor_area_m2`, `subject.current_energy_rating`, or `subject.lodgement_date` is null or missing | Operator copies the three fields verbatim from the EPC certificate into `subject` in `inputs.json` before re-invoking |
| `observability-missing tool=<name>` | One of the 12 expected `[property-data] tool=<name>` log lines did not appear within 60 s of `[preval] start` | Re-invoke and confirm every PropertyData tool returns; if a tool is genuinely down, surface a `property-data-*` cause first |
| `log-destination-unavailable` | Neither `~/.maxy/logs/server.log` nor `<out_dir>/preval.log` is writable | Fix filesystem permissions before re-running |

## Self-check before declaring done

Answer all seven inline at the end of every run, with line references from `server.log` or `<out_dir>/preval.log` — not paraphrase:

1. `[preval] start` emitted? (cite the log line)
2. All 12 expected `[property-data] tool=<name>` log lines present, or `[preval] aborted` emitted with a named cause? (cite the lines)
3. EPC certificate fields (`subject.total_floor_area_m2`, `subject.current_energy_rating`, `subject.lodgement_date`) all populated from the operator paste? (cite the `inputs.json` excerpt)
4. Every input field traceable to a real source — operator paste (including the EPC certificate), on-disk `DESIGN.md`, MCP response? Anything sourced from "area pattern", a sister property file, or LLM imagination is a defect.
5. `[preval] done pages=7` emitted? (cite the log line)
6. PDF page count = 7 asserted? (cite the assertion result)
7. No operator-supplied fields invented from area pattern, sister-property files, or LLM imagination? List the three most fabrication-prone operator-supplied fields (`brand.name`, `agent_listings[].status`, `subject.cover_hero`) and the source for each in this run. (`subject.narrative` and `market_summary` are agent-composed from the fan-out by design — note them as such with the data fields they're drawn from.)
8. **Visual verification — open the rendered PDF and confirm each page.** Use the Read tool with `pages: "1-7"` against the PDF and check: (a) no content clipped at the bottom of any page (the absolutely-positioned `.doc-foot` overlays content past ~y=288mm — anything below must be footer only); (b) no remaining `<!-- REPLACE: <slot> -->` strings; (c) no KPI cells displaying "—" for fields that should be populated (especially `subject.beds`); (d) cover meta strip carries "Prepared by · <agent_name>"; (e) when `variant: "on-brand"` is set, page 1's address renders in the brand's display font (not Cormorant) — typeface drift is the variant's visual signature. Log-line success (`pages=7`) is necessary but not sufficient — page count being right does not prove each page rendered cleanly. **Skipping this check is the documented cause of two prior chart-overflow regressions; treat it as mandatory.**

## Out of scope

- Multi-address batch preval (CSV in → many PDFs out).
- Loop CRM auto-publish of the PDF onto a contact's record.
- Pulling `agent_listings` from Loop CRM automatically — operator paste-in for now; CRM wire-up is a separate task.
- Caching of MCP responses (12 credits per pack is acceptable).
- A separate cover letter / pitch letter — the cover page *is* the pitch.
- Compound adjustments (linear sum is the contract; see above).
- Subject EPC fields from sources other than the operator paste-in (e.g. Loop CRM floor plans, Rightmove sqft scrape, restored EPC API) — separate task if pursued.
