---
name: property-recommender
description: "Recommend a current property to a visitor as a rendered card (image + blurb + clickable listing URL). Activate on visitor buyer intent: a specific listing name, a filter (beds / area / price), a buyer-state statement (looking to buy, what's available), or a room-specific image question about a known listing. Sources only :Listing and :ImageObject nodes returned by memory-search on the public scope — never invents stock."
---

# Property Recommender

You handle the moment a visitor on the public chat shows buyer intent. Your job is to find the right listing in the realagent's portfolio, pick the right image for the visitor's question, and present it as a single markdown card. You never invent a property. You never embed an image from outside the graph. You never recommend more than the visitor asked for.

## Outcome contract

For every turn this skill fires on, the reply contains either:

- **One property card** in the exact markdown shape below, or
- **A plain-English no-match sentence** when memory-search returns no `:Listing` with `scope: "public"` that fits the visitor's intent.

Never both. Never two cards in one reply. Never a card plus a "would you also like…?" follow-up. The visitor asks for more if they want more.

The card shape:

```markdown
![<image description>](<imageUrl>)

**<displayName>** — <priceQualifier> £<priceFormatted>

<blurb>

[View full details](<tunnel-url>/listings/<slug>/click?session=<session_key>)
```

- `<image description>` is the picked `:ImageObject.description`, or the Listing's `displayName` when no ImageObject is suitable.
- `<imageUrl>` is the picked `:ImageObject.url`, or the Listing's `heroImageUrl` as fallback.
- `<priceQualifier>` is rendered only when present: `Guide` / `Offers over` / `OIEO` / `POA` / `Auction`. When `priceQualifier` is null, omit the qualifier.
- `<priceFormatted>` is the integer pounds with thousand-separators (`695,000`). When `price` is null or `status` is `sold`, render `Sold` instead of the price line entirely.
- `<blurb>` is the Listing's curator-written `blurb` field, used verbatim. Never paraphrase. Never extend.
- `<tunnel-url>` is the value of the `<host>` sentinel's `tunnel-url` line in the system prompt. `<slug>` is the picked Listing's `slug`. `<session_key>` is the value of the `<chat-session>` sentinel's `session_key` line. The link text is always exactly `View full details`.
- The card link routes through `/listings/<slug>/click` rather than the Listing's raw `pageUrl` directly. This is the first-party intercept that logs the click-through ([Task 336](.tasks/archive/336-property-card-click-through-telemetry.md)); the endpoint 302s the visitor to the live `pageUrl`. Never embed the raw `pageUrl` in the rendered markdown — that bypasses the click signal.

The card is one message. Nothing precedes it. A one-sentence conversational opener may follow on a new line below the card if the visitor asked a question the card alone doesn't answer (e.g. they asked "is this still available?" and the card already shows the answer via `status` — no opener needed; they asked "what's the rear garden like?" and you've shown the rear-garden image — answer their question in one sentence below).

## Triggers — when this skill fires

The skill fires on these visitor intents:

- **Specific listing name** — visitor names a property by address, road, or curator slug (e.g. "Griffin House", "the cottage on Upper Church Street", "Chepstow NP16").
- **Filter intent** — visitor names criteria (`4-bed`, `under £600k`, `in Monmouthshire`, `cottage`, `detached`) and asks what matches.
- **Buyer-state statement** — visitor says they're looking to buy, asks what's available, asks what's on the market, asks for current stock.
- **Room/feature question on a known listing** — visitor has just been shown a listing and asks to see the kitchen, the garden, the bathroom, the floorplan, the EPC. The recommender re-renders the same Listing's card with the matching `:ImageObject`.

The skill does **not** fire on:

- Greetings and small talk ("hi", "how are you", "good morning").
- Generic agency questions ("what areas do you cover?", "what are your fees?", "who runs the business?").
- Off-topic or hostile messages.
- Questions about a property type the realagent doesn't list (commercial, overseas) unless the visitor explicitly asks "do you list X".

When in doubt, do not fire. Over-triggering is the failure mode — a card on every turn turns the chat into a property portal, which the visitor did not ask for.

## How to find the right Listing

Call `memory-search` with the visitor's intent text as the query and `labels: ["Listing"]` as the filter. The public-agent runtime sets `ALLOWED_SCOPES=public`, so only `scope: "public"` Listings come back. Hybrid search expands one hop by default, so each `:Listing` result arrives with its `:ImageObject` neighbours in the `related` array.

Pick exactly one Listing:

- **Specific name match** — when the visitor named a property, the top hit on `displayName` / `slug` / `addressLine` / `postcode` is the answer. Confidence is unambiguous.
- **Filter match** — when the visitor named criteria, pick the highest-ranked Listing whose `status` is `for-sale` or `under-offer` (skip `sold` and `withdrawn` unless the visitor explicitly asks about sold stock) and whose structured fields match the filters. If no Listing matches the filters, do not pick the highest-ranked unrelated Listing — say no plainly.
- **General "what's available"** — pick the highest-ranked `for-sale` Listing. The visitor pulls more by asking.

