diff --git a/packages/api/src/connections/plaid/route.test.ts b/packages/api/src/connections/plaid/route.test.ts index 2c3f2eda..850d8853 100644 --- a/packages/api/src/connections/plaid/route.test.ts +++ b/packages/api/src/connections/plaid/route.test.ts @@ -63,8 +63,8 @@ const bankRow = { credential: 'enc:{"accessToken":"access-sandbox-1","itemId":"item-1"}', }; -function makeRequest(body: unknown, experiments = ['plaid']) { - return {user: {userId: 'user-1', email: 'alex@example.com', experiments}, params: {}, body} as never; +function makeRequest(body: unknown) { + return {user: {userId: 'user-1', email: 'alex@example.com', experiments: []}, params: {}, body} as never; } beforeEach(() => { @@ -117,20 +117,6 @@ describe('handleCreatePlaidLinkTokenRoute', () => { expect(plaid.requests).toEqual([]); }); - test('refuses a new bank to an organization without the experiment on, before asking Plaid', async () => { - const {handleCreatePlaidLinkTokenRoute} = await import('./route'); - - expect((await handleCreatePlaidLinkTokenRoute(makeRequest({}, []))).unwrapErr().kind).toBe('forbidden'); - expect(plaid.requests).toEqual([]); - }); - - test('signs in to a bank already connected again without the experiment, so it keeps working', async () => { - const {handleCreatePlaidLinkTokenRoute} = await import('./route'); - - expect((await handleCreatePlaidLinkTokenRoute(makeRequest({connectionId: 'bank-1'}, []))).unwrap()).toEqual({linkToken: 'link-sandbox-1'}); - expect(plaid.requests[0]?.body).toMatchObject({access_token: 'access-sandbox-1'}); - }); - test('says so when the server has no Plaid keys', async () => { env.PLAID_CLIENT_ID = undefined; const {handleCreatePlaidLinkTokenRoute} = await import('./route'); @@ -141,14 +127,6 @@ describe('handleCreatePlaidLinkTokenRoute', () => { }); describe('handleConnectPlaidRoute', () => { - test('refuses an organization without the experiment on, before exchanging the token', async () => { - const {handleConnectPlaidRoute} = await import('./route'); - - expect((await handleConnectPlaidRoute(makeRequest({publicToken: 'public-sandbox-1'}, []))).unwrapErr().kind).toBe('forbidden'); - expect(plaid.requests).toEqual([]); - expect(createConnection).not.toHaveBeenCalled(); - }); - test('exchanges the public token and keeps the login as a connection named by its bank, giving agents nothing yet', async () => { const {handleConnectPlaidRoute} = await import('./route'); const connection = (await handleConnectPlaidRoute(makeRequest({publicToken: 'public-sandbox-1'}))).unwrap(); diff --git a/packages/api/src/connections/plaid/route.ts b/packages/api/src/connections/plaid/route.ts index b16a46f8..a2089cff 100644 --- a/packages/api/src/connections/plaid/route.ts +++ b/packages/api/src/connections/plaid/route.ts @@ -1,7 +1,7 @@ -import {Err, Ok, type Result} from 'ts-results-es'; +import {Err, type Result} from 'ts-results-es'; import {z} from 'zod'; -import {ApiErr, type ApiError, Do, hasExperiment, parseSchema} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; import {getConnectionWithCredential} from '@proxy/db/connection'; import {log} from '../../observability/log'; @@ -20,18 +20,9 @@ import {readPlaidCredential, requirePlaidClient, type PlaidClient, type PlaidCre * connection of its own, named by its bank, found by Plaid's id for it. Signing in to a bank again * is Link's update mode on the same login, which keeps its access token, so nothing is saved after * it. A new connection gives agents nothing until a default or an agent's own setting says - * otherwise. Banks are an experiment for now: only an organization with `plaid` on connects one, - * though a bank already connected can be signed in to again without it, so it keeps working. + * otherwise. */ -// Only an organization with the experiment on connects a new bank. -function requirePlaidExperiment(request: AuthenticatedRequest): Result { - if (!hasExperiment(request.user, 'plaid')) { - return Err(ApiErr.forbidden()); - } - return Ok(undefined); -} - const linkTokenBodySchema = z.object({connectionId: z.string().min(1).optional()}); // The access token of the organization's Plaid connection, for Link's update mode. @@ -52,9 +43,6 @@ function requirePlaidConnection(orgId: string, connectionId: string): Promise> { return Do(async ($) => { const {connectionId} = $(parseSchema(linkTokenBodySchema, request.body ?? {})); - if (connectionId === undefined) { - $(requirePlaidExperiment(request)); - } const client = $(requirePlaidClient()); const orgId = $(await requireUserOrgId(request)); const accessToken = connectionId === undefined ? null : $(await requirePlaidConnection(orgId, connectionId)).accessToken; @@ -74,7 +62,6 @@ const connectBodySchema = z.object({publicToken: z.string().min(1)}); /** `POST /api/connections/plaid` — the public token Link answered with, exchanged and saved as a connection named by its bank. */ export function handleConnectPlaidRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { - $(requirePlaidExperiment(request)); const client = $(requirePlaidClient()); const {publicToken} = $(parseSchema(connectBodySchema, request.body)); const orgId = $(await requireUserOrgId(request)); diff --git a/packages/api/src/intent/classify.test.ts b/packages/api/src/intent/classify.test.ts index 9c6196d1..698f9acb 100644 --- a/packages/api/src/intent/classify.test.ts +++ b/packages/api/src/intent/classify.test.ts @@ -5,9 +5,8 @@ mock.module('../utils/env', () => ({env: mockEnv})); const text = 'Create a calendar invite with Jon Burke and Jacky Doe at One Boulevard Plaza at 7pm tomorrow'; -// An organization with no experiment on, and one with banks. +// An organization with no experiment on. const NOBODY = {experiments: []}; -const WITH_BANKS = {experiments: ['plaid' as const]}; // What Jev picks, and what OpenRouter answers: the model's JSON as the message's text. let choice = ''; @@ -82,6 +81,8 @@ describe('pickIntent', () => { 'find_file', 'calculate', 'convert_units', + 'show_balances', + 'find_transactions', 'incomplete', 'none', ]); @@ -90,19 +91,6 @@ describe('pickIntent', () => { expect(calls('https://openrouter.ai/')).toHaveLength(0); }); - test('offers the bank widgets only to an organization with the plaid experiment', async () => { - const {pickIntent} = await import('./classify'); - pick('find_transactions'); - - expect((await pickIntent('uber charges this month', WITH_BANKS)).unwrap()).toMatchObject({widget: 'find_transactions'}); - expect(asked().questions.widget.criteria.find_transactions).toContain('Find bank or card transactions'); - expect(asked().questions.widget.criteria).toHaveProperty('show_balances'); - - expect((await pickIntent('uber charges this month', NOBODY)).unwrap()).toMatchObject({widget: 'none'}); - expect(asked().questions.widget.criteria).not.toHaveProperty('find_transactions'); - expect(asked().questions.widget.criteria).not.toHaveProperty('show_balances'); - }); - test('answers incomplete and none as Jev picks them, and a widget there is no such of as none', async () => { const {pickIntent} = await import('./classify'); for (const [picked, widget] of [ @@ -162,11 +150,11 @@ describe('fillIntent', () => { expect(sent().response_format.json_schema.schema.properties.about).toEqual({type: 'array', items: {type: 'array', items: {type: 'string'}}}); }); - test('fills a bank widget in for an organization with the experiment, and none for one without', async () => { + test('fills a bank widget in, and none for a widget there is no such of', async () => { const {fillIntent} = await import('./classify'); answer({merchant: 'uber', category: 'TRANSPORTATION', flow: 'out', transactionAccount: null}); - expect((await fillIntent('uber charges this month', 'find_transactions', WITH_BANKS)).unwrap().fields).toMatchObject([ + expect((await fillIntent('uber charges this month', 'find_transactions', NOBODY)).unwrap().fields).toMatchObject([ {id: 'merchant', values: ['uber']}, {id: 'category', values: ['TRANSPORTATION']}, {id: 'flow', values: ['out']}, @@ -175,7 +163,7 @@ describe('fillIntent', () => { expect(sent().messages[0]?.content).toContain('FOOD_AND_DRINK (restaurants, groceries'); fetchSpy.mockClear(); - for (const widget of ['find_transactions', 'none', 'make_coffee']) { + for (const widget of ['none', 'make_coffee']) { expect((await fillIntent('uber charges this month', widget, NOBODY)).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'widget', id: widget}); } expect(fetchSpy).not.toHaveBeenCalled(); diff --git a/packages/api/src/intent/route.test.ts b/packages/api/src/intent/route.test.ts index 22128ae9..da1c9cf5 100644 --- a/packages/api/src/intent/route.test.ts +++ b/packages/api/src/intent/route.test.ts @@ -7,7 +7,7 @@ const fillIntent = mock(); mock.module('./classify', () => ({pickIntent, fillIntent})); function makeRequest(body: unknown) { - return {user: {userId: 'user-1', experiments: ['plaid']}, body} as never; + return {user: {userId: 'user-1', experiments: []}, body} as never; } beforeEach(() => { @@ -22,7 +22,7 @@ describe('handlePickIntentRoute', () => { const result = await handlePickIntentRoute(makeRequest({text: ' who is hugo '})); expect(result.isOk()).toBe(true); - expect(pickIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: ['plaid']}); + expect(pickIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: []}); }); test('refuses empty text, and text past 500 characters, without asking the models', async () => { @@ -41,7 +41,7 @@ describe('handleFillIntentRoute', () => { const result = await handleFillIntentRoute(makeRequest({text: ' who is hugo ', widget: 'find_person'})); expect(result.isOk()).toBe(true); - expect(fillIntent).toHaveBeenCalledWith('who is hugo', 'find_person', {userId: 'user-1', experiments: ['plaid']}); + expect(fillIntent).toHaveBeenCalledWith('who is hugo', 'find_person', {userId: 'user-1', experiments: []}); }); test('refuses a request without a widget, or with text that is empty or past 500 characters', async () => { diff --git a/packages/api/src/intent/widgets.ts b/packages/api/src/intent/widgets.ts index 513088b2..c330a6dc 100644 --- a/packages/api/src/intent/widgets.ts +++ b/packages/api/src/intent/widgets.ts @@ -35,7 +35,7 @@ export type Widget = { /** What Jev reads to tell the widgets apart, and the LLM reads to know what it fills in. */ description: string; fields: WidgetField[]; - /** The experiment an organization needs on to be offered it, as banks are behind `plaid`. */ + /** The experiment an organization needs on to be offered it. */ experiment?: Experiment; }; @@ -160,14 +160,12 @@ export const WIDGETS: Widget[] = [ { id: 'show_balances', name: 'Balances', - experiment: 'plaid', description: 'See the balance of a bank account, card or loan, or how much money the person has or owes in all', fields: [{id: 'balanceAccount', name: 'Account', hint: 'the account asked about, as written without my ("checking", "amex", "savings", "credit card"); null for every account'}], }, { id: 'find_transactions', name: 'Transactions', - experiment: 'plaid', description: 'Find bank or card transactions, charges, payments and deposits, or add up what was spent or received: at a merchant, of a kind, on an account, over some days ("how much did I spend on restaurants last month", "find the uber charge", "when did I last get paid")', fields: [ diff --git a/packages/api/src/mcp/route.test.ts b/packages/api/src/mcp/route.test.ts index e02f881c..cac2a89e 100644 --- a/packages/api/src/mcp/route.test.ts +++ b/packages/api/src/mcp/route.test.ts @@ -89,21 +89,12 @@ describe('handleMcpRoute', () => { expect(result.instructions).toContain('Start with list_connections'); expect(result.instructions).toContain('- Email (alex@example.com), connection mail-1: Read, Archive'); expect(result.instructions).toContain('check Personal Agent Proxy first'); - expect(result.instructions).toContain('Personal Agent Proxy connects Email, Google, Granola, Notion, Linear, PostgreSQL, MySQL and any custom MCP server'); + expect(result.instructions).toContain('Personal Agent Proxy connects Email, Google, Granola, Notion, Linear, Plaid, PostgreSQL, MySQL and any custom MCP server'); expect(result.instructions).toContain('https://app.example.com/connections/new'); expect(result.instructions).toContain('give them its url'); expect(result.instructions).toContain('send_feedback'); }); - test('the instructions name a bank among the services only to an organization with the experiment on', async () => { - getAgentByAccessToken.mockResolvedValueOnce(Ok({...signedIn, experiments: ['plaid']})); - const response = await post(rpc('initialize', {protocolVersion: '2025-06-18'})); - - expect((response.body as {result: {instructions: string}}).result.instructions).toContain( - 'Personal Agent Proxy connects Email, Google, Granola, Notion, Linear, Plaid, PostgreSQL, MySQL and any custom MCP server', - ); - }); - test('lists the five tools, read-only ones marked so', async () => { const res = await post(rpc('tools/list')); const tools = (res.body as {result: {tools: Array<{name: string; annotations: {readOnlyHint: boolean}}>}}).result.tools; diff --git a/packages/docs/content/connections/plaid.mdx b/packages/docs/content/connections/plaid.mdx index c2051499..2788d148 100644 --- a/packages/docs/content/connections/plaid.mdx +++ b/packages/docs/content/connections/plaid.mdx @@ -8,18 +8,6 @@ Plaid connects Personal Agent Proxy to the bank, in the US and Canada. Agents on can't move money or change anything at the bank. Every request goes to Plaid live; nothing is copied. - -Banks are being tried out with some organizations first, so the catalog only offers Plaid to an -organization that has them turned on. On a self-hosted Personal Agent Proxy, turn them on for every -organization in its database: - -```sql -UPDATE "Organization" SET experiments = array_append(experiments, 'plaid') -WHERE NOT 'plaid' = ANY(experiments); -``` - - - ## Before you start: Plaid keys Hosted Personal Agent Proxy has its own Plaid keys, so skip to [Connect a bank](#connect-a-bank). A diff --git a/packages/integrations/src/catalog.ts b/packages/integrations/src/catalog.ts index 5891859f..cb68e16f 100644 --- a/packages/integrations/src/catalog.ts +++ b/packages/integrations/src/catalog.ts @@ -961,7 +961,6 @@ const plaid: Integration = { id: 'plaid', name: 'Plaid', description: 'Balances and transactions from your bank accounts, cards and loans', - experiment: 'plaid', actions: [ { id: 'readBalances', diff --git a/packages/utils/src/experiments.ts b/packages/utils/src/experiments.ts index 244258ae..a4f36459 100644 --- a/packages/utils/src/experiments.ts +++ b/packages/utils/src/experiments.ts @@ -5,10 +5,7 @@ * or is dropped, delete it here, and the compiler points at every place that asked for it; its id * left in the database is ignored. */ -export const EXPERIMENTS = { - // Connecting banks through Plaid (the `plaid` integration). - plaid: 'Bank accounts through Plaid', -} as const satisfies Record; +export const EXPERIMENTS = {} as const satisfies Record; export type Experiment = keyof typeof EXPERIMENTS; diff --git a/packages/web/src/client/me-client.ts b/packages/web/src/client/me-client.ts index 448f4a61..4ca17c9a 100644 --- a/packages/web/src/client/me-client.ts +++ b/packages/web/src/client/me-client.ts @@ -11,7 +11,7 @@ const meSchema = z.object({ }), // False until the welcome flow is finished or skipped; the human side shows it until then. onboarded: z.boolean(), - // The experiments on for the organization; read them with useExperiment. + // The experiments on for the organization; read them with useExperiments. experiments: z.array(z.string()).transform((ids) => ids.filter(isExperiment)), }); diff --git a/packages/web/src/components/integration-catalog.test.tsx b/packages/web/src/components/integration-catalog.test.tsx index ba1fdeac..bfe9d26f 100644 --- a/packages/web/src/components/integration-catalog.test.tsx +++ b/packages/web/src/components/integration-catalog.test.tsx @@ -5,7 +5,6 @@ import {cleanup, fireEvent, render, screen, within} from '@testing-library/react import {TooltipProvider} from '@proxy/ui/components/tooltip'; import {IntegrationCatalog, SignInFailed} from '@/components/integration-catalog'; import type {Connection} from '@/lib/types'; -import type {Experiment} from '@proxy/utils'; const GOOGLE_DOCS = 'https://docs.personalagentproxy.com/connections/google/'; @@ -26,7 +25,6 @@ afterEach(() => { function renderCatalog(args: { google?: boolean; plaid?: boolean; - experiments?: Experiment[]; connections?: Connection[]; name?: string; onConnectForm?: (integrationId: string) => void; @@ -36,7 +34,7 @@ function renderCatalog(args: { , @@ -90,12 +88,6 @@ describe('IntegrationCatalog', () => { fireEvent.click(row.getByRole('button')); expect(onConnectForm).toHaveBeenCalledWith('plaid'); }); - - test('a bank isn’t offered to an organization without the experiment on', () => { - renderCatalog({experiments: []}); - - expect(screen.queryByText('Plaid')).toBeNull(); - }); }); describe('SignInFailed', () => { diff --git a/packages/web/src/components/widgets/unsupported-widget.tsx b/packages/web/src/components/widgets/unsupported-widget.tsx index ff9eaaa1..fac41c97 100644 --- a/packages/web/src/components/widgets/unsupported-widget.tsx +++ b/packages/web/src/components/widgets/unsupported-widget.tsx @@ -2,7 +2,6 @@ import {BotIcon} from 'lucide-react'; import {Link} from 'react-router'; import {buttonVariants} from '@proxy/ui/components/button'; import {WidgetFrame} from '@/components/widgets/widget-frame'; -import {useExperiment} from '@/hooks/use-experiment'; import type {AgentLogin} from '@/lib/types'; // "Claude", "Claude or Poke", and past two, "one of your agents". @@ -18,7 +17,6 @@ function whomToAsk(agents: AgentLogin[]): string { // rest is for the person's agent, or, with none yet, offers to add one. export function UnsupportedWidget({agents}: {agents: AgentLogin[]}) { const active = agents.filter((agent) => agent.revokedAt === null); - const banks = useExperiment('plaid'); return (

Quick actions make events, find people, emails and files, write emails, do math and convert - units{banks ? ', and show your balances and spending' : ''}.{' '} + units, and show your balances and spending.{' '} {active.length > 0 ? `For anything else, ask ${whomToAsk(active)}, which can work with your connections.` : 'For anything else, add an agent and ask it: it can work with your connections.'} diff --git a/packages/web/src/hooks/use-experiment.ts b/packages/web/src/hooks/use-experiment.ts index df1df161..dc426a9a 100644 --- a/packages/web/src/hooks/use-experiment.ts +++ b/packages/web/src/hooks/use-experiment.ts @@ -1,15 +1,10 @@ -import {hasExperiment, type Experiment} from '@proxy/utils'; +import type {Experiment} from '@proxy/utils'; import {useRouteLoaderData} from 'react-router'; import type {Me} from '@/client/me-client'; -// Whether the experiment is on for the signed-in person's organization, from the human side's -// loader; off outside it, as in the welcome flow. Hiding something here is only for looks: the -// api checks the experiment itself. -export function useExperiment(experiment: Experiment): boolean { - return hasExperiment({experiments: useExperiments()}, experiment); -} - -// Every experiment on for the signed-in person's organization, the same way. +// Every experiment on for the signed-in person's organization, from the human side's loader; none +// outside it, as in the welcome flow. Hiding something here is only for looks: the api checks the +// experiment itself. export function useExperiments(): Experiment[] { return useRouteLoaderData('human')?.experiments ?? []; } diff --git a/packages/web/src/pages/home-page.tsx b/packages/web/src/pages/home-page.tsx index a48ed387..a2f18693 100644 --- a/packages/web/src/pages/home-page.tsx +++ b/packages/web/src/pages/home-page.tsx @@ -10,7 +10,6 @@ import {AppShell} from '@/components/app-shell'; import {FillingWidget, IntentWidget} from '@/components/widgets/intent-widget'; import {UnsupportedWidget} from '@/components/widgets/unsupported-widget'; import {Input} from '@proxy/ui/components/input'; -import {useExperiment} from '@/hooks/use-experiment'; import {apiMessage} from '@/lib/agent-errors'; import type {homeLoader} from '@/loaders'; @@ -33,10 +32,6 @@ const EXAMPLES = [ 'Find the pricing spreadsheet Paul shared', "What's an 18% tip on $86.40?", '72°F in Celsius', -]; - -// Examples for an organization with banks connected through Plaid. -const BANK_EXAMPLES = [ "What's my checking balance?", 'How much did I spend on restaurants last month?', ]; @@ -79,7 +74,6 @@ function saysUnsupported(answer: Answer, pausedOn: string): boolean { // fields come; Esc, or a click beside it, puts it back. export function HomePage() { const me = useRouteLoaderData('human'); - const banks = useExperiment('plaid'); const {connections, agents, activity} = useLoaderData(); // The time the page opened, for its greeting. const [opened] = useState(() => new Date()); @@ -251,9 +245,7 @@ export function HomePage() {

- {!active && ( - - )} + {!active && } {active && (