---
name: space-report-builder
description: Build a polished commercial-real-estate "Space Report" PowerPoint deck for any tenant rep firm. The skill is multi-tenant — each firm has a `brand-profile.json` controlling logo, palette, fonts, and copy. The skill takes a list of CoStar property URLs (one per shortlisted space), pulls building and per-floor data, gathers interior photos and floor plans from CoStar's Images tab, the per-space listing pages, AND the broker marketing brochures linked from the Documents section, presents a contact sheet for the user to curate the photos, then assembles a finished `.pptx` matching the firm's house style (cover → matrix → one slide per property → closing). Trigger this skill whenever the user mentions building a space report, putting together a tenant rep deck, a Veracity space report, an "office options" deck, a "shortlist" deck, or anything similar — even when they don't explicitly say the words "space report." If the user mentions CoStar URLs alongside a deliverable, this skill almost certainly applies.
---

# Space Report Builder

A skill for producing a polished tenant-representation "Space Report" deck — a structured PowerPoint that walks a client through a curated shortlist of office spaces. The reference example is `references/example-report.pdf` (Veracity Commercial Partners); the deck you produce should match that example's structural fidelity, but the actual logo, colors, fonts, copy, and contact details come from the firm's `brand-profile.json`.

The hard part of this skill isn't the slide layout — `scripts/build_deck.py` handles that deterministically. The hard part is **getting the right photos with the right captions for each space**. CoStar's building-level Images tab gives you generic alt-text ("Building Photo," "Floor Plan"), but the example uses rich captions like "OPEN WORKSTATION AREA," "SOFT SEATING & COLLABORATION," "TERRACE & OPEN AREA." Those captions live in the broker marketing brochures and the per-space listing pages on CoStar. Plan to spend most of your time on photo gathering, parsing, and curation, not on PowerPoint mechanics.

## What the user provides

- **A list of CoStar property URLs** — one per building. Each URL looks like `https://product.costar.com/detail/lookup/{id}/summary`. The user will tell you which floor(s) of each building are in scope (sometimes one building has multiple suites in the report — see 260 Townsend in the example, which appears twice for 2nd and 5th floors).
- **The client name** (the "Prepared for" on the cover) and **the report's location/theme** (e.g., "South of Market") for the cover page.
- **A confidentiality date** (defaults to current month/year if not given).

The user is logged into CoStar in the connected Chrome browser. Don't try to log in for them.

## Pipeline overview

This skill runs in five stages. Always work through them in order and keep your todo list updated so the user can see progress.

1. **Set up the run** — confirm inputs, create a workspace folder for this report's assets.
2. **Extract data per property** — open each CoStar URL, pull building data and the Space table, identify the per-floor rows in scope. **Search every per-suite Lease modal for virtual tour links** (Matterport, Asteroom, Kuula, 3D tour, etc.) and capture any URL found into the manifest's `virtual_tour_url` field. This is mandatory — see "Virtual tour discovery" under Content rules.
3. **Gather images** — for each property, collect candidate photos from three sources: CoStar Images tab, per-space listing pages on CoStar, and the broker marketing brochure PDFs. Always parse the brochure even if CoStar has photos — brochures consistently have the best captioned interior shots.
4. **Curate** — present the user with a contact sheet showing all candidate photos per property, with auto-suggested captions. The user picks 3 interior photos + 1 floor plan per space.
5. **Build the deck** — run `scripts/build_deck.py` with the curated data + images. The deck builder will automatically include a **Virtual Tours summary slide** at the end of the deck if any property in the manifest has a `virtual_tour_url`. Deliver the `.pptx` and review it with the user.

## Stage 1: Set up the run

Create a per-report workspace under `outputs/<report-slug>/` (e.g., `outputs/sf-southofmarket-2026-04/`). Inside it:

```
<report-slug>/
├── manifest.json        ← grows as you extract data
├── images/
│   ├── prop-01-139townsend/
│   │   ├── candidates/  ← all photos pulled from any source
│   │   └── selected/    ← user's curated picks
│   ├── prop-02-123townsend/
│   └── ...
├── brochures/           ← downloaded broker PDFs
└── space-report.pptx    ← final deliverable
```

