Skip to content
Open
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
9 changes: 9 additions & 0 deletions apps/x/apps/main/src/oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { capture as analyticsCapture, identify as analyticsIdentify, reset as an
import { isSignedIn } from '@x/core/dist/account/account.js';
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';

function buildRedirectUri(port: number): string {
return `http://localhost:${port}/oauth/callback`;
Expand Down Expand Up @@ -330,6 +331,9 @@ export async function connectProvider(provider: string, credentials?: { clientId
if (provider === 'google') {
triggerGmailSync();
triggerCalendarSync();
// Copilot instructions route email tasks based on native Gmail
// connection state — rebuild them on the next agent run.
invalidateCopilotInstructionsCache();
} else if (provider === 'fireflies-ai') {
triggerFirefliesSync();
}
Expand Down Expand Up @@ -481,6 +485,7 @@ export async function completeRowboatGoogleConnect(state: string): Promise<void>
});
triggerGmailSync();
triggerCalendarSync();
invalidateCopilotInstructionsCache();
emitOAuthEvent({ provider: 'google', success: true });
console.log('[OAuth] Rowboat-mode Google connect complete');
} catch (error) {
Expand Down Expand Up @@ -519,6 +524,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
}

await oauthRepo.delete(provider);
if (provider === 'google') {
invalidateCopilotInstructionsCache();
}
if (provider === 'rowboat') {
analyticsCapture('user_signed_out');
analyticsReset();
Expand Down Expand Up @@ -598,6 +606,7 @@ export async function disconnectGoogleIfScopesStale(): Promise<void> {
tokens: null,
error: 'Google permissions changed. Please reconnect to continue.',
});
invalidateCopilotInstructionsCache();

// Nudge any already-open window to re-read state. The renderer's initial
// mount also re-reads, so the prompt shows even if no window is up yet.
Expand Down
47 changes: 36 additions & 11 deletions apps/x/packages/core/src/application/assistant/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import { getConnectionStatus as getGmailConnectionStatus } from "../../knowledge/sync_gmail.js";

const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());

Expand All @@ -31,19 +32,35 @@ Load the \`composio-integration\` skill when the user asks to interact with any
`;
}

