Integrations · next big build

Yardi browser agent: architecture and plan

5 Oct 2026. Proposed — pending Fede's review ConAm / Yale 25 Station first: one Yardi user login, no API yet. The PMS calendar is one capability of this agent. Property readiness: Yale 25.
Short answer: not from scratch. Reuse the robot's session machinery, rewrite the screens, and put reads on the robot for the first time. The session and ops core (logins, locks, budgets, cookie store, logging) is vendor-neutral once AppFolio names become parameters. The login steps, forms and 78 routes are AppFolio-only and get a Yardi twin. One shared core, one agent per vendor, same ports. New piece: AppFolio reads use the official API today; for Yardi the robot reads too, so polling is slower and budgeted.

9 · Decisions for Fede

Fede, 5 Oct evening: the two bug fixes go now; the refactor chain waits for ConAm's login date, and must be built for N vendors (a third PMS is a driver plus a row, never another refactor).
AskRecommendation
D1. One shared core, one agent per vendor (option B), inside the existing robot repo as two packages?Yes. Start with the two bug fixes and the account-key refactor; the AppFolio agent keeps shipping throughout.
D2. The sync job owns Yardi polling (option B), with the cadences in section 7 as the starting point?Yes.
D3. Phase 0 at the Willows: build the list-showings read route now as the first "read through the robot" proof?Yes, dark, when build days resume; it is also rung 2 of the calendar ladder.
D4. Mock-server API proof of concept for Yardi now?Hold. The mock serves residents, balances, leases and work orders, none of the prospect family; it would prove phase 2, not phase 1.
Architecture, options, scope, phases, refactor list, polling and unknowns (sections 1 to 8)

1 Today2 Yardi phase 13 Options4 Scope5 Phases6 Refactor now7 Polling8 Unknowns9 Decisions

1 · How PropFlow talks to AppFolio today

Two lanes. Reads come from the official Reports API on a schedule (1 min, 5 min, hourly) with a per-account budget. Writes, and the few reads the API cannot do, ride the browser robot: it logs in as a staff user, keeps the session warm and replays AppFolio's own web requests. No clicking in production, no model in the loop.

AppFolio (customer database) Reports API (official)guest cards · applications · rent roll · WOs Staff web app (forms + internal JSON)create guest card · note · showing · applicationrenewal · work order · unit fields · move-out Sync job (reads)1 min / 5 min / 1 h cadences, budget Browser robot (writes)warm session · login budget · MFA by SMSone deploy, one account row per database PropFlow PMS ports: read client · guest-card writer ·work-order writer · calendar provider mirror of PMS records (vendor-neutral)write outbox, drained every 15 minClara · tours · follow-ups · dashboard readswrites

2 · Yardi phase 1: one lane, same ports

No API, so the robot carries reads too. It logs into ConAm's Yardi as the PropFlow user, keeps the session warm the same way, and serves the same read and write contract. The sync job keeps the schedule and budget and calls Yardi robot routes instead of the Reports API. Everything above the ports does not change.

Yardi (ConAm database) CRM IQ (leasing)guest cards · activity · appointmentsapplication stage · calendar Voyager 8 (back office)units · availability · rent roll · residentswork orders · renewals · reports Sync job (schedule + budget)slower cadences, calls robot read routes Yardi browser agent (reads + writes)shared core: session, locks, budget, logsYardi driver: login, MFA, screens, forms PropFlow (unchanged) same PMS ports, same calendar providerinterface (vendor id: yardi) same mirror, same outboxClara · tours · follow-ups · dashboard readswrites

3 · Options, with a recommendation

QuestionOptionsRecommendation
One agent or two?A. One robot app with a vendor switch inside.
B. Shared core package + one deployed agent per vendor (AppFolio, Yardi), same route contract.
C. A separate Yardi repo from scratch.
B. The robot's own April plan (ADR-0013), never executed. Separate deploys because sessions, login storms and breakers are per vendor and today's breaker is per server instance; a shared core because the session machinery is the hard part and it already works. C throws away the part that took months to get right.
Who polls?A. The robot polls Yardi and pushes to PropFlow.
B. The existing sync job keeps the schedule and calls Yardi read routes on the robot.
C. Both.
B. Cadence classes, the request budget, the import record and the mirror all live in the sync job already. The robot stays a dumb, stateless door. Cost: a robot read takes seconds and runs one at a time per account, so Yardi cadences are slower than AppFolio's (section 7).
How do reads come out of Yardi?A. Replay the JSON calls CRM IQ's screens make.
B. Download the report exports Voyager already offers (rent roll, availability).
C. Scrape HTML tables.
A for leasing, B for back office, C last resort. Decided by the first week with a login: open each screen with the network log on and record what it calls. Same method that produced the AppFolio routes.
CalendarDecided 5 Oct: Yardi's property calendar is the building's calendar, read and written like Google or Outlook.Ships as the calendar provider with vendor id yardi, backed by the agent's appointment routes. Not reopened here.

