Files
PaperClipAI/ui/src/api/tools.ts
T
Devin FoleyandPaperclip 49b3804830 fix(connections): load permissions and show AI account identity (#18653)
## Thinking Path

> - Paperclip manages AI agents and their access to saved connections.
> - The Permissions page loads account details and permitted agent
choices.
> - Company-wide AI accounts repeat retained installation checks for
every candidate agent.
> - This makes target checks grow with the square of the roster size.
> - The browser waits without a deadline, and connection names do not
identify the provider login.
> - This pull request batches direct AI checks and bounds loading with
recovery controls.
> - Account details load separately and show a provider email or an
explicit profile-permission limit.

## Linked Issues or Issue Description

**What happened?**

Opening a saved AI account can leave Permissions on “Loading AI
account…” for minutes. The account itself can be connected. The existing
capability batch excludes AI connections, so a company-wide install
repeats checks for every installed agent for every candidate. Saved
names and Paperclip ownership also do not establish which provider login
a credential uses.

**Expected behavior**

Direct AI accounts reuse retained-target checks within one descriptive
request. The page shows loaded account controls or a bounded failure
with Retry. Permissions shows the provider email and organization when
the selected credential permits profile access. An unavailable profile
must not block maintenance or disconnect a working inference credential.
Writes still authorize current permissions.

**Steps to reproduce**

1. Save an AI account in a company with several agents.
2. Install the account company-wide.
3. Open its Permissions page.
4. Delay the account-list or grants response to exercise the loading
failure.

The maintained benchmark reproduces database work with synthetic local
data. Refs #17945, which added batching for non-AI connections. Searches
found no open PR for this AI-account loading fix.

## What Changed

- Include direct AI accounts in the existing board capability batch.
Referenced pool members, delegated actors, complex selectors, and
mutation authorization keep their existing evaluator.
- Add opt-in per-caller GET deadlines to the API client. A timed-out
caller detaches the stuck coalesced request so Retry starts a fresh
fetch. Other callers retain their own cancellation and results.
- Apply a 15-second deadline to AI account lists and grants reads. The
permissions view retries transient failures once and reports other
failures immediately.
- Share the parent Permissions grants query with AI account details.
Keep loaded controls visible after a background error and offer Retry.
- Add a grant-scoped identity GET and OpenAPI contract. The service
checks current membership and credential audience before it reads the
selected credential. It projects only the provider email and
organization; provider IDs, tokens, and raw errors stay private.
- Show account details independently of maintenance controls. Anthropic
profile denial has an explicit message and preserves connected status.
Transient profile failures have a separate Retry action. Observations
are cached for five minutes under the company, connection, grant, and
grant revision.
- Extend real PostgreSQL and authenticated HTTP coverage,
client/component regressions, map guidance, and the existing benchmark's
AI mode.
- Address the Apex file-size finding by moving gallery identity lookup
into the existing app display helper module. `AppDetail.tsx` is 984
lines; lookup behavior is unchanged.

## Verification

Local source checkout on macOS, Node 25.6.1, pnpm 9.15.4, and disposable
PostgreSQL 18.1. Provider calls use fixtures. No hosted deployment or
provider qualification is claimed.

- Passed `pnpm -r typecheck` and `pnpm build`.
- Passed all 87 tests in `connection-management.integration.test.ts` and
all 33 tests in `connection-install-capabilities.test.ts`.
- Passed 196 AI connection, identity-provider, and OpenAPI tests:
`ai-connections.test.ts`, `ai-account-identity.test.ts`, and
`openapi-routes.test.ts`.
- Passed 107 focused UI/client tests: `client.test.ts`,
`ManagedAiConnectionDetails.test.tsx`,
`AiConnectionAccountDetails.test.tsx`, and `AppDetail.test.tsx`.
- After the Apex extraction, passed UI typecheck/build and 106 component
tests across `AppDetail.test.tsx`, `AppNotConnected.test.tsx`,
`ManagedAiConnectionDetails.test.tsx`, and
`AiConnectionAccountDetails.test.tsx`.
- The authenticated HTTP tests compare complete grants responses across
human roles, for tool and AI connections. They verify a real install
mutation succeeds before revocation and fails afterward.
- A company-wide personal AI fixture checks 20 targets instead of 400.
Pool-member permission loss still denies the complete pool description.
- UI states covered: shared cached grants, stuck account/grants fetches,
one automatic retry, terminal failure, manual recovery, authorization
failure, background failure with retained controls, and unmount
cancellation. Client tests also cover stalled response bodies and
independent coalesced callers.
- Identity coverage includes actual bearer-key/JWT reads, private-user
and cross-company denial before provider access, revoked credentials,
provider 401/403/429/500, and grant-change isolation. A real local HTTP
fixture stalls the response body, verifies the shared 15-second
deadline, and keeps unrelated HTTP requests responsive. The test
shortens the native deadline to 100 ms. Profile permission denial leaves
the saved account connected.
- Passed `pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node
scripts/check-module-boundaries.mjs`, and `git diff --check`.
- The full local `pnpm test:run` started with the loading-only change.
All six chat shards passed. It recorded one failure in the unchanged
`heartbeat-retry-scheduling.test.ts` quota-rejection case. That exact
case passed a focused rerun on the final head. The initial failure cause
remains unconfirmed and its local log is retained; this is not a full
local-suite pass. The redundant local run was stopped with SIGINT after
final-head CI completed successfully.
- [Final-head
CI](https://github.com/paperclipai/paperclip/actions/runs/41515556725)
passed on `ead8d276d4a2023614fff54b3e9a858f28c3178b`: typecheck, build,
all 12 server shards, all 3 chat shards, workspace suites, all 9
serialized suites, all 8 end-to-end shards, Runner checks, and the
aggregate verify gate. Security checks passed. Optional Storybook checks
were skipped by workflow policy. GitHub reports the branch mergeable
without conflicts.
- Apex reviewed head `ead8d276d4a2023614fff54b3e9a858f28c3178b` at 5/5,
confirmed the extraction resolves its earlier file-size finding, and has
no unresolved review threads.

The existing benchmark ran for the loading-only change now recorded in
commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce`, with `--purpose=ai
--baseline-ref=026fd9e3452`. The identity follow-up leaves that batch
source unchanged. Two warmups and five alternating samples exercise each
of eight fixture shapes. Complete positional capabilities, reasons, and
operations match the baseline in every sample.

| AI fixture | SQL calls before → after | Target reads before → after |
| --- | ---: | ---: |
| 1 agent, company install | 9 → 9 | 1 → 1 |
| 32 agents, company install | 5,248 → 164 | 1,024 → 32 |
| 100 agents, company install | 50,400 → 504 | 10,000 → 100 |
| 100 agents, partial install | 13,175 → 503 | 2,575 → 100 |

These counts measure the real service over local PostgreSQL. They do not
predict hosted page latency. To reproduce the standalone benchmark, use
a checkout of commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce` and run
the command below. Its isolation check intentionally rejects the later
identity follow-up's additional runtime changes.

```sh
node --import ./server/node_modules/tsx/dist/esm/index.mjs scripts/benchmark-connection-install-capabilities.ts --purpose=ai --baseline-ref=026fd9e3452
```

Run the final-head regression suites from the repository root:

```sh
cd server
pnpm exec vitest run src/__tests__/connection-management.integration.test.ts src/__tests__/connection-install-capabilities.test.ts src/__tests__/ai-connections.test.ts src/services/ai-account-identity.test.ts src/__tests__/openapi-routes.test.ts
cd ../ui
pnpm exec vitest run src/api/client.test.ts src/components/ai-connections/ManagedAiConnectionDetails.test.tsx src/components/ai-connections/AiConnectionAccountDetails.test.tsx src/pages/apps/AppDetail.test.tsx
```

## Risks

Descriptive permissions reuse retained facts within one request. A
concurrent change can leave a displayed choice stale; the next request
and every write still authorize fresh. Pools with referenced members
retain their existing cost.

The account and grants clients now bound each caller's wait. A browser
abort does not cancel an authorization read already admitted by the
server. The independent profile request uses a fixed Anthropic origin, a
bounded body, no redirects, and a 15-second deadline. Inference-only
credentials can return no email; that is reported without guessing from
a Paperclip owner or changing connection health. Profiles are fetched
only for the caller's authorized selected grant. No ambient CLI
identity, persistent identity cache, background polling, or
billing-identity change is added. Other API reads keep their existing
behavior. No migration is required. Staging has not been redeployed.

## Model Used

OpenAI `gpt-6.1-sol` through the Codex harness in T3 Code, with xhigh
reasoning, code editing, execution, and test tools. The host does not
expose the context-window size.

## 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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-11-02 12:29:03 -08:00

611 lines
27 KiB
TypeScript

import type { AggregatorAppsResponse, ArcadeDiscoverySetupInput } from "@paperclipai/shared/aggregator-apps";
import type { ComposioAppAccountInput, ComposioAppSetupInput, ComposioAppSetupResult, ComposioAppSnapshot, ComposioAppsResponse } from "@paperclipai/shared";
import type {
ToolApplication,
ConfigureRailwaySsh,
RailwaySshSetup,
ToolConnection,
ToolConnectionInstall,
ToolConnectionInstallSnapshot,
ToolConnectionRemovalSummary,
ConnectToolAppResult,
ConnectToolApp,
FinishToolAppResult,
ToolCatalogEntry,
ToolRuntimeSlot,
ToolPolicy,
ToolConnectionHealthCheckResult,
ToolCatalogRefreshResult,
ToolAccessDecision,
ToolAccessDecisionInput,
CreateToolPolicy,
DuplicateToolPolicy,
McpJsonImportPreview,
ToolRuntimeHealthSummary,
ToolRunDecisionLookup,
ToolOAuthStartResult,
ToolStdioCommandTemplate,
ToolProfileBinding,
ToolProfileBindingTargetType,
ToolProfileDefaultAction,
ToolProfileEffectiveSummary,
ToolProfileEntry,
ToolProfileEntryEffect,
ToolProfileEntrySelectorType,
ToolProfileStatus,
ToolProfileWithDetails,
ToolProfileNewToolReviewDecision,
ToolProfileNewToolsReview,
ToolProfileNewToolsReviewResult,
ToolRiskLevel,
UpdateToolPolicy,
ReorderToolPolicies,
AppDefinition,
ToolAppsAttentionResponse,
ToolConnectionActivityResponse,
ToolConnectionLifecycleEventType,
ToolConnectionTestAgentsResponse,
ToolConnectionTestAgentAccessResponse,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,
ToolActionRequest,
ToolActionRequestStatus,
ToolActionRequestsResponse,
ToolMcpGatewayWithTokens,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayToken,
CreateToolMcpGateway,
CreateToolMcpGatewayToken,
UpdateToolMcpGateway,
CreateToolTrustRuleFromActionRequest,
ToolRedactedValueSummary,
ConnectionGrant,
ConnectionGrantKind,
ConnectionGrantDelegation,
ConnectionGrantsResponse,
ToolConnectionCreateCapabilities,
ToolAppMetadataPreflightResult,
} from "@paperclipai/shared";
import { api, detachInflightGet, type RequestOptions } from "./client";
/**
* Tools & Access API client (Phase 6, PAP-10389).
*
* Mirrors the governed MCP/tool-access contracts shipped by Phases 2-5
* (`server/src/routes/tool-access.ts` and `tool-gateway.ts`). The UI consumes
* server-side enforcement contracts directly instead of faking tool access in
* the browser.
*/
export type ToolApplicationsResponse = { applications: ToolApplication[] };
export type ToolConnectionsResponse = { connections: ToolConnection[] };
export type ToolCatalogResponse = { catalog: ToolCatalogEntry[] };
export type ToolRuntimeSlotsResponse = { runtimeSlots: ToolRuntimeSlot[] };
export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary;
export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] };
export type ToolPoliciesResponse = { policies: ToolPolicy[] };
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
export type ToolGalleryResponse = {
apps: AppDefinition[];
/** Server-authoritative OAuth callback URI, omitted when this deployment has no supported browser origin. */
oauthCallbackUrl?: string;
capabilities: ToolConnectionCreateCapabilities;
credentialSources: {
vercelConnect: {
available: boolean;
enabled: boolean;
authentication: "workload_oidc" | "access_token" | null;
manageUrl: string;
reason: string | null;
};
};
};
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
export type CloudConnectorEnrollmentStatus = {
configured: boolean;
status: "not_configured" | "unenrolled" | "pending" | "active" | "suspended" | "unverified";
brokerBaseUrl: string;
instanceId: string | null;
environment: "development" | "test" | "staging" | "production";
origins: string[];
verificationUrl?: string;
expiresAt?: string;
};
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
expiresAt?: string | Date | null;
};
/** Gateway update payload — `companyId` is injected by the client method. */
export type UpdateGatewayInput = Omit<UpdateToolMcpGateway, "companyId">;
export type ReviewNewToolsInput = {
decisions: Array<{ catalogEntryId: string; decision: ToolProfileNewToolReviewDecision }>;
};
export type StdioTemplateSummary = ToolStdioCommandTemplate;
export type StdioTemplatesResponse = { templates: StdioTemplateSummary[] };
/** Admin "run your own" command-template create input (M8b, PAP-10862). */
export interface CreateStdioTemplateInput {
templateId: string;
name: string;
description?: string | null;
command: string;
args?: string[];
envKeys?: string[];
}
export interface CreateToolApplicationInput {
name: string;
description?: string | null;
type: ToolApplication["type"];
pluginId?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface UpdateToolApplicationInput {
name?: string;
description?: string | null;
status?: ToolApplication["status"];
metadata?: Record<string, unknown> | null;
}
export interface CreateToolConnectionInput {
applicationId?: string;
applicationName?: string;
name: string;
transport: NonNullable<ToolConnection["transport"]>;
status?: ToolConnection["status"];
config?: Record<string, unknown>;
credentialRefs?: ToolConnection["credentialRefs"];
enabled?: boolean;
}
export interface UpdateToolConnectionInput {
agentInstructions?: import("@paperclipai/shared").ConnectionAgentInstructions | null;
name?: string;
status?: ToolConnection["status"];
config?: Record<string, unknown>;
transportConfig?: Record<string, unknown>;
credentialRefs?: ToolConnection["credentialRefs"];
enabled?: boolean;
}
export interface ToolProfileEntryInput {
selectorType: ToolProfileEntrySelectorType;
effect?: ToolProfileEntryEffect;
applicationId?: string | null;
connectionId?: string | null;
catalogEntryId?: string | null;
toolName?: string | null;
riskLevel?: ToolRiskLevel | null;
conditions?: Record<string, unknown> | null;
}
export interface CreateToolProfileInput {
profileKey: string;
name: string;
description?: string | null;
status?: ToolProfileStatus;
defaultAction?: ToolProfileDefaultAction;
metadata?: Record<string, unknown> | null;
entries?: ToolProfileEntryInput[];
}
export interface UpdateToolProfileInput {
profileKey?: string;
name?: string;
description?: string | null;
status?: ToolProfileStatus;
defaultAction?: ToolProfileDefaultAction;
metadata?: Record<string, unknown> | null;
entries?: ToolProfileEntryInput[];
}
export interface ToolProfileBindingInput {
targetType: ToolProfileBindingTargetType;
targetId: string;
priority?: number;
metadata?: Record<string, unknown> | null;
}
export type UnbindToolProfileInput = Pick<ToolProfileBindingInput, "targetType" | "targetId">;
/** Redacted tool-gateway audit row (subset of `activity_log`). */
export interface ToolGatewayAuditRow {
id: string;
companyId: string;
action: string;
actorType: string | null;
actorId: string | null;
entityType: string | null;
entityId: string | null;
details: Record<string, unknown> | null;
createdAt: string;
}
/** Normalized outcome for the humanized Activity feed. */
export type ToolAuditOutcome =
| "allowed"
| "blocked"
| "asked_first"
| "waiting"
| "failed"
| "unknown";
/**
* Audit row enriched server-side with humanized display names and a normalized
* outcome — the shape returned by `GET /tool-gateway/audit`.
*/
export interface ToolGatewayActivityEvent extends ToolGatewayAuditRow {
agentId: string | null;
runId: string | null;
applicationId: string | null;
connectionId: string | null;
agentDisplayName: string | null;
actorDisplayName?: string | null;
appDisplayName: string | null;
applicationDisplayName: string | null;
connectionDisplayName: string | null;
toolDisplayName: string | null;
lifecycleType?: ToolConnectionLifecycleEventType | null;
normalizedOutcome: ToolAuditOutcome;
invocation: {
id: string;
toolName: string;
status: string;
policyDecision: string | null;
approvalState: string;
argumentsSummary: ToolRedactedValueSummary | null;
resultSummary: ToolRedactedValueSummary | null;
resultSizeBytes: number | null;
errorCode: string | null;
errorMessage: string | null;
startedAt: string | null;
completedAt: string | null;
} | null;
}
export type ToolGatewayActivityResponse = {
events: ToolGatewayActivityEvent[];
nextCursor: string | null;
};
export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d" | "all";
export interface ListActivityParams {
gateway?: string | null;
app?: string | null;
agent?: string | null;
outcome?: string | null;
window?: ToolAuditWindow;
search?: string | null;
limit?: number;
cursor?: string | null;
}
export type ToolPolicyTestResponse = {
decision: ToolAccessDecision;
auditEvent: unknown | null;
};
export const toolsApi = {
getCloudConnectorEnrollment: () =>
api.get<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment"),
startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) =>
api.post<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment", { companyId, label, returnTo }),
// --- Applications ---
listGallery: (companyId: string) =>
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
preflightAppMetadata: (companyId: string, galleryKey: string, methodKey?: string | null) => {
const query = methodKey ? `?methodKey=${encodeURIComponent(methodKey)}` : "";
return api.get<ToolAppMetadataPreflightResult>(
`/companies/${companyId}/tools/apps/${encodeURIComponent(galleryKey)}/preflight${query}`,
);
},
connectApp: (companyId: string, input: ConnectToolApp) =>
api.post<ConnectToolAppResult>(`/companies/${companyId}/tools/apps/connect`, input),
startOAuth: (
connectionId: string,
input: {
asCurrentUser?: boolean;
asAgentId?: string;
interactionId?: string;
workspaceServices?: import("@paperclipai/shared").GoogleWorkspaceServiceSelection;
} = {},
) =>
api.post<ToolOAuthStartResult>(`/tools/oauth/${connectionId}/start`, input),
finalizeOAuthAccess: (
companyId: string,
connectionId: string,
input: { grantKind: ConnectionGrantKind },
) =>
api.post<FinishToolAppResult>(
`/companies/${companyId}/tools/apps/${connectionId}/finalize-oauth-access`,
input,
),
finishApp: (companyId: string, connectionId: string, input: {
enabledCatalogEntryIds: string[];
askFirstCatalogEntryIds: string[];
reviewedCatalogEntryIds?: string[];
agentInstructions?: import("@paperclipai/shared").ConnectionAgentInstructions | null;
access: "all_agents" | { agentIds: string[] };
}) =>
api.post<FinishToolAppResult>(
`/companies/${companyId}/tools/apps/${connectionId}/finish`,
input,
),
listAppsAttention: (companyId: string) =>
api.get<ToolAppsAttentionResponse>(`/companies/${companyId}/tools/apps/attention`),
listApplications: (companyId: string) =>
api.get<ToolApplicationsResponse>(`/companies/${companyId}/tools/applications`),
createApplication: (companyId: string, input: CreateToolApplicationInput) =>
api.post<ToolApplication>(`/companies/${companyId}/tools/applications`, input),
updateApplication: (applicationId: string, input: UpdateToolApplicationInput) =>
api.patch<ToolApplication>(`/tool-applications/${applicationId}`, input),
deleteApplication: (applicationId: string) =>
api.delete<ToolApplication>(`/tool-applications/${applicationId}`),
// --- Connections ---
listConnections: (companyId: string) =>
api.get<ToolConnectionsResponse>(`/companies/${companyId}/tools/connections`),
getConnection: (connectionId: string) =>
api.get<ToolConnection>(`/tool-connections/${connectionId}`),
// --- Installs (Phase 3b, PAP-13618): which agents carry this connection's
// tools in their runtime context. `installed ⊆ permitted`; the server
// auto-extends access (adds profile bindings) for any newly-installed target.
getConnectionInstalls: (connectionId: string) =>
api.get<{ connectionId: string; installs: ToolConnectionInstall[] }>(
`/tool-connections/${connectionId}/installs`,
),
putConnectionInstalls: (
connectionId: string,
installs: Array<{ targetType: "company" | "agent"; targetId: string }>,
) =>
api.put<ToolConnectionInstallSnapshot>(
`/tool-connections/${connectionId}/installs`,
{ installs },
),
// --- Identity grants (PAP-17835): who a connection acts as. The response
// carries server-computed capabilities and the audience member directory, so
// the UI never rebuilds the permission matrix from `membershipRole`.
listConnectionGrants: (connectionId: string, options?: RequestOptions) =>
api.get<ConnectionGrantsResponse>(`/tool-connections/${connectionId}/grants`, { timeoutMs: 15_000, ...options }),
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
api.post<ConnectionGrantDelegation>(
`/tool-connections/${connectionId}/grants/${grantId}/delegations`,
{ agentId },
),
revokeConnectionGrantDelegation: (
connectionId: string,
grantId: string,
delegationId: string,
) => api.delete<ConnectionGrantDelegation>(
`/tool-connections/${connectionId}/grants/${grantId}/delegations/${delegationId}`,
),
revokeConnectionGrant: (connectionId: string, grantId: string) =>
api.delete<ConnectionGrant>(`/tool-connections/${connectionId}/grants/${grantId}`),
// An empty `memberUserIds` is the canonical "all organization members".
// Replacement is atomic server-side, so this never partially widens access.
replaceConnectionGrantMembers: (connectionId: string, grantId: string, memberUserIds: string[]) =>
api.put<ConnectionGrant>(
`/tool-connections/${connectionId}/grants/${grantId}/members`,
{ memberUserIds },
),
/**
* Start the signed-in user's own personal authorization. `subjectUserId` must
* be the caller: the server refuses any other subject, so there is no way to
* initiate consent on someone else's behalf.
*/
startPersonalAuthorization: (
companyId: string,
connectionId: string,
input: { subjectUserId: string; scopes?: string[]; returnTo?: string },
) =>
api.post<{ url: string; handoff?: ToolOAuthStartResult["handoff"] }>(
`/companies/${companyId}/tools/connections/${connectionId}/start-authorization`,
input,
),
createConnection: (companyId: string, input: CreateToolConnectionInput) =>
api.post<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
api.patch<ToolConnection>(`/tool-connections/${connectionId}`, input),
configureRailwaySsh: (connectionId: string, input: ConfigureRailwaySsh) =>
api.post<RailwaySshSetup | null>(`/tool-connections/${connectionId}/railway/ssh`, input),
// Removal is a credential-revoking teardown (PAP-17119), so the response
// carries the cleanup receipt alongside the archived connection.
archiveConnection: (connectionId: string) =>
api.delete<ToolConnection & { removal: ToolConnectionRemovalSummary }>(
`/tool-connections/${connectionId}`,
),
checkConnectionHealth: (connectionId: string) =>
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/health-check`, {}),
reconnectConnection: (connectionId: string, credentialValues: Record<string, string>) =>
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/reconnect`, {
credentialValues,
}),
refreshCatalog: (connectionId: string) =>
api.post<ToolCatalogRefreshResult>(`/tool-connections/${connectionId}/catalog/refresh`, {}),
listCatalog: (connectionId: string) =>
api.get<ToolCatalogResponse>(`/tool-connections/${connectionId}/catalog`),
listConnectionActivity: (connectionId: string, limit = 20) =>
api.get<ToolConnectionActivityResponse>(
`/tool-connections/${connectionId}/activity?limit=${limit}`,
),
listTestAgents: (connectionId: string) =>
api.get<ToolConnectionTestAgentsResponse>(
`/tool-connections/${connectionId}/test-agents`,
),
getTestAgentAccess: (connectionId: string, agentId: string) =>
api.get<ToolConnectionTestAgentAccessResponse>(
`/tool-connections/${connectionId}/test-agents/${agentId}/access`,
),
setupComposioApp: (connectionId: string, toolkit: string, input: ComposioAppSetupInput) =>
api.post<ComposioAppSetupResult>(`/tool-connections/${connectionId}/composio/apps/${encodeURIComponent(toolkit)}/setup`, input),
listAggregatorApps: (connectionId: string) => {
const path = `/tool-connections/${connectionId}/aggregator/apps`;
// React Query already deduplicates within a viewing-user key. Path-only
// request coalescing could hand a previous user's in-flight response to a new account.
detachInflightGet(path);
return api.get<AggregatorAppsResponse>(path, { cache: "no-store" });
},
syncAggregatorApps: (connectionId: string, force = false) => api.post<AggregatorAppsResponse>(`/tool-connections/${connectionId}/aggregator/apps/sync`, { force }),
refreshAggregatorApps: (connectionId: string, toolkits: string[] = []) => api.post<AggregatorAppsResponse>(`/tool-connections/${connectionId}/aggregator/apps/refresh`, { toolkits }),
configureArcadeDiscovery: (connectionId: string, input: ArcadeDiscoverySetupInput) => api.put<AggregatorAppsResponse>(`/tool-connections/${connectionId}/aggregator/discovery`, input),
listComposioApps: (connectionId: string) => api.get<ComposioAppsResponse>(`/tool-connections/${connectionId}/composio/apps`),
syncComposioApps: (connectionId: string, force = false) => api.post<ComposioAppsResponse>(`/tool-connections/${connectionId}/composio/apps/sync`, { force }),
refreshComposioApps: (connectionId: string, toolkits: string[]) => api.post<ComposioAppsResponse>(`/tool-connections/${connectionId}/composio/apps/refresh`, { toolkits }),
manageComposioAppAccount: (connectionId: string, toolkit: string, input: ComposioAppAccountInput) =>
api.post<ComposioAppSetupResult & { apps: ComposioAppSnapshot[] }>(`/tool-connections/${connectionId}/composio/apps/${encodeURIComponent(toolkit)}/accounts`, input),
runTestCall: (
connectionId: string,
input: { agentId: string; toolName: string; parameters?: Record<string, unknown> },
) =>
api.post<ToolConnectionTestCallResult>(
`/tool-connections/${connectionId}/test-calls`,
input,
),
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
api.get<ToolConnectionTestCallStatus>(
`/tool-connections/${connectionId}/test-calls/${actionRequestId}`,
),
importMcpJson: (companyId: string, body: { mcpJson: unknown }) =>
api.post<McpJsonImportPreview>(`/companies/${companyId}/tools/mcp/import-json`, body),
listStdioTemplates: (companyId: string) =>
api.get<StdioTemplatesResponse>(`/companies/${companyId}/tools/stdio-templates`),
createStdioTemplate: (companyId: string, input: CreateStdioTemplateInput) =>
api.post<StdioTemplateSummary>(`/companies/${companyId}/tools/stdio-templates`, input),
disableStdioTemplate: (companyId: string, templateId: string, reason?: string | null) =>
api.post<StdioTemplateSummary>(
`/companies/${companyId}/tools/stdio-templates/${encodeURIComponent(templateId)}/disable`,
{ reason: reason ?? null },
),
// --- Profiles ---
listProfiles: (companyId: string) =>
api.get<ToolProfilesResponse>(`/companies/${companyId}/tools/profiles`),
getProfileNewTools: (profileId: string) =>
api.get<ToolProfileNewToolsReview>(`/tool-profiles/${profileId}/new-tools`),
reviewProfileNewTools: (profileId: string, input: ReviewNewToolsInput) =>
api.post<ToolProfileNewToolsReviewResult>(`/tool-profiles/${profileId}/new-tools/review`, input),
createProfile: (companyId: string, input: CreateToolProfileInput) =>
api.post<ToolProfileWithDetails>(`/companies/${companyId}/tools/profiles`, input),
updateProfile: (profileId: string, input: UpdateToolProfileInput) =>
api.patch<ToolProfileWithDetails>(`/tool-profiles/${profileId}`, input),
duplicateProfile: (
profileId: string,
input: { name: string; includeAssignments?: boolean },
) => api.post<ToolProfileWithDetails>(`/tool-profiles/${profileId}/duplicate`, input),
deleteProfile: (
profileId: string,
input: { force?: boolean; reassignToProfileId?: string } = {},
) => api.delete<{ deleted: true }>(`/tool-profiles/${profileId}`, input),
addProfileEntry: (profileId: string, input: ToolProfileEntryInput) =>
api.post<ToolProfileEntry>(`/tool-profiles/${profileId}/entries`, input),
updateProfileEntry: (entryId: string, input: Partial<ToolProfileEntryInput>) =>
api.patch<ToolProfileEntry>(`/tool-profile-entries/${entryId}`, input),
deleteProfileEntry: (entryId: string) =>
api.delete<ToolProfileEntry>(`/tool-profile-entries/${entryId}`),
bindProfile: (companyId: string, profileId: string, input: ToolProfileBindingInput) =>
api.post<ToolProfileBinding>(`/companies/${companyId}/tools/profiles/${profileId}/bind`, input),
unbindProfile: (companyId: string, profileId: string, input: UnbindToolProfileInput) =>
api.post<{ unbound: number }>(`/companies/${companyId}/tools/profiles/${profileId}/unbind`, input),
getEffectiveProfilesForAgent: (companyId: string, agentId: string) =>
api.get<ToolProfileEffectiveSummary>(
`/companies/${companyId}/tools/profiles/effective/agents/${encodeURIComponent(agentId)}`,
),
// --- Runtime ---
listRuntimeSlots: (companyId: string) =>
api.get<ToolRuntimeSlotsResponse>(`/companies/${companyId}/tools/runtime-slots`),
stopRuntimeSlot: (companyId: string, slotId: string) =>
api.post<ToolRuntimeSlot>(`/companies/${companyId}/tools/runtime-slots/${slotId}/stop`, {}),
restartRuntimeSlot: (companyId: string, slotId: string) =>
api.post<ToolRuntimeSlot>(`/companies/${companyId}/tools/runtime-slots/${slotId}/restart`, {}),
getRuntimeHealth: (companyId: string) =>
api.get<ToolRuntimeHealthResponse>(`/companies/${companyId}/tools/runtime-health`),
getRunDecisionLookup: (companyId: string, runId: string) =>
api.get<ToolRunDecisionLookup>(`/companies/${companyId}/tools/runs/${runId}/decisions`),
listLiveRuntimeSlots: (companyId: string) =>
api.get<ToolRuntimeSlot[]>(`/tool-gateway/runtime-slots?companyId=${encodeURIComponent(companyId)}`),
// --- Policies (trust rules + decision simulator) ---
listPolicies: (companyId: string) =>
api.get<ToolPoliciesResponse>(`/companies/${companyId}/tools/policies`),
createPolicy: (companyId: string, input: CreateToolPolicy) =>
api.post<ToolPolicy>(`/companies/${companyId}/tools/policies`, input),
reorderPolicies: (companyId: string, input: ReorderToolPolicies) =>
api.post<ToolPoliciesResponse>(`/companies/${companyId}/tools/policies/reorder`, input),
duplicatePolicy: (companyId: string, policyId: string, input: DuplicateToolPolicy = {}) =>
api.post<ToolPolicy>(`/companies/${companyId}/tools/policies/${policyId}/duplicate`, input),
updatePolicy: (companyId: string, policyId: string, input: UpdateToolPolicy) =>
api.patch<ToolPolicy>(`/companies/${companyId}/tools/policies/${policyId}`, input),
deletePolicy: (companyId: string, policyId: string) =>
api.delete<ToolPolicy>(`/companies/${companyId}/tools/policies/${policyId}`),
listTrustRules: (companyId: string) =>
api.get<ToolTrustRulesResponse>(`/companies/${companyId}/tools/trust-rules`),
revokeTrustRule: (companyId: string, policyId: string, reason?: string | null) =>
api.post<ToolPolicy>(`/companies/${companyId}/tools/trust-rules/${policyId}/revoke`, {
reason: reason ?? null,
}),
testPolicy: (companyId: string, input: Omit<ToolAccessDecisionInput, "companyId">) =>
api.post<ToolPolicyTestResponse>(`/companies/${companyId}/tools/policy/test`, input),
// --- Review queue (Ask first) ---
listActionRequests: (companyId: string, status: ToolActionRequestStatus = "pending") =>
api.get<ToolActionRequestsResponse>(
`/companies/${companyId}/tools/action-requests?status=${encodeURIComponent(status)}`,
),
approveActionRequest: (companyId: string, actionRequestId: string, rememberAction = false) =>
api.post<ToolActionRequest>(`/tool-gateway/action-requests/${actionRequestId}/approve`, { companyId, rememberAction }),
declineActionRequest: (companyId: string, actionRequestId: string, reason?: string) =>
api.post<ToolActionRequest>(`/tool-gateway/action-requests/${actionRequestId}/decline`, { companyId, reason }),
createTrustRuleFromActionRequest: (
companyId: string,
actionRequestId: string,
input: CreateToolTrustRuleFromActionRequest = {},
) =>
api.post<ToolPolicy>(
`/companies/${companyId}/tools/action-requests/${actionRequestId}/trust-rule`,
input,
),
// --- Named MCP gateways ---
listGateways: (companyId: string) =>
api.get<ToolMcpGatewaysResponse>(`/companies/${companyId}/tools/gateways`),
createGateway: (companyId: string, input: CreateToolMcpGateway) =>
api.post<ToolMcpGatewayWithTokens>(`/companies/${companyId}/tools/gateways`, input),
updateGateway: (companyId: string, gatewayId: string, input: UpdateGatewayInput) =>
api.patch<ToolMcpGatewayWithTokens>(`/tool-gateway/gateways/${gatewayId}`, { ...input, companyId }),
createGatewayToken: (companyId: string, gatewayId: string, input: CreateGatewayTokenInput) =>
api.post<ToolMcpGatewayTokenCreated>(`/tool-gateway/gateways/${gatewayId}/tokens`, { ...input, companyId }),
revokeGatewayToken: (companyId: string, tokenId: string) =>
api.post<ToolMcpGatewayToken>(`/tool-gateway/gateway-tokens/${tokenId}/revoke`, { companyId }),
// --- Audit / Activity ---
/**
* Humanized Activity feed with server-side filters and cursor pagination.
* Returns `{ events, nextCursor }`.
*/
listActivity: (companyId: string, params: ListActivityParams = {}) => {
const search = new URLSearchParams({ companyId });
if (params.gateway) search.set("gateway", params.gateway);
if (params.app) search.set("app", params.app);
if (params.agent) search.set("agent", params.agent);
if (params.outcome) search.set("outcome", params.outcome);
// Omitting the window is the API's canonical all-time request. This also
// keeps the page usable during a rolling restart against an older server
// that does not recognize the newer explicit `all` value.
if (params.window && params.window !== "all") search.set("window", params.window);
if (params.search) search.set("search", params.search);
if (params.cursor) search.set("cursor", params.cursor);
search.set("limit", String(params.limit ?? 50));
return api.get<ToolGatewayActivityResponse>(`/tool-gateway/audit?${search.toString()}`);
},
/** Flat audit sample (no pagination) — used by derived counters/banners. */
listAudit: (companyId: string, limit = 100) =>
api
.get<ToolGatewayActivityResponse>(
`/tool-gateway/audit?companyId=${encodeURIComponent(companyId)}&window=30d&limit=${Math.min(limit, 100)}`,
)
.then((res) => res.events),
};