function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, nativeGmailConnected: boolean = false): string {
// Conditionally include Composio- and native-Gmail-related instruction sections
const emailDraftSuffix = nativeGmailConnected
? ` Do NOT load this skill for reading, fetching, or checking emails — load the \`read-emails\` skill and use the native \`gmail-*\` tools for that instead.`
: composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;

const thirdPartyBlock = composioEnabled
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
const nativeGmailBlock = nativeGmailConnected
? `\n**Email (Gmail):** Reading, checking, searching, or summarizing email uses the NATIVE Gmail tools (\`gmail-listThreads\`, \`gmail-readThread\`, \`gmail-searchEmails\`) — load the \`read-emails\` skill first. Do NOT use Composio for Gmail. Do NOT read raw files from \`gmail_sync/\`.\n`
: '';

const composioServiceExamples = nativeGmailConnected
? '(GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.)'
: '(Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.)';
const thirdPartyBlock = composioEnabled
? `${nativeGmailBlock}\n**Third-Party Services:** When users ask to interact with any external service ${composioServiceExamples} — ${nativeGmailConnected ? '' : 'reading emails, '}listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
: nativeGmailBlock;

const gmailToolPriority = nativeGmailConnected
? ` For Gmail reading/search, use the native \`gmail-*\` tools (load the \`read-emails\` skill).`
: '';
const toolPriority = composioEnabled
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
? `For third-party services ${nativeGmailConnected ? '(GitHub, Slack, Notion, etc.)' : '(GitHub, Gmail, Slack, etc.)'}, load the \`composio-integration\` skill.${gmailToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${gmailToolPriority}`;

const gmailToolsLine = nativeGmailConnected
? `- \`gmail-checkConnection\`, \`gmail-listThreads\`, \`gmail-readThread\`, \`gmail-searchEmails\` - Native Gmail tools for reading, checking, searching, and summarizing email. Load the \`read-emails\` skill for usage guidance.\n`
: '';

const slackToolsLine = composioEnabled
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
Expand Down Expand Up @@ -266,7 +283,7 @@ ${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with fu
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
${composioToolsLine}
${gmailToolsLine}${composioToolsLine}

**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.

Expand Down Expand Up @@ -332,13 +349,21 @@ export async function buildCopilotInstructions(): Promise<string> {
} catch {
// repo unavailable — default to disabled
}
let nativeGmailConnected = false;
try {
const status = await getGmailConnectionStatus();
nativeGmailConnected = status.connected && status.hasRequiredScope;
} catch {
// connection check unavailable — keep composio routing
}
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
if (!nativeGmailConnected) excludeIds.push('read-emails');
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, nativeGmailConnected);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export const skill = String.raw`
# Composio Integration

**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
**Load this skill** when the user asks to interact with ANY third-party service — GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.

**Gmail exception:** if the native Gmail tools (\`gmail-listThreads\`, \`gmail-readThread\`, \`gmail-searchEmails\`) are available, use those for ALL Gmail reading/fetching/searching (load the \`read-emails\` skill). Only use the Composio \`gmail\` toolkit when the native tools report they are not connected (\`composioFallback: true\`).

## Available Tools

Expand All @@ -16,7 +18,7 @@ export const skill = String.raw`

| Service | Slug |
|---------|------|
| Gmail | \`gmail\` |
| Gmail | \`gmail\` (fallback only — prefer the native \`gmail-*\` tools) |
| Google Calendar | \`googlecalendar\` |
| Google Sheets | \`googlesheets\` |
| Google Docs | \`googledocs\` |
Expand Down Expand Up @@ -89,13 +91,13 @@ User says: "Get me the open issues on rowboatlabs/rowboat"
→ finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`

### Example: Gmail Fetch
### Example: Notion Search

User says: "What's my latest email?"
User says: "Find my Notion page about Q3 planning"

1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
→ finds \`GMAIL_FETCH_EMAILS\`
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
1. \`composio-search-tools({ query: "search pages", toolkitSlug: "notion" })\`
→ finds \`NOTION_SEARCH_NOTION_PAGE\`
2. \`composio-execute-tool({ toolSlug: "NOTION_SEARCH_NOTION_PAGE", toolkitSlug: "notion", arguments: { query: "Q3 planning" } })\`

### Example: LinkedIn Profile (no-arg tool)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import builtinToolsSkill from "./builtin-tools/skill.js";
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
import docCollabSkill from "./doc-collab/skill.js";
import draftEmailsSkill from "./draft-emails/skill.js";
import readEmailsSkill from "./read-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
Expand Down Expand Up @@ -54,6 +55,12 @@ const definitions: SkillDefinition[] = [
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
content: draftEmailsSkill,
},
{
id: "read-emails",
title: "Read Emails",
summary: "Read, check, search, and summarize the user's Gmail inbox using the native gmail-* tools and cached per-thread summaries.",
content: readEmailsSkill,
},
{
id: "meeting-prep",
title: "Meeting Prep",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export const skill = String.raw`
# Read Emails Skill

You are helping the user read, check, search, or summarize their Gmail inbox using the NATIVE Gmail tools. These run against a local sync of the user's inbox — fast, private, and already enriched with per-thread LLM summaries.

Do NOT use the \`composio-integration\` skill or any \`composio-*\` tool for Gmail unless a native tool response says \`composioFallback: true\`.

## Tools

| Tool | What it does |
|------|--------------|
| \`gmail-checkConnection\` | Is native Gmail connected, and as which account? |
| \`gmail-listThreads\` | Page through the synced inbox (\`section: 'important'\` or \`'other'\`). Each thread carries a cached 1-2 sentence \`summary\` and \`importance\` classification. |
| \`gmail-readThread\` | Full plain-text messages of ONE thread by \`threadId\`. |
| \`gmail-searchEmails\` | Live Gmail search with a query string — for anything the cached inbox can't answer. |

## Summarizing the Inbox (the common case)

For "summarize my emails", "what's in my inbox", "anything important today":

1. Call \`gmail-listThreads\` (defaults to \`section: 'important'\`). Include \`section: 'other'\` only if the user asks about newsletters/notifications or "everything".
2. Compose your answer directly from the returned \`summary\` + \`importance\` fields (plus \`from\`/\`subject\`/\`date\`).
3. **Do NOT** call \`gmail-readThread\` once per thread, and do NOT re-summarize message bodies — the cached summaries already exist and are fresh from sync. Read a full thread ONLY when the user drills into a specific one.

Threads without a \`summary\` include a \`latestSnippet\` to work from.

## Freshness

- The cache syncs continuously in the background (typically minutes fresh).
- When the user says "check my email NOW" / "any NEW emails?", pass \`sync: true\` on \`gmail-listThreads\` — it triggers a background re-sync (non-blocking). If results look stale, mention fresh data lands shortly and retry once.

## Searching ("emails from Stripe last month")

Use \`gmail-searchEmails\` with a Gmail query string. Common operators:
- \`from:stripe.com\`, \`to:someone@example.com\`
- \`subject:invoice\`
- \`newer_than:7d\`, \`older_than:30d\`, \`after:2026/05/01\`
- \`is:unread\`, \`has:attachment\`, \`label:work\`
- Free text matches message content.

Results may come from the cache (with summaries) or live metadata only. Follow up with \`gmail-readThread({ threadId })\` when the user wants the content.

## Reading One Thread

\`gmail-readThread({ threadId })\` returns the most recent messages (default 10, \`omittedOlderMessages\` counts the rest), with quoted replies stripped and long bodies truncated. It also includes the cached \`summary\`, \`importance\`, and any \`draft_response\` the classifier prepared. Attachments are listed with a \`savedPath\` (workspace-relative) — use file tools / \`parseFile\` on it if the user asks about an attachment.

## Pagination

Default page size is 20. Follow \`nextCursor\` only when the user asks for more ("show me the rest", "older emails").

## Not Connected

If a tool returns \`connected: false\`:
- Tell the user to connect their Google account in **Settings** (the \`action\` field says exactly what to suggest).
- Only if the response also says \`composioFallback: true\` may you fall back to the \`composio-integration\` skill for Gmail.

## Drafting

This skill is for READING email. When the user wants to draft or compose a reply, load the \`draft-emails\` skill.
`;

export default skill;
52 changes: 52 additions & 0 deletions apps/x/packages/core/src/application/lib/builtin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import {
gmailCheckConnection,
gmailListThreads,
gmailReadThread,
gmailSearchEmails,
type GmailListThreadsInput,
type GmailReadThreadInput,
type GmailSearchEmailsInput,
} from "./gmail-tools.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";

Expand Down Expand Up @@ -1257,6 +1266,49 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},

