diff --git a/AGENTS.md b/AGENTS.md index ba0767d..51670ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,8 @@ Personal Agent Proxy holds the integrations and personal information an AI agent - **Model**: an integration from the catalog (`@proxy/integrations`: Email over IMAP and SMTP, Granola's meeting notes, Notion's pages and Linear's issues over their MCP servers) 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. Integrations join the catalog once they work end to end; Google Workspace comes later. 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. - **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 (Archive needs Read; Send needs nothing, so an agent can send without reading the mailbox). 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. 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). 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 picks the agent from `AGENT_PROVIDERS` and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code's a terminal command) 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) 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; 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. Organizations from before the flow were marked done by its migration. -- **Human side** behind `/login`: `/connections`, `/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, and Granola, Notion and Linear are connected by signing in to them; `ConnectEmailForm` and `IntegrationCatalog` serve the welcome flow too), `/connections/:id` (the defaults under Permissions, with how many agents differ per action, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents` (chooses an agent company that does not have a login yet), `/agents/:id` (the password shows once, after creating or resetting; 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). 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. +- **Accounts**: a human signs in with Google or a magic link (`src/auth/` in the api). 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` and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code's a terminal command) 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) 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. Organizations from before the flow were marked done by its migration. +- **Human side** behind `/login`: `/connections`, `/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, and Granola, Notion and Linear are connected by signing in to them; `ConnectEmailForm` and `IntegrationCatalog` serve the welcome flow too), `/connections/:id` (the defaults under Permissions, with how many agents differ per action, 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, 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). 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, ChatGPT) adds `/mcp` as a custom connector and signs in; the person picks which agent login it works as on `/connect-agent`. 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/: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`, with a button per command the agent may run on the record, and Send beside Save as draft on a new email (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. - **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. diff --git a/packages/integrations/src/agent-providers.ts b/packages/integrations/src/agent-providers.ts index 9e6e884..eb01b8c 100644 --- a/packages/integrations/src/agent-providers.ts +++ b/packages/integrations/src/agent-providers.ts @@ -24,6 +24,11 @@ export type AgentProvider = { * in development at `http://localhost`. Every other agent needs a public https address. */ local: boolean; + /** + * A `login` agent that won't take a password typed in chat, and keeps it in a vault of its own + * instead: asked for one, it sends a link to a page the person fills the login in on. + */ + vault?: boolean; /** Bundled with the web app, under `packages/web/public`. */ faviconUrl: string; }; @@ -52,6 +57,7 @@ export const AGENT_PROVIDERS: AgentProvider[] = [ company: 'Instinct', connects: 'login', local: false, + vault: true, faviconUrl: '/agent-providers/instinct.png', }, { diff --git a/packages/web/src/components/agent-setup.tsx b/packages/web/src/components/agent-setup.tsx new file mode 100644 index 0000000..c5bc25a --- /dev/null +++ b/packages/web/src/components/agent-setup.tsx @@ -0,0 +1,475 @@ +import {findAgentProvider, type AgentProvider, type AgentProviderId} from '@proxy/integrations'; +import {CheckIcon, LoaderCircleIcon} from 'lucide-react'; +import {useEffect, useState, type ReactNode} from 'react'; +import {useRevalidator} from 'react-router'; +import {createAgent, resetAgentPassword} from '@/client/agents-client'; +import {AgentProviderTile} from '@/components/agent-provider-tile'; +import {CopyButton} from '@/components/copy-button'; +import {StepHeading} from '@/components/step-heading'; +import {Button} from '@proxy/ui/components/button'; +import {cn} from '@proxy/ui/lib/utils'; +import {describeFetchError} from '@/lib/loader-utils'; +import type {AgentLogin} from '@/lib/types'; + +// How often the setup asks whether the MCP client has signed in yet. +const POLL_MS = 3000; + +// Whether an agent elsewhere on the internet can reach this Personal Agent Proxy: an MCP client +// needs a public https address, and a login's sign-in page is no use to a remote agent otherwise. +const PUBLIC = window.location.protocol === 'https:'; + +const SELF_HOSTING_URL = 'https://docs.personalagentproxy.com/self-hosting/get-started/'; + +type Login = {username: string; password: string}; + +// Which agent, then what that agent needs to work through Personal Agent Proxy: an MCP client +// adds the server and signs in, which makes its login; another is handed a login, made then and +// there when it is chosen. The welcome flow, New agent and setting an agent up again all go +// through here. An agent that has a login already is shown as it is, so arriving here after +// signing in from Claude says Claude is connected. With `fixed`, it is that agent's alone. +export function AgentSetup({ + agents, + providers, + fixed, + continueLabel, + onContinue, +}: { + agents: AgentLogin[]; + providers: AgentProvider[]; + fixed?: AgentProviderId; + continueLabel: string; + onContinue: (agent: AgentLogin | null) => void; +}) { + const live = agents.filter((agent) => agent.revokedAt === null); + const {revalidate} = useRevalidator(); + // The agent already set up, so arriving back here after signing in from Claude shows Claude. + const [chosenId, setChosenId] = useState( + () => + fixed ?? + providers.find((provider) => live.some((agent) => agent.providerId === provider.id))?.id ?? + null, + ); + const [login, setLogin] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const chosen = chosenId ? findAgentProvider(chosenId) : undefined; + const existingFor = (provider: AgentProvider) => + live.find((agent) => agent.providerId === provider.id) ?? null; + + const choose = async (provider: AgentProvider) => { + setChosenId(provider.id); + setLogin(null); + setError(null); + if (provider.connects !== 'login' || existingFor(provider) !== null) { + return; + } + setPending(true); + const result = await createAgent(provider.id); + setPending(false); + if (result.isErr()) { + setError(describeFetchError(result.error)); + return; + } + setLogin({username: result.value.agent.username, password: result.value.password}); + void revalidate(); + }; + + const reset = async (agent: AgentLogin) => { + setPending(true); + setError(null); + const result = await resetAgentPassword(agent.id); + setPending(false); + if (result.isErr()) { + setError(describeFetchError(result.error)); + return; + } + setLogin({username: agent.username, password: result.value.password}); + }; + + if (!chosen) { + // Over http, only an agent on this computer can reach Personal Agent Proxy: those first, + // the rest greyed out. + const choices = PUBLIC + ? providers + : [...providers].sort((a, b) => Number(b.local) - Number(a.local)); + return ( + <> + + Personal Agent Proxy gives it your connections and information, with every request it + makes in the activity log. + + {!PUBLIC && } +
+ {choices.map((provider) => { + const usable = PUBLIC || provider.local; + return ( + void choose(provider)} + /> + ); + })} +
+ + ); + } + + const existing = existingFor(chosen); + const actions = ( +
+ {!fixed && ( + + )} + +
+ ); + if (chosen.connects === 'mcp') { + return ; + } + return ( + existing && void reset(existing)} + /> + ); +} + +// What a Personal Agent Proxy on an http address, such as a development machine, is told: only +// an agent on this computer can reach it; the rest need it on a server with a domain. +function NotPublicCallout() { + return ( +
+

