Files
PaperClipAI/ui/src/api/chatEndpoints.ts
T
DottaandPaperclip d9b3a5653e feat(chat): add initial Slack communication guidance and connection menus (#13760)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat connectors let people use the same tasks and agent tools from
external conversations.
> - Agents need communication guidance that fits the conversation
medium.
> - That guidance belongs in the original task context, without repeated
instructions on each turn.
> - Connection owners also need clear settings and a consistent way to
remove a connection.
> - This pull request adds initial Slack guidance, optional connection
instructions, and chat connection menus.
> - The benefit is clearer Slack replies with the existing Paperclip
workflow and permissions.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Agent replies in Slack and chat connection management in the Apps
catalog.

**Current behavior**

Slack tasks do not carry a saved communication profile. The catalog
shows a separate Manage button and does not offer removal on every chat
connection row.

**Proposed behavior**

Save Slack guidance when a new conversation creates a task. Restore that
original guidance when a model session is rebuilt. Do not append it to
ordinary follow-ups. Expose optional additional instructions in Slack
Settings. Put Manage and Remove connection in a three-dot menu for all
chat providers. Keep Finish setup visible for drafts.

**Reason and benefit**

Small answers fit in Slack. Substantial deliverables use ordinary
document or artifact tools with a useful Slack summary. Connection
settings apply to new tasks and cannot change permissions. Users can
remove both active and unfinished chat connections from the catalog.

**Breaking changes**

Two additive database columns store endpoint preferences and the initial
conversation snapshot. Existing endpoints default to empty preferences.
Existing conversations keep their original behavior. Non-Slack guidance
is unchanged.

Related public context:
https://github.com/paperclipai/paperclip/pull/13741 improves native chat
recovery. This change adds communication context to those existing
execution paths. A search found no duplicate communication-guidance PR.

## What Changed

- Add a provider-guidance registry, enabled for Slack first.
- Persist optional endpoint communication instructions and capture an
immutable snapshot when a conversation creates a task.
- Resolve guidance from the verified company-scoped connection. Restore
it for fresh native and legacy sessions without per-turn reminders,
extra model calls, or extra context queries.
- Add the Slack Settings field, validation, audit coverage, and
Storybook save/error states.
- Add Manage and Remove connection menus for all seven chat providers.
Keep the draft setup button. Require removal confirmation and allow
retry after failure.
- Add regression coverage, an active/draft menu story, and connector
documentation.

## Verification

All CI checks are green for 5f48df4e0. Greptile scored this head 5/5
with no actionable findings. No review threads remain unresolved.

- Passed `pnpm -r typecheck` and `pnpm build` on PR head 5f48df4e0.
- Passed design-token checks, UI typecheck, and all 20 catalog tests
after rebase. Tests cover all seven providers, active/draft removal,
confirmation, cache refresh, errors, and cancellation.
- Verified the active/draft menu in Storybook. The interaction test runs
without browser console errors.
- Passed focused guidance, endpoint persistence/isolation, heartbeat
trust, native context, ACPX, adapter utility, and CLI recovery tests.
Full UI and CLI groups passed (6,512 and 502 tests).
- Tested real Slack conversations on staging: concise updates with
public links, a planning question with buttons, a saved plan, a saved
report, task creation and assignment, and explicit detailed output. Old
tasks retained original preferences after an edit; a new task used the
changed preferences. Restored the staging setting afterward.
- Existing safe progress remained visible without duplicate final
replies or private reasoning.
- Broad local tests found resource/time-sensitive failures that passed
targeted reruns. One Cursor archive-download fixture failed on both this
branch and the unchanged main checkout. The full local suite is not
claimed clean. All PR-head CI test shards passed, including general,
serialized, Runner, and browser suites. The redundant local full-suite
rerun was stopped after CI completed successfully.
- Live delegation was not tested because the staging company has only
one agent. Live testing also found separate latency and runner
task-editing capability gaps; this PR does not add connector-specific
workflow behavior to hide them.

## Risks

- Prompt guidance changes the form of new Slack replies. Explicit
requests for detail still take precedence.
- The additive migration is idempotent. Conversation snapshots remain
fixed when connection settings change.
- Native and legacy recovery must preserve the initial context without
duplicates; targeted tests cover these paths.
- Removing a connection stops new work through the existing lifecycle
action. It retains Paperclip task history and does not delete the
external app or bot.

## Model Used

OpenAI GPT-6 through Codex, with repository editing, shell tools, and
browser testing. The host does not expose a more specific model ID or
context-window size. No separate model calls were added to the product.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (targeted suites; broad
local limitations are listed above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-21 14:14:25 -05:00

343 lines
10 KiB
TypeScript

import { api } from "./client";
import type {
SlackAppConfiguration,
UpdateChatEndpointInput,
PhotonProjectInspection,
PhotonChannelConfiguration,
ChatPublicationBatchStatus,
ChatPublicationState,
ChatPublicationSummary,
ChatActivityItem,
ChatFileTransferResolutionPrecondition,
} from "@paperclipai/shared";
export type {
ChatPublicationSummary,
ChatActivityItem,
} from "@paperclipai/shared";
export type ChatProvider =
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "agentmail" | "imessage-photon";
export type ChatEndpointStatus =
| "draft"
| "verifying"
| "active"
| "paused"
| "attention"
| "revoked"
| "archived";
export type ChatEndpointSetupAction =
"configure" | "verify" | "pause" | "resume" | "reconnect" | "remove";
export interface ChatEndpointResource {
id: string;
type: string;
providerResourceId: string;
label: string;
availability: "available" | "unavailable" | "removed";
enabled: boolean;
detail?: string | null;
participants?: string[];
}
export interface ChatIdentityLink {
id: string;
principalId: string;
/** Latest discovery-only connect command received by this endpoint. */
lastConnectAt?: string | null;
externalLabel: string;
externalDetail?: string | null;
paperclipUserId?: string | null;
paperclipUserLabel?: string | null;
status: "linked" | "pending" | "revoked";
}
export interface ChatConversation {
id: string;
externalLabel: string;
externalUrl?: string | null;
issueId?: string | null;
issueIdentifier?: string | null;
issueTitle?: string | null;
state:
"active" | "waiting" | "completed" | "unavailable" | "endpoint_removed";
updatedAt: string;
lastPublicationStatus?: ChatPublicationState | null;
}
export interface ExternalChannelBindingSummary {
endpointId: string;
provider: ChatProvider;
botLabel?: string | null;
externalLabel: string;
externalUrl?: string | null;
conversationId: string;
publicationState?: string | null;
assignedAgentLocked: true;
}
export interface ChatIdentityLinkPreview {
selfService?: boolean;
canConfirm?: boolean;
endpointId: string;
companyId: string;
companyName: string;
companyPrefix: string;
provider: ChatProvider;
providerAccountLabel?: string | null;
botLabel?: string | null;
externalLabel: string;
externalDetail?: string | null;
expiresAt: string;
}
export interface ChatEndpoint {
communicationInstructions?: string;
publicationMode?: "automatic" | "explicit";
externalExecutionPolicy?: "restricted" | "agent";
id: string;
companyId: string;
provider: ChatProvider;
status: ChatEndpointStatus;
assignedAgentId: string;
assignedAgentName: string;
connectionId?: string | null;
providerAccountId?: string | null;
providerAccountLabel?: string | null;
botLabel?: string | null;
botUsername?: string | null;
botExternalId?: string | null;
photonAllocation?: "dedicated" | "shared";
allowDirectMessages?: boolean;
allowGroupChats?: boolean;
allowUnlinkedPeople: boolean;
replyMode?: "subscribed" | "mention_each_reply" | null;
healthMessage?: string | null;
lastError?: string | null;
lastActivityAt?: string | null;
resources?: ChatEndpointResource[];
identityLinks?: ChatIdentityLink[];
conversations?: ChatConversation[];
activity?: ChatActivityItem[];
setup?: {
step: string;
testStartedAt?: string | null;
testSkipped?: boolean;
authorizationUrl?: string | null;
providerUrl?: string | null;
webhookUrl?: string | null;
messagingEndpoint?: string | null;
command?: string | null;
slackApp?: SlackAppConfiguration;
webhookVerifiedAt?: string | null;
webhookSecretConfigured?: boolean;
callbackSurfaces?: {
events: ChatCallbackSurfaceState;
interactivity: ChatCallbackSurfaceState;
slashCommands: ChatCallbackSurfaceState;
};
callbacksNeedUpdate?: boolean;
};
}
export interface ChatCallbackSurfaceState {
status: "current" | "stale" | "unverified";
observedAt?: string | null;
}
export interface ChatEndpointSetupSecret {
webhookSecret: string;
}
type ListResponse<T> =
| T[]
| {
items?: T[];
endpoints?: T[];
resources?: T[];
principals?: T[];
conversations?: T[];
deliveries?: T[];
publications?: T[];
};
function rows<T>(response: ListResponse<T>): T[] {
if (Array.isArray(response)) return response;
return (
response.items ??
response.endpoints ??
response.resources ??
response.principals ??
response.conversations ??
response.deliveries ??
response.publications ??
[]
);
}
export const chatEndpointsApi = {
list: async (companyId: string) =>
rows(
await api.get<ListResponse<ChatEndpoint>>(
`/companies/${companyId}/chat-endpoints`,
),
),
get: (endpointId: string) =>
api.get<ChatEndpoint>(`/chat-endpoints/${endpointId}`),
create: (
companyId: string,
input: { provider: ChatProvider; assignedAgentId: string },
) => api.post<ChatEndpoint>(`/companies/${companyId}/chat-endpoints`, input),
update: (
endpointId: string,
input: UpdateChatEndpointInput,
) => api.patch<ChatEndpoint>(`/chat-endpoints/${endpointId}`, input),
setup: (
endpointId: string,
input: {
action: ChatEndpointSetupAction;
credentials?: Record<string, string>;
photon?: PhotonChannelConfiguration;
},
) => api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/setup`, input),
inspectPhoton: (endpointId: string, input: { projectId: string; projectSecret: string }) =>
api.post<PhotonProjectInspection>(`/chat-endpoints/${endpointId}/photon/inspect`, input),
generateSetupSecret: (endpointId: string) =>
api.post<ChatEndpointSetupSecret>(
`/chat-endpoints/${endpointId}/setup-secret`,
{},
),
test: (endpointId: string) =>
api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/test`, {}),
finishSlackSetup: (endpointId: string) => api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/finish`, {}),
setupTestStatus: (endpointId: string) => api.get<{ messageReceivedAt: string | null }>(`/chat-endpoints/${endpointId}/test-status`),
requestIdentityAccess: (token: string) => api.post<{ status: "member" | "pending_approval" }>("/chat-identity-links/request-access", { token }),
listResources: async (endpointId: string) =>
rows(
await api.get<ListResponse<ChatEndpointResource>>(
`/chat-endpoints/${endpointId}/resources`,
),
),
updateResources: (
endpointId: string,
resources: Array<{ id: string; enabled: boolean }>,
) =>
api.put<ChatEndpointResource[]>(`/chat-endpoints/${endpointId}/resources`, {
resources,
}),
listPrincipals: async (endpointId: string) =>
rows(
await api.get<ListResponse<ChatIdentityLink>>(
`/chat-endpoints/${endpointId}/principals`,
),
),
createLinkIntent: (endpointId: string, principalId: string) =>
api.post<{ confirmationUrl: string }>(
`/chat-endpoints/${endpointId}/principals/${principalId}/link-intent`,
{},
),
revokeLink: (endpointId: string, principalId: string) =>
api.delete<void>(
`/chat-endpoints/${endpointId}/principals/${principalId}/link`,
),
previewIdentityLink: (token: string) =>
api.get<ChatIdentityLinkPreview>(
`/chat-identity-links/preview?token=${encodeURIComponent(token)}`,
),
confirmIdentityLink: (token: string) =>
api.post<{ ok: true; endpointId: string }>("/chat-identity-links/confirm", {
token,
}),
listConversations: async (endpointId: string) =>
rows(
await api.get<ListResponse<ChatConversation>>(
`/chat-endpoints/${endpointId}/conversations`,
),
),
listActivity: async (endpointId: string) =>
rows(
await api.get<ListResponse<ChatActivityItem>>(
`/chat-endpoints/${endpointId}/activity`,
),
),
listActivityPage: (endpointId: string, cursor?: string) =>
api.get<{ items: ChatActivityItem[]; nextCursor: string | null }>(
`/chat-endpoints/${endpointId}/activity?limit=25${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
),
getIssueBinding: (issueId: string) =>
api.get<ExternalChannelBindingSummary | null>(
`/issues/${issueId}/chat-binding`,
),
replayDelivery: (endpointId: string, deliveryId: string) =>
api.post<void>(
`/chat-endpoints/${endpointId}/deliveries/${deliveryId}/replay`,
{},
),
replayPublication: (endpointId: string, publicationId: string) =>
api.post<void>(
`/chat-endpoints/${endpointId}/publications/${publicationId}/replay`,
{},
),
resolvePublication: (
endpointId: string,
publicationId: string,
action: "mark_delivered" | "retry_anyway" | "cancel",
fileTransfer?: ChatFileTransferResolutionPrecondition,
) =>
api.post<void>(
`/chat-endpoints/${endpointId}/publications/${publicationId}/resolve`,
{
action,
...(fileTransfer
? {
fileTransfer: {
phase: fileTransfer.phase,
version: fileTransfer.version,
},
}
: {}),
},
),
resolveAction: (
endpointId: string,
actionId: string,
action: "mark_delivered" | "retry_anyway" | "cancel",
) =>
api.post<void>(
`/chat-endpoints/${endpointId}/actions/${actionId}/resolve`,
{ action },
),
getPublicationBatchStatus: (
endpointId: string,
conversationId: string,
publicationId: string,
) =>
api.get<ChatPublicationBatchStatus>(
`/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`,
),
publishComment: (
endpointId: string,
conversationId: string,
commentId: string,
) =>
api.post<ChatPublicationSummary>(
`/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`,
{ commentId },
),
publishBoardMessage: (
endpointId: string,
conversationId: string,
body: string,
idempotencyKey: string,
attachmentIds: string[] = [],
) =>
api.post<ChatPublicationSummary>(
`/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`,
{
body,
idempotencyKey,
...(attachmentIds.length ? { attachmentIds } : {}),
},
),
};