4 · Scope: the basic guest-card work, not just the calendar

#CapabilityPropFlow portYardi screen (expected)R/WPhase
1New guest cards since last pollread client · guest cardsCRM IQ prospects listR1
2Guest card detail: name, phone, email, source, unit interest, activityread client · guest cardCRM IQ guest cardR1
3Create guest card for a Clara leadguest-card writerCRM IQ add prospectW1
4Add a note / activityguest-card writerCRM IQ guest cardW1
5Calendar: list appointments (busy)calendar providerCRM IQ calendarR1
6Calendar: book, move, cancelcalendar providerCRM IQ appointmentW1
7Application status per applicantread client · applicationsCRM IQ stage / Voyager applicantsR1
8Unit availability and pricingread client · unitsVoyager availability reportR1
9Rent roll (residents, leases, balances)read client · rent rollVoyager rent roll exportR1
10Resident contact inforead client · tenantsVoyager residentsR1
11Work orderswork-order writerVoyager service requestsR/W2 (API exists)
12Renewalsread client · renewalsVoyager expirationsR2 (API exists)

Per-screen build notes live in the PMS knowledge repo (pms-knowledge-base/yardi/, readable through the /yardi skill), one note per screen with URL, selectors and captured requests, marked verified only after a route round-trips on Yale.

5 · Phases

PhaseWhatProofGate
0 · WillowsProve "reads through the robot" on a PMS we can already log into: one list-showings read route on the AppFolio robot, surfaced as the calendar busy source (rung 2 of the calendar ladder). Same shape the Yardi reads will take.Willows showings appear in the tour availability check.None; dark.
1 · Yardi browser agentShared core + Yardi driver (login, MFA, session probe, forms), then routes for rows 1–10 above in this order: calendar read, guest card read, guest card create + note, calendar write, applications, units, rent roll, residents.Each route round-trips on Yale with ConAm's login; the Yale readiness tracker rows turn green.Login from ConAm. Turning on at Yale is Fede's call (new capability, default off).
1b · Product sideRemove the AppFolio-only assumptions in the sync job, credential rows, job names and tool handlers (section 6), so yardi is a real branch, not a null.Yale polls on the Yardi cadences; the mirror fills.None; fixes, no switch.
2 · API where it existsWork orders, renewals, balances and residents move to the SIPP interfaces already modelled (and served by the mock server) once ConAm licenses them; robot routes stay as fallback.Mock-server tests plus one live ping.ConAm's SIPP contract.
3 · Prospect interfaceGuest cards, activity and availability move to the paid Prospect interface; the calendar stays on the robot (Yardi has no calendar API).Same routes, API-backed.The $25K/yr quote.

6 · What to refactor now (plans only today)

Rule (Fede, 5 Oct): the refactor is built for N vendors, not two. Drivers register by vendor id, accounts are rows with a vendor field, and no code branches on a vendor name. Adding a third PMS is a new driver folder and a config row.

Two findings to fix first, before any Yardi work: (1) the robot saves the login session under the account id but loads it under the key derived from the AppFolio subdomain; the two are equal for all three customers today, so nothing is broken yet, but any account whose id differs from that key loses its warm session, and the first vendor without an AppFolio subdomain hits it. (2) When the AppFolio text pattern misses, the robot logs the first part of the incoming MFA text, which can include the code. Both are bug fixes: no switch, small PRs, the second touches the secrets floor.
Robot repo, must happen before a Yardi route exists
  1. One account key per (vendor, database, account); every store keyed by it.
  2. Account row gains vendor, base URL, database; drop the AppFolio-only checks (host regex, numeric ids, SMS-only MFA) and the built-in JP&Co special case.
  3. Pull the login chain (budget → lock → login → harvest → probe → save) out of the 1,569-line cookie-refresh file into a session manager that takes a vendor driver: login steps, "is this the login page", session probe, cookie filter.
  4. The one HTTP door takes the driver's "session expired" test and form-token strategy; Rails handling becomes the AppFolio driver.
  5. Login identity carries the vendor (Yardi logins are username + database, not email).
  6. MFA as a strategy: SMS, email, authenticator app, SSO.
