diff --git a/AGENTS.md b/AGENTS.md index 6219a4d5..d86e7c7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,31 +15,6 @@ - `bun run build` - Production builds of the web app, the docs and the website - `docker build -t proxy .` - The self-hosting image (`Dockerfile`): the api serving the web app's build on one origin, applying the migrations on start (`docker-entrypoint.sh`). `docker-compose.yml` runs it with Postgres and Caddy; `.github/workflows/image.yml` publishes it to `ghcr.io/personalagentproxy/proxy`. Hosted Personal Agent Proxy runs on Render from `render.yaml`: the image as one service (api and web app on one origin, two instances), Render Postgres, and the docs and the website as static sites -## Layout - -- `packages/ui` - `@proxy/ui`, the styling the web app, the docs and the website share: the Tailwind theme (`src/styles.css`, imported by each app's own stylesheet) and stock shadcn UI (`base-nova` style on Base UI) in `src/components`, imported as `@proxy/ui/components/`. Add components with `bunx --bun shadcn add ` from `packages/web` or `packages/docs`, which puts them here. The only local change to the shadcn files is one size step up on controls (buttons, inputs, selects, input groups) so they match common app sizing; re-apply it if a component is re-added. -- `packages/web` - Vite + React + Tailwind, styled with `@proxy/ui`. -- `packages/docs` - The docs, a Next app styled with `@proxy/ui` and exported as static files (`output: 'export'`, every page `/index.html` with trailing-slash URLs). Pages are MDX in `content/` (the path is the URL, a folder's `index.mdx` is the folder) with a `title` and `description` in their frontmatter, listed in order in `src/nav.ts`, the sidebar, where a page can have pages nested under it, folded in behind a chevron unless one of them is open or the entry sets `open`. `src/lib/docs.ts` compiles them at build time (GFM, heading ids, Shiki), and `src/components/mdx-components.tsx` styles each element and gives pages `` and `` without an import. Every page is prerendered; a page missing from `src/nav.ts`, or bad frontmatter, fails the build. -- `packages/website` - The website at personalagentproxy.com, a Next app styled with `@proxy/ui` and exported as static files like the docs. Its buttons lead to the app's two sign-in pages, `/login` and `/agent/login`, on `http://localhost:5173` in development and `https://app.personalagentproxy.com` once built. Above the headline, `LogoDock` (`src/components/logo-dock.tsx`) shows Personal Agent Proxy between the agents and the services, each side scrolling through its logos (in `public/logos/`); the tiles' shadow and lighting are `.logo-tile` in `src/app/globals.css`. `/privacy` and `/terms` are the privacy policy (which Google's OAuth verification reads, so what it says about the product must stay true of the code) and the terms, on one shell (`src/components/legal-page.tsx`), with the contact address, legal entity and jurisdiction in `src/lib/legal.ts`; every page shares `SiteFooter`. -- `packages/map` - The screen map, a Vite + React app styled with `@proxy/ui`, development only and never built: `scripts/crawl.ts` writes `generated/map.json` and the screenshots, `src/map-page.tsx` lays them out as a tree on a React Flow canvas (dagre, `src/layout.ts`), each screen its screenshot with its route above it, the same size at any zoom. The screens are the routes `src/router-paths.ts` reads from the web app's `router.tsx` as text; the crawl sees the links on each page (a sidebar's or breadcrumbs' as `nav`, hidden unless ticked), and `src/button-edges.ts` lists by hand what happens after a button or a form, which a test checks against the router. Not started by `bun run dev`. -- `packages/utils` - `@proxy/utils`, the Result helpers shared by every package: `Do`, `requirePresent` and `parseSchema` (`src/parse.ts`), and `httpRequest`, the fetch wrapper that returns a `FetchError` union instead of throwing (`src/fetch.ts`). Also `ApiError`/`ApiErr` and `wrapDb` (`src/api-error.ts`), the error every server-side function returns, the session cookie's name and parsing (`src/session-cookie.ts`), and the experiments (`src/experiments.ts`): features turned on for some organizations before everyone, `EXPERIMENTS` naming each by its id, `hasExperiment` asking whether a signed-in person (`request.user`), agent (`SignedInAgent`) or the web app's `Me` has one on (`useExperiment` in the web app, off outside the human side's layout; hiding something there is only for looks, the api checks it too). An experiment that ships or is dropped is deleted from `EXPERIMENTS`, and the compiler points at what asked for it -- `packages/integrations` - `@proxy/integrations`, the catalog the api and the web app share: each integration's actions, collections and fields (`src/catalog.ts`), the agents a login can be made for (`src/agent-providers.ts`), the email providers' servers (`src/email-providers.ts`), and access (`src/access.ts`): per action of a connection, an agent's own setting, else the connection's default, an action counting only with the one it `requires`, and tools (`src/tools.ts`): what an agent can do with a connection, generated from the catalog, one per list, open, write and command of each collection (`emails_list`, `emails_archive`), each with the action it needs and a JSON Schema of its params. A custom MCP server (`mcp` in the catalog, `custom`) has no actions of its own: `integrationFor(connection)` (`src/mcp-tools.ts`) builds its integration from the tools the connection keeps (`McpTool`, as the server lists them), one action per tool named by the tool, its risk from the tool's annotations (low when it only reads, medium when what it changes can be undone, high when it can't, and `unknown`, shown without a label, when it says nothing), and `integrationTools` passes them on as `call` tools with the server's own input schema (`ServerTool`, beside the catalog's `CollectionTool`). The web app adds the icons (`src/lib/integrations.ts`, whose `integrationOf(connection)` is what every page uses) -- `packages/db` - `@proxy/db`: Prisma 6 on Postgres. `src/index.ts` is the client; each module (`auth.ts`, `user.ts`, `organization.ts`, `connection.ts`, `info.ts`, `agent.ts`, `audit.ts`) exports functions returning `Result` through `wrapDb`. A connection's `tools` column holds a custom MCP server's tools as JSON, and `name` what the person called it; both null for every other integration. An organization's `experiments` are the ids of those on for it and `insider` turns all of them on, both set from the internal admin and nothing in the app; the session (`auth.ts`) and both ways an agent signs in (`signed-in-agent.ts`) read them in the same query, ids no longer in `EXPERIMENTS` dropped. `relationMode = "prisma"`: no foreign keys in the database, so Prisma runs the cascades and every relation column has its own index. Locally the database is Prisma's embedded Postgres (PGlite, `scripts/dev-db.ts`), which needs `connection_limit=1&pgbouncer=true` in `DATABASE_URL` -- `packages/api` - `@proxy/api`: Express on Bun. Routes are `handleXRoute` functions mounted in `src/server/create-api-router.ts`; JSON routes return `Result` through `withAuthResult`, which puts the signed-in user on `req.user`. Every environment variable is in `src/utils/env-schema.ts` with its docs, which the docs' Environment variables page is built from; add new ones there. Personal Agent Proxy is one origin everywhere: when `packages/web/dist` exists, as in the Docker image, the api serves the web app's build too (`src/server/serve-web.ts`), and every address the api builds starts from `APP_URL`. Browser-called writes take `requireBrowserOrigin` (CSRF); routes that read a cookie have no CORS. `src/connections/` lists, deletes and sets defaults on an organization's connections, connects a mailbox with an app password after signing in to its IMAP and SMTP servers (`email/`), and connects Granola, Notion and Linear by signing in to their MCP servers (`mcp/`, shared by all three: OAuth with PKCE against the server's authorization server, a client registered for each sign-in so nothing is set up beforehand, the handshake in an encrypted cookie, `mcpConnectRoutes` making both routes on the sign-in flow every OAuth integration shares, `connect-flow.ts`; tokens are refreshed as they run out, one refresh at a time per connection on each server, read again first so ones another call or server saved meanwhile are used rather than refreshed twice, and saved only over the ones they replace, whatever else the credential holds kept beside them, by `connections/token-session.ts`, which Google's connection will share; each tool call is one stateless `tools/call`, `mcp-client.ts`, which also lists a server's tools and, for a server that may keep a session (`handshake`), initializes one around each call as the spec asks). Every request to an MCP server, its metadata or its authorization server goes through `utils/checked-request.ts`, which checks the address as a database's host is checked (`utils/host-check.ts`: nothing private unless `ALLOW_PRIVATE_HOSTS`, and `https` unless the address is private) before each request, since a custom server and its endpoints are wherever it said, and never follows a redirect. A custom MCP server is connected by its address (`custom/`): `POST /api/connections/mcp` with `{name, url, token?, returnTo?}` (the name is the person's, kept through the sign-in in its cookie, since a server says nothing about itself that would do) asks the server for its tools, connects a server that answers at once, with no sign-in or the token typed in as a bearer token (as written when it has its scheme in front, `Basic …`), and for one that turns a request without a token down begins its sign-in there, under the browser's origin check, answering `{kind: 'signIn', url}` with the sign-in page the browser goes to: `mcp-discovery.ts` finds the authorization server from the server's protected resource metadata (the `WWW-Authenticate` header's `resource_metadata`, else the well-known paths), its endpoints from that server's metadata (else `/authorize`, `/token` and `/register` under it) and the scopes the server's own metadata names, and the sign-in, begun with `signInFlow` (`connect-flow.ts`, which `mcpConnectRoutes` builds on for the servers known in advance), keeps the endpoints in the handshake cookie and in the credential beside the tokens. The connection's account is the address without its scheme (`mcp.example.com/mcp`), connecting the same address again replaces its credential and tools, a new connection's defaults are the tools that say they only read, `POST /api/connections/:id/tools` asks the server for its tools again (replacing the tools drops the defaults and agents' settings for tools the server no longer lists, `updateConnectionTools`), and a tool is run as it is (`custom-mcp.ts`): its params go to the server as given, a tool that fails answers the agent with the server's own text as a `validation_error`, and the run is logged under the collection `tools` by the tool's name, with its params as JSON, cut to 200 characters. Granola's notes and transcripts are on by default, Notion's Read pages and Linear's Read issues alone; a Notion or Linear sign-in is for one workspace, and a Notion one lasts at most 180 days, however often it is refreshed. `src/agents/` makes, revokes and deletes agent logins (the password is sent once, only its argon2id hash is kept) and sets an agent's own settings per action of a connection where they differ from the default. `src/records/` reads and writes a collection's records through the integration's `Connector` (`connectors.ts`): Information's live in the database (`info-connector.ts`), a mailbox's are fetched over IMAP on every request (`email-connector.ts`), which merges the inbox and the folders the server marks as Drafts and Sent into one list (ids start with the folder: `inbox-…`, `drafts-…`, `sent-…`) and runs the commands: flags, moves to the folders the server marks as Archive (Gmail's All Mail) or Trash, and sending over SMTP, a draft or a new email, filing a copy in Sent where the server doesn't (Gmail does). Received emails are never edited or deleted for good; only drafts are. Granola's notes are fetched from its MCP server on every request (`granola-connector.ts`; the reading of the XML-like text Granola answers in is in `connections/granola/granola-mcp.ts`): its Notes and Transcripts collections are the same meetings by Granola's meeting id, and since Granola's tools have no pages, a page is 30 days of meetings; one that finds none skips back to the newest meeting before it, so the list ends where the meetings do. Granola's dates and attendees are shown as Granola writes them. Notion's workspace is fetched from its MCP server the same way (`notion-connector.ts`, the reading of its answers in `connections/notion/notion-mcp.ts`), as the person who signed in, as a tree in one nested collection: Notion has no list of every page, so the top is their Recents and a search (Notion's own, AI search where the workspace has it) finds the rest, one page of 50; inside a page are the pages and databases in its content, inside a database its saved views (`view-…` ids), or the rows of its only one, and inside a view its rows, paged with Notion's cursor (view queries have no plan quota; Notion's row and SQL queries do). A page reads as Notion-flavored Markdown, a row's properties beside it as "Name: value" lines (`notion-properties.ts`), and only a page's, database's or view's id is ever sent to Notion's fetch; a new page is a private draft at the top, or goes inside the page, database or view it is made in, a row's title in the database's title property; an edit sends the title, the properties written differently and the content, each only where it changed, and pages are never deleted. Notion's MCP server has no tool that deletes or trashes a page. Linear's issues are fetched from its MCP server the same way (`linear-connector.ts`, the reading of its answers in `connections/linear/linear-mcp.ts`), as the person who signed in, by their identifier (`ENG-123`): a list is the issues updated last first, 50 to a page with Linear's cursor, narrowed by the type of status (`?statusType=Started`) and searched in titles and descriptions, and an issue opens with its comments, oldest first. Teams, statuses, people, projects and labels are written by name, as Linear shows them and finds them, its refusals passed on to the agent; an edit sends only the fields written differently, an empty one clearing it. Linear's MCP server has no tool that archives or deletes an issue. A bank is connected through Plaid, for now only by an organization with the `plaid` experiment on, the catalog's `experiment` on the integration (`isOffered` in `@proxy/integrations`), which the link-token route for a new bank and the connect route check, the MCP server's instructions leave Plaid out of the services without, and the catalog's `IntegrationCatalog` asks for (`experiments`, from `useExperiments` on the human side and the welcome loader in the welcome flow) before offering it; a bank already connected is signed in to again without it (`connections/plaid/`, with the server's own keys, `PLAID_CLIENT_ID`, `PLAID_SECRET` and `PLAID_ENV`, `sandbox` or `production`, which `/auth/methods` reports as `needs.plaid`): Plaid Link runs in the browser (`react-plaid-link`, `ConnectBankForm`, at `/connections/new/plaid` and `/welcome/connections/plaid`), an OAuth bank's sign-in in a popup, so there is no redirect URI; `POST /api/connections/plaid/link-token` makes the Link token (two years of Transactions, US and Canada) and `POST /api/connections/plaid` with `{publicToken}` exchanges what Link answered for the access token the credential keeps beside the login's id (`providerAccountId`), each bank login a connection of its own named by its bank, giving agents nothing until ticked. Signing in to a bank again, which Plaid errors such as `ITEM_LOGIN_REQUIRED` tell the agent to ask for, is Link's update mode on the same login (`link-token` with `{connectionId}`, the connection page's Sign in to the bank again), which keeps its access token, and disconnecting removes the login at Plaid (`/item/remove`, which ends Plaid's monthly fee for it). Its records (`records/plaid-connector.ts`) are only read, live from Plaid's `/accounts/get` (balances as of Plaid's last update, without the per-request fee of live Balance) and `/transactions/get`: `accounts`, each account with its balances (Read balances), and the nested `transactions` (Read transactions), the accounts at the top each holding its transactions, newest first, 50 to a page by offset, the page token carrying the days it covers; a search is one day (that day and before it) or two (between them), at the top in every account, since Plaid has none in words. A transaction's id is its account's, its day and its own (`:2026-10-11:`), since Plaid finds one only among an account's on a day; amounts read as a statement does, money out negative. Google is connected by signing in to Google with the app's own Google client, the one Google sign-in uses (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, a second redirect URI `/api/connections/google/callback`), asking for every scope at once, Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts, with offline access (`connections/google/`: `google-oauth.ts` the sign-in and refresh, with the client's secret, keeping the refresh token Google doesn't send again; `google-client.ts` the calls with a bearer token and Google's errors, a disabled API passing Google's own message on, which names the API and where to enable it); the credential holds the tokens, the scopes Google granted, since the consent screen lets a person untick some, and the address; the connection keeps Google's id for the account (`Connection.providerAccountId`, its `sub`), so signing in again renews the same connection, and its address, even after the address changed (`findAccountConnection`, by address for one without an id and for the MCP integrations). Each action needs one of the sets of scopes allowing it (`google/scopes.ts`, where Calendar's actions need `calendar.events` and `calendar.calendarlist.readonly` together, and `PRODUCT_SCOPES` is what a sign-in asks for), so an action whose scope wasn't granted is unavailable: `connections/unavailable.ts`, the one place the rest of the api asks, reads it from the credential on every request (`unavailable` on a connection; one that can't be read leaves every action unavailable rather than failing the list), no default or agent setting can turn it on, `runAgentTool` refuses it saying to sign in to Google again, and the connection page shows its checkbox disabled with a "Sign in again" link (`?account=` opening Google on the account with `login_hint`). An integration's `disclosure`, Google's Limited Use line, is shown above a connection's permissions. Disconnecting a Google connection revokes its grant at Google first (`connections/disconnect.ts`), unless another connection in any organization is for the same account, since a grant covers all of the client's tokens for it, and deletes it even when Google can't be reached. Its Gmail is the `emails` collection, the same fields and commands as a mailbox's under the Google action ids (`readEmails` … `sendEmails`, both built by `emailsCollection` in the catalog), fetched live from Gmail's API (`records/google-emails.ts` over `connections/google/gmail-api.ts`): one list, 50 to a page with Gmail's page token, all mail but Spam and Trash, or one folder, each a label (Inbox `INBOX`, Draft `DRAFT`, Sent `SENT`, Trash `TRASH`, Spam `SPAM`) or, for Archive, a search for what is in none of them; an email's labels say its folder, so ids are Gmail's message ids, a draft's `draft-`, the id Gmail edits, deletes and sends it by and which stays as it is edited. `messages.list` gives only ids, so each is asked for with its headers, 8 at once, again twice when Gmail answers busy; Gmail's per-user quota of 6,000 units a minute makes a page cost about 1,000. A search is Gmail's own. An email opens as its text part, or its HTML part as text (read in one pass, `htmlToText`), with its attachments' names; its `url` is Gmail's page for it in the account (`?authuser=`), `#all/`, a draft's `#drafts?compose=`. Marking takes `UNREAD` off or puts it on, starring `STARRED`, archiving takes `INBOX` off, and Move to Trash is `messages.trash`, never `messages.delete`: nothing is deleted for good but drafts, the only email edited or deleted, an edit keeping the draft's Cc, Bcc and thread, its recipients' names where they are written as they read and its HTML where its text is, and refusing a draft with attachments, which it would drop; a draft is edited, deleted and sent by its message id too. Email goes out from the connection's own address, a draft with `drafts.send` and a new one with `messages.send`, written by `MailComposer` as the IMAP connector writes it; Gmail files it in Sent. Its Drive is one nested collection, `files` (`records/google-files.ts`, behind `google-connector.ts`, which dispatches on the collection), fetched live from Drive's and Sheets' APIs (`drive-api.ts`, `sheets-api.ts`): at the top four places, My Drive, Shared with me, Shared drives (`drive-…` ids, of the kind Shared drive, which is never edited or trashed) and Recent, inside them and inside folders their files, folders first, 50 to a page with Drive's page token, and a search is Drive's full-text one, anywhere or inside the folder opened; the trail is walked up a file's parents to My Drive, its shared drive, or, where the account can't see further up, Shared with me. A document opens as Markdown and a presentation as text, as Drive exports them, up to its 10 MB export cap, beyond which its content says the file is too large, so it can still be renamed or trashed; a spreadsheet opens into its tabs (`sheet--`) and a tab into its rows (`row---`), 50 to a page, the page token the next row's number, each row's values "Header: value" lines under the tab's first row, a header repeated or a column without one going by its letter (`Status (E)`); a page past the tab's last row is empty and a row past it not found without asking Sheets, which refuses a range outside the grid. Folders, documents and spreadsheets are made in My Drive (`root`, where one made without a parent goes too), a folder or a shared drive, never in Shared with me, Shared drives or Recent, the record's `kind` saying which, the one select a create writes and an edit can't change: a document is uploaded as Markdown and a spreadsheet as CSV with the Google type Drive converts it to (a multipart upload, `drive-api.ts`), and a row made inside a tab goes after the last of its rows holding anything (`values.append` on the whole tab, written again from column A where Sheets started it further right), its "Header: value" lines put under the headers with `readValueLines`. An edit renames a file or a tab (`updateSheetProperties`), replaces a document's content whole by uploading Markdown again, only where it is written differently from the record as `runAgentTool` read it (`before`, passed to every connector's update), which drops its comments and suggestions, and changes a row only in the cells written differently (`values:batchUpdate`); a presentation's and a spreadsheet's content are only read, and the places and shared drives aren't changed. Files go to Trash with the `trash` command (`trashed: true`), never `files.delete`, and tabs and rows don't. A second collection, `ranges` (`records/google-ranges.ts`), is only run, as a database's queries are: Read (`readOnly`, Read files) and Write (Edit files) take a spreadsheet's id or link and a range in A1 notation, checked against a strict pattern before it reaches Google's address, and read or write its values as CSV (`records/csv.ts`; a read answers 1000 rows at most), written as if typed in, so a formula stays one, and answered as Sheets shows them afterwards. Every files action takes the `drive` scope, the Sheets API's writes included. Its Calendar is the nested `events` collection (`records/google-events.ts` over `connections/google/calendar-api.ts`): at the top the calendars in the account's list, primary first, inside each its events from now on, soonest first (the collection is `upcoming`, so lists say so and the agent side's next page is Later), 50 to a page with Google's token wrapped with where the list began, every time a repeating event comes round its own (`singleEvents`), a search being Google's `q`, one beginning with a day (`2026-10-15 standup`) listing from that day's start on the calendar's clock. A calendar's id is Google's; an event's is `:`. Times read as Google writes them, with their offset, an all-day event's end as its last day; they are written with an offset or on the calendar's clock (sent with its time zone), a missing end an hour or the day, and a start moved without a new end keeps the event's length. Guests are a field with an action of its own (Invite guests): writing guests, or changing an event that has any, needs it beyond Create and edit events, and Google emails them (`sendUpdates=all`; none for an event without guests); the account and rooms are kept when the guests are rewritten, as are the answers of those still invited. Accept, Decline and Maybe (Answer invitations) set the account's own answer on an event someone else invited it to. Events are never deleted. Its Contacts are the `contacts` collection (`records/google-contacts.ts` over `connections/google/people-api.ts`): the account's contacts, most recently changed first, 50 to a page, or the first 30 of Google's prefix search (after the empty search Google asks for first); ids are the resource name without `people/`. An edit sends only the fields written differently, with the contact's etag, an email, number or address still there keeping its label, and what the person's own Google profile shows beside the contact is read, never written. Contacts are never deleted, and Other contacts aren't listed. `google-connector.ts` runs a command on the collection it was run on, since `emails` and `files` both have `trash`. PostgreSQL and MySQL (MariaDB too) are two integrations on one code path: a database is connected with its host, port, database, user, password and encryption (`connections/sql/`: `route.ts` makes both routes per dialect, `sql-client.ts` opens one connection per request with `Bun.sql`, to the address `utils/host-check.ts` resolves the host to, refusing private addresses unless `ALLOW_PRIVATE_HOSTS` is set, and `sql-statement.ts` checks an agent's query is one statement that reads), and `records/sql-connector.ts` reads and writes it through `records/sql-dialect.ts`, where the two differ: the tables are one nested collection, a table holding its rows, a row read and written as "column: value" lines and found again by its primary key (ids `public.users:42`, `pairs:3,4`; a table without a key is never written to), and a query, a second collection that is only ever run, goes through a read-only transaction with a time limit, a cursor on Postgres and `sql_select_limit` on MySQL, 200 rows at most, answered as JSON lines. MySQL's read-only transaction doesn't stop DDL, so the statement check is what keeps a query to reading; the docs tell people to make a read-only database user as well. Lists take `?search=` (only where the collection has a `searchHint`; Granola has none), `?filter=` (the collection's `filterField`, such as `?filter=Draft`), `?parent=` (only in a `nested` collection: the list inside a record, answered with its `trail`, the records above it; a record made there goes in it) and `?page=` (`list-query.ts`) and answer a page, newest first (a mailbox's 50 emails, Granola's 30 days), with the next page's token; a mailbox's token holds where each folder goes on, so paging through the merged list shows every email once, and its search uses Gmail's own syntax on Gmail (`X-GM-EXT-1`) and IMAP text search elsewhere. Agents sign in at `/agent-auth/login` with a cookie of their own (`src/agent-auth/`); `src/agent-side/` serves them: the connections an agent can use, the tools it has with each, and `runAgentTool` (`tools.ts`), the one place a tool runs, which checks it against the agent's access as it is now, validates its params against the tool's schema (an edit changes only the fields given), calls the connector, or a custom server's tool as it is, answering `{text}`, and logs it, denied ones too, to the activity log that `src/activity/` lists. Over HTTP that is `/api/agent/me`, `GET /api/agent/connections/:id/tools` and `POST /api/agent/connections/:id/tools/:tool` with `{params}`; `src/mcp/` serves the same over MCP at `/mcp` (Streamable HTTP without sessions, JSON answers, no stream): five tools whatever the login may do, `list_connections` (the entry point), `list_tools`, `read` (only tools that read, marked read-only), `run` and `send_feedback`, and server instructions naming the agent's connections, telling it to point the person to a record by its `url`, never by Personal Agent Proxy's own addresses, and to tell us with `send_feedback` when something doesn't work as it expected. Feedback (`agent-side/feedback.ts`, also `POST /api/agent/feedback` with `{message, connection?, tool?}`) is kept in `AgentFeedback` for us to read; nothing in the app shows it. MCP clients sign in with OAuth 2.1 (`src/oauth/`), Personal Agent Proxy being its own authorization server: metadata under `/.well-known/`, Dynamic Client Registration at `/oauth/register` (open to anyone, each registration first deleting the clients that registered over a day ago and never finished a sign-in), `/oauth/authorize` sealing the request into the web app's `/connect-agent` page, where the person chooses the agent login the client works as or makes one, and `/oauth/token` with PKCE (S256 only) and refresh tokens replaced on every use; codes and tokens are kept as their SHA-256, and revoking an agent ends its clients' tokens. These endpoints take any origin (CORS), since none reads a cookie; credentials and Information records are stored encrypted with `ENCRYPTION_KEY` (`src/utils/secret-crypto.ts`). Has its own `.prettierrc.json` with a 200-column print width -- `skills/` - Agent skills, symlinked into `.claude/skills` and `.codex/skills` by `bun run setup` - -## The app - -Personal Agent Proxy holds the integrations and personal information an AI agent needs, so every agent company does not have to rebuild them and you do not have to trust each one's security. A human connects services and hands out agent logins; an agent signs in with one and works through what it was given. Everything is served by `packages/api`; the web app has no data of its own. - -- **Model**: an integration from the catalog (`@proxy/integrations`: Email over IMAP and SMTP, Google's Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts over Google's APIs, one sign-in for a Google account, Granola's meeting notes, Notion's pages and Linear's issues over their MCP servers, a bank's balances and transactions through Plaid, PostgreSQL and MySQL over their own protocols) has the actions an agent can be allowed with a connection of it (Read, Archive, Write drafts, Send) and collections, each a list of fields with the actions reading and writing it need, and the commands it offers beyond editing (Mark as read, Archive, Send), each needing one action. A command runs on a `record`, where its `where` picks out which (Archive only in the inbox, Send only on a draft), or on values typed in (`new`: sending a new email). A mailbox is one collection, its folder a field the agent side filters by. A `nested` collection is a tree, as Notion's pages are: a record can hold others of the collection (`hasChildren`), listed inside it with the list tool's `parent` and made inside it with the create tool's, and access is the same anywhere in it. A collection marked `listsAll`, as Plaid's are, shows every field of a record in its row on the agent side, a day as written. A collection without a `read` action is never listed or opened, only run, as a database's queries and a spreadsheet's ranges are, and a `new` command marked `readOnly` counts as a read for MCP clients. Integrations join the catalog once they work end to end. Any other service with an MCP server is connected as a custom MCP server: the server's tools are the actions, each allowed on its own, and there are no collections; an agent runs a tool with the params the server describes and gets back what it answered. A connection is a connected integration and its account, owned by the organization; an integration can be connected more than once, such as two mailboxes. Information is the built-in `info` connection (addresses, payment cards, notes), made with the organization, so access and the agent side treat it like any other. Nothing is copied from a provider: every request goes to it live. Fields marked `system` are set by the provider, never typed in. A record carries `url` beside its fields (`DataRecord.url`, never a field): its page in the provider's own app, for an agent to hand the person, since the agent side's pages only open with the agent's login. It is set wherever the provider has one (Gmail, Granola, Notion, Linear, Calendar, Drive, a tab or a row of a sheet, Contacts), on lists too, and the collection's `openIn` names where it opens ("Open in Gmail"); a mailbox over IMAP, a database and Information have none, and the agent is told to name the record instead. -- **Access** is one list of actions per connection, and every action the catalog lists can be set: what Personal Agent Proxy can do with the provider is the limit. Two layers: the connection's default (a `ConnectionDefault` row per action that is on) and an agent's own setting, on or off, where it differs (`AgentGrant`). An agent gets its own setting, else the default as it is now; an action that `requires` another counts only with it, all the way down (Archive needs Read; Invite guests needs Create and edit events, which needs Read events; Send needs nothing, so an agent can send without reading the mailbox), and a field can name an action of its own (`Field.action`), which `runAgentTool` asks for beyond the write wherever the field holds anything, before the write or after it, refusing and logging as denied without it, and an action a sign-in didn't cover, as Google's consent screen allows, is unavailable whatever is ticked. A collection's `writes` name the action creating, editing and deleting need, and `editable` which records they apply to (drafts). An agent login is for one agent from `AGENT_PROVIDERS`; each organization can have one login per company, named by it, and any number of custom MCP agents (`custom`, for any MCP client not in the list), each named by the person and told apart by their names (`Agent` is unique by organization, provider and name). It has a generated username and a password that is shown once. Every request on the agent side is an audit entry, allowed or denied, with what a list searched for. -- **Accounts**: a human signs in with Google or a magic link (`src/auth/` in the api); the sign-in page says that continuing accepts the terms and the privacy policy, on the website, and a sign-up records it (`User.termsAcceptedAt`); accounts from before the notice have none until they are asked. A session is a `Session` row and an httpOnly cookie; the web app's `/api/me` loader on the human side's route redirects to `/login?callbackUrl=…` without one. Every user gets one `Organization` at sign-up, created with the user. `ALLOWED_SIGNUP_EMAILS` limits who can sign up; the login page asks `/auth/methods` whether to offer Google. In development the login page has a Log in as dev user button, signing in as `dev@dev.proxy.local`, made on its first use (`POST /auth/dev/login`; `{fresh: true}`, as the screen map's crawl sends, makes a throwaway user instead); in development, or without `RESEND_KEY`, the api prints magic links instead of emailing them. Cookies are secure when `APP_URL` is HTTPS. A new organization first goes through the welcome flow (`/welcome`, outside the human side's layout; the human side redirects there until `Organization.onboardedAt` is set, which `POST /api/me/onboarded` does when the flow is finished or skipped): step one is `AgentSetup` (`src/components/agent-setup.tsx`, which New agent and setting an agent up again use too): it picks the agent from `AGENT_PROVIDERS`, the choice in the address (`?agent=claude`) so the browser's back button returns to it, and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code and Hermes Agent use terminal commands) while the page polls for the login its sign-in makes, and over http, as in development, only a `local` agent (Claude Code, Claude Desktop, Hermes Agent) can be picked, the rest greyed out with a pointer to self-hosting on a server with a domain, a `login` agent given a login at once, as a message to paste to it with the sign-in page, username and password, or, for a `vault` agent (Instinct), which won't take a password in chat, as three steps: a message asking it for a link to its vault, the login to fill in on the page it sends, and a message telling it to sign in; step two (`/welcome/connections`) is the catalog, a plus per integration with how many accounts are connected: a mailbox goes to `/welcome/connections/email`, the form on a page with Continue once signed in, and a sign-in started with `?returnTo=welcome` comes back to `/welcome/connections/:id`, the new connection's permissions (its defaults, saved as they are ticked) with Confirm back to the catalog; a database goes to `/welcome/connections/postgres` or `/welcome/connections/mysql` the same way as a mailbox. Organizations from before the flow were marked done by its migration. -- **Human side** behind `/login`: `/connections` (a right click on a row renames the connection, `PUT /api/connections/:id/name`, an empty name being none of its own, which a custom MCP server can't have, or deletes it, each behind a dialog), `/connections/new` (the catalog, a plus per integration with how many accounts are connected: `/connections/new/email` connects a mailbox with an app password, with a Test connection that signs in and saves nothing, `/connections/new/postgres` and `/connections/new/mysql` a database the same way, `/connections/new/mcp` a custom MCP server under a name of the person's, by its address, with an access token where the server takes one instead of a sign-in (`ConnectMcpForm`, which sends the browser to the server's sign-in when the api says it asks for one), and Google, Granola, Notion and Linear are connected by signing in to them; an integration needing a client the server doesn't have (Google without `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, Plaid without `PLAID_CLIENT_ID` and `PLAID_SECRET`, which `/auth/methods` reports) is greyed out with a pointer to the docs, and its sign-in, reached anyway, comes back with `?error=google-unconfigured` saying the same; `ConnectEmailForm`, `ConnectDatabaseForm`, `ConnectMcpForm` and `IntegrationCatalog` serve the welcome flow too, at `/welcome/connections/`), `/connections/:id` (titled by the name the person gave it where they did, as the list and the agent side are, the defaults under Permissions, with how many agents differ per action, an integration whose actions name a `group` (Google's Gmail, Calendar, Drive and Contacts) showing each group under its name with how many are on, an Enable all button above the actions (Disable all once every one the sign-in covered is on; on the agent page it sets the agent's own settings), a new Google connection starting with nothing on, a custom MCP server's tools being its actions, with a button that asks the server for them again, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents`, `/agents/new` (the welcome flow's agent step, among the companies without a login yet and Custom MCP agent, on to the agent's page), `/agents/:id/setup` (the same for one agent, to hand it a new password or add the connector again; the only place a password shows, once), `/agents/:id` (what the agent can reach and did, the MCP clients working as it when there are any; access laid out like an editor's settings: one checkbox per action of each connection, the only control for it, each connection folded by default to a row saying Default or Changed; a setting of the agent's own is marked with a bar, a button that makes it the connection's default for every agent, and one that resets it, and a filter and Changed only narrow the list; `ConnectionAccess` serves both pages), `/activity` (filters in the URL, so the agent and connection pages link to their part of the log; a custom server's tool reads "Ran Search docs with {…}"), `/settings` (the account: its address, and Delete account, behind a dialog that asks for the address typed again: `DELETE /api/me` undoes each connection at its provider as disconnecting does, `beforeDisconnect`, then deletes the user and their organization with everything in it, `deleteUser`, which also drops sign-in links waiting for the address, and clears the session cookie). Each page has a loader in `src/loaders.ts` and revalidates after a change. Layout follows the spanish repo: the shadcn sidebar as a rail, an `h-12` header, one `max-w-3xl` column, one-line `RowList` rows. Type has one size, `text-sm`, in rows, summaries and hints alike, except column headers (`RowHeader`), which are `text-xs`: the thing itself in the normal colour, what is said about it (column headers, times, summaries, descriptions) in `text-muted-foreground`, and `font-medium` only for page and section titles. -- **MCP**: an agent that speaks MCP (Poke, Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, or any other as a Custom MCP agent) adds `/mcp` as a custom connector and signs in; the person picks which agent login it works as on `/connect-agent`, or makes one there: a client whose name points at no company's agent is offered a new custom agent named after it, which the person can rename. Setting up a custom agent waits for one that wasn't there before to sign in, since there can be several. The agent page shows the server's address and the MCP clients working as the login, each with a Disconnect; the activity log marks requests that came in over MCP (`AuditEntry.via`). -- **Agent side** behind `/agent/login`, with its own cookie: `/agent` lists what the login can do with each connection and the lists it can open, `/agent/:connection/tool/:tool` one of a custom MCP server's tools, a form of its params built from the tool's schema and what it answered below (the home page lists the tools the login may run), `/agent/:connection/:collection` a collection's records (newest first, 50 to a page with an Older link, narrowed by its `filterField`, such as the folder, in the address as the list tool takes it, `?folder=Draft`, and searched where the collection has a `searchHint`, which says what the search takes; in a nested collection `?parent=` opens it inside a record, with the records above it as breadcrumbs, a record holding others opening into them, and New making one there), `…/new` and `…/:record` the generic `RecordForm` and `RecordFields` (a record with a `url` shows its Open in … link with the address written out, each list row the link beside it, and both pages say to give the person that link, or, without one, to name the record), with a button per command the agent may run on the record, Send beside Save as draft on a new email, Write beside Read on a range, and a command that answers something, as running a query does, shown under the form (loaders in `src/agent-loaders.ts`). Every page runs a tool (`toolName` in `@proxy/integrations`) through the api. Every page but the form itself ends with a link to `/agent/feedback`, the same feedback as `send_feedback`, carrying the connection and tool it was on. It is built for a model driving a browser: no sidebar, no icon-only buttons, nothing that only shows on hover. Lists it can neither read nor add to are left out, and one it can only add to (sending without Read) opens on its form; asking for one anyway shows the api's refusal, which it logs as denied. -- **Testing email locally**: any IMAP server with TLS on 993 and SMTP on 465 or 587 works through Other. A self-signed one needs `NODE_TLS_REJECT_UNAUTHORIZED=0` on the api, never in production. The server's hostname needs a dot and can't be an IP address: `localhost4.localdomain4` reaches 127.0.0.1 on most Linux machines. - ## Code Style ### Early Returns @@ -199,8 +174,12 @@ Don't flatter the user. Skip openers like "great question", "good catch", "you'r ## Git -Never add Claude or Codex to any commit, message or pull request. +NEVER give Claude or Codex credit for any commit, message or pull request. ## Scheduling Never offer to `/schedule` follow-up work. Do not suggest scheduling background agents, recurring tasks, or one-time future runs at the end of replies. + +## Editing this file + +Never edit `AGENTS.md`. If information in this file becomes outdated as a result of a change that was made, alert the user but don't do the edit yourself unless explicitly asked.