Skip to content

Commit 6e4b5d1

Browse files
authored
feat: add human-friendly connected-app action prompts (#282)
1 parent 67708ec commit 6e4b5d1

10 files changed

Lines changed: 225 additions & 44 deletions

File tree

apps/gateway-worker/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ OAuth link.
9292
Composio v3.1 REST pages and catalog/tool payloads are byte-bounded before
9393
parsing, then schema- and cardinality-bounded so a provider pagination fault
9494
cannot grow Worker memory without limit.
95+
The toolkit-action endpoint returns product-owned starter prompts, not Composio's
96+
agent-facing API descriptions. One shared presentation boundary covers every
97+
toolkit, removes transport jargon, asks for missing details in plain language,
98+
and requires confirmation before permanent changes.
9599
The lightweight `/v1/composer/skills` catalog reads active connected-app slugs from the reconciled
96100
database state alongside custom skills; opening `@` never waits on a provider catalog or account sync.
97101
Catalog and connected-account provider snapshots may load in parallel, but DB
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { ComposioTool } from "@cheatcode/composio";
2+
import type { ToolkitAction } from "@cheatcode/types/api";
3+
4+
const DESTRUCTIVE_ACTION_PATTERN =
5+
/\b(delete|destroy|disconnect|empty|erase|purge|remove|revoke|uninstall)\b/i;
6+
const READ_ACTION_PATTERN =
7+
/^(check|download|export|find|inspect|list|look up|read|retrieve|search|show|view)\b/i;
8+
const DRAFT_ACTION_PATTERN = /^(compose|create|write)\b.*\bdraft\b/i;
9+
const SEND_EXISTING_ACTION_PATTERN = /^(publish|send)\b.*\b(draft|post)\b/i;
10+
const MESSAGE_ACTION_PATTERN = /^(forward|post|reply|send)\b/i;
11+
const CHANGE_ACTION_PATTERN =
12+
/^(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;
13+
const ARTICLE_ACTION_PATTERN =
14+
/^(add|create|delete|edit|find|forward|move|open|post|publish|remove|reply to|restore|search|send|update|upload|view)\s+(.+)$/i;
15+
const DETERMINER_PATTERN = /^(a|all|an|any|every|my|one|some|the|these|this|those|your)\b/i;
16+
const PREPOSITION_PATTERN = /^(and|by|for|from|in|inside|on|or|to|with)\b/i;
17+
const UNCOUNTABLE_NOUNS = new Set(["access", "content", "data", "information", "mail"]);
18+
const IRREGULAR_PLURAL_NOUNS = new Set(["children", "feet", "men", "people", "teeth", "women"]);
19+
const SINGULAR_NOUNS_ENDING_IN_S = new Set([
20+
"alias",
21+
"analysis",
22+
"basis",
23+
"crisis",
24+
"source",
25+
"status",
26+
]);
27+
const CONSONANT_SOUND_VOWEL_WORDS =
28+
/^(ewe|euro|one|uni(?:corn|form|que|t|vers)|user|utility|u[rs]l)\b/i;
29+
30+
export function presentIntegrationAction(
31+
tool: ComposioTool,
32+
toolkitDisplayName?: string,
33+
): ToolkitAction {
34+
const fallbackName = actionNameFromSlug(tool.slug);
35+
const toolkitName = toolkitDisplayName ?? tool.toolkit?.name;
36+
const name = humanizeActionName(tool.name ?? fallbackName, toolkitName) || fallbackName;
37+
return {
38+
name,
39+
prompt: actionPrompt(name, tool),
40+
slug: tool.slug,
41+
};
42+
}
43+
44+
function humanizeActionName(value: string, toolkitName: string | undefined): string {
45+
const cleaned = value
46+
.trim()
47+
.replace(/\s*\([^)]*\)\s*$/u, "")
48+
.replace(/\bfrom natural language\b/giu, "")
49+
.replace(/\bauth(?:enticated)? user\b/giu, "your account")
50+
.replace(/\s+by\s+user IDs?\b/giu, " for an account")
51+
.replace(/\s+(?:by|using|with)\s+(?:its\s+)?(?:[A-Za-z]+\s+){0,2}IDs?\b/giu, "")
52+
.replace(/\buser IDs?\b/giu, "account")
53+
.replace(/\bCRM object\b/giu, "CRM record")
54+
.replace(/^get about user$/iu, "View user profile")
55+
.replace(/^get about me$/iu, "View my profile")
56+
.replace(/^trash\s+(.+)$/iu, "Move $1 to trash")
57+
.replace(/^move to trash$/iu, "Move an item to trash")
58+
.replace(/^untrash\s+(.+)$/iu, "Restore $1 from trash")
59+
.replace(/^insert row database\b/iu, "Add database row")
60+
.replace(/^insert\b/iu, "Add")
61+
.replace(/^patch\b/iu, "Update")
62+
.replace(/^query\b/iu, "Search")
63+
.replace(/^replace\b/iu, "Update")
64+
.replace(/^batch modify\b/iu, "Update multiple")
65+
.replace(/^(fetch|get|list|retrieve)\b/iu, "View")
66+
.replace(/^real-time search\b/iu, "Search")
67+
.replace(/\bsend-as alias\b/giu, "email alias")
68+
.replace(/\bpage markdown\b/giu, "page content")
69+
.replace(/\bview query results\b/giu, "filtered results")
70+
.replace(/\bview query\b/giu, "filtered view")
71+
.replace(/\b(?:([A-Za-z]+)\s+)?block children\b/giu, "content inside $1 block")
72+
.replace(/\bfile upload\b/giu, "uploaded file")
73+
.replace(/\bchanges start page token\b/giu, "change tracking token")
74+
.replace(/\bgoogle about this result\b/giu, "details about this result")
75+
.replace(/\bwith filter\b/giu, "with filters")
76+
.replace(/\s+/gu, " ")
77+
.trim();
78+
return sentenceCaseActionName(cleaned, toolkitName);
79+
}
80+
81+
function sentenceCaseActionName(value: string, toolkitName: string | undefined): string {
82+
const brandWords = new Map(
83+
toolkitName?.split(/\s+/u).map((word) => [word.toLocaleLowerCase(), word]) ?? [],
84+
);
85+
return value
86+
.split(" ")
87+
.map((word, index) => {
88+
const brandedWord = brandWords.get(word.toLocaleLowerCase());
89+
if (brandedWord) {
90+
return brandedWord;
91+
}
92+
if (index === 0) {
93+
return word;
94+
}
95+
return /^[A-Z][a-z]+$/u.test(word) ? word.toLocaleLowerCase() : word;
96+
})
97+
.join(" ");
98+
}
99+
100+
function actionNameFromSlug(slug: string): string {
101+
const words = slug.split("_").slice(1).join(" ").toLocaleLowerCase();
102+
return words ? words.charAt(0).toLocaleUpperCase() + words.slice(1) : "Use this action";
103+
}
104+
105+
function actionPrompt(name: string, tool: ComposioTool): string {
106+
const goal = naturalActionGoal(lowerFirst(name));
107+
if (isDestructiveAction(name, tool)) {
108+
return `Help me ${goal}. Find the right item and ask for confirmation before making permanent changes.`;
109+
}
110+
if (DRAFT_ACTION_PATTERN.test(name)) {
111+
return `Help me ${goal}. Ask who it is for, the subject, and what it should say.`;
112+
}
113+
if (SEND_EXISTING_ACTION_PATTERN.test(name)) {
114+
return `Help me ${goal}. Find the right one and show it to me before sending.`;
115+
}
116+
if (MESSAGE_ACTION_PATTERN.test(name)) {
117+
return `Help me ${goal}. Ask for the recipient and content, then show me the final version before sending.`;
118+
}
119+
if (READ_ACTION_PATTERN.test(name)) {
120+
return `Help me ${goal}. Ask what I am looking for if needed.`;
121+
}
122+
if (CHANGE_ACTION_PATTERN.test(name)) {
123+
return `Help me ${goal}. Ask for the details you need, then show me what will change before doing it.`;
124+
}
125+
return `Help me ${goal}. Ask for any details you need in plain language.`;
126+
}
127+
128+
function isDestructiveAction(name: string, tool: ComposioTool): boolean {
129+
if (DESTRUCTIVE_ACTION_PATTERN.test(`${name} ${tool.slug.replaceAll("_", " ")}`)) {
130+
return true;
131+
}
132+
return /\bpermanently\b/i.test(`${tool.humanDescription ?? ""} ${tool.description ?? ""}`);
133+
}
134+
135+
function lowerFirst(value: string): string {
136+
return value.charAt(0).toLocaleLowerCase() + value.slice(1);
137+
}
138+
139+
function naturalActionGoal(value: string): string {
140+
const match = ARTICLE_ACTION_PATTERN.exec(value);
141+
if (
142+
!match?.[1] ||
143+
!match[2] ||
144+
DETERMINER_PATTERN.test(match[2]) ||
145+
PREPOSITION_PATTERN.test(match[2])
146+
) {
147+
return value;
148+
}
149+
const nounPhrase = match[2].split(/\s+(?:by|for|from|in|inside|on|to|with)\s+/iu)[0] ?? match[2];
150+
const firstNoun = nounPhrase.split(/\s+/u)[0]?.toLocaleLowerCase() ?? "";
151+
const noun = nounPhrase.split(/\s+/u).at(-1)?.toLocaleLowerCase() ?? "";
152+
if (
153+
!noun ||
154+
UNCOUNTABLE_NOUNS.has(firstNoun) ||
155+
UNCOUNTABLE_NOUNS.has(noun) ||
156+
isPluralNoun(firstNoun) ||
157+
isPluralNoun(noun)
158+
) {
159+
return value;
160+
}
161+
const article = articleFor(match[2]);
162+
return `${match[1]} ${article} ${match[2]}`;
163+
}
164+
165+
function articleFor(value: string): "a" | "an" {
166+
if (CONSONANT_SOUND_VOWEL_WORDS.test(value)) {
167+
return "a";
168+
}
169+
return /^[aeiou]/iu.test(value) ? "an" : "a";
170+
}
171+
172+
function isPluralNoun(value: string): boolean {
173+
return (
174+
IRREGULAR_PLURAL_NOUNS.has(value) ||
175+
(value.endsWith("s") && !value.endsWith("ss") && !SINGULAR_NOUNS_ENDING_IN_S.has(value))
176+
);
177+
}

apps/gateway-worker/src/integrations-catalog.ts

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ToolkitCategory,
1212
} from "@cheatcode/types/api";
1313
import { z } from "zod";
14+
import { presentIntegrationAction } from "./integration-action-presentation-support";
1415
import {
1516
loadIntegrationAccountSnapshot,
1617
reconcileIntegrationAccountSnapshot,
@@ -112,41 +113,35 @@ export async function getIntegrationCatalog(
112113

113114
const TOOLKIT_ACTION_LIMIT = 30;
114115

115-
const RawComposioToolSchema = z.object({
116-
description: z.string().max(4_000).optional(),
117-
isDeprecated: z.boolean().optional(),
118-
name: z.string().max(200),
119-
slug: z.string().max(200),
120-
});
121-
const RawComposioToolsSchema = z.array(RawComposioToolSchema).max(TOOLKIT_ACTION_LIMIT);
122-
123-
// Lists a toolkit's top actions for the detail drawer (name + description). Uses the
124-
// raw, user-independent tool definitions so it works whether or not the user has
125-
// connected the toolkit yet.
116+
// Lists a toolkit's top actions for the detail drawer. Provider descriptions are
117+
// agent-facing API documentation, so this boundary converts them into safe starter
118+
// prompts that remain useful without exposing IDs, schemas, or transport jargon.
126119
export async function listToolkitActions(
127120
env: IntegrationCatalogEnv,
128121
slug: string,
129122
): Promise<ToolkitActionsResponse> {
130123
const apiKey = await requireComposioApiKey(env.COMPOSIO_API_KEY);
131124
const composio = new ComposioClient(apiKey);
132125
try {
133-
const page = await composio.listTools(
134-
{
135-
important: true,
136-
limit: TOOLKIT_ACTION_LIMIT,
137-
toolkit: slug,
138-
},
139-
COMPOSIO_REQUEST_TIMEOUT_MS,
140-
);
141-
const tools = RawComposioToolsSchema.parse(page.items)
126+
const [page, catalog] = await Promise.all([
127+
composio.listTools(
128+
{
129+
important: true,
130+
limit: TOOLKIT_ACTION_LIMIT,
131+
toolkit: slug,
132+
},
133+
COMPOSIO_REQUEST_TIMEOUT_MS,
134+
),
135+
readCachedCatalog(env.ENTITLEMENTS_CACHE),
136+
]);
137+
const toolkitDisplayName = catalog?.toolkits.find(
138+
(toolkit) => toolkit.name === slug,
139+
)?.displayName;
140+
const tools = page.items
142141
.filter((tool) => tool.isDeprecated !== true)
143142
.slice(0, TOOLKIT_ACTION_LIMIT);
144143
return {
145-
actions: tools.map((tool) => ({
146-
description: tool.description ?? "",
147-
name: tool.name ?? tool.slug,
148-
slug: tool.slug,
149-
})),
144+
actions: tools.map((tool) => presentIntegrationAction(tool, toolkitDisplayName)),
150145
};
151146
} catch (error) {
152147
throw new APIError(503, "upstream_provider_outage", "Unable to load toolkit actions", {

apps/web/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ Deliverable inserts its stable `/deliverables/<output-id>/<filename>` project re
4040
`@` is exclusively the skill picker: it merges custom skills with active connected apps from the
4141
lightweight database-backed skill catalog. The file browser reads durable project-file metadata and
4242
does not create or wake Daytona merely because the user opens it.
43+
Connected-app detail drawers launch the gateway's product-owned starter prompt for an action.
44+
The browser never derives user copy from provider API documentation; clicking an action produces a
45+
plain-language request that can be sent immediately and asks for missing or high-stakes details.
4346
Computer preview wakeups run only while Browser is selected; opening Files never revives an
4447
unrelated dev server or changes the selected surface. Browser wakeups rotate the preview session
4548
and reload the visible iframe once after an actual sandbox/process recovery. Silent capability

apps/web/src/components/skills/integration-skill-drawer.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,6 @@ function ActionRow({
287287
toolkit: ToolkitCatalogEntry;
288288
}) {
289289
const Icon = actionIcon(action.slug);
290-
const example = actionExample(action);
291290
return (
292291
<div className={cn("relative", isLast ? null : "pb-6")}>
293292
{isLast ? null : (
@@ -296,7 +295,7 @@ function ActionRow({
296295
<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]" />
297296
<PromptLaunchButton
298297
className="group block cursor-pointer rounded-xl px-2 py-1 transition-colors duration-150 hover:bg-background active:bg-background"
299-
prompt={example}
298+
prompt={action.prompt}
300299
query={{ tool: toolkit.name }}
301300
>
302301
<span className="mt-[3px] flex items-start gap-3">
@@ -308,7 +307,7 @@ function ActionRow({
308307
{action.name}
309308
</span>
310309
<span className="mt-1.5 line-clamp-2 block text-fg-secondary text-sm leading-5">
311-
{example}
310+
{action.prompt}
312311
</span>
313312
</span>
314313
</span>
@@ -338,18 +337,6 @@ function actionIcon(slug: string) {
338337
return FileText;
339338
}
340339

341-
function actionExample(action: ToolkitAction): string {
342-
const description = action.description
343-
.trim()
344-
.replace(/^[-\s]+/, "")
345-
.replace(/\s+/g, " ");
346-
if (!description) {
347-
return action.name;
348-
}
349-
const firstSentence = description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
350-
return firstSentence ?? description;
351-
}
352-
353340
function accountDescription(account: IntegrationAccount): string {
354341
if (account.isDefault) {
355342
return "Saved as the default connected account.";

packages/composio/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ The client intentionally exposes only the v3.1 routes Cheatcode owns:
2121
- list toolkits and tools
2222
- execute a version-selected tool
2323

24+
Tool parsing preserves Composio's agent-facing description, optional human
25+
description, and input schema as separate fields. Callers define their own
26+
presentation contract instead of displaying provider tool documentation as UI copy.
27+
2428
There is no generic request escape hatch.
2529

2630
## Code Checks

packages/composio/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { ComposioClient, isComposioNotFoundError } from "./client";
2+
export type { ComposioTool } from "./types";

packages/composio/src/schemas.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
const IdentifierSchema = z.string().min(1).max(500);
1313
const SlugSchema = z.string().min(1).max(200);
1414
const TimestampSchema = z.string().datetime();
15+
const ToolDescriptionSchema = z.string().max(16_000);
1516

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

8687
const RawToolSchema = z
8788
.object({
88-
description: z.string().max(4_000).optional(),
89+
description: ToolDescriptionSchema.optional(),
90+
human_description: ToolDescriptionSchema.optional(),
8991
input_parameters: z.unknown().optional(),
9092
is_deprecated: z.boolean().optional(),
9193
name: z.string().max(200).optional(),
9294
slug: z.string().min(1).max(200),
95+
toolkit: z
96+
.object({ name: z.string().min(1).max(200), slug: SlugSchema })
97+
.strip()
98+
.optional(),
9399
version: z.string().max(120).optional(),
94100
})
95101
.strip();
@@ -173,10 +179,12 @@ export function parseToolPage(value: unknown): ComposioToolPage {
173179
const inputParameters = normalizeToolParameters(item.input_parameters);
174180
return {
175181
...(item.description !== undefined ? { description: item.description } : {}),
182+
...(item.human_description !== undefined ? { humanDescription: item.human_description } : {}),
176183
...(inputParameters !== undefined ? { inputParameters } : {}),
177184
...(item.is_deprecated !== undefined ? { isDeprecated: item.is_deprecated } : {}),
178185
...(item.name !== undefined ? { name: item.name } : {}),
179186
slug: item.slug,
187+
...(item.toolkit !== undefined ? { toolkit: item.toolkit } : {}),
180188
...(item.version !== undefined ? { version: item.version } : {}),
181189
};
182190
});

packages/composio/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ export interface ComposioToolkit {
4242

4343
export interface ComposioTool {
4444
description?: string;
45+
humanDescription?: string;
4546
inputParameters?: unknown;
4647
isDeprecated?: boolean;
4748
name?: string;
4849
slug: string;
50+
toolkit?: { name: string; slug: string };
4951
version?: string;
5052
}
5153

packages/types/src/api.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,9 @@ export const IntegrationCatalogSchema = z.strictObject({
292292
});
293293

294294
const ToolkitActionSchema = z.strictObject({
295-
description: z.string(),
296-
name: z.string(),
297-
slug: z.string(),
295+
name: z.string().min(1).max(200),
296+
prompt: z.string().min(1).max(400),
297+
slug: z.string().min(1).max(200),
298298
});
299299

300300
export const ToolkitActionsResponseSchema = z.strictObject({

0 commit comments

Comments
 (0)