Product repo, can land alongside
  1. The sync job's AppFolio master switch and its "if AppFolio" branches get a vendor table; account fingerprint and budget rows keyed by vendor.
  2. Credential rows: provider is a real field with Yardi's shape (login URL, username, database, MFA method) next to AppFolio's.
  3. Queue job names and the agent-runtime vendor folder stop saying AppFolio.
  4. One robot address per vendor instead of one global address.
  5. The ~22 Clara and admin tool handlers that call the AppFolio robot directly go through the ports.
  6. Registry branches for Yardi return real adapters instead of null; the side-effect stub becomes the real one.
Robot repo layerVerdictSize
Route wrapper, token check, account binding, cookie store, login budget, locks, retries, keep-alive, logger, alert scrubbing, browser factoryReuse as-is once AppFolio names become parameters15 core files measured at 4,870 lines; the audit's wider count of generic infrastructure is ~11k of 66k
Login steps, session probes, Rails forms, the 4k-line API clientExtract the shape, rewrite the AppFolio parts as a driver~7 files, ~7k lines
The operations (guest cards, showings, renewals, units, tenants, work orders, vendors) and their 77 routesYardi gets its own; nothing ports~95 files, ~48k lines, plus 78 routes
HousekeepingDelete the demo UI and research route; fix or drop the stale Dockerfile; make the refresh breaker shared across instances; consider per-customer robot tokenssmall

Engine split: done through step 6 (6 Oct)

Tracking issue: appfolio-browser-agent #469. Six small steps merged in one day, each proven before the next started: full test suite, the live preview probe on the Willows, then after merge the nightly renewal smoke and a quiet #alerts. Customers saw no change. Fede's bar: "the most important thing is testing and making sure nothing breaks."

StepWhat moved behind the engine boundaryPRProof
1The boundary itself: lib/core/, the AppFolio driver, a lint rule that forbids the core from importing anything AppFolio-named#4703893 → 3909 tests; KV keys pinned byte-identical; Willows smoke green
2MFA code parsing (SMS today; email, app code, SSO are driver choices)#471→ 3924; 27 tests fail if the parser is stubbed; smoke green
3Login identity (the key behind login budgets and locks), hashes frozen#472→ 3935; smoke green
4Read-only guard hosts, secret-stripping cookie names, login-page signature#473→ 3950; Situs write refusal unchanged; smoke green
5Account record: vendor, base URL, database, username; host rule owned by the driver#474→ 3962; three customer rows resolve identically; smoke green
6Last session checks (three login-body checks, off-tenant hosts, breaker key) + a session-manager seam, heartbeat as first caller#476→ 4008; breaker key string frozen; smoke green

Real browser proof (6 Oct, 19:32Z): after all six steps were live, a fresh browser logged in to JP&Co's AppFolio with the SMS code, through the production robot, in 79 s (run; recordings on issue #469). The nightly smoke alone is HTTP over the stored cookies, so from now on every engine, session or MFA change is proven with this real login run as well, on Fede's go per run.

What a Yardi robot needs now: one driver file under lib/vendors/yardi/ that registers itself, plus its screens. No AppFolio code is touched.

Step 7, not started on purpose: the physical move to packages/browser-core + agents/appfolio (ADR-0013) changes the Vercel project root and the lockfile, so it cannot merge dark. Recommendation: hold until the Yardi login is dated; the lint rule already enforces the boundary.

