import { SpekoProviderSetup } from "@/components/voice/SpekoProviderSetup"; import { NativeVoiceConversation } from "@/components/voice/NativeVoiceConversation"; import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared"; import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails"; import { SlackSetup } from "./slack/SlackSetup"; import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state"; import { slackConversationUrl } from "./slack/conversation-url"; import { useAccountIdentity } from "@/api/companies-query"; import { defaultSlackAppName } from "./slack-app-name"; import { GitHubChatSetup } from "./GitHubChatSetup"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; import { SetupWizardFooter } from "@/components/SetupWizard"; import { ChatSetupNavigation } from "@/components/chat/ChatSetupNavigation"; import { SlackIdentityStep } from "./SlackIdentityStep"; import { PhotonConnectStep } from "./PhotonConnectStep"; import { EmailEndpointSetup } from "./EmailEndpointSetup"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type Dispatch, type SetStateAction, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueryView } from "@/components/QueryView"; import { AlertTriangle, CheckCircle2, Copy, CircleHelp, ExternalLink, Eye, EyeOff, Hammer, Loader2 } from "lucide-react"; import { AgentAvatar } from "@/components/AgentAvatar"; import { AgentCharacter } from "@/components/AgentCharacter"; import { AgentSelect } from "@/components/AgentMultiSelect"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Textarea } from "@/components/ui/textarea"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; import { agentsApi } from "@/api/agents"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { chatEndpointsApi, type ChatEndpoint, type ChatProvider, type ChatEndpointSetupAction, } from "@/api/chatEndpoints"; import { useNavigate, useSearchParams } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { copyTextToClipboard } from "@/lib/clipboard"; import { useCopyAction } from "@/lib/use-copy-action"; import { isAgentStatusInvokable, slackAppConfigurationSchema, type SlackAppConfiguration } from "@paperclipai/shared"; import { sanitizedSetupErrorMessage } from "./chat-setup-error"; import { createGitHubPrivateKeyReadGuard, readGitHubPrivateKeyFile, } from "./github-private-key-file"; import { describeError } from "@/api/errors"; const providerNames: Record = { speko: "Speko", agentmail: "AgentMail", slack: "Slack", github: "GitHub", discord: "Discord", "microsoft-teams": "Microsoft Teams", telegram: "Telegram", "imessage-photon": "iMessage Photon", }; const knownProviders = new Set(Object.keys(providerNames)); function isProvider(value: string | null): value is ChatProvider { return value !== null && knownProviders.has(value); } function publicOrigin(value: string | null | undefined): string | null { if (!value) return null; try { return new URL(value).origin; } catch { return null; } } export function isChatEndpointRepairing( endpoint: Pick< ChatEndpoint, "provider" | "status" | "providerAccountId" | "botExternalId" > | null, resumeEndpointId: string | null, reconnectRequested: boolean, ): boolean { if (!resumeEndpointId || !endpoint) return false; const recoveringStatus = endpoint.status === "attention" || endpoint.status === "revoked"; // A secret-only GitHub draft affected by setup trouble has no App identity // or reusable App credentials. It must remain first-time setup, where App ID // and private key are required, rather than offering a misleading reconnect. if ( endpoint.provider === "github" && recoveringStatus && !endpoint.providerAccountId && !endpoint.botExternalId ) { return false; } return ( recoveringStatus || (reconnectRequested && (endpoint.status === "active" || endpoint.status === "paused")) ); } export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: { provider: ChatProvider; onChat: () => void; onTools: () => void; slackLabels?: string[]; }) { const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: "Choose connection" }]); return () => setBreadcrumbs([]); }, [setBreadcrumbs]); return (

Choose how to connect

