§2 · System architecture

Three parties. One registry. Thirteen endpoints.

People API is a small, boring, single-node HTTP server with an MCP sidecar and a reference SPA. The design choices below exist to keep that true as long as possible.

For: engineers judging the implementation.

Three-party architecture

Everything runs through the registry — the central server that owns intents, offers, session tokens, and vendors. Each party talks only to the registry; there is no direct vendor-to-buyer channel. That's how privacy stays a type decision rather than a trust decision.

Three parties, one registry
BUYERIntent producerx-user-idAGENT · MCPProxy + translatorstdioREGISTRYExpress + SQLitetier filter · scoring · tokensVENDOROffer producerx-api-keyPOST /intents6 MCP toolsGET /registryPOST /offersSSE · events

Request lifecycles

The two flows that matter: a buyer creating an intent, and a vendor submitting an offer. Every other endpoint is a variation on these.

Create-intent lifecycle

POST /api/intents
BuyerExpressSQLiteSSE busPOST /api/intentsx-user-id requiredZod.parse(body)422 on invalidINSERT intents ...{ id: int_... }emit(intent_created)201 { id, status }

Submit-offer lifecycle

POST /api/offers
VendorExpressScoringSQLitePOST /api/offersx-api-keyresolve vendor + load intentscoreOffer(offer, intent, vendor){ score, decision }accept / reject / pendingINSERT offer (+ token on accept)201 or 422 with reason

On auto-accept, the server creates a session token st_<uuid> bound to (vendorId, intentId) with a 24-hour TTL and returns it alongside the offer. The vendor now has Tier 2 access until the token expires.

Data layer

Four tables. Foreign keys on. WAL mode. JSON columns for ragged structures like attributes, constraints, and tags. Budget and location are split-column for index and filter efficiency.

Schema · 4 tablespeopleapi.db
intentsidTEXTuserIdTEXTcategoryTEXTdescriptionTEXTattributesJSONconstraintsJSONbudgetMinINTEGERbudgetMaxINTEGERcurrencyTEXTurgencyTEXTmetroTEXTzipTEXTtagsJSONstatusTEXTautoApproveBelowINTEGERcreatedAtTEXTvendorsidTEXTnameTEXTemailTEXTcategoriesJSONapiKeyTEXTratingREALcreatedAtTEXToffersidTEXTintentIdTEXTvendorIdTEXTpriceINTEGERcurrencyTEXTdeliveryDaysINTEGERinStockINTEGERattributesJSONdescriptionTEXTmatchPercentageINTEGERisAlternativeINTEGERscoreINTEGERstatusTEXTcreatedAtTEXTsession_tokenstokenTEXTvendorIdTEXTintentIdTEXTtierTEXTissuedAtTEXTexpiresAtTEXT
PK FKJSON columns serialized as TEXT.WAL mode, foreign keys ON.
  • intents. The primary resource. Composite index on (status, category, metro) drives registry filtering.
  • vendors. One row per registered vendor. The apiKey column stores a hashed pak_<uuid>; the plain key is returned exactly once at registration.
  • offers. One row per offer with server-computed score. Status enum mirrors the protocol: pending, accepted, rejected, countered, expired.
  • session_tokens. Short-lived, (vendorId, intentId, tier)-scoped. The registry consults this table on every Tier 2 request.

Why SQLite (for now)

SQLite is not a compromise on a reference implementation — it's the right tool. The design target is single-file, single-node, zero-config. WAL mode gives us concurrent readers without locking. The synchronous better-sqlite3 API collapses the connection-pool layer entirely; route handlers can use it like a local data structure.

The roadmap names the migration path to Postgres for multi-node deployments — and schema has been written with that in mind. No SQLite-only tricks, no sharded primary keys to unwind.

Frontend architecture

A Vite SPA in client/. Five screens: buyer dashboard, intent detail, create-intent form, public registry, vendor portal (with sub-views). One API client in client/src/api/client.ts; all server state lives there. The useEvents() hook subscribes to the SSE stream and triggers targeted refetches on intent_* and offer_* events.

Real-time layer

A single in-memory Set<Response> holds open SSE connections. Route handlers call emitEvent(type, data) after any mutation; the emit helper writes to every connected client. No queue, no broker, no retry logic — the client-side EventSource reconnects automatically on drop.

Four event types fire today:

EventEmitted fromSubscribers refetch
intent_createdPOST /api/intentsVendor registry view
intent_updatedPATCH /api/intents/:idBuyer dashboard; affected vendors
offer_createdPOST /api/offersBuyer intent-detail view
offer_status_changedAccept / reject / counter handlersVendor dashboard; buyer intent-detail

Stack rationale

Every choice below was made to keep the reference implementation small, readable, and easy to replace. No layer is load-bearing beyond its role.

LayerChoiceWhy
BackendNode.jsSingle runtime for HTTP, MCP, and scripts. Fast to iterate on.
BackendTypeScriptShare types between server, client, and MCP without duplication.
BackendExpressUniversal, boring, composable. No framework-religion fights on a reference impl.
BackendSQLite (WAL)Zero-config, single-file, embedded. WAL mode handles the single-node concurrency we need.
BackendZodSingle source of truth for types and runtime validation of every request body.
AgentMCP SDKProtocol standard. Every MCP-compatible client gets People API for free.
FrontendReact 19Reference SPA for humans who want a UI.
FrontendViteFast dev, clean prod build. Express owns prod asset serving.
FrontendTailwindOpinion-free styling. No bespoke design system needed for a reference UI.
FrontendReact Router 7Client routing, five screens, no SSR needed.
RealtimeServer-Sent EventsOne-direction push. No WebSocket overhead, trivial to proxy.
TestingVitestFast, first-class TS, identical assertion surface to Jest.
Source tree on GitHub