mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators create agents and configure their runtimes in the board UI. > - The old creation flow presents several choices and a large form before an agent can start. > - The existing onboarding controls already provide clear provider connection steps. > - This pull request uses those controls in a new-agent wizard and organizes the full configuration pages. > - Operators can connect, test, save, and assign a first task while keeping the existing configuration tools. ## Linked Issues or Issue Description Related: #10974. That earlier open PR also reorganizes agent configuration. This PR follows the reviewed Storybook designs for agent creation and the current configuration tabs. **What existing behavior does this improve?** Agent creation, provider connection, runtime tests, and full agent configuration. **Current behavior** The creation dialog leads to a large manual configuration form. Provider login controls differ from onboarding. Environment variables and secret access appear in separate places. **Proposed behavior** Choose a name and adapter. Connect Claude or Codex through the existing onboarding controls. Configure and test the runtime, save the agent, and open a task dialog with that agent assigned. Use the same design on the existing configuration tabs. **Reason and benefit** The first setup asks for fewer decisions. The full editor keeps instructions, skills, runtime controls, secret access, permissions, keys, and revisions available in clear sections. **Breaking changes** The board creation and configuration layouts change. The test-environment API adds an optional, allowlisted `testCredentials` field for one-shot probes. Database contracts stay the same. Native ACPX tests now reject unsupported local platforms before a CLI login can mask the runtime restriction. ## What Changed - Added a new-agent wizard with numbered steps, adapter branding, provider connections, editable model choices, runtime tests, and confirmation. - Added Codex app-server, Claude ACPX, and OpenCode runner choices. - Stored API credentials through existing secret APIs and persisted references in agent configuration. New setup keys are isolated from credentials used by existing agents. - Preserved external-agent invitations beside the wizard, including optional messages, one-time prompts, and clipboard fallback. - Added OpenRouter provider and secret bindings for Pi and OpenCode. - Added adapter-specific prerequisite fields for Cursor, Gemini, Kimi, and Hermes. Cursor Cloud keys are saved as new organization secrets. - Fixed Cursor Cloud repository field mapping, omitted empty remote environment values, and added useful model and repository error messages. - Preserved complete MCP assignments when multiple valid profiles contain more than 250 tools in total. Generated profiles retain exact tool selectors. - Added service branding and deployment-aware adapter choices. Cloud setup offers Claude, Codex, and OpenCode; local native runners require the experimental setting. - Made the agent list responsive at intermediate widths. - Applied the reviewed design to the real agent configuration pages. Kept the instruction editor, skills, and existing mutations. - Combined secret access and environment variables under one Save and Discard action. - Added interactive Storybook screens for setup, configuration, confirmation, authentication, and test results. - Fixed Pi provider-error parsing and thinking-effort persistence. Native ACPX validates Linux x64 on the actual local, SSH, or sandbox target. - Redacted the complete transient probe-credential field from HTTP error logs, including rejected provider names. ## Verification - Current head `df0292fe6` has a fresh Greptile 5/5 review with no unresolved findings. All 31 executed CI checks passed, including the aggregate verification gate and all browser E2E shards. Storybook visual regression is skipped by its workflow; the local Storybook build passed. - Browser tests completed real assigned tasks with direct Codex, Claude, OpenCode, Pi, and native Codex. - Verified external-agent invitation generation and automatic prompt copying in the live browser. - Pi and OpenCode used an existing OpenRouter secret. Browser checks covered save and reload, instruction edits, skill selection, environment-variable Save and Discard, and assigned task creation. - Invalid Claude API credentials remained on the connection step with an error. A live Pi/OpenRouter invalid-key probe returned a provider failure and left the user-secret inventory unchanged (zero entries before and after). - Full workspace typecheck and build passed after rebasing onto current master. After review fixes, server and UI typechecks, token gates, and the full build passed again. Storybook built successfully. - All 5,542 local UI tests passed. The Cursor Cloud and Pi adapter regressions passed all 24 tests. Review regressions passed 69 server tests and all 18 agent-list tests. - The local full test command ran 6,971 general server tests successfully. Editing review fixes during that long run caused nine tests to use stale modules; fresh isolated runs passed. An unrelated embedded-Postgres fixture hit the host shared-memory limit; its 15 affected tests passed when the fixture groups ran separately. - Local workspace groups passed after rerunning 18 CLI tests sequentially to avoid host database limits and parallel-load timeouts. The local full command stopped at the general server phase, so serialized server verification comes from the five passing CI shards. - Browser testing at 390px confirmed that the agent action menu opens and the page has no horizontal overflow. CI browser E2E shards passed. - Review the `Onboarding / New agent` and `Agents / Configuration refresh` Storybook groups. In the real app, create an agent, run its connection test, save it, assign a task, and reload its configuration. ## Risks - This changes the main agent setup and configuration UI. Regression tests cover routing, persistence, secret bindings, and form actions. - Native Claude ACPX requires Linux x64. Direct Claude works on macOS. Remote checks execute a bounded platform probe and reject unsupported or unverified targets. - A native OpenCode task reached the provider context limit because of its tool payload. Its provider connection test passed. Direct OpenCode completed a task. This existing native execution limit is not fixed here. - Claude and Codex connection keys use the existing user-secret store. Other runtime setup keys use distinct organization secrets. Existing credentials are never rotated. Probes do not store entered keys. Failed agent creation removes newly staged credentials. - Cursor Cloud has not completed a live task. Its authenticated account still needs GitHub repository access. The live run passed MCP provisioning, remote environment validation, and explicit Auto model selection before the repository prerequisite blocked execution. - Generated runtime MCP profiles can exceed the public profile-edit request limit. They still contain exact catalog selectors and preserve permission boundaries. - No database migration, dependency, lockfile, or workflow changes are included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell execution, and browser automation. The runtime did not expose the exact model ID or 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 (e.g. `docs/...`, `fix/...`) 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>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import type {
|
|
Agent,
|
|
AgentDesiredSkillEntry,
|
|
AgentSkillAssignmentMode,
|
|
AgentPermissions,
|
|
AgentDetail,
|
|
AgentInstructionsBundle,
|
|
AgentInstructionsFileDetail,
|
|
AgentSkillSnapshot,
|
|
AdapterEnvironmentTestResult,
|
|
AdapterAuthSignalResponse,
|
|
AdapterAuthSessionResponse,
|
|
AdapterAuthSessionOwnerResponse,
|
|
ClaudeSetupTokenSessionResponse,
|
|
ClaudeSetupTokenSessionOwnerResponse,
|
|
ClaudeSetupTokenSessionPrompt,
|
|
ClaudeSetupTokenCompletionResponse,
|
|
ClaudeSetupTokenOverwrite,
|
|
ClaudeOAuthTokenStatusResponse,
|
|
SubmitBrowserCodeRequest,
|
|
AgentKeyCreated,
|
|
AgentRuntimeState,
|
|
AgentTaskSession,
|
|
AgentWakeupResponse,
|
|
HeartbeatRun,
|
|
Approval,
|
|
AgentConfigRevision,
|
|
ClearAgentErrorResponse,
|
|
AgentApiKeyScope,
|
|
} from "@paperclipai/shared";
|
|
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
|
|
import { ApiError, api } from "./client";
|
|
|
|
export interface AgentKey {
|
|
id: string;
|
|
name: string;
|
|
scope: AgentApiKeyScope;
|
|
createdAt: Date;
|
|
revokedAt: Date | null;
|
|
}
|
|
|
|
export interface AdapterModel {
|
|
id: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface DetectedAdapterModel {
|
|
model: string;
|
|
provider: string;
|
|
source: string;
|
|
candidates?: string[];
|
|
}
|
|
|
|
export interface ClaudeLoginResult {
|
|
exitCode: number | null;
|
|
signal: string | null;
|
|
timedOut: boolean;
|
|
loginUrl: string | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface OrgNode {
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
status: string;
|
|
reports: OrgNode[];
|
|
}
|
|
|
|
export interface AgentHireResponse {
|
|
agent: Agent;
|
|
approval: Approval | null;
|
|
}
|
|
|
|
export interface AgentPermissionUpdate {
|
|
canCreateAgents: boolean;
|
|
canCreateSkills: boolean;
|
|
canAssignTasks: boolean;
|
|
trustPreset?: AgentPermissions["trustPreset"];
|
|
authorizationPolicy?: AgentPermissions["authorizationPolicy"];
|
|
}
|
|
|
|
export interface AgentWakeRequest {
|
|
source?: "timer" | "assignment" | "on_demand" | "automation";
|
|
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
|
reason?: string | null;
|
|
payload?: Record<string, unknown> | null;
|
|
idempotencyKey?: string | null;
|
|
forceFreshSession?: boolean;
|
|
debug?: { providerTrace: "raw" };
|
|
}
|
|
|
|
function withCompanyScope(path: string, companyId?: string) {
|
|
if (!companyId) return path;
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
return `${path}${separator}companyId=${encodeURIComponent(companyId)}`;
|
|
}
|
|
|
|
function agentPath(id: string, companyId?: string, suffix = "") {
|
|
return withCompanyScope(`/agents/${encodeURIComponent(id)}${suffix}`, companyId);
|
|
}
|
|
|
|
export const agentsApi = {
|
|
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
|
|
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
|
|
listConfigurations: (companyId: string) =>
|
|
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
|
|
get: async (id: string, companyId?: string) => {
|
|
try {
|
|
return await api.get<AgentDetail>(agentPath(id, companyId));
|
|
} catch (error) {
|
|
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
|
|
// resolve using company agent list while ignoring terminated agents.
|
|
if (
|
|
!(error instanceof ApiError) ||
|
|
error.status !== 409 ||
|
|
!companyId ||
|
|
isUuidLike(id)
|
|
) {
|
|
throw error;
|
|
}
|
|
|
|
const urlKey = normalizeAgentUrlKey(id);
|
|
if (!urlKey) throw error;
|
|
|
|
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
|
|
const matches = agents.filter(
|
|
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
|
|
);
|
|
if (matches.length !== 1) throw error;
|
|
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
|
|
}
|
|
},
|
|
getConfiguration: (id: string, companyId?: string) =>
|
|
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
|
|
listConfigRevisions: (id: string, companyId?: string) =>
|
|
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
|
|
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
|
api.get<AgentConfigRevision>(agentPath(id, companyId, `/config-revisions/${revisionId}`)),
|
|
rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
|
api.post<Agent>(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}),
|
|
create: (companyId: string, data: Record<string, unknown>) =>
|
|
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
|
hire: (companyId: string, data: Record<string, unknown>) =>
|
|
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
|
|
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
|
api.patch<Agent>(agentPath(id, companyId), data),
|
|
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
|
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
|
instructionsBundle: (id: string, companyId?: string) =>
|
|
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
|
|
updateInstructionsBundle: (
|
|
id: string,
|
|
data: {
|
|
mode?: "managed" | "external";
|
|
rootPath?: string | null;
|
|
entryFile?: string;
|
|
clearLegacyPromptTemplate?: boolean;
|
|
},
|
|
companyId?: string,
|
|
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
|
|
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
|
api.get<AgentInstructionsFileDetail>(
|
|
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
|
),
|
|
saveInstructionsFile: (
|
|
id: string,
|
|
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
|
|
companyId?: string,
|
|
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
|
|
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
|
api.delete<AgentInstructionsBundle>(
|
|
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
|
),
|
|
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
|
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
|
clearError: (id: string, companyId?: string) =>
|
|
api.post<ClearAgentErrorResponse>(agentPath(id, companyId, "/clear-error"), {}),
|
|
approve: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/approve"), {}),
|
|
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
|
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
|
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
|
skills: (id: string, companyId?: string) =>
|
|
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
|
|
syncSkills: (
|
|
id: string,
|
|
desiredSkills: Array<string | AgentDesiredSkillEntry>,
|
|
mode: AgentSkillAssignmentMode,
|
|
companyId?: string,
|
|
) => api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills, mode }),
|
|
createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) =>
|
|
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }),
|
|
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
|
|
api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
|
|
runtimeState: (id: string, companyId?: string) =>
|
|
api.get<AgentRuntimeState>(agentPath(id, companyId, "/runtime-state")),
|
|
taskSessions: (id: string, companyId?: string) =>
|
|
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
|
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
|
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
|
adapterModels: (
|
|
companyId: string,
|
|
type: string,
|
|
options?: { refresh?: boolean; environmentId?: string | null },
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (options?.refresh) params.set("refresh", "1");
|
|
if (options?.environmentId) params.set("environmentId", options.environmentId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api.get<AdapterModel[]>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
|
|
);
|
|
},
|
|
detectModel: (companyId: string, type: string) =>
|
|
api.get<DetectedAdapterModel | null>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
|
),
|
|
testEnvironment: (
|
|
companyId: string,
|
|
type: string,
|
|
data: {
|
|
adapterConfig: Record<string, unknown>;
|
|
testCredentials?: Record<string, string>;
|
|
environmentId?: string | null;
|
|
},
|
|
) =>
|
|
api.post<AdapterEnvironmentTestResult>(
|
|
`/companies/${companyId}/adapters/${type}/test-environment`,
|
|
data,
|
|
),
|
|
getAdapterAuthSignal: (companyId: string, type: string, environmentId?: string | null) => {
|
|
const query = environmentId ? `?environmentId=${encodeURIComponent(environmentId)}` : "";
|
|
return api.get<AdapterAuthSignalResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/auth-signal${query}`,
|
|
);
|
|
},
|
|
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
|
|
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
|
|
wakeup: (
|
|
id: string,
|
|
data: AgentWakeRequest,
|
|
companyId?: string,
|
|
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
|
loginWithClaude: (id: string, companyId?: string) =>
|
|
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
|
startAdapterAuthLogin: (
|
|
companyId: string,
|
|
type: string,
|
|
data: { environmentId: string; ttlSeconds?: number },
|
|
) =>
|
|
api.post<AdapterAuthSessionResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
|
|
data,
|
|
),
|
|
// The owner response repeats the live prompt on every read while the
|
|
// session holds an active public status, so this polling call carries the
|
|
// same no-store request option as the active-session read below.
|
|
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
|
|
api.get<AdapterAuthSessionOwnerResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
|
|
{ cache: "no-store" },
|
|
),
|
|
// Reads the caller's active login session for one company and adapter, with
|
|
// no session id, so the browser rediscovers its own session after a reload
|
|
// with no local state. A 404 means no active session for the caller.
|
|
getActiveAdapterAuthLoginSession: (companyId: string, type: string) =>
|
|
api.get<AdapterAuthSessionOwnerResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`,
|
|
{ cache: "no-store" },
|
|
),
|
|
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
|
|
api.post<AdapterAuthSessionOwnerResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}/cancel`,
|
|
{},
|
|
),
|
|
// The Claude submitted-browser-code login uses the company-and-environment
|
|
// setup-token routes. The route fixes the `claude_local` adapter. The start
|
|
// response carries the panel mode; the authorization URL rides only through the
|
|
// guarded prompt read. The completion response carries a non-secret
|
|
// `storedSessionId` claim and no token.
|
|
// Reads the stored Claude OAuth token status for the authenticated owner. A
|
|
// 200 carries only the secret id and the latest version; a 404 means the owner
|
|
// has no stored value (indistinguishable from a foreign value). The client
|
|
// applies the stored token first and captures the version for a later
|
|
// version-checked overwrite.
|
|
getClaudeOAuthTokenStatus: (companyId: string) =>
|
|
api.get<ClaudeOAuthTokenStatusResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/claude-oauth-token-status`,
|
|
),
|
|
startClaudeSetupTokenLogin: (
|
|
companyId: string,
|
|
data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite },
|
|
) =>
|
|
api.post<ClaudeSetupTokenSessionOwnerResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
|
|
{ adapterType: "claude_local", ...data },
|
|
),
|
|
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string) =>
|
|
api.get<ClaudeSetupTokenSessionResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}`,
|
|
),
|
|
// Reads the caller's active Claude setup-token login session, with no
|
|
// session id, so the browser rediscovers its own session after a reload with
|
|
// no local state. A 404 means no active session for the caller.
|
|
getActiveClaudeSetupTokenLoginSession: (companyId: string) =>
|
|
api.get<ClaudeSetupTokenSessionOwnerResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`,
|
|
{ cache: "no-store" },
|
|
),
|
|
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string) =>
|
|
api.get<ClaudeSetupTokenSessionPrompt>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
|
|
),
|
|
submitClaudeSetupTokenBrowserCode: (companyId: string, sessionId: string, browserCode: string) =>
|
|
api.post<ClaudeSetupTokenSessionResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/code`,
|
|
{ browserCode } satisfies SubmitBrowserCodeRequest,
|
|
),
|
|
completeClaudeSetupTokenLogin: (companyId: string, sessionId: string) =>
|
|
api.post<ClaudeSetupTokenCompletionResponse>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/completion`,
|
|
{},
|
|
),
|
|
cancelClaudeSetupTokenLogin: (companyId: string, sessionId: string) =>
|
|
api.post<void>(
|
|
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/cancel`,
|
|
{},
|
|
),
|
|
availableSkills: () =>
|
|
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
|
};
|
|
|
|
export interface AvailableSkill {
|
|
name: string;
|
|
description: string;
|
|
isPaperclipManaged: boolean;
|
|
}
|