What should this {providerNames[provider]} connection do?

); } export function ChatEndpointSetup() { const [params] = useSearchParams(); if (params.get("provider") === "github") return ; return params.get("provider") === "agentmail" ? : ; } function ChatSdkEndpointSetup() { const { userId, settled: identitySettled } = useAccountIdentity(); const [params, setParams] = useSearchParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const provider = isProvider(params.get("provider")) ? (params.get("provider") as ChatProvider) : null; const toolHref = params.get("toolHref") || "/apps"; const preselectedAgent = params.get("agentId") ?? ""; const resumeEndpointId = params.get("resume") ?? ""; const reconnectRequested = params.get("reconnect") === "1"; const [purpose, setPurpose] = useState<"choice" | "chat">( params.get("purpose") === "chat" ? "chat" : "choice", ); const [agentId, setAgentId] = useState(preselectedAgent); const [slackCredentialsReady, setSlackCredentialsReady] = useState(params.get("stage") === "credentials"); const [viewedStep, setViewedStep] = useState(null); const [slackIdentityReady, setSlackIdentityReady] = useState(false); const [automaticBusy, setAutomaticBusy] = useState(false); const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId)); const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null); const [endpoint, setEndpoint] = useState(null); const [credentials, setCredentials] = useState>({}); const [generatedWebhookSecret, setGeneratedWebhookSecret] = useState(""); const [setupError, setSetupError] = useState(null); useEffect(() => { setBreadcrumbs([ { label: "Connectors", href: "/apps" }, { label: "Connect chat" }, ]); return () => setBreadcrumbs([]); }, [setBreadcrumbs]); const agentsQuery = useQuery({ queryKey: ["chat-endpoint-setup-agents", selectedCompanyId], queryFn: () => agentsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); const resumeQuery = useQuery({ queryKey: ["chat-endpoint-setup-resume", resumeEndpointId], queryFn: () => chatEndpointsApi.get(resumeEndpointId), enabled: Boolean(resumeEndpointId), refetchInterval: query => ["creating", "install", "credentials_saved"].includes(query.state.data?.setup?.slackRegistration?.status ?? "") ? 1_500 : false, }); useEffect(() => { if (!resumeQuery.data) return; setEndpoint(current => { if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); return resumeQuery.data; }); setAgentId(resumeQuery.data.assignedAgentId); setPurpose("chat"); }, [resumeQuery.data]); const githubVerificationQuery = useQuery({ queryKey: ["chat-endpoint-github-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), enabled: Boolean( provider === "github" && endpoint?.id && endpoint.setup?.step === "provider_setup" && endpoint.setup?.webhookSecretConfigured && !endpoint.setup.webhookVerifiedAt, ), refetchInterval: 1_500, }); useEffect(() => { if ( !githubVerificationQuery.data || provider !== "github" || !endpoint || endpoint.id !== githubVerificationQuery.data.id || endpoint.setup?.step !== "provider_setup" || !endpoint.setup?.webhookSecretConfigured || endpoint.setup.webhookVerifiedAt ) return; setEndpoint(githubVerificationQuery.data); }, [endpoint, githubVerificationQuery.data, provider]); const experimentalSettingsQuery = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), enabled: endpoint?.setup?.step === "test", }); const experimentalSettingsView = useQueryView(experimentalSettingsQuery); const activeAgents = useMemo( () => (agentsQuery.data ?? []).filter((agent) => isAgentStatusInvokable(agent.status), ), [agentsQuery.data], ); const selectedAgent = agentsQuery.data?.find(agent => agent.id === agentId); const slackDetails = slackDraft?.agentId === agentId ? slackDraft.app : endpoint?.setup?.slackApp ?? defaultSlackAppConfiguration(selectedAgent?.name ?? endpoint?.assignedAgentName ?? ""); const slackDetailsValidation = slackAppConfigurationSchema.safeParse(slackDetails); const slackDetailsEditable = !automaticBusy && (!endpoint || endpoint.status === "draft" && !endpoint.botExternalId && (!endpoint.setup?.slackRegistration || endpoint.setup.slackRegistration.status === "failed")); const syncEndpointSnapshot = ( next: ChatEndpoint, onlyIfStillVisible = false, ) => { if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true); if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured" && endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null); setEndpoint((visible) => onlyIfStillVisible && visible?.id !== next.id ? visible : next, ); queryClient.setQueryData(["chat-endpoint-setup-resume", next.id], next); queryClient.setQueryData(queryKeys.chatEndpoints.detail(next.id), next); if (next.provider === "github") { queryClient.setQueryData( ["chat-endpoint-github-webhook-verification", next.id], next, ); } }; const slackChoices = useQuery({ queryKey: ["slack-setup-options", selectedCompanyId], queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!), enabled: provider === "slack" && Boolean(selectedCompanyId), }); const slackChoicesView = useQueryView(slackChoices); const createEndpoint = useMutation({ mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => { if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again."); return chatEndpointsApi.create(selectedCompanyId!, { provider: provider!, assignedAgentId: agentId, ...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), }); }, onSuccess: (next, { exit }) => { setManagedAutoStart(!exit); setViewedStep(exit ? 0 : null); syncEndpointSnapshot(next); if (next.provider === "imessage-photon" || next.provider === "slack" || next.provider === "speko") { const resumed = new URLSearchParams(params); resumed.set("resume", next.id); setParams(resumed, { replace: true }); } if (exit) navigate("/apps"); }, onError: (error) => pushToast({ title: "Couldn't start setup", body: describeError(error).body, tone: "error", }), }); const saveSlackDetails = useMutation({ scope: { id: `slack-app-details:${endpoint?.id}` }, mutationFn: (details: SlackAppConfiguration) => chatEndpointsApi.update(endpoint!.id, { slackApp: details }), onSuccess: next => syncEndpointSnapshot(next), }); const persistSlackDetails = async () => { if (endpoint && slackDetailsEditable && slackDetailsValidation.success && JSON.stringify(slackDetailsValidation.data) !== JSON.stringify(endpoint.setup?.slackApp)) await saveSlackDetails.mutateAsync(slackDetailsValidation.data); }; const continueFromAgent = async (exit: boolean) => { if (exit && provider !== "slack") { navigate("/apps"); return; } if (!endpoint) { if (!agentId) { if (exit) navigate("/apps"); return; } createEndpoint.mutate({ exit }); return; } try { if (provider === "slack") await persistSlackDetails(); } catch { return; } if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); } }; const useOwnSlackApp = useMutation({ mutationFn: async () => { if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true }); await persistSlackDetails(); return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" }); }, onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); }, onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }), }); const setupAction = useMutation({ mutationFn: async ({ action, values, }: { action: ChatEndpointSetupAction; values?: Record; }) => { const endpointId = endpoint!.id; try { return await chatEndpointsApi.setup(endpointId, provider === "imessage-photon" ? { action, ...(values?.projectSecret ? { credentials: { projectSecret: values.projectSecret } } : {}), ...(values?.projectId && values.allocation === "shared" ? { photon: { allocation: "shared" as const, projectId: values.projectId } } : values?.projectId && values?.lineId ? { photon: { allocation: "dedicated" as const, projectId: values.projectId, lineId: values.lineId } } : {}), } : { action, credentials: values }); } catch (error) { // Another open tab may have completed verification, or the successful // response may have been lost. Read the canonical state before retrying. if (action === "verify") { const current = await chatEndpointsApi.get(endpointId).catch(() => null); if (current?.setup?.step === "test" || current?.setup?.step === "complete") return current; } throw error; } }, onMutate: () => setSetupError(null), onSuccess: (next) => { setSetupError(null); setViewedStep(null); syncEndpointSnapshot(next); setCredentials({}); if (next.setup?.step !== "test" && next.setup?.step !== "complete") setSlackIdentityReady(false); }, onError: (error, variables) => setSetupError(sanitizedSetupErrorMessage(error, variables.values)), }); const generateSetupSecret = useMutation({ mutationFn: () => chatEndpointsApi.generateSetupSecret(endpoint!.id), onMutate: async () => { const endpointId = endpoint!.id; await Promise.all([ queryClient.cancelQueries({ queryKey: ["chat-endpoint-github-webhook-verification", endpointId], exact: true, }), queryClient.cancelQueries({ queryKey: ["chat-endpoint-setup-resume", endpointId], exact: true, }), queryClient.cancelQueries({ queryKey: queryKeys.chatEndpoints.detail(endpointId), exact: true, }), ]); return { endpointId }; }, onSuccess: async ({ webhookSecret }, _variables, context) => { const endpointId = context.endpointId; const markRotated = (current: ChatEndpoint) => ({ ...current, setup: { ...current.setup, step: "provider_setup" as const, webhookSecretConfigured: true, webhookVerifiedAt: null, }, }); queryClient.removeQueries({ queryKey: ["chat-endpoint-github-webhook-verification", endpointId], exact: true, }); setGeneratedWebhookSecret(webhookSecret); setEndpoint((current) => current && current.id === endpointId ? markRotated(current) : current, ); queryClient.setQueryData( ["chat-endpoint-setup-resume", endpointId], (current) => (current ? markRotated(current) : current), ); queryClient.setQueryData( queryKeys.chatEndpoints.detail(endpointId), (current) => (current ? markRotated(current) : current), ); try { const current = await chatEndpointsApi.get(endpointId); syncEndpointSnapshot(current, true); } catch { // Keep the one-time secret copyable. Verification polling will retry the // canonical endpoint read without restoring a pre-rotation snapshot. } }, onError: (error) => pushToast({ title: "Couldn't generate webhook secret", body: describeError(error).body, tone: "error", }), }); const testConnection = useMutation({ mutationFn: () => provider === "slack" ? chatEndpointsApi.finishSlackSetup(endpoint!.id) : chatEndpointsApi.test(endpoint!.id), onSuccess: (next) => { syncEndpointSnapshot(next); if (next.status === "active") navigate(`/apps/chat/${next.id}/settings`); }, onError: (error) => pushToast({ title: provider === "slack" ? "Couldn't finish setup" : "Test not complete", body: describeError(error).body, tone: "error", }), }); const finishSetup = () => { if (provider === "slack" && endpoint?.setup?.step === "complete") { navigate(`/apps/chat/${endpoint.id}/settings`); return; } testConnection.mutate(); }; const repairing = isChatEndpointRepairing( endpoint, resumeEndpointId, reconnectRequested, ); const isSlack = provider === "slack"; const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod }); const automaticSlack = isSlack && slackNavigation.provisioned; const automaticSlackComplete = automaticSlack && slackNavigation.complete; const tryStep = isSlack ? slackNavigation.finalStage : 2; const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0 : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1; const step = Math.min(viewedStep ?? availableStep, availableStep); const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending; const slackVerificationQuery = useQuery({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), enabled: Boolean(isSlack && step === 3 && endpoint?.setup?.step === "provider_setup" && !endpoint.setup.webhookVerifiedAt && !setupAction.isPending), refetchInterval: 1_500, }); const slackVerificationView = useQueryView(slackVerificationQuery); useEffect(() => { const next = slackVerificationQuery.data; if (!next || !endpoint || next.id !== endpoint.id || setupAction.isPending || endpoint.setup?.step !== "provider_setup" || endpoint.setup.webhookVerifiedAt || !next.setup?.webhookVerifiedAt) return; setEndpoint(next); }, [slackVerificationQuery.data, endpoint, setupAction.isPending]); const autoVerificationAttempt = useRef(null); useEffect(() => { if (!isSlack || step !== 3 || endpoint?.setup?.step !== "provider_setup" || !endpoint.setup.webhookVerifiedAt || setupAction.isPending) return; const attempt = `${endpoint.id}:${endpoint.setup.webhookVerifiedAt}`; if (autoVerificationAttempt.current === attempt) return; autoVerificationAttempt.current = attempt; setupAction.mutate({ action: "verify" }); }, [isSlack, step, endpoint, setupAction]); if (!provider) return (

This chat provider is not supported.

); if (!selectedCompanyId) return (

Select an organization to connect chat.

); if (purpose === "choice" && provider !== "speko") { return setPurpose("chat")} onTools={() => navigate(toolHref)} />; } return (
setViewedStep(isSlack ? slackNavigation.toStage(value) : value)} />
{step === 0 ? ( <>

Which agent do you want to chat with?

This agent is permanent for the connection. Connect another channel to represent a different agent.

{endpoint ? ( ) : } {isSlack && (selectedAgent || endpoint) && setSlackDraft({ agentId, app })} onBlur={() => void persistSlackDetails().catch(() => {})} /> {saveSlackDetails.error &&

Couldn't save the Slack app details. Try again before continuing.

} {slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && }
} {isSlack && !endpoint && !slackChoices.data && ((slackChoicesView.kind === "error") ?
Couldn't load Slack setup.
:

Loading Slack…

)} {provider === "github" && } void continueFromAgent(true)}> ) : null} {endpoint && !(automaticSlackComplete && step === tryStep) && ( )} {endpoint && isSlack && !automaticSlack && step === 4 && ( navigate("/apps")} onConnected={() => { setSlackIdentityReady(true); setViewedStep(5); }} /> )} {endpoint && step === tryStep && provider === "speko" && (

Test your Speko voice connection

Ask the agent to do some work, add a spoken follow-up, and listen for its result. Accepted work stays in Paperclip when you end the call.

navigate("/apps")}>
)} {endpoint && step === tryStep && provider !== "speko" && (!automaticSlack || automaticSlackComplete) && ( automaticSlack ?

You can now talk to {selectedAgent?.name ?? endpoint.assignedAgentName} in Slack

: navigate(`/apps/chat/${endpoint.id}/access`)} onTest={finishSetup} onSaveExit={() => navigate("/apps")} /> )} {step !== 0 && !isSlack && provider !== "speko" &&
}
); } function ProviderConnectStep({ provider, slackStage, onSlackCredentialsContinue, onSlackVerificationContinue, slackVerificationError, onSlackAppCreated, agentName, endpoint, credentials, setCredentials, repairing, pending, onAutomaticBusy, managedAutoStart = false, onEndpointSaved, generatedWebhookSecret, generatingSetupSecret, onGenerateSetupSecret, onAction, }: { provider: ChatProvider; slackStage: "app" | "credentials" | "finish"; onSlackCredentialsContinue: () => void; onSlackVerificationContinue: () => void; slackVerificationError: boolean; onSlackAppCreated: () => void; agentName: string; endpoint: ChatEndpoint; credentials: Record; setCredentials: Dispatch>>; repairing: boolean; pending: boolean; managedAutoStart?: boolean; onAutomaticBusy: (busy: boolean) => void; onEndpointSaved: (endpoint: ChatEndpoint) => void; generatedWebhookSecret: string; generatingSetupSecret: boolean; onGenerateSetupSecret: () => void; onAction: ( action: ChatEndpointSetupAction, values?: Record, ) => void; }) { const { userId, settled: identitySettled } = useAccountIdentity(); const { pushToast } = useToast(); const navigate = useNavigate(); const slackBotToken = (credentials.botToken ?? "").trim(); const slackBotTokenInvalid = provider === "slack" && slackBotToken.length > 0 && !slackBotToken.startsWith("xoxb-"); const slackSigningSecretHasTokenPrefix = provider === "slack" && /^x[a-z0-9]*-/i.test((credentials.signingSecret ?? "").trim()); const slackCredentialsSaved = provider === "slack" && Boolean(endpoint.providerAccountId); const continueWithSavedSlackCredentials = slackCredentialsSaved && !repairing && !slackBotToken && !credentials.signingSecret?.trim(); const reportCopyFailure = () => pushToast({ title: "Couldn't copy to clipboard", body: "Select and copy the value manually.", tone: "error", }); const field = (key: string, label: string, type = "password") => ( ); const openProviderSetup = (fallback: string) => window.open( endpoint.setup?.authorizationUrl ?? endpoint.setup?.providerUrl ?? fallback, "_blank", "noopener,noreferrer", ); const endpointValue = (label: string, value: string | null | undefined) => (

{label}

{value ?? "This endpoint is unavailable. Check the server's public URL."}
); const [manifestCopied, setManifestCopied] = useState(false); // A hook rather than a sticky boolean: this step stays mounted when the // secret is regenerated, so a latched "copied" would keep vouching for a // value the reader never copied. The status resets itself, and a refused // clipboard reads as a failure instead of a success. const webhookSecretCopy = useCopyAction(); const [openingSlackApp, setOpeningSlackApp] = useState(false); const slackAdvanceTimer = useRef | null>(null); useEffect(() => { setOpeningSlackApp(false); return () => { if (slackAdvanceTimer.current !== null) { clearTimeout(slackAdvanceTimer.current); slackAdvanceTimer.current = null; } }; }, [slackStage]); const [privateKeyVisible, setPrivateKeyVisible] = useState(false); const [privateKeyFileError, setPrivateKeyFileError] = useState( null, ); const [privateKeyFileLoaded, setPrivateKeyFileLoaded] = useState(false); const [privateKeyFileLoading, setPrivateKeyFileLoading] = useState(false); const privateKeyFileInputRef = useRef(null); const privateKeyReadGuard = useRef(createGitHubPrivateKeyReadGuard()).current; useEffect( () => () => { privateKeyReadGuard.invalidate(); }, [privateKeyReadGuard], ); const loadPrivateKeyFile = async (event: ChangeEvent) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (!file) return; const readRevision = privateKeyReadGuard.start(); setPrivateKeyFileError(null); setPrivateKeyFileLoaded(false); setPrivateKeyFileLoading(true); try { const privateKey = await readGitHubPrivateKeyFile(file); if (!privateKeyReadGuard.isCurrent(readRevision)) return; setCredentials((current) => ({ ...current, privateKey })); setPrivateKeyVisible(false); setPrivateKeyFileLoaded(true); } catch (error) { if (!privateKeyReadGuard.isCurrent(readRevision)) return; setPrivateKeyFileError( describeError(error).body, ); } finally { if (privateKeyReadGuard.isCurrent(readRevision)) { setPrivateKeyFileLoading(false); } } }; const replacePrivateKey = (privateKey: string) => { privateKeyReadGuard.invalidate(); setPrivateKeyFileError(null); setPrivateKeyFileLoaded(false); setPrivateKeyFileLoading(false); setCredentials((current) => ({ ...current, privateKey })); }; const slackApp = endpoint.setup?.slackApp ?? { ...defaultSlackAppConfiguration(agentName), ...(endpoint.setup?.command ? { command: endpoint.setup.command } : {}), }; const automaticSlack = isProvisionedSlackSetup(endpoint.setup?.slackSetupMethod); const slackValidation = slackAppConfigurationSchema.safeParse(slackApp); const saveSlackAndExit = () => navigate("/apps"); const slackAppName = slackApp.appName.trim(); const slackBotName = slackApp.botName.trim(); const slackCommand = slackApp.command.trim(); const slackWebhookUrl = endpoint.setup?.webhookUrl ?? ""; const slackManifest = JSON.stringify(buildSlackAppManifest({ app: { appName: slackAppName, botName: slackBotName, command: slackCommand }, agentName, webhookUrl: slackWebhookUrl, ...(automaticSlack && endpoint.setup?.slackOAuthCallbackUri ? { redirectUri: endpoint.setup.slackOAuthCallbackUri } : {}), }), null, 2); const slackCreateUrl = `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(slackManifest)}`; useEffect(() => setManifestCopied(false), [slackManifest]); const teamsClientId = credentials.clientId?.trim() || ""; const teamsManifestSettings = JSON.stringify( { bots: [ { botId: teamsClientId, scopes: ["personal", "team", "groupChat"], supportsFiles: true, isNotificationOnly: false, commandLists: [ { scopes: ["personal", "groupChat"], commands: [ { title: "/status", description: "Show the active Paperclip task status", }, { title: "/new", description: "Start a new Paperclip task in this chat", }, { title: "/close", description: "Close the active chat conversation", }, ], }, ], }, ], webApplicationInfo: { id: teamsClientId, resource: "https://paperclip.ing", }, authorization: { permissions: { resourceSpecific: [ { name: "ChannelMessage.Read.Group", type: "Application" }, { name: "ChatMessage.Read.Chat", type: "Application" }, ], }, }, }, null, 2, ); if (provider === "speko") return navigate("/apps")} agentName={agentName} credentials={credentials} onChange={setCredentials} onConnect={(values) => onAction(repairing ? "reconnect" : "configure", values)} callbackUrl={endpoint.setup?.webhookUrl} pending={pending} repairing={repairing} signingSecretConfigured={endpoint.setup?.webhookSecretConfigured} />; if (provider === "imessage-photon") return ; if (provider === "discord") { const applicationId = credentials.applicationId?.trim() ?? ""; const guildId = credentials.guildId?.trim() ?? ""; const installUrl = applicationId ? `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(applicationId)}&permissions=309237763136&scope=bot${guildId ? `&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true` : ""}` : null; return (

Connect {agentName} to Discord

{repairing ? "Reconnect verifies this same Discord application and server installation. It does not add or remove the bot from the server. Leave fields blank to reuse saved credentials." : "Create one dedicated Discord application and bot for this Paperclip agent."}

  1. In Discord Developer Portal, create an application. Copy its Application ID from General Information.
  2. Open Bot, create the bot, enable Message Content Intent, then reset and copy its token.
  3. Enable Developer Mode in Discord, right-click the target server, and copy its Server ID.
  4. Enter those values below, then use the generated install link to add the bot to that server.
{field("applicationId", "Application ID", "text")} {field("guildId", "Server ID", "text")} {field("botToken", "Bot token")} {installUrl && ( )}

The install link grants only View Channels, Send Messages, Create Public Threads, Send Messages in Threads, Read Message History, Add Reactions, Embed Links, and Attach Files. Paperclip still requires each discovered channel to be enabled in Access.

); } if (provider === "telegram") return (

Create {agentName} in Telegram

{repairing ? "Reconnect verifies this same BotFather bot and automatically refreshes its Paperclip webhook and command menu. It does not recreate the bot or change its chat memberships. Leave the token blank to reuse the saved credential." : "Create a bot with BotFather, then paste the token it gives you."}

  1. Open BotFather and send /newbot.
  2. Enter the bot display name.
  3. Choose an available username ending in bot.

Paperclip works with Telegram's default bot privacy mode and registers its command menu automatically. In a group, ordinary mentions are not delivered to bots: start or continue work with{" "} /task@bot_username <request>, or reply directly to a message from the bot.

{field("botToken", "Bot token")} {(!endpoint.setup?.webhookUrl || automaticSlack && !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://")) && (

Configure a public HTTPS URL for this Paperclip instance before connecting Telegram.

)}
); if (provider === "microsoft-teams") return (

Connect {agentName} to Microsoft Teams

{repairing ? "Reconnect verifies this same Microsoft app, tenant, and bot identity. It does not upload or reinstall the Teams app. Leave fields blank to reuse saved credentials." : "Use your own Microsoft app credentials for this bot."}

This setup requires a Microsoft 365 work or school organization where you can register an Entra app, create an Azure Bot, and upload or install a Teams app. Personal or free Teams accounts at teams.live.com cannot complete this setup. This release supports Microsoft 365 commercial cloud tenants only; GCC, GCC High, DoD, and Microsoft 365 operated by 21Vianet are not supported yet.

  1. In Microsoft Entra, create a single-tenant app registration. Copy its Application (client) ID and Directory (tenant) ID, then create a client secret and copy its value.
  2. In Azure, create an Azure Bot. Choose Single Tenant, use that Application ID, set its messaging endpoint to the Paperclip URL below, and add the Microsoft Teams channel.
  3. In Teams Developer Portal, create an app, add a bot with the same Application ID, then apply the manifest settings shown below. The block binds the Teams resource-specific consent permissions to that Entra app; these are not Microsoft Graph permissions in Entra. These permissions let the installed app receive every message in a team or group chat without an @mention, so describe that access to installers. Download the package and install it in the target team or group chat.
{endpointValue( "Paperclip messaging endpoint", endpoint.setup?.messagingEndpoint, )} {field("clientId", "Application / Client ID", "text")} {field("tenantId", "Directory / Tenant ID", "text")} {field("clientSecret", "Client secret value")}

Microsoft portal field map

Use these exact portal sections and reuse the same Application ID in all three places.

  1. Microsoft Entra admin center · App registrations: select New registration, choose{" "} Accounts in this organizational directory only (Single tenant) , then select Register. Copy{" "} Application (client) ID and{" "} Directory (tenant) ID. Under{" "} Certificates & secrets · Client secrets, select New client secret and copy its{" "} Value, not its Secret ID.
  2. Azure · Create Azure Bot: set{" "} Microsoft App ID to{" "} Single Tenant, set Creation type{" "} to Use existing app registration, and enter the Application ID and Tenant ID above. After creation, open{" "} Settings · Configuration and paste the Paperclip{" "} Messaging endpoint; then open{" "} Settings · Channels and enable{" "} Microsoft Teams.
  3. Teams Developer Portal · Apps: select{" "} New app. Under{" "} Configure · App features · Bot, add an existing bot using the same Application ID; enable{" "} Personal, Team, and{" "} Group chat scopes plus file support. Under{" "} Configure · Permissions, add the two RSC{" "} Application permissions shown below. Complete the required app details and icons, explain that the app can receive every message in an installed team or group chat, then download the app package.
  4. Microsoft Teams · Apps · Manage your apps: select{" "} Upload an app · Upload a custom app, choose the downloaded package, and install it in each intended personal chat, group chat, or team. One team install covers its standard channels. Private and shared channels require a separate app installation and are not supported by this release. If upload is unavailable, a Teams administrator must enable or approve custom apps.