Skip to content

Scribe API Reference

Every session endpoint lives under /api/v1/sessions and identifies the visitor by the X-Session-ID header, falling back to the idx_session cookie. A request with neither is a 422. Endpoints are public and scoped to the brokerage that owns the request’s Host.

The thread has its own endpoints under /api/v1/sessions/scribe/, documented in Thread. Two public listing endpoints the full view reads are in Listings. The contact endpoint is /api/contact, outside the sessions prefix; it is documented near the end because Scribe’s connect form posts to it. Admin-side endpoints for the thread are in Admin.

Source: idx-api/src/idx_api/routers/sessions.py, routers/scribe_chat.py, routers/listings.py, routers/search.py, routers/leads.py and routers/brokerages.py.

GET /api/v1/sessions/scribe-status
X-Session-ID: 2a5538bb-...

Everything the sheet renders, in one call. progress is always present, ready or not. observations is empty until ready.

{
"ready": true,
"agent": {
"id": 6,
"name": "Josh Carothers",
"photo_url": "https://...",
"bio_snippet": "Designated broker with ...",
"job_title": "Designated Broker"
},
"progress": {
"properties_viewed": 3,
"properties_required": 3,
"minutes_elapsed": 24.5,
"minutes_required": 2.0,
"preference_count": 6,
"avg_confidence": 0.68,
"confidence_required": 0.5
},
"observations": [
{ "kind": "location", "text": "You keep coming back to Post Falls", "confidence": 0.9, "evidence_count": 4 },
{ "kind": "price", "text": "Most of what you opened was between $900k and $1.4M", "confidence": 0.7, "evidence_count": 5 },
{ "kind": "beds", "text": "Homes with 4+ bedrooms keep catching your eye", "confidence": 0.67, "evidence_count": 2 },
{ "kind": "disinterest", "text": "You skipped past three condominium listings", "confidence": 0.6, "evidence_count": 3 }
],
"preferences": {
"price_range": { "min": 900000, "max": 1350000, "confidence": 0.7 },
"beds": { "min": 4, "confidence": 0.67 },
"baths": { "min": 2.5, "confidence": 0.5 },
"location": { "city": "Post Falls", "confidence": 0.9 },
"property_type": { "value": "Residential", "confidence": 0.85 },
"sqft": { "min": 2467, "confidence": 0.55 },
"lot_size": { "min_acres": 6.33, "confidence": 0.5 },
"features": ["pool", "mountain_view"]
},
"negative_preferences": [
{ "type": "property_type", "value": "Condominium", "confidence": 0.6 }
],
"summary": {
"properties_viewed": 3,
"properties_revisited": 1,
"photos_engaged": 12,
"searches_performed": 2,
"time_spent_minutes": 24.5,
"preference_count": 6,
"avg_confidence": 0.68
},
"user_preferences": [
{ "type": "feature", "key": "home_office", "value": "Must have", "confidence": 1.0 }
],
"freeform_notes": "Near a school, room for a shop"
}
Field Notes
ready All gates met, or at least one confirmed preference exists. See readiness gates
agent The agent assigned to the session, or null. Present when ready
progress Always present. minutes_required is a float. For an unknown session every current value is zero and the three *_required values are the configured gates
observations At most 6, highest confidence first, [] until ready. kind is one of price, beds, baths, location, property_type, sqft, lot_size, feature, return_visit, disinterest. Confidence rounded to 2 places
preferences Present when ready. Keys absent when not inferred. price_range.min and .max are rounded to $1,000. beds.min and sqft.min are integers; beds.max appears beside min when the visitor gave a ceiling through the thread. baths.min is an integer when whole and a float otherwise. lot_size.min_acres is a float. features is a list of slugs. A value the visitor stated in the thread carries confidence 1.0 and its observation reads “You told me: …”
negative_preferences type is the attribute (property_type, price_bucket, feature), value the thing ("Condominium", "over_1m", "Fireplace")
summary Engagement counts. properties_viewed counts distinct listings
user_preferences Preferences the visitor added or confirmed. Returned whether or not ready
freeform_notes What the visitor typed in the notes box, or null. Returned whether or not ready so the sheet can prefill
Code Meaning
200 Status returned. An unknown session id is still 200 with ready: false
422 No session id on the request
GET /api/v1/sessions/suggested-agents

