Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 2 additions & 24 deletions packages/api/src/connections/plaid/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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');
Expand All @@ -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();
Expand Down
19 changes: 3 additions & 16 deletions packages/api/src/connections/plaid/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void, ApiError> {
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.
Expand All @@ -52,9 +43,6 @@ function requirePlaidConnection(orgId: string, connectionId: string): Promise<Re
export function handleCreatePlaidLinkTokenRoute(request: AuthenticatedRequest): Promise<Result<{linkToken: string}, ApiError>> {
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;
Expand All @@ -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<Result<ConnectionResponse, ApiError>> {
return Do(async ($) => {
$(requirePlaidExperiment(request));
const client = $(requirePlaidClient());
const {publicToken} = $(parseSchema(connectBodySchema, request.body));
const orgId = $(await requireUserOrgId(request));
Expand Down
24 changes: 6 additions & 18 deletions packages/api/src/intent/classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -82,6 +81,8 @@ describe('pickIntent', () => {
'find_file',
'calculate',
'convert_units',
'show_balances',
'find_transactions',
'incomplete',
'none',
]);
Expand All @@ -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 [
Expand Down Expand Up @@ -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']},
Expand All @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/intent/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
4 changes: 1 addition & 3 deletions packages/api/src/intent/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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: [
Expand Down
11 changes: 1 addition & 10 deletions packages/api/src/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 0 additions & 12 deletions packages/docs/content/connections/plaid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout>
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);
```

</Callout>

## Before you start: Plaid keys

Hosted Personal Agent Proxy has its own Plaid keys, so skip to [Connect a bank](#connect-a-bank). A
Expand Down
1 change: 0 additions & 1 deletion packages/integrations/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 1 addition & 4 deletions packages/utils/src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
export const EXPERIMENTS = {} as const satisfies Record<string, string>;

export type Experiment = keyof typeof EXPERIMENTS;

Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/client/me-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
});

Expand Down
10 changes: 1 addition & 9 deletions packages/web/src/components/integration-catalog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/';

Expand All @@ -26,7 +25,6 @@ afterEach(() => {
function renderCatalog(args: {
google?: boolean;
plaid?: boolean;
experiments?: Experiment[];
connections?: Connection[];
name?: string;
onConnectForm?: (integrationId: string) => void;
Expand All @@ -36,7 +34,7 @@ function renderCatalog(args: {
<IntegrationCatalog
connections={args.connections ?? []}
configured={{google: args.google ?? true, plaid: args.plaid ?? true}}
experiments={args.experiments ?? ['plaid']}
experiments={[]}
onConnectForm={args.onConnectForm ?? mock()}
/>
</TooltipProvider>,
Expand Down Expand Up @@ -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', () => {
Expand Down
4 changes: 1 addition & 3 deletions packages/web/src/components/widgets/unsupported-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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 (
<WidgetFrame
icon={BotIcon}
Expand All @@ -33,7 +31,7 @@ export function UnsupportedWidget({agents}: {agents: AgentLogin[]}) {
>
<p>
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.'}
Expand Down
13 changes: 4 additions & 9 deletions packages/web/src/hooks/use-experiment.ts
Original file line number Diff line number Diff line change
@@ -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<Me>('human')?.experiments ?? [];
}
Loading
Loading