Nora search
Brokers describe a client’s requirements in plain language. Nora turns them into editable criteria, the Broker Tool searches live listings over MCP, and every result says why it’s there. Clickable prototypes of the target UX, plus the contracts and rules to build it.
The request we design for“I’m looking for 5,000–7,000 square feet around Union Square, Flatiron, or NoHo. It needs to be a furnished full-floor opportunity. The client prefers a three-year term.”
Nora interprets. The Broker Tool decides.
Plain language in, criteria out
The request becomes a structured criteria object the broker can see and edit before trusting any result.
Must-haves vs nice-to-haves
Every criterion is tagged required or preferred. Required filters; preferred only ranks. The broker can flip either.
One source of truth
Nora never filters or stores listings. It calls search_spaces as the broker, with the broker’s permissions.
Exact and near, never blurred
Two separate groups. Near matches name exactly which requirement they miss. Requirements are never relaxed silently.
Unknown is not a match
Missing, unverified or stale fields show as unknown. Only verified values can satisfy a requirement.
Explainable ranking
Fixed point rules, the same every search, visible to the broker. Nora’s explanation is built only from returned fields.
Desktop
Each frame is the live prototype. Click inside to use it, or open it full size.
get_space_details. Each field shows verified, reported or unknown. Open full sizeMobile prototype
Each phone is live: type a request, edit criteria, tap a result. Scroll sideways for all four.
Build notes
Flow
- Ask. The broker types a request on the dashboard and starts a search.
- Interpret. Nora returns a criteria object, splitting requirements from preferences. The UI renders it as editable controls.
- Search. The server calls
search_spaceson the Broker Tool MCP server as the broker. - Show. The dashboard renders exact matches, then near matches, each with checks, key fields and a one-line reason. Editing a criterion re-runs step 3 only.
Criteria contract
Nora’s only output. The model returns structured output only; the server validates it against the schema and checks every neighborhood name against the Broker Tool’s list before searching. Listing data never goes into the interpretation prompt.
{
"rsf": { "min": 5000, "max": 7000, "required": true },
"full_floor": { "value": true, "required": true },
"furnished": { "value": true, "required": true },
"neighborhoods": { "values": ["Union Square", "Flatiron", "NoHo"], "required": false },
"term_months": { "min": 36, "max": 36, "required": false },
"statuses": ["Active"], "include_near_matches": true, "limit": 10
}
| Criterion | Default when the request doesn’t say | Becomes required on | Becomes preferred on |
|---|---|---|---|
| Size | Required. A single figure becomes ±10%. | “needs”, “must”, “at least” | “ideally”, “prefers” |
| Full floor | Required | “needs to be”, “must” | “would like”, “nice to have” |
| Furnished | Required. Plug-and-play and turnkey count. | “needs”, “must” | “prefers”, “ideally” |
| Neighborhoods | Preferred, ordered by mention. First listed is the first choice. | “must be in”, “only in” | “around”, “near” |
| Term | Preferred | “must”, “non-negotiable” | “prefers”, “ideally” |
Anything Nora can’t map to a field is returned in unparsed and shown to the broker, never guessed into a filter.
Match rules (first match wins)
- Excluded: status is not in
statuses(default Active only), or the broker can’t see the listing. - Exact: every required criterion is met, and the listing was verified within 60 days.
- Near: one or two required criteria are missed or unknown, and size, if missed, is within 15% of the range.
- Not shown: everything else.
Preferred criteria never decide which group a listing is in. They only rank within a group.
Field states
| State | Meaning | Against a requirement | UI |
|---|---|---|---|
| verified | Confirmed in the listing record | met or missed | Value, green or red check |
| unverified | Reported by the listing broker, not confirmed | unknown | “Listed as furnished, not verified” |
| unknown | Not in the record | unknown | “Unknown”, dashed grey |
| stale | last_verified older than 60 days | blocks exact | “Verified 83 days ago, over 60-day limit” |
Ranking
Computed in the Broker Tool so every client gets the same order. Score is points earned over points available, 0–100. Ties go to the most recently verified listing. Near matches sort by fewest gaps first, then score.
| Signal | Points | Applies when |
|---|---|---|
| Area preference | 1st choice 40, other listed area 30, else 0 | Neighborhoods are preferred (20 / 14 if required) |
| Term preference | 20 if the ranges overlap | Term is preferred |
| Furnished / full floor | 15 each | Either is preferred |
| Size fit | Up to 15, highest at the middle of the range | Range has min and max |
| Recency | 10 if ≤14 days, 6 if ≤30, 3 if ≤60 | Always |
MCP tools
Both are read-only and idempotent. Full JSON Schemas are in mcp/tools.json; real request and response pairs from the mock are in mcp/examples/.
search_spaces(criteria) →
{
"searched_at": "2026-10-21T21:14:00Z",
"listings_scanned": 21,
"excluded_by_status": 1,
"exact_matches": [ { "listing": {…}, "match": "exact", "checks": [...], "score": 99, "score_parts": [...] } ],
"near_matches": [ { "listing": {…}, "match": "near", "checks": [...], "score": 74 } ]
}
listing.furnished = { "state": "verified" | "unverified" | "unknown", "value": true | false | null }
check = { "key": "furnished", "required": true, "result": "met" | "missed" | "unknown", "detail": "…" }
get_space_details({ "id": "L-1115" }) → full record + asking_rent_psf, building_class,
ceiling_height, listing_broker, notes
error: not_found (missing or not visible)
Server routes
Two routes on the current Next.js app. Editing criteria calls only the second, so the model isn’t re-run on every change.
POST /api/nora/interpret
{ "input": "I'm looking for 5,000–7,000 square feet around…" }
200 { "criteria": { … see contract … }, "unparsed": [] }
POST /api/nora/search
{ "criteria": { … }, "request_id": "uuid" }
→ calls search_spaces over MCP with the broker's session
200 { "result": { …search_spaces output… },
"summary": "6 listings meet every requirement…",
"reasons": { "L-1041": "Meets every requirement. It's in Union Square…" } }
Summaries and per-listing reasons are templated from checks and returned fields (see explain() in src/nora.js). If a model rewrites them later, validate that every figure and field it mentions appears in the result.
Permissions and data
- The MCP call carries the broker’s identity. The Broker Tool applies its existing visibility rules; Nora adds none and bypasses none.
get_space_detailsruns the same check, so a link to a result can’t leak a listing.- Results are not cached across brokers. Re-running a search always hits the Broker Tool.
- Rent and lease economics stay out of model prompts.
Acceptance
| MVP criterion | How it’s met | Test |
|---|---|---|
| Start from natural language | Ask Nora on the dashboard, New Search, Nora nav item | Interpretation suite |
| Editable criteria | Criteria panel; each row toggles on and must/nice; edits re-search | Manual |
| Current, clickable, ranked | Live MCP call with timestamp; cards open get_space_details; fixed ranking | “ranking is deterministic” |
| Exact vs near distinct | Separate sections, solid vs dashed cards, gaps in red or grey | “near matches state their gaps” |
| Never invents details | Unknown and unverified never count as met; reasons built from fields | Matching suite |
Run node tests/engine.test.js (Node 18+, no installs). Port the same cases to the real server’s test suite.
Edge cases
- Nothing understood: no search runs; Nora asks for size, area, floor or term.
- No exact matches: say so first, then near matches. Never move a near match into the exact group.
- Neighborhood not in the Broker Tool’s list: show it in
unparsedwith “not a known area”. - Listing changes between search and open: details reflect the new record; if it’s gone or no longer visible, say so and offer to re-run.
- MCP timeout or error: keep the criteria, show the error and a retry. Don’t show stale results as current.
Phases
search_spaces and get_space_details, field states, match rules, ranking, permission checks, tests./api/nora/interpret with structured output, schema validation, neighborhood validation, unparsed.Files in this package
screens/Prototype.htmlThe clickable dashboard + Nora prototype
src/broker-tool-mock.jsMock MCP server: inventory, match rules, ranking
src/nora.jsRequest interpretation and explanations
src/app.jsDashboard and Nora UI
mcp/tools.jsonTool definitions with JSON Schemas
mcp/examples/Real request and response pairs
tests/engine.test.js16 rule tests, no dependencies
README.mdHow to run and what’s mocked