Up to three agents ranked by how closely their matching criteria line up with the session’s preferences. See agent ranking.

{
"has_preferences": true,
"suggested_agents": [
{
"id": 6,
"name": "Josh Carothers",
"photo_url": "https://...",
"bio_snippet": "Designated broker with ...",
"job_title": "Designated Broker",
"similarity_score": 0.813,
"match_reasons": ["Expert in Post Falls", "Specializes in $800,000-$1,500,000 range"]
},
{
"id": 4,
"name": "Joe Kroetch",
"photo_url": "https://...",
"bio_snippet": null,
"job_title": "Owner",
"similarity_score": 0.5,
"match_reasons": ["Primary agent for your brokerage"]
}
]
}

has_preferences is false, with an empty list, when the session is unknown or has no preferences and no primary agent. similarity_score is 0 to 1, rounded to 3 places; filler agents carry 0.5 (primary) or 0.3. match_reasons may be empty.

POST /api/v1/sessions/assign-agent
Content-Type: application/json
{ "agent_id": 6 }

Stores the visitor’s choice on the session. Later contact submissions from the same session use it even if they omit agent_id.

{
"status": "ok",
"assigned_agent": {
"id": 6,
"name": "Josh Carothers",
"photo_url": "https://...",
"job_title": "Designated Broker"
}
}
Code Meaning
200 Assigned
400 The agent is not an active, enabled agent of the session’s brokerage
404 No session with that id
GET /api/v1/sessions/preferences

Stored preferences (inferred and user-added), highest confidence first, excluding rejected ones. Unlike scribe-status, this returns the flat rows as stored.

{
"has_data": true,
"preferences": [
{ "type": "location", "key": "post_falls", "value": "Post Falls", "confidence": 0.9, "evidence_count": 4, "confirmed": false, "rejected": false },
{ "type": "price_range", "key": "budget", "value": "900000-1350000", "confidence": 0.7, "evidence_count": 5, "confirmed": false, "rejected": false }
]
}

An unknown session returns has_data: false with an empty list.

POST /api/v1/sessions/preferences
Content-Type: application/json
{ "type": "feature", "key": "home_office", "value": "Must have" }

Creates a user-added preference at confidence 1.0, or marks an existing inferred one as confirmed. The session is created if it does not exist yet.

Field Required Values
type yes price_range, beds, baths, location, property_type, feature, sqft_range
key yes The value for most types ("3", "Post Falls", "400000-600000"); the feature slug for feature
value no Free text, shown as the label
{ "status": "ok", "action": "created", "preference": { "type": "feature", "key": "home_office", "value": "Must have", "confidence": 1.0, "evidence_count": 1, "confirmed": true, "rejected": false } }

action is "created" or "updated". An unknown type is a 400.

POST /api/v1/sessions/preferences/{type}/{key}/confirm
Content-Type: application/json
{ "confirm": true }

true sets confidence to 1.0 and marks it confirmed; false marks it rejected with confidence 0. Inference never overwrites a confirmed or rejected row.

{ "status": "ok", "action": "confirmed" }
Code Meaning
200 Updated
404 No session, or no preference with that type and key
DELETE /api/v1/sessions/preferences/{type}/{key}
{ "status": "ok", "deleted": true }
Code Meaning
200 Deleted
400 The row was inferred, not user-added. Reject it instead
404 No session or no such preference
POST /api/v1/sessions/preferences/notes
Content-Type: application/json
{ "notes": "Near a school, room for a shop" }

Replaces the session’s freeform_notes (whitespace-trimmed). The sheet calls this before moving to the picker, before opening search, and before the connect form.

{ "status": "ok", "notes_saved": true }

404 when the session does not exist.

POST /api/v1/sessions/events
Content-Type: application/json
{
"events": [
{
"event_type": "property_view",
"path": "/listing/26-1234",
"listing_id": "26-1234",
"occurred_at": "2026-09-19T21:10:00Z",
"data": { "list_price": 950000, "beds": 4, "baths": 2.5, "sqft": 2600, "lot_size_acres": 5.1, "city": "Post Falls", "property_type": "Residential", "features": ["Fireplace"] }
},
{
"event_type": "page_exit",
"path": "/listing/26-1234",
"listing_id": "26-1234",
"occurred_at": "2026-09-19T21:12:30Z",
"data": { "time_on_page_ms": 150000, "scroll_depth_percent": 80, "photos_viewed_percent": 60 }
}
],
"replay_session_id": null
}

