diff --git a/AGENTS.md b/AGENTS.md index cb6dd5d..0d2bf9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ - `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, and the session cookie's name and parsing (`src/session-cookie.ts`) - `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. `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. 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): four tools whatever the login may do, `list_connections` (the entry point), `list_tools`, `read` (only tools that read, marked read-only) and `run`, and server instructions naming the agent's connections and telling it to point the person to a record by its `url`, never by Personal Agent Proxy's own addresses. 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 +- `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. 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 @@ -37,7 +37,7 @@ Personal Agent Proxy holds the integrations and personal information an AI agent - **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 Create dev user button; 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`, 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 {…}"). 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. 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. +- **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 diff --git a/packages/api/src/agent-side/feedback.test.ts b/packages/api/src/agent-side/feedback.test.ts new file mode 100644 index 0000000..3b23a08 --- /dev/null +++ b/packages/api/src/agent-side/feedback.test.ts @@ -0,0 +1,50 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const saveAgentFeedback = mock(); + +mock.module('@proxy/db/feedback', () => ({saveAgentFeedback})); + +const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude'}; + +beforeEach(() => { + mock.clearAllMocks(); + saveAgentFeedback.mockResolvedValue(Ok(undefined)); +}); + +describe('sendAgentFeedback', () => { + test('keeps the message, trimmed, with the agent and what it is about', async () => { + const {sendAgentFeedback} = await import('./feedback'); + const result = await sendAgentFeedback({agent, via: 'mcp'}, {message: ' emails_list ignored the folder \n', connection: 'mail-1', tool: 'emails_list'}); + + expect(result.unwrap()).toEqual({sent: true}); + expect(saveAgentFeedback).toHaveBeenCalledWith({ + orgId: 'org-1', + agentId: 'agent-1', + providerId: 'claude', + message: 'emails_list ignored the folder', + connectionId: 'mail-1', + tool: 'emails_list', + via: 'mcp', + }); + }); + + test('without a connection or a tool, keeps none', async () => { + const {sendAgentFeedback} = await import('./feedback'); + await sendAgentFeedback({agent, via: 'web'}, {message: 'The pages are slow', connection: ''}); + + expect(saveAgentFeedback).toHaveBeenCalledWith(expect.objectContaining({connectionId: null, tool: null, via: 'web'})); + }); + + test('an empty or overlong message is refused in words the agent can act on', async () => { + const {sendAgentFeedback} = await import('./feedback'); + const empty = await sendAgentFeedback({agent, via: 'mcp'}, {message: ' '}); + const long = await sendAgentFeedback({agent, via: 'mcp'}, {message: 'x'.repeat(5001)}); + const missing = await sendAgentFeedback({agent, via: 'mcp'}, {}); + + expect(empty.unwrapErr()).toMatchObject({kind: 'validation_error', message: expect.stringContaining('empty')}); + expect(long.unwrapErr()).toMatchObject({kind: 'validation_error', message: expect.stringContaining('under 5000')}); + expect(missing.unwrapErr().kind).toBe('parse_error'); + expect(saveAgentFeedback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/agent-side/feedback.ts b/packages/api/src/agent-side/feedback.ts new file mode 100644 index 0000000..1a5b4c4 --- /dev/null +++ b/packages/api/src/agent-side/feedback.ts @@ -0,0 +1,44 @@ +import {Err, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; +import {saveAgentFeedback} from '@proxy/db/feedback'; + +import type {AgentCaller} from './tools'; + +// Feedback an agent sends the makers of Personal Agent Proxy, such as a tool that didn't work as it +// expected, over MCP or from the agent pages. It is kept for us to read; nothing in the app shows it. + +const MAX_MESSAGE = 5000; + +const feedbackSchema = z.object({ + message: z.string(), + connection: z.string().max(200).optional(), + tool: z.string().max(200).optional(), +}); + +/** Keeps the agent's feedback: `{message}`, and the connection and tool it is about where it names them. */ +export function sendAgentFeedback(caller: AgentCaller, args: unknown): Promise> { + return Do(async ($) => { + const {message, connection, tool} = $(parseSchema(feedbackSchema, args)); + const text = message.trim(); + if (text === '') { + return $(Err(ApiErr.validationError('The feedback is empty: say what happened and what you expected.'))); + } + if (text.length > MAX_MESSAGE) { + return $(Err(ApiErr.validationError(`The feedback is ${text.length} characters long; keep it under ${MAX_MESSAGE}.`))); + } + $( + await saveAgentFeedback({ + orgId: caller.agent.orgId, + agentId: caller.agent.agentId, + providerId: caller.agent.providerId, + message: text, + connectionId: connection || null, + tool: tool || null, + via: caller.via, + }), + ); + return {sent: true}; + }); +} diff --git a/packages/api/src/agent-side/route.test.ts b/packages/api/src/agent-side/route.test.ts index 10da8f1..4789ae6 100644 --- a/packages/api/src/agent-side/route.test.ts +++ b/packages/api/src/agent-side/route.test.ts @@ -7,6 +7,10 @@ const listAgentConnections = mock(); mock.module('./tools', () => ({runAgentTool, listAgentTools, listAgentConnections})); +const sendAgentFeedback = mock(); + +mock.module('./feedback', () => ({sendAgentFeedback})); + const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'}; function makeRequest(params: Record, body: unknown = undefined) { @@ -53,3 +57,14 @@ describe('handleAgentListToolsRoute', () => { expect(listAgentTools).toHaveBeenCalledWith(signedIn, 'mail-1'); }); }); + +describe('handleAgentFeedbackRoute', () => { + test('sends the body as feedback from the signed-in agent, over the web', async () => { + sendAgentFeedback.mockResolvedValue(Ok({sent: true})); + const {handleAgentFeedbackRoute} = await import('./route'); + const result = await handleAgentFeedbackRoute(makeRequest({}, {message: 'Save did nothing'})); + + expect(result.unwrap()).toEqual({sent: true}); + expect(sendAgentFeedback).toHaveBeenCalledWith({agent: signedIn, via: 'web'}, {message: 'Save did nothing'}); + }); +}); diff --git a/packages/api/src/agent-side/route.ts b/packages/api/src/agent-side/route.ts index 34c43da..f5915a3 100644 --- a/packages/api/src/agent-side/route.ts +++ b/packages/api/src/agent-side/route.ts @@ -5,6 +5,7 @@ import {type ApiError, Do, parseSchema} from '@proxy/utils'; import type {Tool} from '@proxy/integrations'; import type {AgentRequest} from '../server/middleware/require-agent'; +import {sendAgentFeedback} from './feedback'; import {listAgentConnections, listAgentTools, runAgentTool, type AgentConnection, type ToolResult} from './tools'; // The agent side over HTTP, for the web app's agent pages: the same connections, tools and runs @@ -29,3 +30,8 @@ export function handleAgentRunToolRoute(request: AgentRequest): Promise> { + return sendAgentFeedback({agent: request.agent, via: 'web'}, request.body ?? {}); +} diff --git a/packages/api/src/mcp/route.test.ts b/packages/api/src/mcp/route.test.ts index 7e18e7b..dd7e7d3 100644 --- a/packages/api/src/mcp/route.test.ts +++ b/packages/api/src/mcp/route.test.ts @@ -13,6 +13,10 @@ const runAgentTool = mock(); mock.module('../agent-side/tools', () => ({listAgentConnections, listAgentTools, runAgentTool})); +const sendAgentFeedback = mock(); + +mock.module('../agent-side/feedback', () => ({sendAgentFeedback})); + mock.module('../observability/log', () => ({ log: {debug: mock(), info: mock(), warn: mock(), error: mock()}, serializeError: mock(() => ({})), @@ -88,9 +92,10 @@ describe('handleMcpRoute', () => { expect(result.instructions).toContain('Personal Agent Proxy connects Email, Google, Granola, Notion, Linear, 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('lists the four tools, read-only ones marked so', async () => { + 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; @@ -99,9 +104,19 @@ describe('handleMcpRoute', () => { ['list_tools', true], ['read', true], ['run', false], + ['send_feedback', false], ]); }); + test('send_feedback goes to the agent side over MCP', async () => { + sendAgentFeedback.mockResolvedValue(Ok({sent: true})); + const args = {message: 'emails_list ignored the folder', connection: 'mail-1', tool: 'emails_list'}; + const res = await post(rpc('tools/call', {name: 'send_feedback', arguments: args})); + + expect(sendAgentFeedback).toHaveBeenCalledWith({agent: signedIn, via: 'mcp'}, args); + expect((res.body as {result: unknown}).result).toMatchObject({structuredContent: {sent: true}}); + }); + test('list_connections says what the agent can do with each', async () => { const res = await post(rpc('tools/call', {name: 'list_connections', arguments: {}})); diff --git a/packages/api/src/mcp/tools.ts b/packages/api/src/mcp/tools.ts index 2372032..96e855f 100644 --- a/packages/api/src/mcp/tools.ts +++ b/packages/api/src/mcp/tools.ts @@ -5,15 +5,17 @@ import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; import type {SignedInAgent} from '@proxy/db/agent'; import {findIntegration, INFO_INTEGRATION_ID, integrationFor, INTEGRATIONS, type Tool} from '@proxy/integrations'; +import {sendAgentFeedback} from '../agent-side/feedback'; import {listAgentConnections, listAgentTools, runAgentTool, type AgentConnection} from '../agent-side/tools'; import {requireAppUrl} from '../oauth/oauth-config'; import {log, serializeError} from '../observability/log'; /** - * The MCP server's tools: four that stay the same whatever an agent may do, so a client's tool + * The MCP server's tools: five that stay the same whatever an agent may do, so a client's tool * list never goes stale. `list_connections` is where an agent starts; `list_tools` gives a * connection's own tools, generated from the catalog, which `read` and `run` then run, `read` - * only those that read so a client can let it go without asking. + * only those that read so a client can let it go without asking. `send_feedback` tells us what + * didn't work as the agent expected. */ type JsonSchema = {type: 'object'; properties: Record; required: string[]; additionalProperties: false}; @@ -62,6 +64,23 @@ export const MCP_TOOLS: McpToolDefinition[] = [ inputSchema: {type: 'object', properties: {connection: CONNECTION, tool: TOOL, params: PARAMS}, required: ['connection', 'tool'], additionalProperties: false}, annotations: {title: 'Run', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true}, }, + { + name: 'send_feedback', + title: 'Send feedback', + description: + 'Tells the makers of Personal Agent Proxy about something that didn’t work as you expected: a tool that failed or answered something odd, a description or error that misled you, or something you needed and couldn’t do. Say what you tried, what happened and what you expected. It goes to the makers, not to the person, and changes nothing in their accounts.', + inputSchema: { + type: 'object', + properties: { + message: {type: 'string', description: 'What you tried, what happened and what you expected. Leave out the person’s private information, such as what an email says.'}, + connection: {type: 'string', description: 'The connection it is about, from list_connections; left out for none'}, + tool: {type: 'string', description: 'The tool it is about, such as emails_list or read; left out for none'}, + }, + required: ['message'], + additionalProperties: false, + }, + annotations: {title: 'Send feedback', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false}, + }, ]; // "Read, Archive, Send": what the actions are called, in the catalog's order; a custom server's @@ -115,6 +134,7 @@ export function serverInstructions(signedIn: SignedInAgent): Promise): Promise { const result = await call(signedIn, name, args); if (result.isErr()) { diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index 0c2f9c1..c43a7b7 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -2,7 +2,7 @@ import express from 'express'; import {handleListActivityRoute} from '../activity/route'; import {handleAgentLoginRoute, handleAgentLogoutRoute} from '../agent-auth/session'; -import {handleAgentListToolsRoute, handleAgentMeRoute, handleAgentRunToolRoute} from '../agent-side/route'; +import {handleAgentFeedbackRoute, handleAgentListToolsRoute, handleAgentMeRoute, handleAgentRunToolRoute} from '../agent-side/route'; import { handleCreateAgentRoute, handleDeleteAgentRoute, @@ -113,6 +113,7 @@ export function createApiRouter(): express.Router { router.get('/api/agent/me', withAgentAuthResult('Agent me route', handleAgentMeRoute)); router.get('/api/agent/connections/:connectionId/tools', withAgentAuthResult('Agent list tools route', handleAgentListToolsRoute)); router.post('/api/agent/connections/:connectionId/tools/:toolName', withAgentAuthResult('Agent run tool route', handleAgentRunToolRoute)); + router.post('/api/agent/feedback', withAgentAuthResult('Agent feedback route', handleAgentFeedbackRoute)); // MCP clients such as Claude, ChatGPT and Poke: the MCP server, signed in with OAuth 2.1, where // Personal Agent Proxy is its own authorization server. Clients find it from the MCP server's diff --git a/packages/db/package.json b/packages/db/package.json index dd09d43..6802c41 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,6 +9,7 @@ "./audit": "./src/audit.ts", "./auth": "./src/auth.ts", "./connection": "./src/connection.ts", + "./feedback": "./src/feedback.ts", "./info": "./src/info.ts", "./instance-meta": "./src/instance-meta.ts", "./oauth": "./src/oauth.ts", diff --git a/packages/db/prisma/migrations/v018_add_agent_feedback/migration.sql b/packages/db/prisma/migrations/v018_add_agent_feedback/migration.sql new file mode 100644 index 0000000..82ce199 --- /dev/null +++ b/packages/db/prisma/migrations/v018_add_agent_feedback/migration.sql @@ -0,0 +1,21 @@ +-- What agents tell the makers of Personal Agent Proxy, such as a tool that didn't work as they +-- expected, through the MCP server's send_feedback or the agent pages' feedback form. +CREATE TABLE "AgentFeedback" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "message" TEXT NOT NULL, + "connectionId" TEXT, + "tool" TEXT, + "via" "AuditVia" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AgentFeedback_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AgentFeedback_orgId_idx" ON "AgentFeedback"("orgId"); + +-- CreateIndex +CREATE INDEX "AgentFeedback_createdAt_idx" ON "AgentFeedback"("createdAt"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7fd11a2..da02059 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -105,6 +105,7 @@ model Organization { connections Connection[] agents Agent[] audit AuditEntry[] + feedback AgentFeedback[] } model OrgMember { @@ -284,6 +285,30 @@ model AuditEntry { @@index([orgId, createdAt]) } +// What an agent told the makers of Personal Agent Proxy, such as a tool that didn't work as it +// expected: sent with the MCP server's send_feedback or the agent pages' feedback form, and read +// by us, never shown in the app. Plain ids, like the activity log, so it stays when the agent or +// the connection is gone; it goes with the organization. +model AgentFeedback { + id String @id @default(cuid()) + orgId String + agentId String + // The agent's AGENT_PROVIDERS id, such as `claude`, kept beside the login it came from. + providerId String + message String + // The connection and the tool it is about, where the agent named them. + connectionId String? + tool String? + via AuditVia + + createdAt DateTime @default(now()) + + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + + @@index([orgId]) + @@index([createdAt]) +} + // An MCP client that registered itself to sign agents in, such as Claude or ChatGPT (OAuth // Dynamic Client Registration). Anyone can register one; it gets nothing until a person signs in // and lets it work as one of their agents. One that hasn't finished a sign-in a day after diff --git a/packages/db/src/feedback.ts b/packages/db/src/feedback.ts new file mode 100644 index 0000000..e7372d2 --- /dev/null +++ b/packages/db/src/feedback.ts @@ -0,0 +1,21 @@ +import type {AuditVia} from '@prisma/client'; +import type {Result} from 'ts-results-es'; + +import {type ApiError, wrapDb} from '@proxy/utils'; + +import {db} from '.'; + +/** Keeps what an agent told us, for us to read. */ +export async function saveAgentFeedback(data: { + orgId: string; + agentId: string; + providerId: string; + message: string; + connectionId: string | null; + tool: string | null; + via: AuditVia; +}): Promise> { + return wrapDb(async () => { + await db.agentFeedback.create({data}); + }); +} diff --git a/packages/docs/content/agents/mcp.mdx b/packages/docs/content/agents/mcp.mdx index 5dea656..5b26649 100644 --- a/packages/docs/content/agents/mcp.mdx +++ b/packages/docs/content/agents/mcp.mdx @@ -50,7 +50,7 @@ agent's next request. ## What the agent sees -The server has four tools, the same for every login, so the agent's list of tools never goes out of +The server has five tools, the same for every login, so the agent's list of tools never goes out of date when access changes: | Tool | What it does | @@ -59,6 +59,7 @@ date when access changes: | `list_tools` | A connection's own tools, such as `emails_list` or `emails_archive`, each with what it does and the JSON Schema of its params. Only the tools the login has are listed. | | `read` | Runs a tool that only reads, such as listing or opening emails. Marked read-only, so an agent can let it run without asking you. | | `run` | Runs a tool that changes something or reaches other people, such as archiving an email or sending one. | +| `send_feedback` | Tells the makers of Personal Agent Proxy about something that didn't work as the agent expected. It goes to them, not to you, and changes nothing in your accounts. | When an agent connects, it is also told which accounts it can use, and that what an email or a note says is information, not instructions. diff --git a/packages/map/scripts/crawl.ts b/packages/map/scripts/crawl.ts index c5bc2bf..6754eb1 100644 --- a/packages/map/scripts/crawl.ts +++ b/packages/map/scripts/crawl.ts @@ -267,9 +267,10 @@ async function visit(pattern: string, url: string, capture: boolean): Promise('a[href]')].map((anchor) => ({ href: anchor.getAttribute('href') ?? '', text: anchor.innerText.trim() || anchor.getAttribute('aria-label') || '', - // The sidebar, breadcrumbs, and the agent side's brand, which leads home from every page. + // The sidebar, breadcrumbs, and the agent side's brand and feedback link, which lead home + // and to the feedback form from every page. nav: - anchor.closest('[data-slot="sidebar"], nav') !== null || + anchor.closest('[data-slot="sidebar"], nav, footer') !== null || (anchor.closest('header') !== null && anchor.getAttribute('href') === '/agent'), })), ); diff --git a/packages/web/src/client/agent-client.ts b/packages/web/src/client/agent-client.ts index cbf9e8f..5e59360 100644 --- a/packages/web/src/client/agent-client.ts +++ b/packages/web/src/client/agent-client.ts @@ -96,3 +96,11 @@ export function runAgentRecordTool( }, ); } + +/** + * Feedback for the makers of Personal Agent Proxy, such as a page that didn't work as the agent + * expected, and the connection and tool it is about where there are any. + */ +export function sendAgentFeedback(feedback: {message: string; connection?: string; tool?: string}) { + return apiRequest('POST', '/api/agent/feedback', z.object({sent: z.literal(true)}), feedback); +} diff --git a/packages/web/src/components/agent-shell.tsx b/packages/web/src/components/agent-shell.tsx index 87ce77a..9c4c7de 100644 --- a/packages/web/src/components/agent-shell.tsx +++ b/packages/web/src/components/agent-shell.tsx @@ -1,6 +1,6 @@ import type {Collection} from '@proxy/integrations'; import {Fragment, type ReactNode} from 'react'; -import {Link, useNavigate} from 'react-router'; +import {Link, useNavigate, useParams} from 'react-router'; import {signOutAgent} from '@/client/agent-client'; import {AgentLogo} from '@/components/brand-logo'; import {Button} from '@proxy/ui/components/button'; @@ -9,9 +9,15 @@ import {useAgentMe} from '@/hooks/use-agent-target'; // The agent side's frame. Built for a model driving a browser: no sidebar, no icon-only controls // and nothing that only shows on hover, so every action is a labeled link or button on the page. -export function AgentShell({children}: {children: ReactNode}) { +// Every page but the feedback form ends with a link to it, about the connection and the tool it is on. +export function AgentShell({children, feedback = true}: {children: ReactNode; feedback?: boolean}) { const navigate = useNavigate(); const agent = useAgentMe()?.agent; + const {connectionId, toolName} = useParams(); + const about = new URLSearchParams({ + ...(connectionId ? {connection: connectionId} : {}), + ...(toolName ? {tool: toolName} : {}), + }).toString(); return (
@@ -40,6 +46,18 @@ export function AgentShell({children}: {children: ReactNode}) {
{children}
+ {feedback && ( +
+ Something here not working as you expected?{' '} + + Send feedback + {' '} + to the makers of Personal Agent Proxy. +
+ )} ); } diff --git a/packages/web/src/pages/agent/agent-feedback-page.tsx b/packages/web/src/pages/agent/agent-feedback-page.tsx new file mode 100644 index 0000000..32c62f7 --- /dev/null +++ b/packages/web/src/pages/agent/agent-feedback-page.tsx @@ -0,0 +1,96 @@ +import {useId, useState} from 'react'; +import {Link, useSearchParams} from 'react-router'; +import {sendAgentFeedback} from '@/client/agent-client'; +import {AgentHeading, AgentShell, Crumbs} from '@/components/agent-shell'; +import {SuccessLine} from '@/components/success-line'; +import {Button} from '@proxy/ui/components/button'; +import {Label} from '@proxy/ui/components/label'; +import {Textarea} from '@proxy/ui/components/textarea'; +import {useAgentMe} from '@/hooks/use-agent-target'; +import {agentErrorMessage} from '@/lib/agent-errors'; + +// Feedback for the makers of Personal Agent Proxy, as the MCP server's send_feedback takes it: what +// the agent tried, what happened and what it expected. Opened from the link at the foot of every +// agent page, which says the connection and the tool it was on, `?connection=` and `?tool=`, sent +// along with it. +export function AgentFeedbackPage() { + const id = useId(); + const [params] = useSearchParams(); + const connections = useAgentMe()?.connections ?? []; + const [message, setMessage] = useState(''); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const connectionId = params.get('connection') ?? undefined; + const tool = params.get('tool') ?? undefined; + const connection = connections.find((candidate) => candidate.id === connectionId); + const about = [connection && (connection.name ?? connection.account), tool] + .filter(Boolean) + .join(', '); + + const send = async () => { + setSending(true); + setError(null); + const result = await sendAgentFeedback({message, connection: connectionId, tool}); + setSending(false); + if (result.isErr()) { + setError(agentErrorMessage(result.error)); + return; + } + setMessage(''); + setSent(true); + }; + + return ( + + +
+ Send feedback +
+
+

+ Tell the makers of Personal Agent Proxy about something that didn’t work as you expected: + a page or button that failed or showed something odd, a hint or error that misled you, or + something you needed and couldn’t do. Say what you tried, what happened and what you + expected. It goes to the makers, not to the person, and changes nothing in their accounts. + Leave out the person’s private information, such as what an email says. +

+ {sent && ( + + + Sent, thank you. You can send more, or go back to{' '} + + what you can access + + . + + + )} +
{ + event.preventDefault(); + void send(); + }} + > +
+ +