DoorOps: Production & Agent-Safety Architecture
The interesting question about DoorOps isn't "does it use AI." It's the one a technical hiring manager actually cares about: how do you let a language model take real actions on real tenants and real money — over SMS, and increasingly on its own — without it ever doing something dangerous? DoorOps answers that in code, not in a policy doc. This page traces the answer; the devlog tells the same story with every dead end left in.
A real business running on texts and a spreadsheet
Before any software existed, this was a manual process on a real rental portfolio: a tenant gives notice, a unit gets re-listed by hand, prospects text a personal cell, a driver's license photo is requested before anyone gets a lockbox code, a screening decision is made by eyeballing a report, a lease is built by copy-pasting the last tenant's Word doc, and rent is "reconciled" by periodically checking a banking app. The unpleasant parts — chasing overdue rent, escalating a hostile prospect — got avoided more often than they should have.
Off-the-shelf tools didn't fit for a concrete reason, not a hand-wave: none of the popular landlord platforms expose a public API for a landlord's own data, and scraping them would violate their terms and risk real accounts. So an early request to "auto-pull my listings and accounting" got an honest no — the product shipped a manual-entry wizard with address auto-parsing and CSV import instead. Every module in DoorOps maps to a real step in that previously-manual process.
The parts that are genuinely a language problem
Most of DoorOps is deterministic software, and deliberately so. AI is used only where the problem is actually linguistic or unstructured, where rules and search fall down:
Where AI earns its place
- Holding a natural-language leasing conversation over SMS — screening a prospect, collecting ID, deciding whether to release a self-guided tour code — in English or Spanish.
- Turning a messy bank descriptor ("DESCR:APTS DOE") into a classified ledger entry matched to the right unit.
- Translating a plain-English owner instruction ("set the Oak Street duplex, unit 2, available by end of month") into one typed, validated action.
Where it deliberately isn't
- The compliance gates. A rule that must always hold can't be a prompt — it's pure code (see below).
- The money math. A database trigger, not a model, is the single source of truth for how much of a charge is paid.
- Price changes, evictions, lease terms. Hard-coded as never autonomous, no matter how the request is phrased.
A thin shell, modules that plug in, and one path to every write
DoorOps is Next.js 14 / React / TypeScript on Vercel, with Supabase (Postgres + Row-Level Security + Storage), Clerk auth, Twilio for SMS, and the @anthropic-ai/sdk called directly — no orchestration framework, a hand-rolled agent loop instead. A thin shell in core/ (auth, a scoped DB client, the agentic loop, a compliance + encrypted-PII core) is reused by every module; individual workflows plug in through a registry, so adding the second module was additive rather than a rewrite.
The model proposes; typed, deterministic code disposes
Two loops do the AI work, and the same principle runs through both: the model decides what to do; our code controls what's actually possible and records every step.
Model choice & routing
All calls go through one configured Anthropic client (core/ai/client.ts) with a per-run model override: a Sonnet-class model is the default for tool-use-heavy reasoning, and a faster model is passed explicitly on the latency-sensitive SMS chat path. The SDK's retry behaviour is deliberately tuned per surface — three retries on the renter path (a transient blip must not eat a renter's message), but on the interactive command console, exactly one retry and a 25-second timeout, with a code comment citing the live incident that motivated it:
Verifiable in modules/command/interpret.ts — a real production incident encoded as a comment next to the fix.
The agentic loop (the renter path)
The leasing assistant is a generic, module-agnostic tool-use loop (core/ai/agent-loop.ts): call the model → if it asked to use a tool, run it and feed the result back → repeat until it returns plain text. The leasing assistant is "just" this loop plus a leasing prompt and six tools (identify property, store ID, release code, book tour, escalate, …). Crucially, the executor enforces the hard rules in code, not the prompt: no code release without an ID on file, no PII stored without consent, blurry images rejected server-side, escalation pauses the AI.
The safety gate, concretely
The single most dangerous thing this system could do is hand a stranger the code to a home someone still lives in. So the release-code decision is a pure function, releaseCodeGate(), enforced by the executor in a fixed order — and it fails closed:
Both live in modules/leasing-assistant/ai/engine-core.ts and are pinned by roughly 50 assertions in engine-core.check.ts — including the fail-closed case, and the gate-ordering guarantee that not_yet_tourable fires before no_id so the block is truthful about why.
The command registry — the model only ever picks an id
The owner console is where the safety model is clearest. An owner types plain English; the console turns it into exactly one registered action. The pipeline is deliberate:
interpret.ts (one LLM call — the model returns minified JSON: an action id + raw params, or one clarifying question; it never writes SQL, never names a table) → parse-core.ts (zod-validates; an unknown id or bad params becomes a clarify, because the rule is "never guess, never repair the model's output into something it didn't say") → resolve-core.ts (deterministic four-tier name→id matching; when two units genuinely match — this portfolio really does have same-rent collisions — it returns the candidates and asks rather than guessing) → guards-core.ts (terminal: a block ends the command, and even the refusal is audited) → preview (which must not write) → an explicit Confirm → execute.ts apply.
Apply itself is defensive: the pending row is compare-and-swap claimed (a duplicate or racing Confirm gets zero rows and no-ops), the guards are re-run at apply time in case the world changed since the preview, the write goes through the tenant-scoped client, and a cmd_command row records before/after JSONB snapshots with an undo() path. The model's blast radius is exactly one validated action id — everything else is deterministic.
Budget-gated, propose-first autonomy
The ops agent ("Autopilot") can act on its own, but its autonomy is earned and bounded — all enforced by a pure policy core (ops-agent/policy-core.ts), never the prompt. Real cost math ($3 / $15 per MTok in/out) is checked against a hard default $75 monthly budget. A hard-coded "never list" (isNeverAutonomous()) fences eviction, legal, lease-terms, and fair-housing-adjacent actions as permanently owner-only — matched by both exact type and a keyword regex so a renamed action can't smuggle itself through. And price changes are structurally barred from ever earning autonomy: promotionTarget("set_listing_rent") returns null, so no number of approvals can promote a rent change to automatic.
A small, real simulation — described as exactly that
DoorOps' own evaluation is honest about its scale: a six-prospect simulation harness (sim/scenarios.ts) that runs scripted renter conversations through the real engine and asserts on emergent behaviour — a clean qualifier who should get the code; a below-income applicant who must be invited to apply, never rejected outright; someone who volunteers protected-class information that must be logged-and-ignored, never acted on; a hostile prospect demanding a decision, who must be escalated to a human; an in-person no-show; and a blurry-then-clear ID that must be re-asked before it's stored. It's real and it runs against the live model — but it's six scenarios, not a rigorous suite, and I say so plainly. The deep evaluation story in this portfolio is Lumenor's; DoorOps' story is the architecture above.
Two real failures, two real fixes
The migration that silently did nothing. An "owner message memory" feature had been shipping to production while quietly doing nothing for weeks — its database migration had never actually applied, because a try/catch swallowed the DB error instead of surfacing it. It was caught during a later evaluation pass, and it's exactly the kind of silent failure that observability is supposed to catch. It's why "the deployed build is the source of truth" (the footer carries the live commit SHA) became a hard rule, not a preference.
The name signal silently scored zero — and on a portfolio where several units share a rent, matching collapsed to a coin-flip.
The rent-matcher that trusted a full surname. Bank deposits are matched to tenants primarily by the payer's name, because lots of units share the same rent, so the dollar amount can only corroborate. The first engine checked whether the bank descriptor contained the tenant's full surname — but one payment rail truncates surnames to about five characters (a nine-letter surname arrives clipped to its first five), so the exact-substring name check silently scored zero and same-rent units got mis-assigned. The fix matched on token prefixes and weighted the name signal at or above the amount; verified against a full month of real deposits (16/16 routed correctly) before shipping. The lesson encoded: when a human can read the descriptor and instantly know the unit, the matcher must use the same cue — and whenever an external system formats a field, check whether it truncates.
Isolation and PII handling, built in from the shell
Tenant isolation is not left to individual queries remembering a filter. A scopedClient(tenantId) wrapper (core/db/scoped.ts) pre-filters every select/update/delete to the tenant, injects tenant_id on insert and throws if a row carries a different one, rejects platform tables that have no tenant column, and fails closed if the tenant id is missing ("never guess a tenant"). A check-script ratchet keeps the raw, unscoped client count from ever growing. Compliance and PII were built as shell-level features from day one: the secure store refuses to save unless consent was received, encrypts bytes with AES-256-GCM on top of a private bucket, logs access without logging the bytes, and auto-purges on a schedule — and identical treatment of every applicant is written to a structured compliance_event log as the auditable record.
One early incident, told the same outcome-focused way the repo's own history tells it: while wiring up the database, a set of credentials was briefly exposed in a working session. It was caught immediately, nothing was used, and everything affected was rotated the same day — the kind of mistake that's easy to make once and becomes a hard rule never to repeat.
Numbers from a system that actually runs a business
Every figure below is real, pulled live from production on 2026-07-15, rounded where useful. There is no staging environment standing in for reality — for its entire life so far, the only user has been a real 22-unit portfolio, which means every bug described above was, at the time, a bug in software running a real business.