The session is created on first contact. Each event needs event_type, path and occurred_at (ISO 8601); listing_id and data are optional. The tracker batches with a five second debounce, flushes at 50 queued events, and uses sendBeacon on unload. Which fields inference reads from each event type is in Events read.

{ "status": "ok", "events_recorded": 2 }
GET /api/v1/sessions/stats

Counts by event type and preference type, first and last seen, conversion state. Intended for debugging. Returns { "found": false, "session_id": "..." } for an unknown session.

POST /api/v1/sessions/clear

Deletes every event, every preference, the thread and the session row. Browser storage (scribe_disabled, scribe_dismissed, scribe_seen_observations) is untouched.

{ "status": "ok", "message": "All session data cleared" }

Returns 200 with "No session data found" for an unknown session.

The thread under the notes. One conversation per session, stored in scribe_conversations and scribe_messages. Same session resolution and Host scoping as the rest of this page. Source: routers/scribe_chat.py, services/scribe_chat.py, services/scribe_tools.py.

GET /api/v1/sessions/scribe/chat
X-Session-ID: 2a5538bb-...
{
"enabled": true,
"model": "qwen3",
"messages": [
{ "id": 10, "role": "user", "content": "four or more bedrooms under a million", "cards": null, "choices": null, "created_at": "2026-09-20T20:14:12.100000Z" },
{ "id": 18, "role": "assistant", "content": "Here are a few homes in Coeur d'Alene and Post Falls that match your criteria. Which area would you prefer?", "cards": [ { "id": "26-9121", "address": "8509 Maple", "city": "Hayden", "price": 589900, "beds": 4, "baths": 3, "sqft": 2508, "lot_acres": 0.24, "photo_url": "https://...", "url": "/listing/26-9121", "lat": 47.7719, "lng": -116.7864 } ], "choices": { "kind": "city", "prompt": "Which area?", "options": [ { "label": "Coeur d'Alene", "value": "Coeur d'Alene", "count": 9 }, { "label": "Post Falls", "value": "Post Falls", "count": 4 } ] }, "created_at": "2026-09-20T20:14:54.386985Z" }
]
}
Field Notes
enabled false when the brokerage has turned the thread or Scribe off; the card then renders neither thread nor composer and POST would be 403. Platform hosts with no brokerage report true
model The effective model: the brokerage’s pick or the platform default
messages Visitor rows and Scribe’s reply rows only, oldest first. role is user or assistant. Rows the model used to call tools are never returned; the text it streamed in such a round is folded into the final reply row. cards is null or the list of cards shown with that reply, in the shape of the cards event. choices is null or the area buttons offered with that reply: the choices event without its type. Cards stored before the map shipped have no lat or lng keys; treat absent and null the same

An unknown session, or one with no thread, returns 200 with an empty messages list. GET never creates a session.

POST /api/v1/sessions/scribe/chat
Content-Type: application/json
X-Session-ID: 2a5538bb-...
{ "message": "we need five bedrooms and I'd rather stay under a million, what matches?" }

message is 1 to 2,000 characters. The session is created if it does not exist. The visitor’s row is stored before the model is called, so a failure still leaves it in the thread.

The response is application/x-ndjson with Cache-Control: no-store and X-Accel-Buffering: no: one JSON object per line, in this order. Lines marked “when” appear only if the named tool ran.

