Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/gateway-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ OAuth link.
Composio v3.1 REST pages and catalog/tool payloads are byte-bounded before
parsing, then schema- and cardinality-bounded so a provider pagination fault
cannot grow Worker memory without limit.
The toolkit-action endpoint returns product-owned starter prompts, not Composio's
agent-facing API descriptions. One shared presentation boundary covers every
toolkit, removes transport jargon, asks for missing details in plain language,
and requires confirmation before permanent changes.
The lightweight `/v1/composer/skills` catalog reads active connected-app slugs from the reconciled
database state alongside custom skills; opening `@` never waits on a provider catalog or account sync.
Catalog and connected-account provider snapshots may load in parallel, but DB
Expand Down
177 changes: 177 additions & 0 deletions apps/gateway-worker/src/integration-action-presentation-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { ComposioTool } from "@cheatcode/composio";
import type { ToolkitAction } from "@cheatcode/types/api";

const DESTRUCTIVE_ACTION_PATTERN =
/\b(delete|destroy|disconnect|empty|erase|purge|remove|revoke|uninstall)\b/i;
const READ_ACTION_PATTERN =
/^(check|download|export|find|inspect|list|look up|read|retrieve|search|show|view)\b/i;
const DRAFT_ACTION_PATTERN = /^(compose|create|write)\b.*\bdraft\b/i;
const SEND_EXISTING_ACTION_PATTERN = /^(publish|send)\b.*\b(draft|post)\b/i;
const MESSAGE_ACTION_PATTERN = /^(forward|post|reply|send)\b/i;
const CHANGE_ACTION_PATTERN =
/^(add|approve|archive|assign|cancel|close|connect|create|disable|edit|enable|invite|log|mark|merge|move|publish|record|reject|restore|schedule|set|start|stop|update|upload)\b/i;
const ARTICLE_ACTION_PATTERN =
/^(add|create|delete|edit|find|forward|move|open|post|publish|remove|reply to|restore|search|send|update|upload|view)\s+(.+)$/i;
const DETERMINER_PATTERN = /^(a|all|an|any|every|my|one|some|the|these|this|those|your)\b/i;
const PREPOSITION_PATTERN = /^(and|by|for|from|in|inside|on|or|to|with)\b/i;
const UNCOUNTABLE_NOUNS = new Set(["access", "content", "data", "information", "mail"]);
const IRREGULAR_PLURAL_NOUNS = new Set(["children", "feet", "men", "people", "teeth", "women"]);
const SINGULAR_NOUNS_ENDING_IN_S = new Set([
"alias",
"analysis",
"basis",
"crisis",
"source",
"status",
]);
const CONSONANT_SOUND_VOWEL_WORDS =
/^(ewe|euro|one|uni(?:corn|form|que|t|vers)|user|utility|u[rs]l)\b/i;

export function presentIntegrationAction(
tool: ComposioTool,
toolkitDisplayName?: string,
): ToolkitAction {
const fallbackName = actionNameFromSlug(tool.slug);
const toolkitName = toolkitDisplayName ?? tool.toolkit?.name;
const name = humanizeActionName(tool.name ?? fallbackName, toolkitName) || fallbackName;
return {
name,
prompt: actionPrompt(name, tool),
slug: tool.slug,
};
}

function humanizeActionName(value: string, toolkitName: string | undefined): string {
const cleaned = value
.trim()
.replace(/\s*\([^)]*\)\s*$/u, "")
.replace(/\bfrom natural language\b/giu, "")
.replace(/\bauth(?:enticated)? user\b/giu, "your account")
.replace(/\s+by\s+user IDs?\b/giu, " for an account")
.replace(/\s+(?:by|using|with)\s+(?:its\s+)?(?:[A-Za-z]+\s+){0,2}IDs?\b/giu, "")
.replace(/\buser IDs?\b/giu, "account")
.replace(/\bCRM object\b/giu, "CRM record")
.replace(/^get about user$/iu, "View user profile")
.replace(/^get about me$/iu, "View my profile")
.replace(/^trash\s+(.+)$/iu, "Move $1 to trash")
.replace(/^move to trash$/iu, "Move an item to trash")
.replace(/^untrash\s+(.+)$/iu, "Restore $1 from trash")
.replace(/^insert row database\b/iu, "Add database row")
.replace(/^insert\b/iu, "Add")
.replace(/^patch\b/iu, "Update")
.replace(/^query\b/iu, "Search")
.replace(/^replace\b/iu, "Update")
.replace(/^batch modify\b/iu, "Update multiple")
.replace(/^(fetch|get|list|retrieve)\b/iu, "View")
.replace(/^real-time search\b/iu, "Search")
.replace(/\bsend-as alias\b/giu, "email alias")
.replace(/\bpage markdown\b/giu, "page content")
.replace(/\bview query results\b/giu, "filtered results")
.replace(/\bview query\b/giu, "filtered view")
.replace(/\b(?:([A-Za-z]+)\s+)?block children\b/giu, "content inside $1 block")
.replace(/\bfile upload\b/giu, "uploaded file")
.replace(/\bchanges start page token\b/giu, "change tracking token")
.replace(/\bgoogle about this result\b/giu, "details about this result")
.replace(/\bwith filter\b/giu, "with filters")
.replace(/\s+/gu, " ")
.trim();
return sentenceCaseActionName(cleaned, toolkitName);
}