+ Only an agent on this computer can reach Personal Agent Proxy at{' '} + {window.location.origin}. The others need a public https + address. +

+

+ For those, run Personal Agent Proxy on a server reachable from the internet, such as a VPS, + and give it a domain name.{' '} + + How to set that up + +

+
+ ); +} + +// One value to copy, such as the server's address: its label, the value in monospace, and a +// copy button. +function CopyLine({label, value, copyLabel}: {label: string; value: string; copyLabel: string}) { + return ( +
+ {label} + {value} + +
+ ); +} + +// Where each MCP client keeps its connectors, as the docs describe it; Claude Code takes a command +// instead, run in a terminal with the server's address. +const MCP_STEPS: Record< + string, + {where: string; url?: string; command?: (serverUrl: string) => string} +> = { + claude: { + where: 'In Claude, open Settings, then Connectors, and choose Add custom connector.', + url: 'https://claude.ai/settings/connectors', + }, + 'claude-code': { + where: 'Run the command above in a terminal.', + command: (serverUrl) => `claude mcp add --transport http personal-agent-proxy ${serverUrl}`, + }, + 'claude-desktop': { + where: 'In Claude Desktop, open Settings, then Connectors, and choose Add custom connector.', + }, + chatgpt: { + where: + 'In ChatGPT, open Settings, then Apps & Connectors, turn on Developer mode under Advanced, and create a connector.', + }, + poke: { + where: + 'On poke.com, under Integrations, add a new one: a name and the address, with the API key left empty.', + url: 'https://poke.com/integrations/new', + }, +}; + +// The server's address to add, and a wait for the client to sign in: when it has, the login it +// made is here. An MCP client reaches the server over the internet, at an https address, so a +// server that isn't one, such as a development machine, is told to move first. +function McpAgent({ + provider, + existing, + actions, +}: { + provider: AgentProvider; + existing: AgentLogin | null; + actions: ReactNode; +}) { + const serverUrl = `${window.location.origin}/mcp`; + const reachable = PUBLIC || provider.local; + const steps = MCP_STEPS[provider.id]; + const command = steps?.command?.(serverUrl); + const {revalidate} = useRevalidator(); + const connected = existing !== null; + + useEffect(() => { + if (connected || !reachable) { + return; + } + const timer = setInterval(() => void revalidate(), POLL_MS); + return () => clearInterval(timer); + }, [connected, reachable, revalidate]); + + return ( + <> + + {provider.name} works through Personal Agent Proxy as a connector, signed in with its own + login. + + {!reachable && } + {command ? ( + + ) : ( + + )} +
    +
  1. + {steps?.where ?? `In ${provider.name}, add a custom connector.`}{' '} + {steps?.url && ( + + Open {provider.name} + + )} +
  2. +
  3. + {command + ? `In ${provider.name}, run /mcp and choose personal-agent-proxy to sign in.` + : 'Paste the server address and add it.'} +
  4. +
  5. + {provider.name} opens Personal Agent Proxy to sign in. Choose {provider.name} when asked + which agent it works as. +
  6. +
