Real estate agent on Deep Agents.
An orchestrator that delegates to four specialists — property search, market
analysis, document review, and client communication — each with its own tools,
its own context window, and a shared /workspace/ filesystem.
cp .env.example .env # add your ANTHROPIC_API_KEY
uv sync # Python 3.14, pinned by .python-version
uv run python main.py "Find 3-bed homes in Hilo under $600k"Interactive mode:
uv run python main.pyConversations are checkpointed to workspace/checkpoints.db, so a thread can be
picked up in a later run. Every invocation prints its thread id:
uv run python main.py --thread 8f2c1e04-... # continue where you left offuv run streamlit run streamlit_app.pyTwo pages over the same project:
- Chat — the browser analogue of
main.py. Drives the real orchestrator, streams each delegation and tool result, and lists what the specialists wrote to/workspace/. History comes from the checkpoint database rather than browser state, so a thread id printed by the CLI can be pasted in and resumed here, and vice versa. The approval gate is a sidebar toggle and fails closed like the CLI's: a thread paused mid-save_draftrefuses a new turn until the toggle is back on. - Market — supply, pricing and absorption over the listings provider. No
model runs, no API key. Its headline numbers come from the agent's own
market_statisticstool rather than a second implementation, so the dashboard cannot disagree with what the analyst was told.
Three things to know before editing either page:
- Theming is
.streamlit/config.toml— native config, no CSS. It defines light and dark, so the settings-menu mode switch keeps working. - The chat sidebar's workspace browser is an
st.fragment, so picking a file does not re-read the checkpoint or re-render the conversation. - Tool-result panels and the file preview render only when opened. A
collapsed
st.expanderships its body otherwise, so a long thread re-sent every tool call's JSON every turn.
orchestrator (write_todos + task)
├── property-search search_listings, get_listing
├── market-analyst find_comparables, market_statistics,
│ search_listings, get_listing → skills/cma-analysis
├── document-reviewer list_documents, extract_document_text → skills/document-review
└── client-liaison qualify_lead, save_draft → skills/client-comms
The orchestrator holds no domain tools. It plans with write_todos, delegates
with task, and synthesises what comes back. Specialists share the filesystem
but not each other's context, so a long document review can't crowd out the
shortlist the buyer agent is maintaining.
market-analyst is the only specialist with two tool groups (subagents.py
passes it market_tools + listing_tools), so it can look up a subject property
itself instead of round-tripping through the orchestrator for an id it was
already given.
| Path | Purpose |
|---|---|
src/real_estate_agent/agent.py |
Orchestrator assembly, backend, permissions |
src/real_estate_agent/subagents.py |
The four specialist definitions |
src/real_estate_agent/tools/ |
Tool groups, each bound to a data source |
src/real_estate_agent/providers/ |
ListingsProvider protocol + mock feed |
skills/ |
Progressive-disclosure methodology, loaded on demand |
workspace/ |
Agent scratch space (gitignored) |
streamlit_app.py, app_pages/, ui/ |
The web UI — a consumer of the package, like main.py |
.streamlit/config.toml |
Theme, in both light and dark |
The default data source is a deterministic mock, not a real feed. The agent runs end-to-end today, but every listing is synthetic.
ListingsProvider (providers/base.py) is the only thing the tools depend on.
Implement search, get, and comparables against your feed and pass it in —
no tool, subagent, or prompt changes:
from real_estate_agent import build_agent
agent = build_agent(provider=MyMlsProvider(api_key=...))The mock models two deliberately different Hawaii markets (Honolulu 96815/96816 at $566k–$1.69M active, median $1.04M and $761/sqft; Hilo 96720 at $257k–$642k, median $521k and $312/sqft) so budget-feasibility logic has something real to bite on. The two Honolulu ZIPs sit ~2.1 miles apart, so the default 1.5-mile comp radius mostly separates them — mostly, because the per-ZIP coordinate jitter lets a small tail of cross-ZIP pairs fall inside it. Hilo is on another island and cannot contaminate an Oahu comp set at any radius a CMA would use.
Everything that differs between ZIPs lives on one _Market record, because a
single global value was wrong for at least one of the three:
- Property mix. 96815 is Waikiki — a wall of condo towers with essentially no detached housing — while 96720 is overwhelmingly single-family. A uniform draw put 2,100-sqft single-family homes on Seaside Ave.
- Association fee. Derived from size and market rather than drawn from a
flat menu. Uncorrelated, it put $165/mo on a $1.3M Waikiki condo and $1,150/mo
on a $380k Hilo one — and at Hawaii's fee levels that drives the affordability
answer rather than decorating it. It is capitalised at 100× the monthly figure
wherever a fee has to be compared against a price, in both the CMA adjustment
grid and
qualify_lead, so one fee cannot be worth two amounts on one screen.
Addresses are anchored on the street, not the ZIP: each street carries its own
coordinates and real house-number range, so 238 Beach Walk lands on Beach Walk
rather than in the 3000s of a two-block street. It replaced a per-ZIP coordinate
box, which cannot work here — Waikiki is a narrow strip along a diagonal
shoreline, so every axis-aligned rectangle covering it also covers open water.
Shrinking the box cut listings in the Pacific from six to none by the
flat-latitude test used to check it, and left eleven when measured against the
real coast: the shape was the defect, and a test whose boundary matches the fix
can only confirm it.
The dataset is deliberately sold-heavy — 76 closed against 22 active — because months-of-inventory divides standing inventory by the monthly rate of a year of sales, so a market at four months needs roughly three closed records per active one. The payoff: Honolulu lands at 2.8 months (seller's) and Hilo at 5.0 (balanced), where the previous fixture reported a deep buyer's market for both and never exercised the other branches.
Comps are screened on property type as well as size, and screened_out reports
both. Without it the scorer — which weighs distance, size, beds and age, but
never type — handed a Waikiki townhouse eight single-family comps while the CMA
methodology required the same type, with nothing in the payload explaining the
contradiction. Pass same_property_type=False to widen deliberately; that is a
weaker comparison, not a neutral one.
Three specialists load a skill; property-search does not. That is the
criterion working as intended — CMA adjustment grids, contract clause
checklists, and fair-housing rules for listing copy are too large to sit in a
system prompt, while property-search's workflow fits in a paragraph.
Skills are not inherited from the orchestrator. Each subagent that needs one
declares it explicitly in subagents.py.
- Write containment.
FilesystemBackendgrants real disk access, sobuild_agentpasses explicitFilesystemPermissionrules: writes are allowed only under/workspace/,.envand.gitare denied for read and write, and everything else is read-only. Rules are evaluated in order, first match wins — the allows must precede the catch-all deny. - No send capability, by design. The agent cannot send email.
save_draftwrites three artifacts and stops: a.md(readable canonical copy), a.emlcarryingX-Unsent: 1so a mail client opens it as an editable draft, and amailto:URL in the return value for one-click compose (omitted above ~1800 chars, where clients truncate). A human still reads the text and presses send — which keeps accountability with the licensed person, where fair-housing and agency law put it. If you later add real delivery, gate it:build_agent(require_approval=True)pausessave_draftfor approval. The CLI's checkpointer is durable, so a pending approval survives the process — reject is not the same as walking away. - Path traversal. Document filenames come from model output, so they are resolved and confined to the documents directory before any read.
- No shell. The
executetool appears in the tool list but errors out — it requires a sandbox backend, andFilesystemBackendis not one. - Fair housing. The
client-commsskill carries explicit prohibited-language rules for listing copy, andcma-analysisforbids demographic adjustments.
The agent is an assistant, not a licensed professional. Prompts and skills push it to recommend an attorney, appraiser, or lender rather than substitute for one.
| Variable | Default | Notes |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required. |
LANGSMITH_API_KEY / LANGSMITH_TRACING / LANGSMITH_PROJECT |
— | Recommended. Current names — use these. The legacy LANGCHAIN_* spellings still work as fallbacks (see below). |
REA_MODEL |
anthropic:claude-opus-5 |
Orchestrator. Keep the provider:model prefix: require_api_key() reads it to decide which key to demand, and assumes Anthropic without one. |
REA_SUBAGENT_MODEL |
inherits REA_MODEL |
Specialists. |
On the legacy LANGCHAIN_* names. An earlier version of this table said they
"no longer work". That is wrong, and worth stating precisely because the mistake
runs in the unsafe direction: langsmith 0.11.1 still reads nine of them —
LANGCHAIN_TRACING_V2, LANGCHAIN_API_KEY, LANGCHAIN_PROJECT,
LANGCHAIN_SESSION, LANGCHAIN_ENDPOINT, LANGCHAIN_BASE_URL,
LANGCHAIN_CUSTOM_HEADERS, LANGCHAIN_REVISION_ID, LANGCHAIN_LOG. Measured:
LANGCHAIN_TRACING_V2=true alone turns tracing on, and LANGCHAIN_API_KEY
and LANGCHAIN_PROJECT are both honoured. So a stale legacy variable in a shell
profile can start billing traces even though nothing in .env mentions
LangSmith. The LANGSMITH_* spellings take precedence where both are set —
LANGSMITH_TRACING=false beats LANGCHAIN_TRACING_V2=true, which is why
tests/conftest.py assigning the modern name is sufficient to keep the suite
offline. Prefer the LANGSMITH_* names; don't assume the old ones are inert.
Three checks, all of which must pass. No API calls, no key required:
scripts/check.sh # runs all three with the pinned versionsOr individually:
uv run pytest tests/ -q # 81 tests, ~1.5s
uvx ty@0.0.65 check # type check
uvx ruff@0.16.1 check . # lintDevelopment is pinned to Python 3.14, so the requires-python = ">=3.11" floor
needs an explicit run. --isolated keeps it out of your project venv:
uv run --python 3.11 --isolated pytest tests/ -qThe definition of done is "N tests, ty clean, ruff clean", and both tool
versions are pinned deliberately — each is pre-1.0, so an unpinned run can
report a different result on identical source. ruff enforces its own pin via
required-version and will refuse to run if you drop it; ty has no equivalent,
so that one is on you. ruff format is deliberately not used here — see
CLAUDE.md.
.github/workflows/check.yml runs scripts/check.sh --floor on Linux for every
push and PR, plus uv sync --locked so a stale uv.lock fails the build. It
calls the script rather than restating the tools, which is the point of having
the script: one place decides what "done" means. No secrets — the suite is
entirely offline.
A second job releases. When version in pyproject.toml names a tag that does
not exist yet, a push to main that passes the checks tags that commit and
publishes a GitHub release, with notes built from the commit subjects since the
previous tag. It is gated on needs: check,
so a release is never cut from a red commit, and it is the only job holding a
writable token — check stays read-only.
That claim used to be false, and how it failed is worth keeping in view.
The defect. config.py called load_dotenv() at import and .env.example
documents LANGSMITH_TRACING=true, so a developer with a real .env had every
tool.invoke() in the suite reporting to LangSmith as its own root run — 17 per
run, against a Stop hook that runs the suite every turn. LangSmith bills per
trace, so those one-span traces cost the same as full agent conversations, and
the tests outspent the runs worth tracing. CI never had the problem — a clean
checkout has no .env — which is why nothing in the build logs revealed it.
The fix: .env is read by the entry points, not by the package. Import-time
load_dotenv() in a library module applies one developer's configuration to
every consumer, and the trace bill was only its most expensive symptom —
REA_MODEL handed the tests a different graph, REA_PROJECT_ROOT repointed the
file roots they assert against. The ordering is load-bearing: PROJECT_ROOT,
DEFAULT_MODEL and SUBAGENT_MODEL are evaluated when config.py is imported,
so main.py keeps the call and its package imports inside main(). One
consequence for library use — from real_estate_agent import build_agent no
longer picks up .env on its own, so a caller supplies the environment the way
any other library expects. tests/conftest.py still forces tracing off as a
second line, since a variable exported in a shell never went through .env at
all. Live runs via main.py and Streamlit trace normally.
The guard that proved nothing. test_the_suite_does_not_trace_to_langsmith
originally asserted only that tracing was off, which is true by default anywhere
there is no .env — so on CI it passed with the fix deleted, and the check
fired only on the one machine that already had the problem. It now asserts the
value the conftest writes, which is absent on a clean checkout and wrong on a
configured one, so both fail. A pytest_collection_finish hook also aborts the
session before the first tool call rather than reporting after the last, which
covers -k-filtered runs that never collect the test.
Both tables ran against the previous Austin/Round Rock fixture, so their
listing ids and statuses are from that dataset — MLS-1022 was a pending Austin
townhouse and is now a sold Waikiki condo. The behaviours each row checks still
hold; the specific evidence is not reproducible without regenerating that
dataset. Re-run rather than re-read if you need the ids.
Run live against anthropic:claude-opus-5, traces in LangSmith:
| Check | Result |
|---|---|
| Orchestrator delegates rather than answering directly | task at root; 6/6 correct matches |
| Subagents load their skills | ±20% size screen and 25% adjustment-drop threshold applied — both exist only in SKILL.md |
| Planning and cross-specialist handoff | 3× write_todos, 2 delegations, liaison read the analyst's CMA file |
| Write containment | permission denied on /src/notes.md; nothing written outside workspace/ |
| Fair-housing guardrail | Refused "family-friendly", "young professionals", "safe neighborhood", "good schools" with the legal basis for each, and supplied compliant copy |
| Approval gate, both directions | Approve writes the draft; reject blocks it; exhausted/absent input fails closed rather than crashing or approving |
Then run live against the same model, with approval switched on:
| Check | Result |
|---|---|
| Streaming and dedupe | Tool calls and results render as they arrive; no message printed twice |
| Delegation | task(subagent_type=client-liaison) from the orchestrator, never answered inline |
| Reject | Rejection reached the specialist; nothing written to drafts/; the orchestrator re-planned from the stated reason — write_todos, then task(property-search) with "Do not estimate or infer anything" |
| Cross-specialist handoff | property-search wrote /workspace/documents/mls-1022-facts.md; the orchestrator pointed client-liaison at that path, and it drafted from the real figures |
| Approve | .md and .eml both written, X-Unsent: 1 present, real listing data, and the model flagged the pending status rather than guessing whether the contract would close |
| Workspace browser | Populated as specialists wrote; the checkpoint database stayed out of it |
Five, since fixed and regression-tested: three the web-UI run exposed, two a later code review found in the same family. All five are one Streamlit rule — widget state is keyed and lifecycle-bound — and none of them raised.
- The approval toggle switched itself off.
st.rerun()fires from inside the sidebar, which aborts the run before any widget declared after it renders — and Streamlit drops a keyed widget's value on a run where it does not render. So switching approval on and then clicking "New conversation" left the requirement off, and the nextsave_draftwould have been written unattended. The toggle is now declared first, withpersist_state="session"behind it. - The approval form re-displayed the previous call's arguments.
st.text_area(..., key=...)stores its first value in session state and reuses it, so a second interrupt at the same index showed a stale payload while the decision applied to the live one — the form still showed a placeholder body after the specialist had redrafted with real figures. A reviewer would have approved text they never saw. Arguments now render withst.code, which holds no state. - "Resume a thread" had a dead button. A bare
st.text_inputcommits on blur or Enter, so a plain button beside it submitted the previous value: typing an id and clicking Load did nothing, silently, until you pressed Enter first. Both now live in onest.form. - The decision control kept the previous reviewer's answer. The same rule as
the argument display, one widget over — and worse, because it defeats the
fail-closed default rather than only showing stale text.
default="Reject"applies to a key Streamlit holds no value for; on any later render the stored value wins. So approving one call left the next interrupt's form already reading "Approve", and a reviewer who checked the new arguments and pressed submit approved something they never chose. Measured:clear_on_submit=Truedoes not restore the default and deleting the key mid-run breaks the widget. The keys now carry a round number that advances on every submission. - The workspace list lagged a turn behind. The sidebar is built near the top of a run, before the turn writes anything, and the page only re-ran when an interrupt was pending — so the answer on screen could cite a CMA by path while the sidebar still read "Nothing written yet". A turn now always re-runs.
Re-verified live afterwards, with the redraft loop that produces a second interrupt: an identical filename and subject with a changed body — the exact shape the stale-argument bug hid — displayed the new body, and the text written on approval matched the text on screen byte for byte.
Three, since fixed:
comparables()ranked by similarity but never rejected on size, handing back comps the CMA methodology discards anyway. It now applies a size screen and reports what it filtered, so a thin comp set is distinguishable from a thin market.- The mock spread square footage too widely for the dataset size, so small properties had no size-matched comps and no CMA was possible. Sizes now cluster; 17 of 22 active listings clear the 3-comp minimum, and the other 5 remain genuine outliers so the insufficient-comps path stays reachable.
- client-liaison had two ways to write a draft and used both, producing
divergent copies.
save_draftis now the only sanctioned path.
Since fixed and regression-tested:
--require-approvalwas unusable. The resume payload was a bare list, but the middleware readsinterrupt(request)["decisions"]— so answering the prompt raisedTypeErroron both approve and reject. It also built one decision regardless of how many tool calls were pending, which the middleware rejects outright.market_statisticsdivided bymonths_backwithout filtering by it, so the same 16 sales yielded months-of-inventory of 5.2 or 21.0 — flipping the reading from balanced to extreme buyer's market. The window is now applied to the sales themselves, via asold_within_monthsprovider filter.qualify_leadblamed the budget for a bedroom shortfall, reporting a $2M budget in a sub-$800k market as clearing "only 8% of inventory". Budget share is now measured against listings that already meet the non-price requirements.status="Active"returned zero listings — string filters were case-sensitive, which reads to the agent as an empty market. Now case-insensitive, and the tool exposes proper enums.- A newline in an email subject raised after the
.mdwas already written, leaving an orphan pointing at a.emlthat never existed. Headers are flattened first and the message is built before anything is written. - Plus, in the same pass: same-second drafts no longer clobber each other,
text extraction is size-capped like the PDF branch,
lot_sqftcan't go negative, the dataset has one_TODAY, andrequire_api_keyderives the needed key from the model's provider prefix instead of always demanding an Anthropic one.
write_todos is not added automatically. The middleware stack resolves from
a per-provider:model harness profile, so planning may or may not be present
depending on the model string. agent.py pins TodoListMiddleware() explicitly
rather than depending on that resolution — tests/ asserts it stays wired.
This held on 0.7.1 and was re-verified on 0.7.8 by building the graph with and
without the explicit middleware and diffing the tool list: write_todos appears
only in the pinned build. Write containment was re-verified the same way — the
first-match-wins rule evaluation and the unmatched-defaults-to-allow fallback are
unchanged, and the execute tool still refuses to run because FilesystemBackend
is not a SandboxBackendProtocol.