function sentenceCaseActionName(value: string, toolkitName: string | undefined): string {
const brandWords = new Map(
toolkitName?.split(/\s+/u).map((word) => [word.toLocaleLowerCase(), word]) ?? [],
);
return value
.split(" ")
.map((word, index) => {
const brandedWord = brandWords.get(word.toLocaleLowerCase());
if (brandedWord) {
return brandedWord;
}
if (index === 0) {
return word;
}
return /^[A-Z][a-z]+$/u.test(word) ? word.toLocaleLowerCase() : word;
})
.join(" ");
}

function actionNameFromSlug(slug: string): string {
const words = slug.split("_").slice(1).join(" ").toLocaleLowerCase();
return words ? words.charAt(0).toLocaleUpperCase() + words.slice(1) : "Use this action";
}

function actionPrompt(name: string, tool: ComposioTool): string {
const goal = naturalActionGoal(lowerFirst(name));
if (isDestructiveAction(name, tool)) {
return `Help me ${goal}. Find the right item and ask for confirmation before making permanent changes.`;
}
if (DRAFT_ACTION_PATTERN.test(name)) {
return `Help me ${goal}. Ask who it is for, the subject, and what it should say.`;
}
if (SEND_EXISTING_ACTION_PATTERN.test(name)) {
return `Help me ${goal}. Find the right one and show it to me before sending.`;
}
if (MESSAGE_ACTION_PATTERN.test(name)) {
return `Help me ${goal}. Ask for the recipient and content, then show me the final version before sending.`;
}
if (READ_ACTION_PATTERN.test(name)) {
return `Help me ${goal}. Ask what I am looking for if needed.`;
}
if (CHANGE_ACTION_PATTERN.test(name)) {
return `Help me ${goal}. Ask for the details you need, then show me what will change before doing it.`;
}
return `Help me ${goal}. Ask for any details you need in plain language.`;
}

function isDestructiveAction(name: string, tool: ComposioTool): boolean {
if (DESTRUCTIVE_ACTION_PATTERN.test(`${name} ${tool.slug.replaceAll("_", " ")}`)) {
return true;
}
return /\bpermanently\b/i.test(`${tool.humanDescription ?? ""} ${tool.description ?? ""}`);
}

function lowerFirst(value: string): string {
return value.charAt(0).toLocaleLowerCase() + value.slice(1);
}

function naturalActionGoal(value: string): string {
const match = ARTICLE_ACTION_PATTERN.exec(value);
if (
!match?.[1] ||
!match[2] ||
DETERMINER_PATTERN.test(match[2]) ||
PREPOSITION_PATTERN.test(match[2])
) {
return value;
}
const nounPhrase = match[2].split(/\s+(?:by|for|from|in|inside|on|to|with)\s+/iu)[0] ?? match[2];
const firstNoun = nounPhrase.split(/\s+/u)[0]?.toLocaleLowerCase() ?? "";
const noun = nounPhrase.split(/\s+/u).at(-1)?.toLocaleLowerCase() ?? "";
if (
!noun ||
UNCOUNTABLE_NOUNS.has(firstNoun) ||
UNCOUNTABLE_NOUNS.has(noun) ||
isPluralNoun(firstNoun) ||
isPluralNoun(noun)
) {
return value;
}
const article = articleFor(match[2]);
return `${match[1]} ${article} ${match[2]}`;
}

