ADR-0012: Agent runtime Lambda for PMS browser-agent jobs
Status: ACCEPTED Date: 2026-04-24 Owner: Fede Supersedes: appfolio-browser-agent ADR-0012 (Node CLI shell-out plan)
Context
Renewals, work orders, and NTVs on PMS back-ends (AppFolio today; Yardi,
MRI, Entrata likely next) all need browser-automation. The prior plan
used a Python consumer on EC2 (propflow-pms-tasks-prod.fifo →
browser-use agent), which had no fair-share across agent types, no per-
vendor rate limiting, no retry/DLQ discipline, and required a new EC2
process per agent type.
Decision
Single SQS + Lambda dispatch layer. Every PMS job publishes to
propflow-agent-jobs.fifo. The worker Lambda propflow-agent-runtime
consumes and routes to a registered handler by agent name. Handlers live
in lambda/agent-runtime/src/handlers/ and register themselves at
import time.
Architecture
UI / Cron / Webhook
└─ POST /api/leasing/renewals/outreach (or other trigger)
└─ triggerRenewalSync() in src/lib/sqs/pms-tasks.ts
└─ publishAgentJob() → propflow-agent-jobs.fifo
└─ Lambda: propflow-agent-runtime
├─ dispatch.runJob() → handler registry
│ └─ handlers/renewal.ts
│ └─ POST /api/run-renewal on appfolio-browser-agent
│ └─ L4 flow (4 HTTP calls to AppFolio)
├─ PATCH /api/leases/:id/renewal on PropFlow → tenant flips
│ to PREPARED → Clara fires follow-up
└─ jobsRepo writes status to propflow-agent-jobs DDB
Architecture decisions
- SQS FIFO.
MessageGroupId = agent:entityId→ per-entity ordering with no head-of-line blocking across entities.MessageDeduplicationId = agent:entityId:enqueuedAtcollapses bursts. - Lambda, not EC2. Pay-per-ms; L4 HTTP completes in ~5–15s. Lambda
reservedConcurrentExecutions=5matches the tested Browserbase cap. - Single worker Lambda. Handler registry routes by
job.agent. Adding an agent =registerAgent("vendor.entity.action", fn). - Handler lives in the propflowai monorepo.
lambda/agent-runtime/alongside existing Lambdas. L4 client is vendored undervendors/appfolio/(sourced from appfolio-browser-agent). - Separate DDB table
propflow-agent-jobs. Isolates job-queue churn frompropflow-prod(the main single-table business data). - AppFolio HTTP flow is proxied through the Vercel admin endpoint
(
appfolio-browser-agent.vercel.app/api/run-renewal) rather than executed in-Lambda. That endpoint owns Browserbase cookie bootstrap — the Lambda bundle stays small (no Stagehand/playwright) and there's one source of truth for L4.
What's in this PR
lambda/agent-runtime/— runtime (types, dispatch, jobsRepo, logger, secrets) + renewal handler + vendored AppFolio L4 client +deploy.sh(idempotent SQS/DLQ/DDB/IAM/Lambda provisioning with pre-promote smoke invoke).src/lib/sqs/pms-tasks.ts—publishAgentJob()+ unifiedtriggerRenewalSync/triggerPmsSync/triggerNtv(all three publish to the same queue, differ only by agent name).- Unit + integration tests.
What this replaces
publishPmsTask()→ deleted. EC2 queue is no longer a publish target.triggerBrowserbaseRenewal()→ deleted. The synchronous direct-POST path from approve/outreach routes. Lambda is canonical.runtime=query param on approve/outreach routes → deleted. Single path.RENEWAL_PATHenv flag → deleted. Always Lambda.AGENT_RUNTIME_DRY_RUNenv flag → deleted. Lambda always executes.- Property-45 hard-coded guard → deleted. Allowlist is the caller's responsibility, not the runtime's.
- EC2 Python
pms-consumer→ follow-up decommission. Queuepropflow-pms-tasks-prod.fifowill be drained + deleted once Lambda has run clean for 48h.
Infra (provisioned by deploy.sh, idempotent)
- SQS FIFO
propflow-agent-jobs.fifo+ DLQpropflow-agent-jobs-dlq.fifowithmaxReceiveCount=5 - DynamoDB
propflow-agent-jobs(PK=JOB#<id>, SK=META, GSI1 on accountId+enqueuedAt) - IAM role
propflow-lambda-agent-runtime-role(SQS + DDB + SSM) - Lambda
propflow-agent-runtimeon arm64 Node 20, 1024 MB, 600s timeout,reservedConcurrentExecutions=5, aliaslive
Follow-ups (tracked, not in this PR)
- Decommission EC2
pms-consumer+propflow-pms-tasks-prod.fifoafter 48h clean on Lambda. - Wire full
signing_data[]body inapiPrepareRenewalTemplates(inside appfolio-browser-agent) so any lease template works — not just the auto-execute ones. appfolio.reset-renewalAPI path for Prepared-not-Sent cancel (current DOM reset can't recover that state reliably).- Register
appfolio.work_order.create+appfolio.ntv.sendhandlers (envelope wiring is in place; handlers themselves TBD). - Unify
/api/leasing/renewals/approve+/api/leasing/renewals/outreachinto a single endpoint — the two routes are 95% identical. - CloudWatch alarms on DLQ depth + job failure rate.