{"type":"status","state":"thinking"}
{"type":"tool","name":"update_notes","state":"running"}
{"type":"tool","name":"update_notes","state":"done"}
{"type":"notes_updated"} when update_notes ran
{"type":"tool","name":"search_listings","state":"running"}
{"type":"tool","name":"search_listings","state":"done"}
{"type":"cards","listings":[ card, ... ]} when search_listings ran; [] when it found nothing
{"type":"choices","kind":"city","prompt":"Which area?","options":[ option, ... ]} when the match was wide; always after its cards
{"type":"suggest_agents","agents":[ agent, ... ]} when suggest_agents ran
{"type":"agent_chosen","agent":{ "id", "name", "photo_url", "job_title" }} when choose_agent ran
{"type":"open_connect"} when open_connect ran
{"type":"delta","content":"Here"} many; qwen3 streams one token per line
{"type":"done","message_id":18} always last
Event Shape Notes
status state: "thinking" Always first
tool name, state: "running" | "done" Bracket each execution. Tools run one at a time; done for one precedes running for the next. name is one of update_notes, search_listings, explain_listing, suggest_agents, choose_agent, open_connect
notes_updated none The card refetches scribe-status so the sentences redraw. Precedes cards when both ran in one round
cards listings[] Card: id, address, city, price, beds, baths, sqft, lot_acres, photo_url, url (/listing/{id}), lat, lng. Never a closing price. address is "Address withheld" when the listing hides it, and lat and lng are null in that case so the map draws no pin for it; they are floats otherwise. The six cards are picked across cities from a wider page, see the spread
choices kind, prompt, options[] Quick replies for the reply. kind is city; prompt is "Which area?"; each option is { "label", "value", "count" } with value the exact city string on the listings and count the number of matching homes there. At most six, largest first, never a blank city. Sent only when the filter search matched more than twelve homes in more than one city (the gate). The card posts the tapped value as the visitor’s next message; the reader drops a malformed option rather than the event, and drops the event when prompt is not a string or options is not a list
suggest_agents agents[] Same item shape as suggested agents: id, name, photo_url, job_title, bio_snippet, similarity_score, match_reasons
agent_chosen agent id, name, photo_url, job_title. The session’s assigned_agent_id is set
open_connect none The card opens the connect form. No lead is created by the thread; /api/contact does that
delta content Appended to the reply. Text is held back while it could still be a tool call written as JSON (empty so far, or starting with { or a code fence) and released once it cannot be, so a reply that begins with { arrives late and in one piece. A round that streamed nothing is retried once without streaming; its text then arrives as one delta
error message "Scribe could not answer just now.", followed by done. A reply row with that text is stored, so done.message_id is real and a reload shows what the bubble showed; it is null only if that write failed twice
done message_id The id of the stored reply row, the same id GET returns

Closing the response early (the card’s Stop button, a closed tab) does not cancel the turn. It runs to completion on the server and stores the full reply, so a reload after Stop shows the whole answer rather than the partial one.

Code Meaning
200 Stream started. Failures inside the turn arrive as an error event, not a status code
403 {"detail": "The Scribe thread is off for this site"}. The brokerage has scribe_chat_enabled or scribe_enabled false
422 No session id, or message empty or over 2,000 characters
429 {"detail": "That is a lot of questions for one hour. Give it a little while."} with Retry-After: 600. The session has sent SCRIBE_CHAT_MAX_TURNS_PER_HOUR messages in the last rolling hour. The message is not stored
DELETE /api/v1/sessions/scribe/chat

Deletes the conversation and its messages. The notes stay; Clear the session removes both.

{ "status": "ok", "deleted": true }

deleted is false when there was no session or no thread. Never a 404.

GET /api/v1/sessions/scribe/results
X-Session-ID: 2a5538bb-...

The thread’s most recent search, run again. The conversation stores what was searched (scribe_conversations.last_search: {filters, query, sort, total, at}), never the rows, so this goes back through the public listings handler, or the semantic handler when the search had words, with the request’s own Host and the live display policy. The full view’s homes pane reads it so the grid and the map show the whole match rather than the six cards. Response carries Cache-Control: no-store.

{
"searched_at": "2026-09-20T20:14:54.386985Z",
"filters": { "city": "Coeur d'Alene", "min_beds": 4, "max_price": 1000000 },
"query": null,
"total": 9,
"cities": [ { "value": "Coeur d'Alene", "count": 9 } ],
"listings": [ card, ... ]
}
Field Notes
searched_at When the thread ran the search, or null when it never has
filters The filters the search used, after any relaxation. {} when nothing was searched
query The words on the semantic path, else null
total The whole match. On the filter path it comes from the facets query below, so it is the true count, not the page length
cities Named city buckets over the whole match, largest first, { "value", "count" }. The blank-city bucket is left out here; /api/listings/facets still returns it. On the semantic path the buckets are counted over the rows returned, since that handler has no facets
listings Up to 48 cards in the cards shape, in the search’s own sort (newest unless the model asked otherwise; rank order on the semantic path)

A session with no thread, or a thread that has not searched, is 200 with { "searched_at": null, "filters": {}, "query": null, "total": 0, "cities": [], "listings": [] }. The search runs on every call, so budget a few hundred milliseconds; the card fetches it when the full view is open and the stream is idle, once per turn. 422 without a session id.

For anyone reading the stream or the stored rows: the system prompt is rebuilt from the live notes on every turn, the last SCRIBE_CHAT_HISTORY_MESSAGES stored rows go back to the model, and up to SCRIBE_CHAT_MAX_TOOL_ROUNDS rounds may call tools before a final round is forced with no tools. A repeated call with identical arguments in one turn is refused with a tool answer and forces the final round. Each tool and each gateway call runs under SCRIBE_CHAT_TIMEOUT_SECONDS; a timeout becomes a plain tool answer, not a crash. Per turn the stored rows are: the visitor’s, then per tool round one reply row carrying tool_calls and one tool row per call, then the final reply row with content, cards, choices and latency_ms. A tool may emit several events; the loop forwards each and keeps the last cards and the last choices of the turn for the stored row, and a cards event clears any earlier choices since choices always follow their cards. Every search also rewrites last_search on the conversation. Only the visitor’s rows and the final reply rows count toward message_count and are returned by GET or the transcript.

Two public listing endpoints the full view calls directly. Both are scoped by Host and the per-feed display policy like /api/listings; neither needs a session id.

GET /api/listings/facets?min_beds=4&max_price=1000000

Every parameter GET /api/listings accepts, including the spatial ones (center_lat, center_lng, radius_miles). Returns how many listings match and how they split by city, from one GROUP BY p.city with a window sum over exactly the scope /api/listings would read for the same parameters, so a caller never pairs a total from one query with buckets from another.

{
"total": 29,
"cities": [
{ "value": "Coeur d'Alene", "count": 9 },
{ "value": "Post Falls", "count": 4 },
{ "value": "Hayden", "count": 3 },
{ "value": null, "count": 1 }
]
}

Buckets are ordered by count, then value, with a null value last for rows the feed left without a city; callers decide whether to show it. A disallowed status or a feed set with nothing enabled returns { "total": 0, "cities": [] } without running SQL. The card calls this beside /api/listings?page_size=24 for “Show me homes that match”, so the pane’s header counts the whole match rather than the rows shown; the thread’s search_listings calls the same handler in-process for its spread and its chips.

POST /api/search/semantic now selects latitude, longitude and internet_address_display_yn on both of its branches and passes every row through the same scrub_suppressed_address that /api/listings uses. A listing whose seller withheld the address comes back with a blank street and no coordinates, on this path as on the filter path. Before this, the words path of the thread was the one route that could have leaked a street the seller had asked not to show. Nothing else about the response changed.

GET /api/v1/admin/scribe-chat/models
Authorization: Bearer <token>

The allowlist the brokerage editor’s model picker offers. Any signed-in role; 401 without a token.

{ "default": "qwen3", "models": ["qwen3", "qwen3.8", "gemma4", "llama3.3"] }

default is always present in models even if SCRIBE_CHAT_MODELS left it out.

GET /api/v1/admin/leads/{lead_id}/scribe-conversation
Authorization: Bearer <token>

The thread linked to a lead, for the agent who picked it up. Scoped through services/lead_scope.py like the timeline: agents see their own leads, brokers and owners their brokerage, admins all.

{
"conversation_id": 2,
"model": "qwen3",
"messages": [
{ "id": 10, "role": "user", "content": "...", "cards": null, "created_at": "..." },
{ "id": 18, "role": "assistant", "content": "...", "cards": [ { "id": "26-9233", "address": "576 Turner Hill", "city": "Bonners Ferry", "price": 989500, "beds": 5, "baths": 3, "sqft": 2664, "photo_url": "https://...", "url": "/listing/26-9233" } ], "created_at": "..." }
]
}

model is null for a conversation created before its first turn. messages is the same visible-rows list as GET /api/v1/sessions/scribe/chat, choices included; the lead page’s transcript does not render the buttons.

Code Meaning
200 Transcript returned
404 "No Scribe conversation for this lead". Also returned for a lead outside the caller’s scope, so the two cases are indistinguishable by design

The lead page’s card renders nothing on 404.

On GET, POST and PATCH /api/admin/brokerages/{id}, beside scribe_enabled and scribe_display_name:

Field Type Notes
scribe_chat_enabled bool Default true. false hides the thread and makes POST .../scribe/chat a 403
scribe_chat_model string | null Must be in the allowlist or null for the platform default. An empty string is stored as null. Anything else is a 422 whose message contains must be one of

GET /api/public/site-config carries the result as scribe.chat, which is scribe_enabled AND scribe_chat_enabled. See Configure Scribe.

Environment variables on the API service, read at container creation. The gateway itself is the vision gateway (GPU_BASE_URL, GPU_API_KEY); no think field is ever sent to any model.

Setting Environment variable Default Meaning
scribe_chat_model SCRIBE_CHAT_MODEL qwen3 The model used when a brokerage has not picked one
scribe_chat_models SCRIBE_CHAT_MODELS qwen3,qwen3.8,gemma4,llama3.3 Comma-separated allowlist the brokerage picker offers. The default is added if missing
scribe_chat_max_tool_rounds SCRIBE_CHAT_MAX_TOOL_ROUNDS 3 Rounds that may call tools before a final answer is forced (1 to 10)
scribe_chat_timeout_seconds SCRIBE_CHAT_TIMEOUT_SECONDS 90 Per gateway call and per tool execution
scribe_chat_history_messages SCRIBE_CHAT_HISTORY_MESSAGES 30 Stored rows (visitor, reply, tool) handed back to the model each turn
scribe_chat_max_turns_per_hour SCRIBE_CHAT_MAX_TURNS_PER_HOUR 40 Visitor messages one session may send in a rolling hour before 429
POST /api/contact
Content-Type: application/json
X-Session-ID: 2a5538bb-...
{
"firstName": "Sam",
"lastName": "Rivera",
"email": "sam@example.com",
"phone": null,
"subject": "buying",
"message": "I've been looking at 4+ bed homes in Post Falls, around $900k to $1.4M...",
"source_page": "/listing/26-1234",
"agent_id": 6,
"source": "scribe"
}

The brokerage comes from the request’s Host. Two fields exist for Scribe:

Field Type Effect
agent_id int, optional The agent the visitor chose. If it names an active, enabled agent of the resolved brokerage, the lead is assigned to that agent directly and lead routing is skipped. If absent, and the X-Session-ID session carries an assigned_agent_id, that agent is used with the same checks.
source string, optional, max 30 "scribe" when sent from the sheet. Stored on sessions.conversion_type and clients.source (both default to "contact_form"), and in the lead_received timeline payload.

Assignment on the timeline:

Case agent_assigned payload
Visitor’s pick used { "assigned_agent_id": 6, "auto_routed": false, "chosen_by": "visitor" }
Routed by the brokerage’s mode { "assigned_agent_id": 4, "auto_routed": true }

A chosen agent who has since gone inactive or been disabled is not an error: the lead is routed normally and a warning is logged. A session whose stored assignment belongs to another brokerage is also routed normally, since the visitor did not send it.

The session’s freeform_notes, if any, are written to leads.notes as "From browsing session:\n..." and appended to the client’s notes.

If the session has a thread, scribe_conversations.lead_id is set to the new lead so the transcript endpoint can find it. When source is "scribe", the last six visible lines are also appended to leads.notes as "From the Scribe thread:\n...", each line prefixed Visitor: or with the brokerage’s Scribe display name and truncated to 300 characters. Neither step can fail the submit.

{ "success": true, "message": "Thank you ...", "lead_id": 68 }
Code Meaning
200 Lead created
400 agent_id was sent explicitly and is not an agent of this brokerage ("That agent is not part of this brokerage"). No lead is written.
400 Missing Host header
404 The host is not a configured brokerage domain and no valid brokerage_id fallback was given
422 Body validation failed (for example an invalid email)

Other fields (utm_*, brokerage_id, source_page) are described in Leads.