Documentation source
Plaid
Bank account and transaction sync for Marbella via the Plaid Link + integrations-substrate connector.
## Overview
The Plaid connector pulls bank accounts and transactions from any institution a
tenant connects via [Plaid Link](https://plaid.com/products/auth/). Data lands in
the entity graph as `plaid-account` and `plaid-transaction` records and is
accessible to agents through the standard integration tools.
Plaid is the cash-visibility source for the Marbella engagement. The connector
follows the integrations-substrate contract exactly, so every sync is recorded in
`integration_runs`, every source object is preserved in `integration_objects`, and
every entity write carries provenance in `integration_entity_links`.
---
## Architecture
The connector rides the [Integration Substrate](/docs/integrations/integration-substrate).
Nothing Plaid-specific lives outside `features/integrations/plaid/`.
```text
Plaid API
-> features/integrations/plaid/client.ts (HTTP wrapper, server-only)
-> sync.ts buildPlaidDefinition / syncPlaid
-> runRawIntegrationSync() (substrate runner)
-> integration_runs / integration_objects / integration_entity_links
-> upsertEntityKeyed() -> entities (plaid-account / plaid-transaction)
-> agent_connections.config.transactionsCursor (cursor persistence, post-run)
```
Key files:
| File | Purpose |
|------|---------|
| `client.ts` | Plaid SDK factory; all HTTP confined here |
| `connection.ts` | `resolvePlaidConnection` — tenant-scoped access-token resolver |
| `normalize.ts` | Pure transforms: `PlaidAccountRaw` → `plaid-account`, `PlaidTransactionRaw` → `plaid-transaction` |
| `descriptor.ts` | `PLAID_DESCRIPTOR` — registered in `features/integrations/registry.ts` |
| `definition.ts` | `buildPlaidDefinition` + side-effect registration for `getIntegrationDefinition("plaid")` |
| `sync.ts` | `syncPlaid` — cursor loop, mutation-restart, cursor persistence |
| `actions.ts` | `plaid.nightlySync` deterministic action |
| `components/plaid-connect-button.tsx` | Plaid Link UI for the admin console |
---
## Credentials and Environment Variables
Add these to `.env.local` (and Vercel environment settings for deployments):
| Variable | Required | Description |
|----------|----------|-------------|
| `PLAID_CLIENT_ID` | Yes | Plaid developer client ID |
| `PLAID_SECRET` | Yes | Plaid secret for the chosen environment |
| `PLAID_ENV` | Yes | `sandbox` or `production` |
| `AGENT_CONNECTION_ENCRYPTION_KEY` | Yes | Shared key for `encryptCredentials` / `decryptCredentials` — also used by all other connectors |
Access tokens are **never** logged or returned in API responses. They are stored
exclusively as `encrypted_credentials` on the `agent_connections` row.
---
## Connection Provisioning
### Plaid Link flow (recommended)
1. Admin visits `/admin/integrations` and clicks **Connect bank account** (the
`PlaidConnectButton` component).
2. The button POSTs to `/api/admin/integrations/plaid/link-token` → Plaid returns
a short-lived `linkToken`.
3. Plaid Link opens in-browser. The user authenticates with their institution.
4. On success, the button POSTs `{ publicToken, institutionName, accounts }` to
`/api/admin/integrations/plaid/exchange`.
5. The exchange route calls `itemPublicTokenExchange`, creates an `agent_connections`
row with `encrypted_credentials: encryptCredentials({ accessToken, itemId })`,
and returns `{ connectionId }`.
The connect button holds a busy guard from token-fetch through exchange to prevent
duplicate connections from double-clicks or tab races.
### Manual fallback
An admin can insert an `agent_connections` row directly via Admin > Integrations >
Connections with:
- `connection_type: "api"`
- `encrypted_credentials`: result of `encryptCredentials({ accessToken, itemId })`
- `config.presetId`: `"plaid-bank-account"` (first entry of `PLAID_PRESET_IDS`)
- `config.institutionName`: human label
- `config.connectionGroupKey`: the Plaid `itemId`
---
## Sync Mechanics
### Cursor semantics
Transactions use Plaid's cursor-based `transactionsSync` endpoint. The cursor for
each connection is stored at `agent_connections.config.transactionsCursor`.
- On first sync, cursor is `undefined` — Plaid returns the full transaction history.
- After a successful sync, `syncPlaid` writes the new `next_cursor` back via an
optimistic conditional UPDATE that matches the cursor value read at sync start.
If a concurrent sync already advanced the cursor (0-row match), the write is
silently skipped — the concurrent run's cursor wins.
- On `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION` (Plaid modifies the ledger
mid-page), the connector resets `cursor` to `null` and replays from scratch.
Entity upserts are idempotent by `external_id`, so a replay is safe.
Accounts use `accountsGet` (no cursor) — the full list is re-fetched and upserted
on every sync.
### dry_run vs apply
The sync route accepts `{ mode?: "dry_run" | "apply" }` (default `apply`).
`dry_run` records an integration run and returns the proposed summary without
writing entity data — useful for verifying credentials and seeing what would sync.
### Removed transactions
Plaid's `transactionsSync` returns a `removed` list. In v1 the connector records
removed IDs in the run summary `conflicts` field for observability but does not
delete or archive the corresponding entities. Entity archiving is a dated follow-up.
### Rate limiting
Plaid enforces 15 requests/minute per Item in production. The sync loop caps
transaction pages at `maxPages` (default 20 × 500 count) per run. The cursor
ensures the next run resumes where the previous one stopped.
---
## API Reference
All three routes require `requireAdmin()` (tenant resolved from request context,
never from request JSON). All inputs are Zod-validated; errors return
`apiErrorResponse()`.
### `POST /api/admin/integrations/plaid/sync`
Trigger a sync for one or all Plaid connections.
```typescript
// Request body
{ connectionId?: string; mode?: "dry_run" | "apply" }
// Response
{ summary: RawIntegrationSyncSummary; connectionErrors?: { connectionId, errorCode }[] }
```
`connectionErrors` surfaces Plaid error codes (`ITEM_LOGIN_REQUIRED`,
`INVALID_ACCESS_TOKEN`, `RATE_LIMIT_EXCEEDED`) per connection without leaking
tokens. `ITEM_LOGIN_REQUIRED` means the user must re-authenticate via Plaid Link.
### `POST /api/admin/integrations/plaid/link-token`
Generate a Plaid Link token for a new connection.
```typescript
// Request body (empty)
{}
// Response
{ linkToken: string; expiration: string }
```
### `POST /api/admin/integrations/plaid/exchange`
Exchange a public token from Plaid Link for a persistent connection.
```typescript
// Request body
{
publicToken: string;
institutionName?: string;
accounts?: { id: string; name: string; mask: string | null; type: string; subtype: string | null }[];
}
// Response
{ connectionId: string }
```
---
## Agent Usage
Agents interact with Plaid data through the standard integration tools — no
Plaid-specific tool calls are needed.
### List connections
```typescript
// Tool: listIntegrationConnections
{ slug: "plaid" }
// Returns: [{ connectionId, label, institutionName, lastSyncAt }]
```
### Trigger a sync
```typescript
// Tool: runIntegrationSync
{ slug: "plaid", connectionId?: string, mode?: "dry_run" | "apply" }
```
### Query synced data
```typescript
// Tool: listEntities
{ entityTypeSlug: "plaid-account" }
{ entityTypeSlug: "plaid-transaction" }
```
Accounts and transactions have standard `external_id` / `external_source`
provenance (`plaid:account` / `plaid:transaction`), so agents can trace any entity
back to its source object in `integration_objects`.
---
## Data Model
### `plaid-account` fields
| Field | Type | Notes |
|-------|------|-------|
| `accountId` | string | Plaid account ID, upsert key |
| `name` | string | Account nickname |
| `officialName` | string \| null | Institution's official name |
| `mask` | string \| null | Last 4 digits |
| `type` | string | `depository`, `credit`, `loan`, etc. |
| `subtype` | string \| null | `checking`, `savings`, etc. |
| `currentBalanceCents` | integer | Integer cents (same convention as `qb-account`) |
| `availableBalanceCents` | integer \| null | Available balance |
| `isoCurrencyCode` | string | e.g. `"USD"` |
| `institutionName` | string \| null | From connection metadata |
| `itemId` | string | Plaid Item (bank login) this account belongs to |
### `plaid-transaction` fields
| Field | Type | Notes |
|-------|------|-------|
| `transactionId` | string | Plaid transaction ID, upsert key |
| `accountId` | string | FK to `plaid-account.accountId` |
| `amountCents` | integer | **Positive = outflow** (Plaid sign convention preserved) |
| `isoCurrencyCode` | string | e.g. `"USD"` |
| `date` | string | Posted date (ISO 8601) |
| `authorizedDate` | string \| null | Authorization date |
| `merchantName` | string \| null | Cleaned merchant name |
| `name` | string | Raw transaction name |
| `pending` | boolean | `true` until posted |
| `paymentChannel` | string | `online`, `in store`, etc. |
| `categoryPrimary` | string \| null | Plaid `personal_finance_category.primary` |
| `categoryDetailed` | string \| null | Plaid `personal_finance_category.detailed` |
---
## Deferred Work
The following items are recorded in `documents/work/2026-06-10-amble-hardening-plaid/followups.md`:
- **Investments/holdings sync** — `POST /investments/holdings/get`
- **Webhook handler** — `SYNC_UPDATES_AVAILABLE` event with JWT verification; the nightly action covers the Marbella engagement in the interim
- **Removed-transaction archiving** — removed IDs surface in sync summary `conflicts` today; full archiving requires an entity-deletion policy decision
- **QB↔Plaid reconciliation views** — cross-reference `qb-account` balances against `plaid-account` balances for cash reconciliation
- **Duplicate-Item connection guard** — prevent two `agent_connections` rows for the same Plaid `itemId`
---
## Related Docs
- [Integration Substrate](/docs/integrations/integration-substrate) — the shared sync, provenance, and write-ledger contract all connectors ride
- [Acuity Scheduling](/docs/integrations/acuity) — the reference connector implementation
- [Agent System](/docs/features/agent-system) — how agents invoke integration tools