Two decisions for Fede, nothing changed meanwhile: (1) the error classifier and the driver disagree on 8 of 28 "AppFolio sent us to log in" shapes (table on #476); both kept, since picking one loosens or tightens login detection at three customers. (2) Six JP&Co-only fallbacks remain in code (list on #474); none is provably dead without reading the JP&Co account row in production.

Still waits for the login: the Yardi screens themselves. Separate decision, not part of this chain: the request-pacing gate from section 9.

7 · Polling: Yardi has no events for a browser user

Nothing in Yardi will call us. The sync job polls, but each poll is a logged-in page or report, costs seconds and runs one at a time per account. Starting cadences, tuned after the first week:

DataAppFolio todayYardi phase 1Why
New guest cards1 min3 min in office hours, 10 min outsideClara's reply speed is the product; one list page per poll
Calendar / appointmentsn/a (Outlook/Google live)Before every booking, plus every 15 minRead-before-write is what stops double-booking
Application status1 min30 minStatus changes are slow; one list page
Unit availability + pricing5 min1 hOne report export
Rent roll, residents5 minNightlyLarge export; answers "who lives here" questions a day late at worst
Work orders, renewals1 min / 1 hPhase 2 (API)Interfaces exist; not worth a screen

Writes stay on the existing outbox, so a robot outage delays a write instead of losing it. A per-database request budget on the robot ships with phase 1 as a guard, not a switch.

8 · Answered by watching one real login

We learn these in the first hour with ConAm's user
  • MFA method on the PropFlow user: SMS, email code, authenticator app, or SSO through YardiOne. Authenticator app is the easy one for a robot.
  • Whether Yardi's session cookies work from our server, or only from the browser that logged in (AppFolio forces the browser for some calls).
  • Whether CRM IQ and Voyager are one login or two.
  • Session timeout and whether a second login kicks the first one out.
  • What the CRM IQ screens call behind the scenes (JSON or HTML).
Questions for ConAm when the user is created (Fede's to send)
  • Login URL for ConAm's database, and whether it goes through YardiOne.
  • Which MFA the user gets; authenticator app preferred.
  • Which properties and roles the user has: CRM IQ agent, Voyager residential read, report scheduler.
  • Concurrent-login policy, so the robot never logs a leasing agent out.

9 · Request pacing: what production actually shows (measured 6 Oct)

Read-only pass over 14 days of the AppFolio sync job's logs (22 Sep → 6 Oct) and its budget ledger, plus the robot's code. Source table with every query: pms-knowledge-base/appfolio/rate-limit-measurement-2026-10-06.md.

What we allow todayWhere AppFolio starts refusing (measured)Refusals in 14 days
Reads (sync job, Reports API)6 per 15 s ≈ 24/min per account≈ 9/min per account (WS: 0.15 % of requests refused at 7–9/min, 3.6 % at 10–14, 13 % at 15+; JP&Co starts at 7–9)WS 1,135 · JP&Co 350 · Situs 4,219 (one hour, 29 Sep)
Writes (robot, staff web UI)Pacer from #461: 2 in flight, 250 ms ≈ 240/min — never firesUnknown. The web UI's limit is not the Reports API's; #468 now logs every robot request so it can be measured0 in the 30 min of logs Vercel keeps

Facts that change the design: none of 5,704 refusals carried a Retry-After header; a retry 2.4 s after a refusal is refused again 68 % of the time at WS; the sync's retry attempts bypass the budget (a bug); the budget answers "open" on a cold start; Situs's sync has been paused by staff since 29 Sep yet its ledger was still being refused on 5 Oct from somewhere on Vercel (unidentified). AppFolio publishes no limit.

Proposed (yes / hold): one shared gate per PMS account used by reads and writes together: everything serialized, about 9 starts per minute (6 for accounts that refuse early, like JP&Co), reads capped at 6 of the 9 so writes always have room, one refusal pauses the account 15 s, a second within a minute 30 s. Cost: sync peaks queue, a renewal prepare takes about 3 minutes instead of under one. Gain: up to ~90 % of WS refusals (upper bound). For Yardi: start from the same gate and measure; its limits are unknown.

Sources: robot repo at commit 175309f (30 Sep) and product repo at origin/main de0f47ce (6 Oct), read by two audit passes on 5 Oct 2026 (architecture map; multi-PMS refactor audit); robot ADR-0013 (agent fleet topology, Proposed 27 Apr 2026); product ADR-0067, ADR-0072, ADR-0094 and docs/planning/yale-integration/research/02; Yardi public documentation list in pms-knowledge-base/yardi/sources.md; ConAm emails of 24 Sep and Fede's report of the 1 Oct call. Line references and the full verdict table are in the audit transcript of session ed890d2e (agents/005).

PropFlow Docs