+ {reachable && ( +

+ {connected ? ( + <> + + {provider.name} is signed in as {existing.name}. + + ) : ( + <> + + Waiting for {provider.name} to sign in… + + )} +

+ )} + {actions} + + ); +} + +// The login, handed to the agent the way it takes one: as a message, or through its vault. One +// that exists already keeps its password, unless a new one is asked for here. +function LoginAgent({ + provider, + existing, + login, + pending, + error, + actions, + onReset, +}: { + provider: AgentProvider; + existing: AgentLogin | null; + login: Login | null; + pending: boolean; + error: string | null; + actions: ReactNode; + onReset: () => void; +}) { + return ( + <> + {provider.vault ? ( + + {provider.name} signs in to Personal Agent Proxy with a login of its own, kept in its + vault rather than in your chat. + + ) : ( + + {provider.name} signs in to Personal Agent Proxy with a login of its own. Send it this + message, and it can get started. + + )} + {login && provider.vault && } + {login && !provider.vault && } + {!login && ( +
+ {pending && ( +

+ + Making the login… +

+ )} + {!pending && existing && ( + <> +

+ {provider.name} already has a login, {existing.username}. Its password was shown + once, when it was made. To hand it over again, make a new one; the current one stops + working at once. +

+ + + )} + {!pending && !existing && !error && ( +

The login could not be made.

+ )} +
+ )} + {error &&

{error}

} + {actions} + + ); +} + +// The message to paste to the agent: the sign-in page and the login, shown this once. +function HandoverMessage({login}: {login: Login}) { + const signInUrl = `${window.location.origin}/agent/login`; + const message = [ + 'Here is my Personal Agent Proxy login. It holds my connections and personal information; sign in with it when a task needs them.', + '', + `Sign-in page: ${signInUrl}`, + `Username: ${login.username}`, + `Password: ${login.password}`, + ].join('\n'); + + return ( + + The password is not shown again once you leave this page. + + ); +} + +// An agent that keeps passwords in a vault won't take one in chat: it is asked for a link to its +// vault, the login is filled in on the page it sends, and then it is told to sign in. +function VaultHandover({provider, login}: {provider: AgentProvider; login: Login}) { + const signInUrl = `${window.location.origin}/agent/login`; + const ask = `Please send me a setup link for a secure vault entry, for my Personal Agent Proxy login at ${signInUrl} with the username ${login.username}.`; + const done = `Setup done. Please sign in to Personal Agent Proxy at ${signInUrl} and look around. Personal Agent Proxy holds many (frequently changing) connections to third-party apps that might be useful to you to achieve tasks. Check there first if you need a tool that you don't have direct access to.`; + + return ( +
    + + + + +
    + + + +

    + The password is not shown again once you leave this page. +

    +
    +
    + + + +
