Skip to content

Commit f1607bc

Browse files
authored
fix: restore connected app discovery and mentions (#280)
## Summary - Include active connected apps alongside custom skills in the composer `@` catalog. - Add a small database-backed composer capability endpoint so opening `@` never waits on Composio. - Recover from over-specific Composio tool searches with one bounded toolkit fetch and deterministic local ranking. - Preserve the existing `/v1/skills` contract for safe, independent Worker and Vercel rollouts. ## Context Direct production bug report; no separate Linear issue or plan document exists. ## Architecture The gateway projects tenant-scoped custom skills and distinct active integration slugs from Postgres in one signed user transaction. The web composer validates and caches this bounded catalog. At execution time, the agent still uses the generic, version-aware Composio list/execute boundary; only an exact zero-result query triggers a bounded broad lookup and local rank. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | Composer catalog | Dedicated `/v1/composer/skills` endpoint | Change `/v1/skills`; call integration catalog separately | Avoids a rollout contract mismatch and removes a second client/provider-dependent request. | | Connected-app representation | Active toolkit slugs from Postgres | Import one skill per connector | The existing generic runtime remains tenant-scoped and supports the full open toolkit catalog without bundled vendor sprawl. | | Search recovery | Exact query, then one bounded broad fetch and local rank | Guess action slugs; always fetch all actions | Preserves the fast path, handles Composio phrase-search misses, and keeps response/runtime bounds explicit. | ## Edge Cases Handled | Scenario | Handling | |---|---| | Worker deploy precedes web deploy | Existing `/v1/skills` response remains unchanged. | | Web deploy precedes Worker deploy | React Query surfaces a transient catalog error; no incompatible parsing of the old route. | | Duplicate active accounts for one toolkit | Database projection returns distinct toolkit slugs. | | Unknown valid toolkit slug | UI derives a safe title-cased display name. | | Natural-language action phrase returns zero | Runtime broadens once and ranks non-deprecated tools deterministically. | | Broad list exceeds returned candidates | `toolsTruncated` remains true so the model can refine with a shorter keyword. | ## How to Review 1. Start with `packages/agent-core/src/mastra/tool-defs/composio-tool.ts` for discovery semantics. 2. Review `apps/gateway-worker/src/skills-routes.ts` and `packages/db/src/integrations.ts` for the catalog boundary. 3. Review `apps/web/src/components/composer/use-composer-menu.ts` and `mention-skill-source.ts` for `@` behavior. 4. The README and skill text changes document those contracts. ## Verification - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` - [ ] Production `@` catalog shows active Gmail and Notion connections after merge/deploy. - [ ] Ordinary Gmail read prompt discovers and executes the correct action after merge/deploy. - [ ] Ordinary Notion read prompt discovers and executes the correct action after merge/deploy.
1 parent 21a9830 commit f1607bc

19 files changed

Lines changed: 241 additions & 71 deletions

File tree

apps/gateway-worker/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ 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 lightweight `/v1/composer/skills` catalog reads active connected-app slugs from the reconciled
96+
database state alongside custom skills; opening `@` never waits on a provider catalog or account sync.
9597
Catalog and connected-account provider snapshots may load in parallel, but DB
9698
reconciliation begins only after both external reads settle. Connect creates
9799
the provider link first and compensates by deleting it if response validation

apps/gateway-worker/src/core-http-routes.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { authenticate } from "./authenticate";
66
import { type GatewayApp, type GatewayContext, requestDatabase } from "./gateway-env";
77
import { rateLimit, rateLimitPublic, withRateLimitHeaders } from "./rate-limit";
88
import { readDownstreamReleaseHealth } from "./release-health";
9-
import { listUserSkillsRoute } from "./skills-routes";
9+
import { listComposerSkillsRoute, listUserSkillsRoute } from "./skills-routes";
1010
import { clientErrorRoute, clientUserEventRoute, vitalsRoute } from "./telemetry-routes";
1111

1212
export function registerCoreHttpRoutes(app: GatewayApp): void {
@@ -82,6 +82,11 @@ function registerOutputRoute(app: GatewayApp): void {
8282
}
8383

8484
function registerSkillRoutes(app: GatewayApp): void {
85+
app.get("/v1/composer/skills", async (c) => {
86+
const userId = await authenticate(c);
87+
await rateLimit(c, userId);
88+
return listComposerSkillsRoute(requestDatabase(c), userId);
89+
});
8590
app.get("/v1/skills", async (c) => {
8691
const userId = await authenticate(c);
8792
await rateLimit(c, userId);

apps/gateway-worker/src/skills-routes.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import {
22
type DatabaseHandle,
3+
listActiveUserIntegrationNames,
34
listUserSkillSummaries,
45
type UserSkillSummary,
56
withUserDb,
67
} from "@cheatcode/db";
7-
import type { UserId } from "@cheatcode/types";
8-
import { MAX_USER_SKILLS, UserSkillSchema, UserSkillsResponseSchema } from "@cheatcode/types/api";
8+
import { IntegrationNameSchema, integrationDisplayName, type UserId } from "@cheatcode/types";
9+
import {
10+
ComposerSkillsResponseSchema,
11+
MAX_CONNECTED_APP_SKILLS,
12+
MAX_USER_SKILLS,
13+
UserSkillSchema,
14+
UserSkillsResponseSchema,
15+
} from "@cheatcode/types/api";
916

1017
function skillSummary(record: UserSkillSummary): unknown {
1118
return UserSkillSchema.parse({
@@ -29,3 +36,32 @@ export async function listUserSkillsRoute(
2936
return Response.json(UserSkillsResponseSchema.parse({ skills: rows.map(skillSummary) }));
3037
});
3138
}
39+
40+
/** `GET /v1/composer/skills` — custom skills plus active connected-app capabilities. */
41+
export async function listComposerSkillsRoute(
42+
database: DatabaseHandle,
43+
userId: UserId,
44+
): Promise<Response> {
45+
return withUserDb(database, userId, async ({ transaction }) => {
46+
const catalog = await transaction(async (tx) => {
47+
const skillRows = await listUserSkillSummaries(tx, userId, MAX_USER_SKILLS);
48+
const integrationNames = await listActiveUserIntegrationNames(
49+
tx,
50+
userId,
51+
MAX_CONNECTED_APP_SKILLS,
52+
);
53+
return {
54+
connectedApps: integrationNames.flatMap(connectedAppSummary),
55+
skills: skillRows.map(skillSummary),
56+
};
57+
});
58+
return Response.json(ComposerSkillsResponseSchema.parse(catalog));
59+
});
60+
}
61+
62+
function connectedAppSummary(name: string): Array<{ displayName: string; name: string }> {
63+
const parsed = IntegrationNameSchema.safeParse(name);
64+
return parsed.success
65+
? [{ displayName: integrationDisplayName(parsed.data), name: parsed.data }]
66+
: [];
67+
}

apps/web/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ as raw bounded requests, show per-batch progress and actionable failures, and be
3737
a compact `/uploads/...` reference after each successful save. `/` is exclusively the
3838
persistent project-file browser and merges durable uploads with generated Deliverables. A selected
3939
Deliverable inserts its stable `/deliverables/<output-id>/<filename>` project reference;
40-
`@` is exclusively the user-skill picker. The file browser reads durable project-file metadata and
40+
`@` is exclusively the skill picker: it merges custom skills with active connected apps from the
41+
lightweight database-backed skill catalog. The file browser reads durable project-file metadata and
4142
does not create or wake Daytona merely because the user opens it.
4243
Computer preview wakeups run only while Browser is selected; opening Files never revives an
4344
unrelated dev server or changes the selected surface. Browser wakeups rotate the preview session

apps/web/src/components/chat/chat-panel-controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import type { OlderMessagesLoadResult } from "@/components/chat/use-message-list
3333
import { agentModelRequestValue } from "@/lib/agent-models";
3434
import { cancelRun, getThread } from "@/lib/api/project-thread";
3535
import { invalidateChatLists, projectKeys, threadKeys } from "@/lib/api/query-keys";
36-
import { USER_SKILLS_QUERY } from "@/lib/api/skills";
36+
import { COMPOSER_SKILLS_QUERY, USER_SKILLS_QUERY } from "@/lib/api/skills";
3737
import { useAppStore } from "@/lib/store/app-store";
3838
import { rememberStreamSeq, streamResumeCursor } from "@/lib/stream/stream-seq";
3939

@@ -402,6 +402,7 @@ function handleSkillCreatedData(
402402
): void {
403403
const parsed = CHEATCODE_DATA_SCHEMAS["skill-created"].safeParse(data);
404404
if (parsed.success) {
405+
void queryClient.invalidateQueries({ queryKey: COMPOSER_SKILLS_QUERY });
405406
void queryClient.invalidateQueries({ queryKey: USER_SKILLS_QUERY });
406407
actions.setActiveComputerTab("files");
407408
actions.setPreviewPanelOpen(true);

apps/web/src/components/composer/composer-context-chips.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,10 @@
11
"use client";
22

3-
import type { IntegrationName } from "@cheatcode/types";
3+
import { type IntegrationName, integrationDisplayName } from "@cheatcode/types";
44
import { Link as LinkIcon, X } from "@/components/ui";
55
import { CheatcodeMark } from "@/components/ui/cheatcode-mark";
66
import { cn } from "@/lib/ui/cn";
77

8-
// Curated display names for the most common toolkits. Any other connected toolkit
9-
// slug falls back to a prettified label via toolLabel().
10-
const TOOL_LABELS: Record<string, string> = {
11-
github: "GitHub",
12-
gmail: "Gmail",
13-
linear: "Linear",
14-
notion: "Notion",
15-
slack: "Slack",
16-
};
17-
18-
function toolLabel(slug: string): string {
19-
return (
20-
TOOL_LABELS[slug] ??
21-
slug
22-
.split("_")
23-
.map((word) => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word))
24-
.join(" ")
25-
);
26-
}
27-
288
export function ComposerContextChips({
299
className,
3010
onClearSkill,
@@ -49,7 +29,7 @@ export function ComposerContextChips({
4929
) : null}
5030
{tool ? (
5131
<ComposerContextChip
52-
label={toolLabel(tool)}
32+
label={integrationDisplayName(tool)}
5333
onClear={onClearTool}
5434
tone="tool"
5535
typeLabel="Tool"

apps/web/src/components/composer/slash-skill-source.ts renamed to apps/web/src/components/composer/mention-skill-source.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
1-
import type { ToolkitCatalogEntry, UserSkill } from "@cheatcode/types/api";
1+
import type { ConnectedAppSkill, UserSkill } from "@cheatcode/types/api";
22
import type { ComposerMenuItem } from "@/components/composer/composer-popover";
33

4-
const MAX_SLASH_ITEMS = 200;
4+
const MAX_MENTION_ITEMS = 200;
55

66
/**
7-
* Builds the skill catalog used by the `@` composer trigger. The optional toolkit
8-
* input remains available to non-composer callers, while chat intentionally passes
9-
* skills only so `@` has one predictable meaning.
7+
* Builds the custom-skill and connected-app catalog used by the `@` trigger.
108
*/
11-
export function slashSkillItems(
9+
export function mentionSkillItems(
1210
query: string,
1311
userSkills: UserSkill[] = [],
14-
toolkits: readonly ToolkitCatalogEntry[] = [],
12+
connectedApps: readonly ConnectedAppSkill[] = [],
1513
): ComposerMenuItem[] {
1614
const needle = query.trim().toLowerCase();
1715
const items: ComposerMenuItem[] = [];
1816
for (const skill of userSkills) {
19-
if (matchesQuery(skill.name, skill.description, needle) && items.length < MAX_SLASH_ITEMS) {
17+
if (matchesQuery(skill.name, skill.description, needle) && items.length < MAX_MENTION_ITEMS) {
2018
items.push({
2119
hint: skill.description,
2220
id: `user-skill:${skill.id}`,
@@ -27,17 +25,15 @@ export function slashSkillItems(
2725
});
2826
}
2927
}
30-
for (const toolkit of toolkits) {
31-
if (
32-
matchesQuery(toolkit.displayName, toolkit.description, needle) &&
33-
items.length < MAX_SLASH_ITEMS
34-
) {
28+
for (const app of connectedApps) {
29+
const hint = `Use ${app.displayName} through your connected account.`;
30+
if (matchesQuery(app.displayName, hint, needle) && items.length < MAX_MENTION_ITEMS) {
3531
items.push({
36-
hint: toolkit.description,
37-
id: `integration:${toolkit.name}`,
32+
hint,
33+
id: `integration:${app.name}`,
3834
insert: "",
39-
integrationName: toolkit.name,
40-
label: toolkit.displayName,
35+
integrationName: app.name,
36+
label: app.displayName,
4137
visual: "integration",
4238
});
4339
}

apps/web/src/components/composer/use-composer-menu.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import type { IntegrationName } from "@cheatcode/types";
44
import { useQuery } from "@tanstack/react-query";
55
import type { KeyboardEvent, RefObject } from "react";
66
import type { ComposerMenuItem } from "@/components/composer/composer-popover";
7+
import { mentionSkillItems } from "@/components/composer/mention-skill-source";
78
import { useProjectFileItems } from "@/components/composer/project-file-source";
8-
import { slashSkillItems } from "@/components/composer/slash-skill-source";
99
import {
1010
type ComposerTriggers,
1111
type TriggerDetector,
1212
useComposerTriggers,
1313
} from "@/components/composer/use-composer-triggers";
14-
import { listUserSkills, USER_SKILLS_QUERY } from "@/lib/api/skills";
14+
import { COMPOSER_SKILLS_QUERY, fetchComposerSkills } from "@/lib/api/skills";
1515
import { detectMentionToken, detectSlashToken } from "@/lib/input/caret-tokens";
1616
import { emitComposerEvent } from "@/lib/telemetry/user-events";
1717

@@ -39,9 +39,9 @@ export interface ComposerMenuController {
3939
}
4040

4141
export function useComposerMenu(options: UseComposerMenuOptions): ComposerMenuController {
42-
const userSkills = useQuery({
43-
queryFn: ({ signal }) => listUserSkills(options.getToken, signal),
44-
queryKey: USER_SKILLS_QUERY,
42+
const skillsCatalog = useQuery({
43+
queryFn: ({ signal }) => fetchComposerSkills(options.getToken, signal),
44+
queryKey: COMPOSER_SKILLS_QUERY,
4545
staleTime: 60_000,
4646
});
4747
const triggers = useComposerTriggers({
@@ -67,7 +67,7 @@ export function useComposerMenu(options: UseComposerMenuOptions): ComposerMenuCo
6767
const items =
6868
triggers.kind === "slash"
6969
? fileItems
70-
: skillMenuItems(triggers.query, userSkills.data ?? [], userSkills.isPending);
70+
: skillMenuItems(triggers.query, skillsCatalog.data, skillsCatalog.isPending);
7171
return {
7272
ariaLabel: triggers.kind === "slash" ? "Project files" : "Skills",
7373
handleKeyDown: (event) => triggers.handleMenuKeyDown(event, items),
@@ -91,10 +91,10 @@ function selectComposerItem(
9191

9292
function skillMenuItems(
9393
query: string,
94-
userSkills: Parameters<typeof slashSkillItems>[1],
94+
catalog: Awaited<ReturnType<typeof fetchComposerSkills>> | undefined,
9595
isPending: boolean,
9696
): ComposerMenuItem[] {
97-
const items = slashSkillItems(query, userSkills);
97+
const items = mentionSkillItems(query, catalog?.skills, catalog?.connectedApps);
9898
if (items.length > 0) {
9999
return items;
100100
}

apps/web/src/lib/api/skills.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

33
import {
4+
type ComposerSkillsResponse,
5+
ComposerSkillsResponseSchema,
46
type SandboxIdeSession,
57
SandboxIdeSessionSchema,
68
type UserSkill,
@@ -13,8 +15,20 @@ import {
1315
readBoundedJsonResponse,
1416
} from "@/lib/api/authorized-fetch";
1517

18+
export const COMPOSER_SKILLS_QUERY = ["composer-skills"] as const;
1619
export const USER_SKILLS_QUERY = ["user-skills"] as const;
1720

21+
/** Custom skills and active connected apps available to the composer. */
22+
export async function fetchComposerSkills(
23+
getToken: () => Promise<null | string>,
24+
signal?: AbortSignal,
25+
): Promise<ComposerSkillsResponse> {
26+
const response = await authorizedFetch(getToken, "/v1/composer/skills", signal ? { signal } : {});
27+
return ComposerSkillsResponseSchema.parse(
28+
await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.metadata),
29+
);
30+
}
31+
1832
/** The caller's custom skills (body-less summaries). */
1933
export async function listUserSkills(
2034
getToken: () => Promise<null | string>,

packages/agent-core/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,5 +179,10 @@ Composio REST tool discovery and execution responses are byte-bounded before
179179
parsing, then projected into bounded, valid JSON before entering model context.
180180
Toolkit names use the shared open-slug contract from
181181
`@cheatcode/types/integrations` across API, context, and tool boundaries.
182+
Discovery first uses Composio's full-text query. Because that query can return zero for an
183+
over-specific natural-language phrase even when the toolkit has the required action, a zero-result
184+
search performs one broad toolkit fetch and deterministically ranks bounded candidates by the
185+
action, object, name, slug, and description terms. The agent still executes only an exact returned
186+
slug and concrete toolkit version.
182187
Callers must honor the returned truncation
183188
flag and narrow tool discovery with `search` when a schema does not fit.

0 commit comments

Comments
 (0)