Confirm with the user: cover title, client name, confidentiality date, and the ordered list of URLs + floor designations. Echo it back as a numbered list before starting extraction — it's much faster to fix order/scope mistakes here than after photos are downloaded.

## Stage 2: Extract data per property

For each CoStar URL, navigate the connected browser to the summary page and run the extraction script. See `references/costar-extraction.md` for the exact DOM patterns and the JavaScript snippets to inject; the short version:

- **Building-level fields** (address, city/state/zip, submarket, year built, RBA, max contiguous, building specs) come from the Summary tab.
- **The Space table** lists every available floor with code prefixes "P" (partial floor) or "E" (entire floor), available SF, use, and rent. Match the user's requested floors against this table by floor number — the table is the source of truth for SF and rent.
- **Rent normalization** — the example report uses `$/SF/MO` (e.g., $65). CoStar usually shows `Withheld` for confidential listings (use `TBD` in the report) or a per-SF rate with a service type (`/FS` = full-service, `/NNN` = triple-net). When a rate is disclosed, calculate monthly cost as `rate × SF`. When `Withheld`, use `TBD` for both `$/SF/MO` and monthly columns — that matches the example's convention.

Append each extracted property to `manifest.json` using the schema in `references/manifest-schema.md`. The manifest is the single source of truth that flows into the deck builder.

## Stage 3: Gather images

This is where most of the time goes. For each property, collect candidates from all three sources in parallel where possible.

### 3a. CoStar Images tab

Navigate to `/detail/lookup/{id}/images`. Image URLs follow the pattern `https://ahprd1cdn.csgpimgs.com/i2/{hash}/118/image.jpg` and each `<img>` element carries an `alt` attribute that classifies it: `Primary Photo`, `Building Photo`, `Aerial`, `Plat Map`, `Floor Plan`. The `/118/` size is the small thumbnail (~330×220); the `/116/` size token serves the high-resolution variant (~2048×1364) — always prefer `/116/` for the deck.

**Critical workaround — CoStar's CDN blocks all programmatic downloads from the product.costar.com origin.** Direct `fetch()` and `XMLHttpRequest` from inside the CoStar tab both fail with CORS / TypeError (Datadog RUM also wraps `fetch` and may interfere). The workspace shell's `curl` is also blocked by an outbound allowlist (`403 blocked-by-allowlist`). `<img>` elements load fine because the browser handles them natively without CORS, but `canvas.drawImage` taints the canvas so `toDataURL` throws SecurityError. `save_to_disk` on screenshots doesn't write to the agent workspace either.

**The working approach is the same-origin PDF tab trick:**