+ ); +} + +function VaultStep({ + number, + title, + children, +}: { + number: number; + title: string; + children: ReactNode; +}) { + return ( +
  • +

    + {number}. {title} +

    + {children} +
  • + ); +} + +// A message to paste to the agent, with a copy button and what to know about it. +function Message({ + value, + copyLabel, + children, +}: { + value: string; + copyLabel: string; + children?: ReactNode; +}) { + if (!children) { + return ( +
    +
    {value}
    + +
    + ); + } + + return ( +
    +
    {value}
    +
    +

    {children}

    + +
    +
    + ); +} + +// One value of the login, to copy into the agent's vault. +function LoginLine({label, value, copyLabel}: {label: string; value: string; copyLabel: string}) { + return ( +
    + {label} + {value} + +
    + ); +} diff --git a/packages/web/src/components/step-heading.tsx b/packages/web/src/components/step-heading.tsx new file mode 100644 index 0000000..e660714 --- /dev/null +++ b/packages/web/src/components/step-heading.tsx @@ -0,0 +1,11 @@ +import type {ReactNode} from 'react'; + +// A setup step's title, and what it is for beneath it. +export function StepHeading({title, children}: {title: string; children?: ReactNode}) { + return ( +
    +

    {title}

    + {children &&

    {children}

    } +
    + ); +} diff --git a/packages/web/src/loaders.ts b/packages/web/src/loaders.ts index beaff96..365dff9 100644 --- a/packages/web/src/loaders.ts +++ b/packages/web/src/loaders.ts @@ -84,6 +84,11 @@ export async function agentLoader({params}: {params: {id?: string}}) { }; } +export async function agentSetupLoader({params}: {params: {id?: string}}) { + const [agent, agents] = await Promise.all([getAgent(params.id ?? ''), listAgents()]); + return {agent: unwrapOrNull(agent), agents: unwrapLoaderResult(agents).agents}; +} + export async function activityLoader({request}: {request: Request}) { const params = new URL(request.url).searchParams; const filter = { diff --git a/packages/web/src/pages/agent-page.tsx b/packages/web/src/pages/agent-page.tsx index 9ed73b8..c78b2fb 100644 --- a/packages/web/src/pages/agent-page.tsx +++ b/packages/web/src/pages/agent-page.tsx @@ -9,20 +9,12 @@ import { Trash2Icon, UnplugIcon, } from 'lucide-react'; -import {useEffect, useState, type ReactNode} from 'react'; -import { - Link, - useLoaderData, - useLocation, - useNavigate, - useParams, - useRevalidator, -} from 'react-router'; +import {useState} from 'react'; +import {Link, useLoaderData, useNavigate, useParams, useRevalidator} from 'react-router'; import type {Result} from 'ts-results-es'; import { deleteAgent, disconnectMcpClient, - resetAgentPassword, setAgentGrants, setAgentRevoked, type McpClient, @@ -34,7 +26,6 @@ import {AgentLogo, IntegrationLogo} from '@/components/brand-logo'; import {BackButton} from '@/components/back-button'; import {ConfirmDialog} from '@/components/confirm-dialog'; import {ConnectionAccess} from '@/components/connection-access'; -import {CopyButton} from '@/components/copy-button'; import {IconButton} from '@/components/icon-button'; import {NotFound} from '@/components/not-found'; import {EmptyRows, RowList} from '@/components/row-list'; @@ -50,53 +41,36 @@ import type {agentLoader} from '@/loaders'; const RECENT = 10; -type Confirming = 'reset' | 'revoke' | 'delete' | null; +type Confirming = 'revoke' | 'delete' | null; -// One agent login: what it signs in with, what it can reach, and what it did. Keyed by the -// agent, so a password shown for one is never carried over to the next. +// One agent login: what it can reach, and what it did. Handing it its login is setup's +// (`/agents/:id/setup`), done once. Keyed by the agent, so the access filter and what is folded +// out start over for each. export function AgentPage() { const {id = ''} = useParams(); return ; } -// The password New agent hands over in the navigation's state, read once. -function passwordFromState(state: unknown): string | null { - if (typeof state !== 'object' || state === null || !('password' in state)) { - return null; - } - return typeof state.password === 'string' ? state.password : null; -} - function AgentDetail({id}: {id: string}) { const {agent, connections, agents, entries, mcpClients} = useLoaderData(); - const location = useLocation(); const navigate = useNavigate(); const revalidator = useRevalidator(); - // The password shows once: arriving from New agent, or right after a reset. It is kept only in - // this page's memory, and cleared from the history entry so a reload doesn't show it again. - const [password, setPassword] = useState(() => passwordFromState(location.state)); const [confirming, setConfirming] = useState(null); const [error, setError] = useState(null); - useEffect(() => { - if (passwordFromState(location.state) !== null) { - navigate(location.pathname, {replace: true, state: null}); - } - }, [location, navigate]); if (!agent || agent.id !== id) { return ; } const revoked = agent.revokedAt !== null; const company = findAgentProvider(agent.providerId)?.company ?? agent.providerId; - const apply = async (change: Promise>): Promise => { + const apply = async (change: Promise>) => { const result = await change; if (result.isErr()) { setError(describeFetchError(result.error)); - return null; + return; } setError(null); await revalidator.revalidate(); - return result.value; }; return ( @@ -120,9 +94,11 @@ function AgentDetail({id}: {id: string}) { } actions={ <> - setConfirming('reset')}> - - + {!revoked && ( + + + + )} {revoked ? (
    {error &&

    {error}

    } -
    - -
    -
    - void apply(disconnectMcpClient(agent.id, clientId))} - /> -
    + {mcpClients.length > 0 && ( +
    + void apply(disconnectMcpClient(agent.id, clientId))} + /> +
    + )}
    - setConfirming(null)} - onConfirm={async () => { - const reset = await apply(resetAgentPassword(agent.id)); - if (reset) { - setPassword(reset.password); - } - }} - /> - - - - - - - {password ? ( - - - - ) : ( - - )} - {password && ( -

    - Copy the password now: it is not shown again once you leave this page. -

    - )} - - ); -} - -// The MCP server's address, for a custom connector in Claude, ChatGPT or Poke, and the clients -// that signed in as this agent there. Signing in to one brings the person to a page that asks -// which agent it works as. +// The MCP clients, such as Claude, that signed in as this agent, each to be signed out on its own. function McpClients({ clients, onDisconnect, @@ -264,56 +197,20 @@ function McpClients({ clients: McpClient[]; onDisconnect: (clientId: string) => void; }) { - const serverUrl = `${window.location.origin}/mcp`; - return ( -
    -
    - - - -

    - Add it as a custom connector in Claude, ChatGPT or Poke. Signing in there asks which agent - it works as. -

    -
    - - {clients.length === 0 && No MCP client works as this agent yet.} - {clients.map((client) => ( -
  • - {client.name} - - connected {formatDate(client.connectedAt)} - - onDisconnect(client.id)}> - - -
  • - ))} -
    -
    - ); -} - -type LineProps = { - label: string; - value: string; - mono?: boolean; - muted?: boolean; - children?: ReactNode; -}; - -function CredentialLine({label, value, mono = false, muted = false, children}: LineProps) { - return ( -
    - {label} - - {value} - - {children} -
    + + {clients.map((client) => ( +
  • + {client.name} + + connected {formatDate(client.connectedAt)} + + onDisconnect(client.id)}> + + +
  • + ))} +
    ); } diff --git a/packages/web/src/pages/agent-setup-page.tsx b/packages/web/src/pages/agent-setup-page.tsx new file mode 100644 index 0000000..5298b59 --- /dev/null +++ b/packages/web/src/pages/agent-setup-page.tsx @@ -0,0 +1,45 @@ +import {AGENT_PROVIDERS, findAgentProvider} from '@proxy/integrations'; +import {useLoaderData, useNavigate} from 'react-router'; +import {AgentSetup} from '@/components/agent-setup'; +import {AppShell, PageTitle} from '@/components/app-shell'; +import {BackButton} from '@/components/back-button'; +import {NotFound} from '@/components/not-found'; +import type {agentSetupLoader} from '@/loaders'; + +// One agent set up again, as when it was added: a new password handed over the way it takes one, +// or the connector added again. +export function AgentSetupPage() { + const {agent, agents} = useLoaderData(); + const navigate = useNavigate(); + const provider = agent && findAgentProvider(agent.providerId); + if (!agent || !provider) { + return ; + } + + return ( + + + Set up {agent.name} + + } + > +
    + {agent.revokedAt === null ? ( + navigate(`/agents/${agent.id}`)} + /> + ) : ( +

    + This login is revoked. Restore it on its page to set it up again. +

    + )} +
    +
    + ); +} diff --git a/packages/web/src/pages/agents-page.tsx b/packages/web/src/pages/agents-page.tsx index 6cd73d2..e919c60 100644 --- a/packages/web/src/pages/agents-page.tsx +++ b/packages/web/src/pages/agents-page.tsx @@ -1,28 +1,11 @@ import {PlusIcon} from 'lucide-react'; -import {useState} from 'react'; -import {useLoaderData, useNavigate} from 'react-router'; -import { - AGENT_PROVIDERS, - findAgentProvider, - type AgentProvider, - type AgentProviderId, -} from '@proxy/integrations'; -import {createAgent} from '@/client/agents-client'; -import {AgentProviderTile} from '@/components/agent-provider-tile'; +import {Link, useLoaderData} from 'react-router'; +import {AGENT_PROVIDERS, findAgentProvider} from '@proxy/integrations'; import {AgentLogo} from '@/components/brand-logo'; import {AppShell, PageTitle} from '@/components/app-shell'; import {EmptyRows, Row, RowHeader, RowList} from '@/components/row-list'; import {Button} from '@proxy/ui/components/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@proxy/ui/components/dialog'; import {formatAgo} from '@/lib/format'; -import {describeFetchError} from '@/lib/loader-utils'; import {cn} from '@proxy/ui/lib/utils'; import type {agentsLoader} from '@/loaders'; @@ -36,7 +19,6 @@ const CELLS = { // Every agent login, newest first. A revoked one stays listed, muted, until it is deleted. export function AgentsPage() { const {agents} = useLoaderData(); - const [creating, setCreating] = useState(false); const active = agents.filter((agent) => agent.revokedAt === null).length; const availableProviders = AGENT_PROVIDERS.filter( (provider) => !agents.some((agent) => agent.providerId === provider.id), @@ -46,14 +28,17 @@ export function AgentsPage() { Agents} actions={ - + availableProviders.length === 0 ? ( + + ) : ( + + ) } > - setCreating(false)} - /> ); } - -// A company gets one login. The generated password is shown once on the new agent's page. -function NewAgentDialog({ - open, - providers, - onClose, -}: { - open: boolean; - providers: AgentProvider[]; - onClose: () => void; -}) { - const navigate = useNavigate(); - const [providerId, setProviderId] = useState(null); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - const close = () => { - setProviderId(null); - setError(null); - onClose(); - }; - - return ( - { - if (next) { - return; - } - close(); - }} - > - -
    { - event.preventDefault(); - if (providerId === null) { - return; - } - - setPending(true); - const result = await createAgent(providerId); - setPending(false); - if (result.isErr()) { - setError(describeFetchError(result.error)); - return; - } - close(); - navigate(`/agents/${result.value.agent.id}`, { - state: {password: result.value.password}, - }); - }} - > - - New agent - - Choose an agent. It starts with each connection's default access; you can change it - next. - - -
    - Agent -
    - {providers.map((provider, index) => ( - setProviderId(provider.id)} - /> - ))} -
    -
    - {error &&

    {error}

    } - - - - -
    -
    -
    - ); -} diff --git a/packages/web/src/pages/new-agent-page.tsx b/packages/web/src/pages/new-agent-page.tsx new file mode 100644 index 0000000..5f379d5 --- /dev/null +++ b/packages/web/src/pages/new-agent-page.tsx @@ -0,0 +1,36 @@ +import {AGENT_PROVIDERS} from '@proxy/integrations'; +import {useLoaderData, useNavigate} from 'react-router'; +import {AgentSetup} from '@/components/agent-setup'; +import {AppShell, PageTitle} from '@/components/app-shell'; +import {BackButton} from '@/components/back-button'; +import type {agentsLoader} from '@/loaders'; + +// A new agent, set up as in the welcome flow: chosen from the companies without a login yet, then +// handed its login or added as a connector, and on to its page. +export function NewAgentPage() { + const {agents} = useLoaderData(); + const navigate = useNavigate(); + const providers = AGENT_PROVIDERS.filter( + (provider) => !agents.some((agent) => agent.providerId === provider.id), + ); + + return ( + + + New agent + + } + > +
    + navigate(agent ? `/agents/${agent.id}` : '/agents')} + /> +
    +
    + ); +} diff --git a/packages/web/src/pages/welcome-page.tsx b/packages/web/src/pages/welcome-page.tsx index 0448911..74a9a2a 100644 --- a/packages/web/src/pages/welcome-page.tsx +++ b/packages/web/src/pages/welcome-page.tsx @@ -1,21 +1,13 @@ -import { - AGENT_PROVIDERS, - findAgentProvider, - type Action, - type AgentProvider, - type AgentProviderId, -} from '@proxy/integrations'; -import {CheckIcon, LoaderCircleIcon} from 'lucide-react'; -import {useEffect, useState, type ReactNode} from 'react'; +import {AGENT_PROVIDERS, type Action} from '@proxy/integrations'; +import {useState, type ReactNode} from 'react'; import {Link, useLoaderData, useNavigate, useRevalidator, useSearchParams} from 'react-router'; -import {createAgent, resetAgentPassword} from '@/client/agents-client'; import {setConnectionDefaults} from '@/client/connections-client'; import {finishOnboarding} from '@/client/me-client'; -import {AgentProviderTile} from '@/components/agent-provider-tile'; +import {AgentSetup} from '@/components/agent-setup'; import {ConnectEmailForm} from '@/components/connect-email-form'; import {ConnectionAccess} from '@/components/connection-access'; -import {CopyButton} from '@/components/copy-button'; import {IntegrationCatalog} from '@/components/integration-catalog'; +import {StepHeading} from '@/components/step-heading'; import {SuccessLine} from '@/components/success-line'; import {Button} from '@proxy/ui/components/button'; import {defaultActions, defaultsOnOrOff, integrationOf} from '@/lib/access'; @@ -24,19 +16,10 @@ import {describeFetchError} from '@/lib/loader-utils'; import type {AgentLogin, Connection} from '@/lib/types'; import type {welcomeConnectionLoader, welcomeLoader} from '@/loaders'; -// How often the agent step asks whether the MCP client has signed in yet. -const POLL_MS = 3000; - // Step one is the agent; the rest are step two: the catalog, the mailbox form, and (on its own // page, `WelcomeConnectionPage`) a new connection's permissions. type Step = 'agent' | 'connections' | 'email'; -// Whether an agent elsewhere on the internet can reach this Personal Agent Proxy: an MCP client -// needs a public https address, and a login's sign-in page is no use to a remote agent otherwise. -const PUBLIC = window.location.protocol === 'https:'; - -const SELF_HOSTING_URL = 'https://docs.personalagentproxy.com/self-hosting/get-started/'; - // The welcome flow, before the app: step one connects the person's agent, in the way that agent // takes (an MCP client adds the server and signs in; another is handed a login), step two their // services, each connection's permissions confirmed as it is made. Done, or skipped, it is not @@ -62,7 +45,12 @@ export function WelcomePage({step}: {step: Step}) { return ( {step === 'agent' && ( - navigate('/welcome/connections')} /> + navigate('/welcome/connections')} + /> )} {step === 'connections' && ( -

    - Only an agent on this computer can reach Personal Agent Proxy at{' '} - {window.location.origin}. The others need a public https - address. -

    -

    - For those, run Personal Agent Proxy on a server reachable from the internet, such as a VPS, - and give it a domain name.{' '} - - How to set that up - -

    - - ); -} - -// One value to copy, such as the server's address: its label, the value in monospace, and a -// copy button. -function CopyLine({label, value, copyLabel}: {label: string; value: string; copyLabel: string}) { - return ( -
    - {label} - {value} - -
    - ); -} - -function Heading({title, children}: {title: string; children?: ReactNode}) { - return ( -
    -

    {title}

    - {children &&

    {children}

    } -
    - ); -} - -type Login = {username: string; password: string}; - -// Which agent, then what that agent needs. Choosing one that is handed a login makes the login -// then and there. An agent that has a login already is shown as it is, so arriving here after -// signing in from Claude says Claude is connected. -function AgentStep({agents, onContinue}: {agents: AgentLogin[]; onContinue: () => void}) { - const live = agents.filter((agent) => agent.revokedAt === null); - const {revalidate} = useRevalidator(); - const [chosenId, setChosenId] = useState( - () => findAgentProvider(live[0]?.providerId ?? '')?.id ?? null, - ); - const [login, setLogin] = useState(null); - const [pending, setPending] = useState(false); - const [error, setError] = useState(null); - const chosen = chosenId ? findAgentProvider(chosenId) : undefined; - const existingFor = (provider: AgentProvider) => - live.find((agent) => agent.providerId === provider.id) ?? null; - - const choose = async (provider: AgentProvider) => { - setChosenId(provider.id); - setLogin(null); - setError(null); - if (provider.connects !== 'login' || existingFor(provider) !== null) { - return; - } - setPending(true); - const result = await createAgent(provider.id); - setPending(false); - if (result.isErr()) { - setError(describeFetchError(result.error)); - return; - } - setLogin({username: result.value.agent.username, password: result.value.password}); - void revalidate(); - }; - - const reset = async (agent: AgentLogin) => { - setPending(true); - setError(null); - const result = await resetAgentPassword(agent.id); - setPending(false); - if (result.isErr()) { - setError(describeFetchError(result.error)); - return; - } - setLogin({username: agent.username, password: result.value.password}); - }; - - if (!chosen) { - // Over http, only an agent on this computer can reach Personal Agent Proxy: those first, - // the rest greyed out. - const providers = PUBLIC - ? AGENT_PROVIDERS - : [...AGENT_PROVIDERS].sort((a, b) => Number(b.local) - Number(a.local)); - return ( - <> - - Personal Agent Proxy gives it your connections and information, with every request it - makes in the activity log. - - {!PUBLIC && } -
    - {providers.map((provider) => { - const usable = PUBLIC || provider.local; - return ( - void choose(provider)} - /> - ); - })} -
    - - ); - } - - const existing = existingFor(chosen); - const another = ( - - ); - if (chosen.connects === 'mcp') { - return ( - - ); - } - return ( - existing && void reset(existing)} - onContinue={onContinue} - /> - ); -} - -// Where each MCP client keeps its connectors, as the docs describe it; Claude Code takes a command -// instead, run in a terminal with the server's address. -const MCP_STEPS: Record< - string, - {where: string; url?: string; command?: (serverUrl: string) => string} -> = { - claude: { - where: 'In Claude, open Settings, then Connectors, and choose Add custom connector.', - url: 'https://claude.ai/settings/connectors', - }, - 'claude-code': { - where: 'Run the command above in a terminal.', - command: (serverUrl) => `claude mcp add --transport http personal-agent-proxy ${serverUrl}`, - }, - 'claude-desktop': { - where: 'In Claude Desktop, open Settings, then Connectors, and choose Add custom connector.', - }, - chatgpt: { - where: - 'In ChatGPT, open Settings, then Apps & Connectors, turn on Developer mode under Advanced, and create a connector.', - }, - poke: { - where: - 'On poke.com, under Integrations, add a new one: a name and the address, with the API key left empty.', - url: 'https://poke.com/integrations/new', - }, -}; - -// The server's address to add, and a wait for the client to sign in: when it has, the login it -// made is here. An MCP client reaches the server over the internet, at an https address, so a -// server that isn't one, such as a development machine, is told to move first. -function McpAgent({ - provider, - existing, - another, - onContinue, -}: { - provider: AgentProvider; - existing: AgentLogin | null; - another: ReactNode; - onContinue: () => void; -}) { - const serverUrl = `${window.location.origin}/mcp`; - const reachable = PUBLIC || provider.local; - const steps = MCP_STEPS[provider.id]; - const command = steps?.command?.(serverUrl); - const {revalidate} = useRevalidator(); - const connected = existing !== null; - - useEffect(() => { - if (connected || !reachable) { - return; - } - const timer = setInterval(() => void revalidate(), POLL_MS); - return () => clearInterval(timer); - }, [connected, reachable, revalidate]); - - return ( - <> - - {provider.name} works through Personal Agent Proxy as a connector, signed in with its own - login. - - {!reachable && } - {command ? ( - - ) : ( - - )} -
      -
    1. - {steps?.where ?? `In ${provider.name}, add a custom connector.`}{' '} - {steps?.url && ( - - Open {provider.name} - - )} -
    2. -
    3. - {command - ? `In ${provider.name}, run /mcp and choose personal-agent-proxy to sign in.` - : 'Paste the server address and add it.'} -
    4. -
    5. - {provider.name} opens Personal Agent Proxy to sign in. Choose {provider.name} when asked - which agent it works as. -
    6. -
    - {reachable && ( -

    - {connected ? ( - <> - - {provider.name} is signed in as {existing.name}. - - ) : ( - <> - - Waiting for {provider.name} to sign in… - - )} -

    - )} -
    - {another} - -
    - - ); -} - -// The login, as a message to hand the agent. One that exists already keeps its password, unless -// a new one is asked for here. -function LoginAgent({ - provider, - existing, - login, - pending, - error, - another, - onReset, - onContinue, -}: { - provider: AgentProvider; - existing: AgentLogin | null; - login: Login | null; - pending: boolean; - error: string | null; - another: ReactNode; - onReset: () => void; - onContinue: () => void; -}) { - return ( - <> - - {provider.name} signs in to Personal Agent Proxy with a login of its own. Send it this - message, and it can get started. - - {login ? ( - - ) : ( -
    - {pending && ( -

    - - Making the login… -

    - )} - {!pending && existing && ( - <> -

    - {provider.name} already has a login, {existing.username}. Its password was shown - when it was made; if it is lost, make a new one. -

    - - - )} - {!pending && !existing && !error && ( -

    The login could not be made.

    - )} -
    - )} - {error &&

    {error}

    } -
    - {another} - -
    - - ); -} - -// The message to paste to the agent: the sign-in page and the login, shown this once. -function HandoverMessage({login}: {login: Login}) { - const signInUrl = `${window.location.origin}/agent/login`; - const message = [ - 'Here is my Personal Agent Proxy login. It holds my connections and personal information; sign in with it when a task needs them.', - '', - `Sign-in page: ${signInUrl}`, - `Username: ${login.username}`, - `Password: ${login.password}`, - ].join('\n'); - - return ( -
    -
    {message}
    -
    -

    - The password is not shown again once you leave this page. -

    - -
    -
    - ); -} - // The catalog: a plus per integration, and how many accounts of it are connected. A mailbox goes // to the form; a sign-in starts at the api and comes back to the connection's page here. function ConnectionsStep({ @@ -519,10 +145,10 @@ function ConnectionsStep({ return ( <> - + Add an account, then choose what {agentName} may do with it. The rest can be connected later. - + {failed &&

    {failed}

    } void}) { return ( <> - + Sign in with an app password made for this connection alone in your mail account. Revoking it there disconnects the mailbox. - + - + Every agent gets what is ticked here unless its own page says otherwise. You can change it any time on the connection's page. - + Connection added successfully
    }, {path: '/info', loader: infoLoader, element: }, {path: '/agents', loader: agentsLoader, element: }, + {path: '/agents/new', loader: agentsLoader, element: }, {path: '/agents/:id', loader: agentLoader, element: }, + {path: '/agents/:id/setup', loader: agentSetupLoader, element: }, {path: '/activity', loader: activityLoader, element: }, ], },