function articleFor(value: string): "a" | "an" {
if (CONSONANT_SOUND_VOWEL_WORDS.test(value)) {
return "a";
}
return /^[aeiou]/iu.test(value) ? "an" : "a";
}

function isPluralNoun(value: string): boolean {
return (
IRREGULAR_PLURAL_NOUNS.has(value) ||
(value.endsWith("s") && !value.endsWith("ss") && !SINGULAR_NOUNS_ENDING_IN_S.has(value))
);
}
45 changes: 20 additions & 25 deletions apps/gateway-worker/src/integrations-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ToolkitCategory,
} from "@cheatcode/types/api";
import { z } from "zod";
import { presentIntegrationAction } from "./integration-action-presentation-support";
import {
loadIntegrationAccountSnapshot,
reconcileIntegrationAccountSnapshot,
Expand Down Expand Up @@ -112,41 +113,35 @@ export async function getIntegrationCatalog(

const TOOLKIT_ACTION_LIMIT = 30;

const RawComposioToolSchema = z.object({
description: z.string().max(4_000).optional(),
isDeprecated: z.boolean().optional(),
name: z.string().max(200),
slug: z.string().max(200),
});
const RawComposioToolsSchema = z.array(RawComposioToolSchema).max(TOOLKIT_ACTION_LIMIT);

// Lists a toolkit's top actions for the detail drawer (name + description). Uses the
// raw, user-independent tool definitions so it works whether or not the user has
// connected the toolkit yet.
// Lists a toolkit's top actions for the detail drawer. Provider descriptions are
// agent-facing API documentation, so this boundary converts them into safe starter
// prompts that remain useful without exposing IDs, schemas, or transport jargon.
export async function listToolkitActions(
env: IntegrationCatalogEnv,
slug: string,
): Promise<ToolkitActionsResponse> {
const apiKey = await requireComposioApiKey(env.COMPOSIO_API_KEY);
const composio = new ComposioClient(apiKey);
try {
const page = await composio.listTools(
{
important: true,
limit: TOOLKIT_ACTION_LIMIT,
toolkit: slug,
},
COMPOSIO_REQUEST_TIMEOUT_MS,
);
const tools = RawComposioToolsSchema.parse(page.items)
const [page, catalog] = await Promise.all([
composio.listTools(
{
important: true,
limit: TOOLKIT_ACTION_LIMIT,
toolkit: slug,
},
COMPOSIO_REQUEST_TIMEOUT_MS,
),
readCachedCatalog(env.ENTITLEMENTS_CACHE),
]);
const toolkitDisplayName = catalog?.toolkits.find(
(toolkit) => toolkit.name === slug,
)?.displayName;
const tools = page.items
.filter((tool) => tool.isDeprecated !== true)
.slice(0, TOOLKIT_ACTION_LIMIT);
return {
actions: tools.map((tool) => ({
description: tool.description ?? "",
name: tool.name ?? tool.slug,
slug: tool.slug,
})),
actions: tools.map((tool) => presentIntegrationAction(tool, toolkitDisplayName)),
};
} catch (error) {
throw new APIError(503, "upstream_provider_outage", "Unable to load toolkit actions", {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Deliverable inserts its stable `/deliverables/<output-id>/<filename>` project re
`@` is exclusively the skill picker: it merges custom skills with active connected apps from the
lightweight database-backed skill catalog. The file browser reads durable project-file metadata and
does not create or wake Daytona merely because the user opens it.
Connected-app detail drawers launch the gateway's product-owned starter prompt for an action.
The browser never derives user copy from provider API documentation; clicking an action produces a
plain-language request that can be sent immediately and asks for missing or high-stakes details.
Computer preview wakeups run only while Browser is selected; opening Files never revives an
unrelated dev server or changes the selected surface. Browser wakeups rotate the preview session
and reload the visible iframe once after an actual sandbox/process recovery. Silent capability
Expand Down
17 changes: 2 additions & 15 deletions apps/web/src/components/skills/integration-skill-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,6 @@ function ActionRow({
toolkit: ToolkitCatalogEntry;
}) {
const Icon = actionIcon(action.slug);
const example = actionExample(action);
return (
<div className={cn("relative", isLast ? null : "pb-6")}>
{isLast ? null : (
Expand All @@ -296,7 +295,7 @@ function ActionRow({
<span className="absolute top-0 -left-5 h-[18px] w-4 rounded-bl-lg border-border-tree border-b-[1.5px] border-l-[1.5px]" />
<PromptLaunchButton
className="group block cursor-pointer rounded-xl px-2 py-1 transition-colors duration-150 hover:bg-background active:bg-background"
prompt={example}
prompt={action.prompt}
query={{ tool: toolkit.name }}
>
<span className="mt-[3px] flex items-start gap-3">
Expand All @@ -308,7 +307,7 @@ function ActionRow({
{action.name}
</span>
<span className="mt-1.5 line-clamp-2 block text-fg-secondary text-sm leading-5">
“{example}”
“{action.prompt}”
</span>
</span>
</span>
Expand Down Expand Up @@ -338,18 +337,6 @@ function actionIcon(slug: string) {
return FileText;
}

function actionExample(action: ToolkitAction): string {
const description = action.description
.trim()
.replace(/^[-–—\s]+/, "")
.replace(/\s+/g, " ");
if (!description) {
return action.name;
}
const firstSentence = description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
return firstSentence ?? description;
}

function accountDescription(account: IntegrationAccount): string {
if (account.isDefault) {
return "Saved as the default connected account.";
Expand Down
4 changes: 4 additions & 0 deletions packages/composio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ The client intentionally exposes only the v3.1 routes Cheatcode owns:
- list toolkits and tools
- execute a version-selected tool

Tool parsing preserves Composio's agent-facing description, optional human
description, and input schema as separate fields. Callers define their own
presentation contract instead of displaying provider tool documentation as UI copy.

There is no generic request escape hatch.

## Code Checks
Expand Down
1 change: 1 addition & 0 deletions packages/composio/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ComposioClient, isComposioNotFoundError } from "./client";
export type { ComposioTool } from "./types";
10 changes: 9 additions & 1 deletion packages/composio/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
const IdentifierSchema = z.string().min(1).max(500);
const SlugSchema = z.string().min(1).max(200);
const TimestampSchema = z.string().datetime();
const ToolDescriptionSchema = z.string().max(16_000);

const RawConnectedAccountSchema = z
.object({
Expand Down Expand Up @@ -85,11 +86,16 @@ const RawToolkitPageSchema = z.object({ items: z.array(RawToolkitSchema).max(500

const RawToolSchema = z
.object({
description: z.string().max(4_000).optional(),
description: ToolDescriptionSchema.optional(),
human_description: ToolDescriptionSchema.optional(),
input_parameters: z.unknown().optional(),
is_deprecated: z.boolean().optional(),
name: z.string().max(200).optional(),
slug: z.string().min(1).max(200),
toolkit: z
.object({ name: z.string().min(1).max(200), slug: SlugSchema })
.strip()
.optional(),
version: z.string().max(120).optional(),
})
.strip();
Expand Down Expand Up @@ -173,10 +179,12 @@ export function parseToolPage(value: unknown): ComposioToolPage {
const inputParameters = normalizeToolParameters(item.input_parameters);
return {
...(item.description !== undefined ? { description: item.description } : {}),
...(item.human_description !== undefined ? { humanDescription: item.human_description } : {}),
...(inputParameters !== undefined ? { inputParameters } : {}),
...(item.is_deprecated !== undefined ? { isDeprecated: item.is_deprecated } : {}),
...(item.name !== undefined ? { name: item.name } : {}),
slug: item.slug,
...(item.toolkit !== undefined ? { toolkit: item.toolkit } : {}),
...(item.version !== undefined ? { version: item.version } : {}),
};
});
Expand Down
2 changes: 2 additions & 0 deletions packages/composio/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ export interface ComposioToolkit {

export interface ComposioTool {
description?: string;
humanDescription?: string;
inputParameters?: unknown;
isDeprecated?: boolean;
name?: string;
slug: string;
toolkit?: { name: string; slug: string };
version?: string;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,9 @@ export const IntegrationCatalogSchema = z.strictObject({
});

const ToolkitActionSchema = z.strictObject({
description: z.string(),
name: z.string(),
slug: z.string(),
name: z.string().min(1).max(200),
prompt: z.string().min(1).max(400),
slug: z.string().min(1).max(200),
});

export const ToolkitActionsResponseSchema = z.strictObject({
Expand Down