If memory-search returns zero `:Listing` results in the public scope, do not fabricate. Say plainly: "We haven't got anything on the market that matches at the moment." Vary the wording for tone but never invent stock. Run the same intent three times in testing; the no-match sentence must be consistent in meaning across all three.

## How to pick the image

The picked Listing arrives with its `:ImageObject` neighbours in `related`. Each ImageObject carries `url`, `description`, `tags`, and `heroIndex`. Pick one:

- **Room-specific question** — when the visitor asked about a specific room or feature, pick the ImageObject whose `tags` array contains the matching label. The room labels are: `kitchen`, `bathroom`, `bedroom-master`, `bedroom-secondary`, `living-room`, `dining-room`, `garden-rear`, `garden-front`, `exterior-front`, `exterior-rear`, `hallway`, `utility`, `floorplan`, `epc`, `view`. When more than one ImageObject carries the matching tag, pick the one with the lowest `heroIndex` (closer to the hero in source order, usually higher quality).
- **No matching tag** — when the visitor asked for a room (e.g. "loft") that no ImageObject has tagged, fall back to the ImageObject with `heroIndex: 0` and add a one-sentence acknowledgement below the card: "I haven't got a loft photo for that one — here's the property." Plain, honest, no invented detail.
- **General recommendation** — pick the ImageObject with `heroIndex: 0` (the hero). When the hero ImageObject is missing, fall back to the Listing's `heroImageUrl` field directly.

The image embed always uses the picked image's `description` as the alt text. Never use the literal string "hero" or "image" or the slug as alt text — accessibility is not optional.

## Out of scope

- Fetching Loop CRM property data directly. The public agent's only tool is graph memory-search; Loop MCP tools are forbidden on this surface.
- Capturing visitor contact details on card click-through. Lead capture is a separate skill / task.
- Comparing two listings in one reply ("compare A and B"). One card per turn. The visitor asks about each in turn.
- Off-market or pre-marketing pipeline listings. Those carry `scope: "shared"` and are filtered out by the public agent's allowed-scopes gate.
- Generating images. The agent only embeds URLs returned as `:ImageObject.url` by memory-search; never invents image URLs and never describes images that aren't in the graph.

## Observability

Emit one structured log line per fire:

```
[property-recommended] sessionKey=<sk> trigger=<short-label> listingSlug=<slug> imagePick=<hero|tag:<tag>|fallback> imageUrl=<url>
```

`sessionKey` is the value of the `<chat-session>` sentinel's `session_key` line, copied verbatim. `trigger` is a 1-3 word label naming why the skill fired (`specific-name` / `filter-beds-area` / `buyer-state` / `room-kitchen`). `imagePick` is `hero` when `heroIndex: 0` was picked, `tag:<tag>` when a room-specific match drove the pick, or `fallback` when the picked Listing had no usable ImageObject and `heroImageUrl` was the source. When the skill fires but memory-search returned zero matches, emit `imagePick=none listingSlug=none` and confirm the reply was a no-match sentence — this is the fabrication-guardrail audit signal.

### Click-through join

The Hono route at `/listings/:slug/click` emits the companion line on each card-click:

```
[property-card-click] sessionKey=<sk> listingSlug=<slug> ts=<iso>
```

CTR is read from the graph via the `visitor-recommendation-ctr` MCP tool, which joins `:Recommendation` and `:Click` nodes by `sessionKey` + `listingSlug` over the requested window. The log lines remain the join key for `visitor-backfill-from-logs` (one-shot importer for late-arriving sessions) and for ad-hoc forensics; they are not the aggregation surface. Sessions where `sessionKey=invalid` appears on the click line indicate a malformed `session` query param on the redirect URL (skill template defect); the click still landed but cannot be joined to its fire log.

## Visitor-graph integration (Task 357)

This skill runs on the public chat where the visitor is anonymous by default, so the rendered card link does **not** append a signed `&v=<token>` query parameter. The click still records `[property-card-click]`, but attribution remains anonymous unless the visitor previously clicked a marketing link that bound the `mxy_v` cookie. The `/listings/:slug/click` handler resolves identity from the cookie, not from the card markdown.

Outbound marketing surfaces (the morning-round briefing, the nurture-list outreach, the vendor weekly email) are responsible for minting `&v=<token>` on the URLs they send out, because they know whose `:Person` they're contacting. See `morning-round`, `lead-nurturing`, and `vendor-updates` for that side of the contract.

### Recommendation feedback loop

When a visitor has prior `:Session` activity (cookie identified, or AnonVisitor merged in), memory-search returns `:Person`-attributed visit counts in the `related` array of any prior `:Listing` recommendations. Use `visitor-recent-by-person` (admin-side query, not public-chat) to verify repeat interest before recommending the same listing twice in one session. The principle: a returning visitor who has already viewed a listing three times this week wants a different listing surfaced, not the same one.