// ========================================================================
// Native Gmail Tools
// ========================================================================
// Always registered (not gated on connection status — the instructions
// cache would drift otherwise). Each execute checks the native Google
// connection itself and returns a connect-in-Settings payload when
// disconnected. Execute bodies live in gmail-tools.ts.

'gmail-checkConnection': {
description: 'Check whether the native Gmail integration is connected and which account it uses. Returns { connected, email } or a not-connected payload with next steps.',
inputSchema: z.object({}),
execute: async () => gmailCheckConnection(),
},

'gmail-listThreads': {
description: "List the user's Gmail inbox from the locally synced cache. Each thread includes an LLM-generated 1-2 sentence summary and importance classification — prefer these summaries for 'summarize my emails' style requests instead of reading full bodies. Load the `read-emails` skill for usage guidance.",
inputSchema: z.object({
section: z.enum(['important', 'other']).optional().describe("Inbox section: 'important' (real correspondence) or 'other' (newsletters, notifications). Defaults to 'important'."),
cursor: z.string().optional().describe('Pagination cursor from a previous call (nextCursor).'),
limit: z.number().int().min(1).max(50).optional().describe('Threads per page. Defaults to 20.'),
sync: z.boolean().optional().describe('Set true to trigger a background re-sync first (does not wait for it).'),
}),
execute: async (input: GmailListThreadsInput) => gmailListThreads(input),
},

'gmail-readThread': {
description: 'Read the full messages of one Gmail thread by threadId (from gmail-listThreads or gmail-searchEmails). Returns plain-text bodies (quoted replies stripped, capped per message) plus the cached summary/importance and any attachments.',
inputSchema: z.object({
threadId: z.string().describe('The Gmail thread id.'),
maxMessages: z.number().int().min(1).max(25).optional().describe('Most-recent messages to include. Defaults to 10; older ones are counted in omittedOlderMessages.'),
}),
execute: async (input: GmailReadThreadInput) => gmailReadThread(input),
},

'gmail-searchEmails': {
description: "Search Gmail live with a Gmail query string (from:, subject:, newer_than:7d, has:attachment, is:unread, label:, free text). Use when the cached inbox can't answer — older mail, specific sender/date searches. Follow up with gmail-readThread for bodies.",
inputSchema: z.object({
query: z.string().describe('Gmail search query, e.g. "from:stripe.com newer_than:30d".'),
maxResults: z.number().int().min(1).max(25).optional().describe('Max threads to return. Defaults to 10.'),
}),
execute: async (input: GmailSearchEmailsInput) => gmailSearchEmails(input),
},

// ========================================================================
// Composio Meta-Tools
// ========================================================================
Expand Down
Loading