1. Call `mcp__cowork__request_cowork_directory` with `~/Downloads` (or wherever the user's browser saves downloads) to mount the Downloads folder so you can read what lands there.
2. On the per-suite Lease modal, click the `Marketing Brochure/Flyer` link. CoStar opens the brochure PDF in a new tab — that tab is now same-origin with the CDN (`ahprd1cdn.csgpimgs.com`).
3. From inside that PDF tab, `fetch()` of other CDN URLs (including image URLs like `/i2/{hash}/116/image.jpg`) works because CORS is no longer cross-origin. Trigger a real browser download via `Blob` + `URL.createObjectURL` + a programmatic anchor click — that lands the file in the user's Downloads folder.
4. Read the downloaded PDFs / images from the mounted Downloads folder via the workspace shell or `Read`.

Caveat: Edge / Chrome will show a "Allow this site to download multiple files?" prompt after the first download. The first file lands; subsequent downloads silently fail until the user clicks Allow. Ask the user to accept the prompt and confirm before triggering more downloads. Once allowed, you can batch the rest.

`scripts/download_costar_images.js` documents the same-origin fetch + blob-download pattern. Read it before injecting.

### 3b. Per-space listing pages

From the Space table, each row links to a per-suite detail page (when one exists) at `/detail/lookup/{id}/lease/{suite-id}` or similar. Click into the rows the user requested, and on those pages screenshot the photo gallery + scrape any captions in the suite description. Per-space pages sometimes have suite-specific interior photos that don't appear in the building Images tab.

### 3c. Broker marketing brochure

The Summary page has a `Documents` section with PDF links. The brochure is usually named after the property (e.g., `Townsend_260.pdf`). Download it to `brochures/<prop>.pdf`, then run `scripts/parse_brochure.py <pdf>` — it uses PyMuPDF to extract every embedded image and pair it with text drawn near the image's bounding box on its page. Extracted images go to `images/<prop>/candidates/brochure-<n>.jpg` along with a sidecar `brochure-<n>.json` containing `{"page": N, "nearby_text": "...", "bbox": [...]}`.

### 3d. Auto-label every candidate

After all candidates are collected, run `scripts/label_images.py` — it uses Claude vision to classify each image into the report's caption vocabulary. The vocabulary lives in `references/caption-vocabulary.md`; common entries include `EXTERIOR`, `RECEPTION`, `OPEN WORKSTATION AREA`, `OPEN BULLPEN`, `CONFERENCE ROOM`, `MEETING ROOM`, `LOUNGE`, `LOUNGE BAR`, `KITCHEN`, `KITCHEN / BREAK AREA`, `COLLABORATION LOUNGE`, `SOFT SEATING & COLLABORATION`, `TERRACE`, `TERRACE & OPEN AREA`, `WORKSTATIONS`, `OPEN FLOOR PLATE`, `INTERIOR / NATURAL LIGHT`, `FLOOR PLAN`, `BUILDING EXTERIOR`. The script writes labels back into `manifest.json`.

## Stage 4: Curation contact sheet

Build an HTML contact sheet at `<report-slug>/contact-sheet.html` using `scripts/build_contact_sheet.py`. It renders a thumbnail for every candidate per property with the auto-label and the source (CoStar / brochure / per-space page). The user **ranks** photos 1, 2, 3, 4+ (and a single floor plan). Slots 1–3 + floor plan become the main property page; slots 4+ flow onto gallery pages, four per page. Always import every selected tile into the manifest's `tiles` array — the builder paginates automatically.

Always show this contact sheet, even when the auto-labels look good. The user has visual taste that the model doesn't, and a wrong photo on a sales-oriented deck is more costly than a 60-second review. Tell the user what to expect: "I've put a contact sheet at `<path>` — mark 3 interior photos and 1 floor plan per property. The first three checkboxes per row will become the four photo tiles in the order they appear."

While the user reviews, draft the cover-page tagline and the matrix-page headline ("Eleven spaces, eight buildings" → adapt the count). The headline is `<count-words> spaces, <buildings-count> buildings` per the example. Format the matrix Notes column tags from the per-space data: anything labeled `Sublease`, `Plug & Play`, `Spec Suite`, `Spec Suite Underway`, `July 2026`, `Largest`, `Full Building`, `Signage`, `Value`, `Terrace` — see `references/badge-rules.md` for the trigger logic.

## Stage 5: Build the deck

Run `scripts/build_deck.py <report-slug>` to produce `space-report.pptx`. The script reads `manifest.json` and the curated images, then composes:

1. **Cover slide** (dark) — confidential-date banner, big serif location title with italic accent on a chosen short word ("South *of* Market"), tagline, "Prepared for [Client]" / "Prepared by Veracity Commercial Partners," logo.
2. **Matrix slide** (light) — stat row (`# buildings`, `# spaces`, sqft range), wide table with Property / Floor / Sq Ft / $/SF/MO / $/Month / Notes. Rent of `TBD` matches the example's convention. Notes column gets orange-bordered uppercase badge tags (see badge rules).
3. **Property pages** (light, one per space) — header strip with logo + property + floor (italicized) + sub-header tagging context (`SOUTH OF MARKET · SUBLEASE THRU APR 2027`). Top-right 3-stat box. Description with orange left-rule. 2×2 (or 1×3 + floor plan) photo grid; each tile has a small dark label badge in top-left. Footer: client + property count. If `virtual_tour_url` is set on the property, a clickable `VIRTUAL TOUR ↗` pill appears in the header.
4. **Virtual Tours summary slide** (light, optional) — emitted automatically when ANY property in the manifest has a `virtual_tour_url`. Layout matches the matrix: same header strip, italic-accent headline (`Walk the spaces virtually.`), and a table with Property / Floor / Tour Link. The URLs render as clickable hyperlinks in PowerPoint and PDF exports. Skipped silently if no tours exist.
5. **Closing slide** (dark) — "Let's walk the short list." + recommendation paragraph + Veracity contact card (220 Montgomery Street, Suite 430, San Francisco, CA 94104; veracitycommercial.com) + "Tenant representation. All day. Every day." italic tagline.

The exact dimensions, colors, fonts, and placeholder coordinates are in `references/deck-spec.md`. The script is fully deterministic from the manifest; if the deck looks wrong, the fix usually belongs in the manifest, not the script.

After producing the .pptx, open it (or instruct the user to) and walk through any spots that need fine-tuning. Common issues: an italic accent word that doesn't suit the title, a description that ran long and wrapped awkwardly, a photo that should be swapped. Each is a quick fix — re-run `build_deck.py` after editing the manifest.

## Content rules (always apply)

These rules govern what goes into the manifest, regardless of brand profile. Apply them while extracting and curating data — they're not optional polish.

### Aspect-ratio rule (never distort images, especially floor plans)

**No picture in the deck may be stretched or squashed.** Every floor plan, interior photo, brochure crop, and logo must be rendered at its native aspect ratio. If a source image doesn't match the tile's aspect, letterbox it (white/cream margins) — never deform it to fill.

This is non-negotiable for floor plans. Architectural drawings encode meaningful proportions: a 30 ft × 50 ft conference room read at the wrong aspect tells the prospect the wrong story about the space. Distorted floor plans are the single most common visual defect in tenant-rep decks; the eye picks them up immediately on a printout and undermines trust in the entire document.

The deck builder enforces this via the `add_picture_letterbox(slide, img_path, box_x, box_y, box_w, box_h)` helper in `scripts/build_deck.py`. **Use it for every floor plan and any other source image where cropping is unacceptable.** Do NOT call `slide.shapes.add_picture(..., width=W, height=H)` directly for floor plans — that command forces PowerPoint to stretch the picture to fill the box and silently distorts every plan whose native aspect doesn't match the tile.

Interior photos are the lone exception: they're cropped (cover semantics) to fit a fixed tile aspect, because a photo's content is the subject, not its framing. The cropping logic lives in `build_property_page` / `build_gallery_page` and is also aspect-preserving in the sense that proportions inside the crop are unchanged — it just removes pixels around the edges.

If you're adding a new image type to the deck, default to `add_picture_letterbox`. Use cropping only when you've decided that losing some pixels is preferable to a smaller picture inside white margins.

### Use every interior photo you can find

Don't curate down to four. The 4-tile main property page is just the headline; **paginate everything else onto gallery pages.** When you collect candidates from CoStar + per-suite listing pages + the broker brochure, deduplicate and put them all in the `tiles` array. The deck builder automatically renders the first four on the main property page (3 interiors + floor plan) and emits additional gallery slides — 4 photos each — for the rest.

In the contact sheet, ask the user to rank tiles 1–4 (which appear on the main page) but include all photos in the manifest's `tiles` array. The remaining photos appear on gallery pages in the order the user marked them.

### Sublease end date is mandatory

If a property's badge is `SUBLEASE`, the matrix `notes` text **must** contain the sublease end date in the form "through [Month Year]" (e.g., "Sub-lease through April 2027.") AND the `subhead_tags` array on the property page must contain `SUBLEASE THRU [MMM YYYY]` (e.g., `SUBLEASE THRU APR 2027`). Subleases are time-bounded and a tenant who shows up for a tour without knowing the end date will be frustrated. Pull the date from the broker brochure or the per-suite listing on CoStar; never publish a SUBLEASE without it.

If you can't find the end date in CoStar or the brochure, escalate to the user before generating the deck — don't fabricate or skip.

### Virtual tour discovery (mandatory)

For every space in scope, **check the per-suite Lease modal AND the broker brochure PDF for a virtual tour link**. Tours are rare but high-signal: a client can walk the floor in 5 minutes before deciding whether to schedule a physical tour. Missing them is a real cost.

**Where to look on CoStar:**
- Open the per-suite Lease modal, scroll to the **Space Notes** section. A `Virtual Tour:` line at the bottom of the notes is the most common pattern. The URL usually follows immediately on the same line.
- Look in the Documents section for files named `*virtual-tour*`, `*matterport*`, or `*3D*`.
- The broker's marketing brochure PDF (downloaded for image extraction) often has a Matterport QR code or `Tour:` URL on the cover or back page.

**Domains to look for in HTML/PDF text:**
- `matterport.com` (most common; Matterport SDK URLs look like `https://my.matterport.com/show/?m={hash}`)
- `asteroom.com`
- `kuula.co`
- Custom tour URLs sometimes embedded in `*-virtual-tour.com`, `tours.{broker}.com`

**Fast JS check from a CoStar lease tab:**
```javascript
({
  tourLinks: Array.from(document.querySelectorAll('a')).map(a => a.href)
    .filter(h => h && /matterport|asteroom|kuula|virtualtour/i.test(h)),
  tourText: (document.body.innerText.match(/(virtual\s*tour|matterport|asteroom)[^\n]{0,300}/gi) || [])
})
```
Iterate through each suite by clicking the lease row, running this snippet, then clicking the next-arrow in the modal header. Do this for every suite the user asked about. If nothing is found, record `virtual_tour_url: null` for that property.

**Where the tour goes in the deck:**
- On each property page, if `virtual_tour_url` is set, the deck builder renders a small clickable `VIRTUAL TOUR ↗` pill in the property page header (next to the address block) and adds `VIRTUAL TOUR` to the sub-header tags row automatically. The hyperlink works in PowerPoint and PDF exports.
- The deck builder also emits a **Virtual Tours summary slide** as the final slide of the deck — a single page listing every property/floor with a tour, the provider (Matterport, Asteroom, etc.), and the clickable URL. The slide carries a "Walk the spaces *virtually*." headline with the italic accent matching the rest of the deck. If no property has a tour, the summary slide is skipped.

Never silently drop a tour you found. Even if only one space in the shortlist has one, it earns its own summary slide — the tour delivers disproportionate value relative to the slide cost.

## Brand profile (multi-tenant)

This skill works for any tenant rep firm. The firm's identity lives in `brand-profile.json` — a single file containing logo, palette, fonts, contact details, and copy variants. The deck builder reads it on every run.

**Resolution order** (first match wins):
1. Explicit `--brand-profile <path>` passed to `build_deck.py`
2. `<report-root>/brand-profile.json`
3. `<report-root>/../brand-profile.json` (i.e. one level up — useful when one folder holds many reports for one firm)
4. The bundled `assets/brand-profile.example.json` (with a console warning — used only as a last-resort fallback)

**First-time setup for a new firm:**
- Run `python scripts/setup_brand.py` to walk through fields interactively, OR
- Copy `assets/brand-profile.example.json` to the firm's working folder and edit it directly.

**Schema:** see `references/brand-profile-schema.md` for the complete field list. The two fields that most differentiate firms are `palette.ink` (background of dark slides) and `palette.accent` (italic-accent words, badges, vertical rules). Most firms also customize `logo.main`, `service_label` (e.g. "TENANT REPRESENTATION" vs "ADVISORY & BROKERAGE"), and `copy.report_eyebrow` ("SPACE REPORT" vs "MARKET REPORT").

**The italic-accent treatment is the template's signature** — one short word per headline rendered in display-serif italic + accent color. This is hard-coded in the layout because it's what makes the deck recognizable. Everything else (colors, fonts, copy, contact details) flows from the brand profile.

## Reference files

Read these as needed; don't load them all up front.

- `references/example-report.pdf` — the visual target. Look at it whenever you're unsure how a slide should feel.
- `references/costar-extraction.md` — DOM selectors, URL patterns, and the JS snippets for pulling Summary / Space / Images / Documents data.
- `references/manifest-schema.md` — the JSON shape `build_deck.py` 